lib/servolux/child.rb
# == Synopsis # Manage a child process spawned via IO#popen and provide a timeout # mechanism to kill the process after some amount time. # # == Details # Ruby provides the IO#popen method to spawn a child process and return an # IO instance connected to the child's stdin and stdout (with stderr # redirected to stdout). The Servolux::Child class adds to this a timeout # thread that will signal the child process after some number of seconds. # If the child exits cleanly before the timeout expires then no signals are # sent to the child. # # A list of signals can be provided which will be sent in succession to the # child until one of them causes the child to exit. The current Ruby thread # suspends for a few seconds to allow each signal to be processed by the # child. By default these signals are SIGTERM, SIGQUIT, SIGKILL and the time # to wait between signals is four seconds. # # The +stop+ method is used to stop the child process (if running) and to # reset the state of the Child instance so that it can be started again. # Stopping the Child instance closes the IO between parent and child # process. # # The +wait+ method is used to wait for the child process to exit. The # Process::Status object is retrieved by the Child and stored as an instance # variable. The +exitstatus+ method (and the other process related methods) # will return non-nil values after the wait method is called. # # == Examples # # child = Servolux::Child.new(:command => 'sleep 120', :timeout => 10) # child.start # child.wait # # child.timed_out? #=> true # child.signaled? #=> true # child.exitstatus #=> nil # class Servolux::Child attr_accessor :command attr_accessor :timeout attr_accessor :signals attr_accessor :suspend attr_reader :io attr_reader :pid # Create a new Child that will execute and manage the +command+ string as # a child process. # # @option opts [String] :command # The command that will be executed via IO#popen. # # @option opts [Numeric] :timeout (nil) # The number of seconds to wait before terminating the child process. # No action is taken if the child process exits normally before the # timeout expires. # # @option opts [Array<String, Integer>] :signals (['TERM', 'QUIT', 'KILL']) # A list of signals that will be sent to the child process when the # timeout expires. The signals increase in severity with SIGKILL being # the signal of last resort. # # @option opts [Numeric] :suspend (4) # The number of seconds to wait for the child process to respond to a # signal before trying the next one in the list. # def initialize( opts = {} ) @command = opts.fetch(:command, nil) @timeout = opts.fetch(:timeout, nil) @signals = opts.fetch(:signals, %w[TERM QUIT KILL]) @suspend = opts.fetch(:suspend, 4) @io = @pid = @status = @thread = @timed_out = nil yield self if block_given? end # Runs the +command+ string as a subprocess; the subprocess’s # standard input and output will be connected to the returned IO object. # The default mode for the new file object is "r", but mode may be set to # any of the modes listed in the description for class IO. # # If a block is given, Ruby will run the +command+ as a child connected to # Ruby with a pipe. Ruby’s end of the pipe will be passed as a parameter # to the block. In this case the value of the block is returned. # # @param [String] mode The mode flag used to open the child process via # IO#popen. # @yield [IO] Execute the block of call passing in the communication pipe # with the child process. # @yieldreturn Returns the result of the block. # @return [IO] The communication pipe with the child process or the return # value from the block if one was given. # def start( mode = 'r', &block ) start_timeout_thread if @timeout @io = IO::popen @command, mode @pid = @io.pid @status = nil return block.call(@io) unless block.nil? @io end # Stop the child process if it is alive. A sequence of +signals+ are sent # to the process until it dies with SIGKILL being the signal of last # resort. # # After this method returns, the IO pipe to the child will be closed and # the stored child PID is set to +nil+. The +start+ method can be safely # called again. # # @return self # def stop unless @thread.nil? t, @thread = @thread, nil t[:stop] = true t.wakeup.join if t.status end kill if alive? @io.close unless @io.nil? || @io.closed? @io = nil self end # Waits for the child process to exit and returns its exit status. The # global variable $? is set to a Process::Status object containing # information on the child process. # # You can get more information about how the child status exited by calling # the following methods on the piper instance: # # * coredump? # * exited? # * signaled? # * stopped? # * success? # * exitstatus # * stopsig # * termsig # # @param [Integer] flags Bit flags that will be passed to the system level # wait call. See the Ruby core documentation for Process#wait for more # information on these flags. # @return [Integer, nil] The exit status of the child process or +nil+ if # the child process is not running. # def wait( flags = 0 ) return if @pid.nil? _, @status = Process.wait2(@pid, flags) unless @status exitstatus end # Returns +true+ if the child process is alive. Returns +nil+ if the child # process has not been started. # # @return [Boolean] # def alive? return if @pid.nil? wait(Process::WNOHANG|Process::WUNTRACED) Process.kill(0, @pid) true rescue Errno::ESRCH, Errno::ENOENT false end # Returns +true+ if the child process was killed by the timeout thread. # # @return [Boolean] # def timed_out? @timed_out end %w[coredump? exited? signaled? stopped? success? exitstatus stopsig termsig]. each { |method| self.class_eval <<-CODE def #{method} return if @status.nil? @status.#{method} end CODE } private # Attempt to kill the child process by sending the configured +signals+ # and waiting for +suspend+ seconds between each signal; this gives the # child time to respond to the signal. # # Returns +true+ if the child died. Returns +false+ if the child is still # not dead after the last signal was sent. Returns +nil+ if the child was # not running in the first place. # def kill return if @io.nil? existed = false @signals.each do |sig| begin Process.kill sig, @pid existed = true rescue Errno::ESRCH, Errno::ENOENT return(existed ? nil : true) end return true unless alive? sleep @suspend return true unless alive? end return !alive? end def start_timeout_thread @timed_out = false @thread = Thread.new(self) { |child| sleep @timeout unless Thread.current[:stop] if child.alive? child.instance_variable_set(:@timed_out, true) child.__send__(:kill) end end } end end