This is part three in a series of posts about signal handling in Ruby.
Now that we have an implementation of
GracefulShutdown, let’s write a
basic test to verify it works correctly. The simplest way to test this is with
fork. It splits our program in two, then we can then send an interrupt signal
from parent to child and assert it exited without error.
describe GracefulShutdown do it "exits without error" do ruby = fork do GracefulShutdown.new.handle_signals do sleep 1.0 raise 'No Interrupt received' end end # Ensure process is running. sleep 0.01 Process.kill('INT', ruby) _, status = Process.waitpid2(ruby) expect(status.success?).to be true end end
This test is pretty straightforward, but I’ll summarize a few key points.
fork is called it returns a process ID (or
pid), which can be used to
track and signal the child process. The block passed to
fork is executed only
by the child and it exits when the block is complete.
Process.kill is used to send
INT, the interrupt signal to be handled by
GracefulShutdown. Inside the test block, an exception is raised after a
moment. We could have it block indefinitely, but our test suite would then hang
instead of giving us a meaningful failure.
The call to
Process.waitpid2 lets the test program block until the child is
finished. It also returns the pid (ignored by assigning it to
_) and a
Status object that can be used to check if the child was successful, ie.
ended with exit code 0.
So far so good, but there is the matter of calling
sleep 0.1 after starting
the test process. This is required to ensure the
GracefulShutdown block is
being executed. But because the startup time of the fork can be influenced by
outside factors, it can fail at random. We end up with a “flapping” test. This
can break build pipelines and cause other mischief so let’s try to refactor that
First we’ll rewrite our test code so it has better structure to support the changes.
it "exits without error" do test = RubyBlock.new do |helper| GracefulShutdown.new.handle_signals do helper.wait_for_signal 1.0 raise 'No Interrupt received' end end test.run_and_send('INT') expect(test).to be_successful end
fork method is replaced with
RubyBlock.new, it accepts the test block
and now takes a
Instead of the
sleep call inside the block we use
This adds some clarity and also provides a mechanism to notify our parent
process that the handler is being executed and actually ready to receive a
signal. Lastly we’ll call
test.run_and_send, that will run our test and send
the signal when the
wait_for_signal call is made.
Next we can create the
RubyBlock class to represent the test process and
successful? helper for our spec.
class RubyBlock def initialize(&test_block) @test_block = test_block end def run_and_send(signal) # IMPLEMENT ME end def successful? @status && @status.success? end end
To avoid the arbitrary
sleep, we’ll instead use the custom signal
child process can send it to the parent as a cue that the block is ready. In our
parent we can
trap(:USR1) and send the test signal.
def run_and_send(signal) trap(:USR1) do Process.kill(signal, pid) end pid = fork do @test_block.call(self) end _, @status = Process.waitpid2(pid) ensure trap(:USR1, 'DEFAULT') end
self is passed into
@test_block.call, this is the best way to provide our
wait_for_signal helper. We could inject it into the global namespace, but is generally frowned upon, so we’ll avoid it. We could also use
instance_exec but the test block will lose scope with the test environment, also not ideal.
We also use an
ensure block to restore the default behaviour for
USR1 so it
doesn’t misfire if there are multiple tests setting traps for it.
wait_for_signal helper is straightforward, it sends the signal and then
sleeps for a moment.
def wait_for_signal(seconds) Process.kill('USR1', Process.ppid) sleep(seconds) end
So now we have all the pieces we need to test our signal handler. But there’s
one more problem. Signals are great for asychronous communication between
processes, but they aren’t completely reliable. We can do one final refactoring
RubyProcess class to use an
IO pipe instead.
run_and_send method gets refactored like this:
def run_and_send(signal) block_rd, @block_wr = IO.pipe pid = fork do @test_block.call(self) end block_rd.gets Process.kill(signal, pid) _, @status = Process.waitpid2(pid) ensure block_rd.closed? or block_rd.close @block_wr.closed? or @block_rd.close end
The signal trap has been replaced with a call to
IO.pipe, which returns a pair
of read and write IO objects. The write end is stored as
@block_wr so the
wait_for_signal helper can write to it. The
fork portion is unchanged, but
next we use
block_rd.gets to block the parent process until the child is
ready. There is also an
ensure section to this method to close the pipe and
keep things tidy.
The changes to
wait_for_signal are similarly simple, instead of sending
to the parent, it just writes
'READY' into the
@block_wr end of the pipe and
then flushes it so the parent process will stop blocking.
def wait_for_signal(seconds) @block_wr.puts 'READY' @block_wr.flush sleep(seconds) end
Now our parent and child process can work together without worry if one gets
delayed. Here’s the full code all put together. The
RubyBlock helper can be
moved into a support file to keep the test code focused.
class RubyBlock def initialize(&test_block) @test_block = test_block end def wait_for_signal(seconds) @block_wr.puts 'READY' @block_wr.flush sleep(seconds) end def run_and_send(signal) block_rd, @block_wr = IO.pipe pid = fork do @test_block.call(self) end block_rd.gets Process.kill(signal, pid) _, @status = Process.waitpid2(pid) ensure block_rd.closed? or block_rd.close @block_wr.closed? or @block_wr.close end def successful? @status && @status.success? end end describe GracefulShutdown do it "exits without error" do test = RubyBlock.new do |helper| GracefulShutdown.new.handle_signals do helper.wait_for_signal 1.0 raise 'No Interrupt received' end end test.run_and_send('INT') expect(test).to be_successful end end
If you checkout out the code for
GracefulShutdown that was posted to
Github with the last post, you may have noticed an alternate way to write these
tests that uses
popen instead of
Here’s the interesting parts:
class RubyProcess WAIT_HELPER = <<-EOS def wait_for_signal(seconds) # Signal parent we are ready. Process.kill('USR1', Process.ppid) # Wait for interrupt. sleep(seconds) end EOS def initialize(code, *args) cmd = ['ruby'] + args @io = IO.popen(cmd, 'w+', err: [:child, :out]) @io.write(WAIT_HELPER) @io.write(code) end def run_and_send(signal) usr1_handler = trap(:USR1) do Process.kill(signal, @io.pid) end # The program starts when the write end of the pipe is closed. @io.close_write _, @status = Process.waitpid2(io.pid) ensure trap(:USR1, usr1_handler) end def successful? @status.success? end def output @io.read end end
This starts a fresh Ruby process which has the test code fed into it on
the test code takes the form of a string. Ruby will delay execution of the test
code until it reaches the end of the file, so we can control execution until the
@io is closed for writing.
The helper is another block of text that defines
wait_for_signal at the top
level, it gets injected into the input stream in the constructor, ahead of the
test code. Because it doesn’t share anything with the parent process, we can’t
IO.pipe trick as easily, so it still uses the signal system we covered
In some ways this code is better. It’s a clean slate, so there’s no chance of
some test state leaking into the example, it’s also simpler, just a single input
into the process. But it forces tests to be written as strings, which isn’t
ideal, and the use of signals to synchronize things is also prone to failure.
fork method is superior, even with the added complexity.