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.

When 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 detail away.

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

The fork method is replaced with RubyBlock.new, it accepts the test block and now takes a helper argument.

Instead of the sleep call inside the block we use helper.wait_for_signal. 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 provide the 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 USR1. Our 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

Note that 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.

The 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 of our RubyProcess class to use an IO pipe instead.

The 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 USR1 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

Bonus Content

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 fork.

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 STDIN, 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 use the IO.pipe trick as easily, so it still uses the signal system we covered earlier.

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. Overall the fork method is superior, even with the added complexity.