├── .gitignore ├── .travis.yml ├── .yardopts ├── Gemfile ├── README.md ├── Rakefile ├── VERSION ├── lib ├── waitutil.rb └── waitutil │ └── version.rb ├── spec ├── Rakefile └── waitutil_spec.rb └── waitutil.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Documentation cache and generated files: 13 | /.yardoc/ 14 | /_yardoc/ 15 | /doc/ 16 | /rdoc/ 17 | 18 | ## Environment normalisation: 19 | /.bundle/ 20 | /lib/bundler/man/ 21 | 22 | Gemfile.lock 23 | .ruby-version 24 | .ruby-gemset 25 | 26 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 27 | .rvmrc 28 | 29 | /.idea 30 | *.iml 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - 2.1.0 6 | - jruby-19mode # JRuby in 1.9 mode 7 | # TODO: resolve issues and re-enable: 8 | #- rbx-2.1.1 9 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private --protected lib/**/*.rb -m markdown - README.md 2 | 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## waitutil 2 | 3 | [![Build Status](https://travis-ci.org/rubytools/waitutil.png?branch=master)](https://travis-ci.org/rubytools/waitutil) 4 | 5 | `waitutil` provides tools for waiting for various conditions to occur, with a configurable 6 | delay time, timeout, and logging. 7 | 8 | GitHub: https://github.com/rubytools/waitutil 9 | 10 | RubyGems: http://rubygems.org/gems/waitutil 11 | 12 | Documentation: http://rubytools.github.io/waitutil/ 13 | 14 | ### Examples 15 | 16 | Wait methods take a block that returns `true` or `false`. 17 | 18 | #### Waiting for conditions 19 | 20 | [`wait_for_condition`](http://rubytools.github.io/waitutil/WaitUtil.html#wait_for_condition-instance_method) 21 | waits for a condition computed by the given function. It takes two optional 22 | parameters, `:timeout_sec` and `:delay_sec`, that control how long the function 23 | waits before raising a timeout exception, and how frequently it checks for the 24 | condition. The time the condition block takes to evaluate is subtracted from 25 | the sleep time between successive checks. The timeout is one minute by 26 | default, and the delay time is one second. 27 | 28 | ```ruby 29 | WaitUtil.wait_for_condition("my_event to happen") do 30 | check_if_my_event_happened 31 | end 32 | ``` 33 | 34 | ##### Customized wait time and delay time 35 | 36 | ```ruby 37 | WaitUtil.wait_for_condition("my_event to happen", :timeout_sec => 30, :delay_sec => 0.5) do 38 | check_if_my_event_happened 39 | end 40 | ``` 41 | 42 | ##### Verbose logging 43 | 44 | ```ruby 45 | WaitUtil.wait_for_condition('my event', :verbose => true) { sleep(1) } 46 | ``` 47 | 48 | Output: 49 | 50 | ``` 51 | I, [2014-02-16T00:34:31.511915 #15897] INFO -- : Waiting for my event for up to 60 seconds 52 | I, [2014-02-16T00:34:32.512223 #15897] INFO -- : Success waiting for my event (1.000153273 seconds) 53 | ``` 54 | 55 | ##### Returning additional information for the timeout log message 56 | 57 | ```ruby 58 | attempt = 1 59 | WaitUtil.wait_for_condition('my event', :verbose => true, :timeout_sec => 3, :delay_sec => 1) do 60 | sleep(1) 61 | attempt += 1 62 | [false, "attempt #{attempt}"] # the second element goes into the log message 63 | end 64 | ``` 65 | 66 | Output: 67 | 68 | ``` 69 | I, [2014-02-16T00:46:53.647936 #17252] INFO -- : Waiting for my event for up to 3 seconds 70 | WaitUtil::TimeoutError: Timed out waiting for my event (3 seconds elapsed): attempt 3 71 | from /home/mbautin/.rvm/gems/ruby-2.1.0/gems/waitutil-0.1.0/lib/waitutil.rb:39:in `wait_for_condition' 72 | from (irb):9 73 | from /home/mbautin/.rvm/rubies/ruby-2.1.0/bin/irb:11:in `
' 74 | ``` 75 | 76 | #### Waiting for service availability 77 | 78 | Wait for a TCP server to be available using [`wait_for_service`](http://rubytools.github.io/waitutil/WaitUtil.html#wait_for_service-instance_method): 79 | 80 | ```ruby 81 | WaitUtil.wait_for_service('my service', 'example.com', 80) 82 | ``` 83 | 84 | ### License 85 | 86 | [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html) 87 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __FILE__) 2 | 3 | require 'bundler' 4 | require 'rspec/core/rake_task' 5 | require 'rake/clean' 6 | require 'rubygems/tasks' 7 | 8 | CLEAN.include("**/*.gem") 9 | 10 | Bundler::GemHelper.install_tasks 11 | 12 | RSpec::Core::RakeTask.new(:spec) 13 | 14 | task :default => :spec 15 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.2.1 2 | -------------------------------------------------------------------------------- /lib/waitutil.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'socket' 3 | 4 | module WaitUtil 5 | 6 | extend self 7 | 8 | class TimeoutError < StandardError 9 | end 10 | 11 | DEFAULT_TIMEOUT_SEC = 60 12 | DEFAULT_DELAY_SEC = 1 13 | 14 | @@logger = Logger.new(STDOUT) 15 | @@logger.level = Logger::INFO 16 | 17 | def self.logger 18 | @@logger 19 | end 20 | 21 | # Wait until the condition computed by the given block is met. The supplied block may return a 22 | # boolean or an array of two elements: whether the condition has been met and an additional 23 | # message to display in case of timeout. 24 | def wait_for_condition(description, options = {}, &block) 25 | delay_sec = options.delete(:delay_sec) || DEFAULT_DELAY_SEC 26 | timeout_sec = options.delete(:timeout_sec) || DEFAULT_TIMEOUT_SEC 27 | verbose = options.delete(:verbose) 28 | unless options.empty? 29 | raise "Invalid options: #{options}" 30 | end 31 | 32 | if verbose 33 | @@logger.info("Waiting for #{description} for up to #{timeout_sec} seconds") 34 | end 35 | 36 | start_time = Time.now 37 | stop_time = start_time + timeout_sec 38 | iteration = 0 39 | 40 | # Time when we started to evaluate the condition. 41 | condition_eval_start_time = start_time 42 | 43 | until is_condition_met(condition_result = yield(iteration)) 44 | current_time = Time.now 45 | if current_time - start_time >= timeout_sec 46 | raise TimeoutError.new( 47 | "Timed out waiting for #{description} (#{timeout_sec} seconds elapsed)" + 48 | get_additional_message(condition_result) 49 | ) 50 | end 51 | 52 | # The condition evaluation function might have taken some time, so we subtract that time 53 | # from the time we have to wait. 54 | sleep_time_sec = condition_eval_start_time + delay_sec - current_time 55 | sleep(sleep_time_sec) if sleep_time_sec > 0 56 | 57 | iteration += 1 58 | condition_eval_start_time = Time.now # we will evaluate the condition again immediately 59 | end 60 | 61 | if verbose 62 | @@logger.info("Success waiting for #{description} (#{Time.now - start_time} seconds)") 63 | end 64 | true 65 | end 66 | 67 | # Wait until a TCP service is available at the given host/port. 68 | def wait_for_service(description, host, port, options = {}) 69 | wait_for_condition("#{description} to become available on #{host}, port #{port}", 70 | options) do 71 | begin 72 | is_tcp_port_open(host, port, options[:delay_sec] || DEFAULT_DELAY_SEC) 73 | rescue SocketError 74 | false 75 | end 76 | end 77 | end 78 | 79 | private 80 | 81 | def is_condition_met(condition_result) 82 | condition_result.kind_of?(Array) ? condition_result[0] : condition_result 83 | end 84 | 85 | def get_additional_message(condition_result) 86 | condition_result.kind_of?(Array) ? ': ' + condition_result[1] : '' 87 | end 88 | 89 | # Check if the given TCP port is open on the given port with a timeout. 90 | def is_tcp_port_open(host, port, timeout_sec = nil) 91 | if RUBY_PLATFORM == 'java' 92 | # Unfortunately, our select-based approach does not work on JRuby. 93 | begin 94 | s = TCPSocket.new(host, port) 95 | s.close 96 | true 97 | rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH 98 | false 99 | end 100 | else 101 | addr_info = begin 102 | Socket.getaddrinfo(host, port) 103 | rescue SocketError 104 | return false 105 | end.select {|item| item[0] == 'AF_INET' } 106 | 107 | return false if addr_info.empty? 108 | 109 | socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0) 110 | ip_addr = addr_info[0][3] 111 | sockaddr = Socket.sockaddr_in(port, ip_addr) 112 | result = begin 113 | begin 114 | socket.connect_nonblock(sockaddr) 115 | true 116 | rescue Errno::EAFNOSUPPORT 117 | @@logger.error("Address family not supported for #{ip_addr}") 118 | false 119 | end 120 | rescue Errno::EINPROGRESS 121 | reader, writer, error = IO.select([socket], [socket], [socket], timeout_sec) 122 | if writer.nil? || writer.empty? 123 | false 124 | else 125 | # Sometimes we have to write some data to the socket to find out whether we are really 126 | # connected. 127 | begin 128 | writer[0].write_nonblock("\x0") 129 | true 130 | rescue Errno::ECONNREFUSED, Errno::EPIPE 131 | false 132 | end 133 | end 134 | end 135 | socket.close 136 | result 137 | end 138 | end 139 | 140 | extend WaitUtil 141 | end 142 | -------------------------------------------------------------------------------- /lib/waitutil/version.rb: -------------------------------------------------------------------------------- 1 | module WaitUtil 2 | VERSION = IO.read(File.expand_path("../../../VERSION", __FILE__)) 3 | end 4 | -------------------------------------------------------------------------------- /spec/Rakefile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubytools/waitutil/98c3e4ad26981cdeedfaa530cdec3774b0193d21/spec/Rakefile -------------------------------------------------------------------------------- /spec/waitutil_spec.rb: -------------------------------------------------------------------------------- 1 | require 'waitutil' 2 | require 'socket' 3 | 4 | RSpec.configure do |configuration| 5 | configuration.include WaitUtil 6 | end 7 | 8 | describe WaitUtil do 9 | describe '.wait_for_condition' do 10 | it 'logs if the verbose option is specified' do 11 | iterations = [] 12 | WaitUtil.logger.should_receive(:info).with('Waiting for true for up to 60 seconds') 13 | WaitUtil.logger.should_receive(:info) do |msg| 14 | msg =~ /^Success waiting for true \(.*\)$/ 15 | end 16 | 17 | ret = wait_for_condition('true', :verbose => true) do |iteration| 18 | iterations << iteration 19 | true 20 | end 21 | expect(ret).to be_true 22 | expect(iterations).to eq([0]) 23 | end 24 | 25 | it 'returns immediately if the condition is true' do 26 | iterations = [] 27 | ret = wait_for_condition('true') {|iteration| iterations << iteration; true } 28 | expect(ret).to be_true 29 | expect(iterations).to eq([0]) 30 | end 31 | 32 | it 'should time out if the condition is always false' do 33 | iterations = [] 34 | start_time = Time.now 35 | begin 36 | wait_for_condition('false', :timeout_sec => 0.1, :delay_sec => 0.01) do |iteration| 37 | iterations << iteration 38 | false 39 | end 40 | fail 'Expected an exception' 41 | rescue WaitUtil::TimeoutError => ex 42 | expect(ex.to_s).to match(/^Timed out waiting for false /) 43 | end 44 | elapsed_sec = Time.now - start_time 45 | expect(elapsed_sec).to be >= 0.1 46 | expect(iterations.length).to be >= 9 47 | expect(iterations.length).to be <= 11 48 | expect(iterations).to eq((0..iterations.length - 1).to_a) 49 | end 50 | 51 | it 'should handle additional messages from the block' do 52 | begin 53 | wait_for_condition('false', :timeout_sec => 0.01, :delay_sec => 0.05) do |iteration| 54 | [false, 'Some error'] 55 | end 56 | fail 'Expected an exception' 57 | rescue WaitUtil::TimeoutError => ex 58 | expect(ex.to_s).to match(/^Timed out waiting for false (.*): Some error$/) 59 | end 60 | end 61 | 62 | it 'should treat the first element of returned tuple as condition status' do 63 | iterations = [] 64 | ret = wait_for_condition('some condition', :timeout_sec => 1, :delay_sec => 0) do |iteration| 65 | iterations << iteration 66 | [iteration >= 3, 'some message'] 67 | end 68 | expect(ret).to be_true 69 | expect(iterations).to eq([0, 1, 2, 3]) 70 | end 71 | 72 | it 'should evaluate the block return value as a boolean if it is not an array' do 73 | iterations = [] 74 | ret = wait_for_condition('some condition', :timeout_sec => 1, :delay_sec => 0) do |iteration| 75 | iterations << iteration 76 | iteration >= 3 77 | end 78 | expect(ret).to be_true 79 | expect(iterations).to eq([0, 1, 2, 3]) 80 | end 81 | end 82 | 83 | describe '.wait_for_service' do 84 | BIND_IP = '127.0.0.1' 85 | 86 | it 'waits for service availability' do 87 | WaitUtil.wait_for_service('Google', 'google.com', 80, :timeout_sec => 0.5) 88 | end 89 | 90 | it 'times out when host name does not exist' do 91 | begin 92 | WaitUtil.wait_for_service( 93 | 'non-existent service', 94 | 'nosuchhost_waitutil_ruby_module.com', 95 | 12345, 96 | :timeout_sec => 0.2, 97 | :delay_sec => 0.1 98 | ) 99 | fail("Expecting WaitUtil::TimeoutError but nothing was raised") 100 | rescue WaitUtil::TimeoutError => ex 101 | expect(ex.to_s.gsub(/ \(.*/, '')).to eq( 102 | 'Timed out waiting for non-existent service to become available on ' \ 103 | 'nosuchhost_waitutil_ruby_module.com, port 12345' 104 | ) 105 | end 106 | end 107 | 108 | if RUBY_PLATFORM != 'java' 109 | # Our current implementation will get stuck on this if running JRuby. 110 | it 'times out when port is closed' do 111 | begin 112 | WaitUtil.wait_for_service( 113 | 'wrong port on Google', 114 | 'google.com', 115 | 12345, 116 | :timeout_sec => 0.2, 117 | :delay_sec => 0.1 118 | ) 119 | rescue WaitUtil::TimeoutError => ex 120 | expect(ex.to_s.gsub(/ \(.*/, '')).to eq( 121 | 'Timed out waiting for wrong port on Google to become available on google.com, ' \ 122 | 'port 12345' 123 | ) 124 | end 125 | end 126 | end 127 | 128 | it 'should succeed immediately when there is a TCP server listening' do 129 | # Find an unused port. 130 | socket = Socket.new(:INET, :STREAM, 0) 131 | sockaddr = if RUBY_ENGINE == 'jruby' 132 | ServerSocket.pack_sockaddr_in(0, "127.0.0.1") 133 | else 134 | Socket.pack_sockaddr_in(0, "127.0.0.1") 135 | end 136 | socket.bind(sockaddr) 137 | port = socket.local_address.ip_port 138 | socket.close 139 | 140 | server_thread = Thread.new do 141 | server = TCPServer.new(port) 142 | loop do 143 | client = server.accept # Wait for a client to connect 144 | client.puts "Hello !" 145 | client.close 146 | break 147 | end 148 | end 149 | 150 | wait_for_service('wait for my service', BIND_IP, port, :delay_sec => 0.1, :timeout_sec => 0.3) 151 | end 152 | 153 | it 'should fail when there is no TCP server listening' do 154 | port = nil 155 | # Find a port that no one is listening on. 156 | attempts = 0 157 | while attempts < 100 158 | port = 32768 + rand(61000 - 32768) 159 | begin 160 | TCPSocket.new(BIND_IP, port) 161 | port = nil 162 | rescue Errno::ECONNREFUSED 163 | break 164 | end 165 | attempts += 1 166 | end 167 | fail 'Could not find a port no one is listening on' unless port 168 | 169 | expect { 170 | wait_for_service( 171 | 'wait for non-existent service', BIND_IP, port, :delay_sec => 0.1, :timeout_sec => 0.3 172 | ) 173 | }.to raise_error(WaitUtil::TimeoutError) 174 | end 175 | end 176 | 177 | end 178 | -------------------------------------------------------------------------------- /waitutil.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | GEM_NAME = 'waitutil' 3 | 4 | require File.expand_path("../lib/#{GEM_NAME}/version", __FILE__) 5 | 6 | Gem::Specification.new do |gem| 7 | gem.authors = ['Mikhail Bautin'] 8 | gem.email = ['mbautin@gmail.com'] 9 | gem.description = 'Utilities for waiting for various conditions' 10 | gem.summary = 'Utilities for waiting for various conditions' 11 | gem.homepage = "http://github.com/rubytools/#{GEM_NAME}" 12 | 13 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 14 | gem.files = `git ls-files`.split("\n").map(&:strip) 15 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | gem.name = GEM_NAME 17 | gem.require_paths = ['lib'] 18 | gem.version = WaitUtil::VERSION 19 | 20 | gem.add_development_dependency 'rake', '~> 10.1' 21 | gem.add_development_dependency 'rspec', '~> 2.14' 22 | gem.add_development_dependency 'rubygems-tasks', '~> 0.2' 23 | 24 | gem.add_development_dependency 'webrick' 25 | end 26 | --------------------------------------------------------------------------------