This is part two in a series of posts about signal handling in Ruby.

In the last post we looked at the two mechanisms Ruby provides for handling signals. Now we will combine them into a utility for gracefully handling interrupts and providing a way to safely shutdown our process.

Here is our proposed usage, we start with a worker that can handle a Shutdown exception, and returns an unfinished jobs to the queue before shutting down.

class Worker < Struct.new(:jobs_queue)
  def work
    job = jobs_queue.pop
    # work...
  rescue Shutdown => shutdown
    jobs_queue.push job if job.incomplete?
    shutdown.continue
  end
end

Then we will run our worker with graceful handling of shutdowns:

GracefulShutdown.new.handle_signals do
  jobs = Queue.new
  Worker.new(jobs).start
end

We will work our way to the full implementation starting with setting up signal traps and raising the Shutdown exception. We also want it to restore existing handlers if no signal is received.

Shutdown = Class.new(RuntimeError)

class GracefulShutdown
  DEFAULT_SIGNALS = ['INT', 'TERM']

  def handle_signals(*signals)
    signals = DEFAULT_SIGNALS if signals.empty?
    handlers = setup(signals)
    yield if block_given?
    teardown(handlers)
  rescue Shutdown
    exit
  end

  private

  def setup(signals)
    signals.each_with_object({}) do |signal, handlers|
      handlers[signal] = trap(signal) do
        raise Shutdown
      end
    end
  end

  def teardown(handlers)
    handlers.each do |signal, handler|
      trap(signal, handler)
    end
  end
end

Now the worker can intercept the Shutdown exception, but it’s must re-raise it, which is a bit unobvious. There’s no good mechanism for ensuring the exception is re-raised , but we can add some clarity with the continue method that facilitates this. We’ll also throw in an ignore, just for extra clarity.

class Shutdown < RuntimeError
  def continue
    raise self
  end

  def ignore
    # No-op, provided for clarity.
  end
end

Now we can finish up with by briefly adding our higher helper method:

def WithGracefulShutdown(*signals, &block)
  GracefulShutdown.new.handle_signals(*signals, &block)
end

There are plenty of improvements that we could make: The Shutdown exception can also provide an abort method, which tears down the handlers and communicates where the shutdown was aborted from. The GracefulShutdown handler could also provide callbacks for logging and a way to force shutdown if the same signal is received twice.

In the next post we’ll look at some of these improvements and how to test signal handling using RSpec. I’ve also packaged up the code from this post into a gem, appropriately named graceful_shutdown. You can sneak a peak at how it’s tested if you look at the source on GitHub.