Class: Backticks::Command

Inherits:
Object
  • Object
show all
Defined in:
lib/backticks/command.rb

Overview

Represents a running process; provides mechanisms for capturing the process’s output, passing input, waiting for the process to end, and learning its exitstatus.

Interactive commands print their output to Ruby’s STDOUT and STDERR in realtime, and also pass input from Ruby’s STDIN to the command’s stdin.

Constant Summary collapse

FOREVER =

Duration that we use when a caller is willing to wait “forever” for a command to finish. This means that ‘#join` is buggy when used with commands that take longer than a year to complete. You have been warned!

86_400 * 365
CHUNK =

Number of bytes to read from the command in one “chunk”.

1_024

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(pid, stdin, stdout, stderr, interactive: false) ⇒ Command

Watch a running command by taking ownership of the IO objects that are passed in.

Parameters:

  • pid (Integer)
  • stdin (IO)
  • stdout (IO)
  • stderr (IO)


40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/backticks/command.rb', line 40

def initialize(pid, stdin, stdout, stderr, interactive:false)
  @pid = pid
  @stdin = stdin
  @stdout = stdout
  @stderr = stderr
  @interactive = !!interactive
  @tap = nil
  @status = nil

  @captured_input  = String.new.force_encoding(Encoding::BINARY)
  @captured_output = String.new.force_encoding(Encoding::BINARY)
  @captured_error  = String.new.force_encoding(Encoding::BINARY)
end

Instance Attribute Details

#captured_errorString (readonly)

Returns all output to stderr that has been captured so far.

Returns:

  • (String)

    all output to stderr that has been captured so far



31
32
33
# File 'lib/backticks/command.rb', line 31

def captured_error
  @captured_error
end

#captured_inputString (readonly)

Returns all input that has been captured so far.

Returns:

  • (String)

    all input that has been captured so far



25
26
27
# File 'lib/backticks/command.rb', line 25

def captured_input
  @captured_input
end

#captured_outputString (readonly)

Returns all output that has been captured so far.

Returns:

  • (String)

    all output that has been captured so far



28
29
30
# File 'lib/backticks/command.rb', line 28

def captured_output
  @captured_output
end

#pidInteger (readonly)

Returns child process ID.

Returns:

  • (Integer)

    child process ID



19
20
21
# File 'lib/backticks/command.rb', line 19

def pid
  @pid
end

#statusnil, Process::Status (readonly)

Returns result of command if it has ended; nil if still running.

Returns:

  • (nil, Process::Status)

    result of command if it has ended; nil if still running



22
23
24
# File 'lib/backticks/command.rb', line 22

def status
  @status
end

Instance Method Details

#capture(limit = nil) ⇒ String?

Block until one of the following happens:

- the command produces fresh output on stdout or stderr
- the user passes some input to the command (if interactive)
- the process exits
- the time limit elapses (if provided) OR 60 seconds pass

Return up to CHUNK bytes of fresh output from the process, or return nil if no fresh output was produced

Parameters:

  • number (Float, Integer)

    of seconds to wait before returning nil

Returns:

  • (String, nil)

    fresh bytes from stdout/stderr, or nil if no output



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/backticks/command.rb', line 132

def capture(limit=nil)
  streams = [@stdout, @stderr]
  streams << STDIN if STDIN.tty? && interactive?

  ready, _, _ = IO.select(streams, [], [], limit)

  # proxy STDIN to child's stdin
  if ready && ready.include?(STDIN)
    data = STDIN.readpartial(CHUNK) rescue nil
    if data
      data = @tap.call(:stdin, data) if @tap
      if data
        @captured_input << data
        @stdin.write(data)
      end
    else
      @tap.call(:stdin, nil) if @tap
      # our own STDIN got closed; proxy this fact to the child
      @stdin.close unless @stdin.closed?
    end
  end

  # capture child's stdout and maybe proxy to STDOUT
  if ready && ready.include?(@stdout)
    data = @stdout.readpartial(CHUNK) rescue nil
    if data
      data = @tap.call(:stdout, data) if @tap
      if data
        @captured_output << data
        STDOUT.write(data) if interactive?
        fresh_output = data
      end
    end
  end

  # capture child's stderr and maybe proxy to STDERR
  if ready && ready.include?(@stderr)
    data = @stderr.readpartial(CHUNK) rescue nil
    if data
      data = @tap.call(:stderr, data) if @tap
      if data
        @captured_error << data
        STDERR.write(data) if interactive?
      end
    end
  end
  fresh_output
rescue Interrupt
  # Proxy Ctrl+C to the child
  (Process.kill('INT', @pid) rescue nil) if @interactive
  raise
end

#eof?Boolean

Determine whether output has been exhausted.

Returns:

  • (Boolean)


74
75
76
77
78
79
80
81
# File 'lib/backticks/command.rb', line 74

def eof?
  @stdout.eof? && @stderr.eof?
rescue Errno::EIO
  # The result of read operation when pty slave is closed is platform
  # dependent.
  # @see https://stackoverflow.com/questions/10238298/ruby-on-linux-pty-goes-away-without-eof-raises-errnoeio
  true
end

#interactive?Boolean

Returns true if this command is tied to STDIN/STDOUT.

Returns:

  • (Boolean)

    true if this command is tied to STDIN/STDOUT



60
61
62
# File 'lib/backticks/command.rb', line 60

def interactive?
  @interactive
end

#join(limit = FOREVER) ⇒ Object

Block until the command exits, or until limit seconds have passed. If interactive is true, proxy STDIN to the command and print its output to STDOUT. If the time limit expires, return ‘nil`; otherwise, return self.

If the command has already exited when this method is called, return self immediately.

Parameters:

  • limit (Float, Integer) (defaults to: FOREVER)

    number of seconds to wait before returning



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/backticks/command.rb', line 104

def join(limit=FOREVER)
  return self if @status

  tf = Time.now + limit
  until (t = Time.now) >= tf
    capture(tf-t)
    res = Process.waitpid(@pid, Process::WNOHANG)
    if res
      @status = $?
      capture(nil) until eof?
      return self
    end
  end

  return nil
end

#success?Boolean

Block until the command completes; return true if its status was zero, false if nonzero.

Returns:

  • (Boolean)


68
69
70
71
# File 'lib/backticks/command.rb', line 68

def success?
  join
  status.success?
end

#tap {|stream, data| ... } ⇒ Object

Provide a callback to monitor input and output in real time. This method saves a reference to block for later use; whenever the command generates output or receives input, the block is called back with the name of the stream on which I/O occurred and the actual data that was read or written.

Yields:

Yield Parameters:

  • stream (Symbol)

    one of :stdin, :stdout or :stderr

  • data (String)

    fresh input from the designated stream

Raises:

  • (StandardError)


90
91
92
93
# File 'lib/backticks/command.rb', line 90

def tap(&block)
  raise StandardError.new("Tap is already set (#{@tap}); cannot set twice") if @tap && @tap != block
  @tap = block
end

#to_sString

Returns a basic string representation of this command.

Returns:

  • (String)

    a basic string representation of this command



55
56
57
# File 'lib/backticks/command.rb', line 55

def to_s
  "#<Backticks::Command(@pid=#{pid},@status=#{@status || 'nil'})>"
end