Ruby Subprocesses with stdout and stderr Streams
I’ve been doing a few things with Ruby which involve controlling and responding to long-running processes, where the Ruby-based ‘wrapper’ takes the task of automating something which is otherwise quite complex. Perhaps the best example is boxes, which uses a collection of Rake tasks to generate Vagrant boxes using Packer –– each build takes somewhere in the region of twenty minutes to complete.
But, I wanted to be able to more closely control the output (hiding much of it from
view) and react to events like build failures, which wasn’t possible by using
system()
. This needed to be able to handle the output as it came line
by line without blocking (handling them as a stream), be able to handle stdout
and stderr
independently, and allow me to collect all of the output from a
subprocess (for providing as a sort-of stack trace).
I went through many different solutions (using Open3.popen3
, PTY
and others), before coming across this hybrid solution using popen3
and separate
threads for each output stream in this Stack Overflow post which met most of
my requirements.
This gave me a basic solution which looks like this:
require 'open3'
cmd = './packer_mock.sh'
data = {:out => [], :err => []}
# see: http://stackoverflow.com/a/1162850/83386
Open3.popen3(cmd) do |stdin, stdout, stderr, thread|
# read each stream from a new thread
{ :out => stdout, :err => stderr }.each do |key, stream|
Thread.new do
until (raw_line = stream.gets).nil? do
parsed_line = Hash[:timestamp => Time.now, :line => "#{raw_line}"]
# append new lines
data[key].push parsed_line
puts "#{key}: #{parsed_line}"
end
end
end
thread.join # don't exit until the external process is done
end
Line 3 pecifies the command that will be run. This is just a shell script which
prints a multitude of characters for testing. Line 4 defines the final data
structure; a Hash
with two Array
s for stdout
and stderr
.
The next interesting bits are Lines 9 and 10 which create a Thread
for
handling the stdout
and stderr
streams seperately. Inside this the until
block reads from the given stream, structures it and stores it. I’m adding a
Time
object here to aid my presentation of it later.
Line 16 would be replaced by a conditional depending on the amount of verbosity the user desired. Finally, Line 21 joins the thread once it has finished executing.
This, then, allows me to continue handling long processes as a stream, but handle each line individually. But the interface is a little awkward to use. Providing a simpler command a single block could simplify this, something like:
Utils::Subprocess.new './packer_mock.sh' do |stdout, stderr, thread|
puts "stdout: #{stdout}" # => "simple output"
puts "stderr: #{stderr}" # => "error: an error happened"
puts "pid: #{thread.pid}" # => 12345
end
Which could be implemented like this rather impressively nested bit of code:
require 'open3'
module Utils
class Subprocess
def initialize(cmd, &block)
# see: http://stackoverflow.com/a/1162850/83386
Open3.popen3(cmd) do |stdin, stdout, stderr, thread|
# read each stream from a new thread
{ :out => stdout, :err => stderr }.each do |key, stream|
Thread.new do
until (line = stream.gets).nil? do
# yield the block depending on the stream
if key == :out
yield line, nil, thread if block_given?
else
yield nil, line, thread if block_given?
end
end
end
end
thread.join # don't exit until the external process is done
end
end
end
end
Unlike the first approach, this just passes back the lines as strings which is nil
if there’s no value. The final argument to the block is the thread the subprocess
is run as. thread.pid
will give the PID.
For now, this works pretty well for boxes and will allow me to throw it into something like Jenkins without a ridiculous amount of logs to parse to see which ones build successfully.