Module: CommandRunner

Defined in:
lib/command_runner.rb

Constant Summary collapse

MAX_TIME =
Time.new(2**63 -1)

Class Method Summary collapse

Class Method Details

.run(*args, timeout: nil) ⇒ Object

Like IO.popen(), but block until the child completes. Takes an optional timeout parameter. If timeout is a number the child will be killed after that many seconds if it haven’t completed. Alternatively it can be a Hash of timeouts to actions. Each action can be a string or integer specifying the signal to send, or a Proc to execute. The Proc will be called with the child PID as argument. These examples are equivalent:

run('sleep 10', timeout: 5) # With a subshell
run(['sleep', '10'], timeout: 5) # No subshell in this one and the rest
run(['sleep', '10'], timeout: {5 => 'KILL'})
run(['sleep', '10'], timeout: {5 => Proc.new { |pid| Process.kill('KILL', pid)}})
run(['sleep', '10'], timeout: {
          5 => 'KILL',
          2 => Proc.new {|pid| puts "PID #{pid} getting SIGKILL in 3s"}
})

Returns a Hash with :out and :status. :out is a string with stdout and stderr merged, and :status is a Process::Status.

As a special case - if an action Proc raises an exception, the child will be killed with SIGKILL, cleaned up, and the exception rethrown to the caller of run.



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/command_runner.rb', line 29

def self.run(*args, timeout: nil)
  # This could be tweakable through vararg opts
  tick = 0.1

  now = Time.now

  # Build deadline_sequence. A list of deadlines and corresponding actions to take
  if timeout
    if timeout.is_a? Numeric
      deadline_sequence = [{deadline: now + timeout, action: 'KILL'}]
    elsif timeout.is_a? Hash
      deadline_sequence = timeout.collect do |t, action|
        unless action.is_a? Integer or action.is_a? String or action.is_a? Proc
            raise "Unsupported action type '#{action.class}'. Must be Integer, String, or Proc"
        end
        unless t.is_a? Numeric
          raise "Unsupported timeout value '#{t}'. Must be a Numeric"
        end
        {deadline: now + t, action: action}
      end.sort! { |a, b| a[:deadline] <=> b[:deadline]}
    else
      raise "Unsupported type for timeout paramter: #{timeout.class}"
    end
  else
    deadline_sequence = [{deadline: MAX_TIME, action: 0}]
  end

  # Spawn child, merging stderr into stdout
  io = IO.popen(*args, :err=>[:child, :out])
  data = ""

  # Run through all deadlines until command completes.
  # We could merge this block into the selecting block above,
  # but splitting like this saves us a Process.wait syscall per iteration.
  eof = false
  deadline_sequence.each do |point|
    while Time.now < point[:deadline]
      if Process.wait(io.pid, Process::WNOHANG)
        read_nonblock_safe!(io, data, tick)
        result = {out: data, status: $?}
        io.close
        return result
      elsif !eof
        eof = read_nonblock_safe!(io, data, tick)
      end
    end

    # Deadline for this point reached. Fire the action.
    action = point[:action]
    if action.is_a? String or action.is_a? Integer
      Process.kill(action, io.pid)
    elsif action.is_a? Proc
      begin
        action.call(io.pid)
      rescue => e
        # If the action block throws and error, clean up and rethrow
        begin
          Process.kill('KILL', io.pid)
        rescue
          # process already dead
        end
        Process.wait(io.pid)
        io.close
        raise e
      end
    else
      # Given the assertions when building the deadline_sequence this should never be reached
      raise "Internal error in CommandRunnerNG. Child may be left unattended!"
    end
  end

  # Either we didn't have a deadline, or none of the deadlines killed off the child.
  Process.wait(io.pid)
  read_nonblock_safe!(io, data, tick)
  result = {out: data, status: $?}
  io.close

  result
end