Skip to content

jtran/smoke_signals

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

28 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SmokeSignals

SmokeSignals is an implementation of Lisp-style conditions and restarts as a Ruby library. Conditions and restarts make it easy to separate policy of error recovery from implementation of error recovery. If you’re unfamiliar with the concept, check out the chapter from Practical Common Lisp.

SmokeSignals is different because:

  • conditions are not errors (although they can be)
  • signaling a condition does not unravel the stack (although it can)
  • conditions can be handled multiple times at different levels of the call stack (or not at all)
  • restarts can be established at any level in the call stack, not just where the condition is signaled
  • implementation of signaling, handling, and restarting is completely hidden. (The only possible exception to this is a design decision which allows ensure blocks to work, making this usable with real side-effectful programs.)

Requirements

Ruby 1.8.7 or 1.9. No other gem dependencies.

Installation

gem install smoke_signals

Usage

require 'smoke_signals'

In a low-level function, signal a condition.

def parse_entry(line)
  SmokeSignals::Condition.new.signal! unless satisfies_preconditions?(line)
  # Do actual parsing...
end

In a mid-level function, implement ways to recover from the condition. This is the mechanism of recovery that is tied to the implementation of the mid-level function.

def parse_log_file(filename)
  File.open(filename) do |io|
    io.lines.map {|line|
      SmokeSignals.with_restarts(:ignore_entry => lambda { nil },
                                 :use_value => lambda {|v| v } ) do
        parse_entry(line)
      end
    }.compact
  end
end

In a high-level function, handle the condition. This sets the policy of recovery without being exposed to the underlying implementation of the mid-level function.

def analyze_log_file(filename)
  entries = SmokeSignals.handle(lambda {|c| c.restart(:ignore_entry) }) do
    parse_log_file(filename)
  end
  # Do something interesting with entries...
end

Signaling a condition does not have to be fatal.

# If no handlers are set, this will do nothing.
SmokeSignals::Condition.new.signal

The bang flavor will raise unless it is rescued or restarted.

# This is a fatal signal.
SmokeSignals::Condition.new.signal!

Since you can handle signals multiple times by different handlers at multiple levels in the call stack, simply handling a fatal signal and returning normally is not enough. You must either rescue it or restart it.

Rescuing a condition is just like rescuing an exception with a rescue block. It returns the value from the entire handle block.

x = SmokeSignals.handle(lambda {|c| c.rescue(42) }) do
  SmokeSignals::Condition.new.signal!
end
# x is 42

If you were using exceptions, you might’ve done this…

x = begin
  raise 'foo'
rescue
  42
end
# x is 42

You can limit which kinds of conditions you handle by passing a hash to handle.

class MyCondition1 < SmokeSignals::Condition; end
class MyCondition2 < SmokeSignals::Condition; end

SmokeSignals.handle(MyCondition1 => lambda {|c| puts 'MyCondition1 signaled' },
                    MyCondition2 => lambda {|c| puts 'MyCondition2 signaled' }) do
  MyCondition1.new.signal if some_condition?
  MyCondition2.new.signal if another_condition?
end

By default MyCondition1 === condition that was signaled is used to determine whether a handler applies or not, kind of like a case. You can change the default behavior by overriding Condition#handle_by(handler). Either return a Proc to handle it or nil.

You can handle a signal multiple times by returning normally from your handler. Doing this you can, for example, observe the fact that a condition has been signaled without otherwise having any effect on control flow.

SmokeSignals.handle(lambda {|c| puts 'this is run 2nd' }) do
  SmokeSignals.handle(lambda {|c| puts 'this is run 1st' }) do
    begin
      SmokeSignals::Condition.new.signal
      puts 'this is run 3rd because no handlers called rescue or restart'
    end
  end
end

In the case of an ensure block, it is executed after any handlers. It must be executed afterwards because the whole point of signal handlers is that they are run before the stack is unwound. At that point, a signal handler may choose to rescue, restart, or return normally to allow other handlers to execute. In contrast, by the time an exception is caught, rescuing is not an option; it’s a necessity.

SmokeSignals.handle(lambda {|c| puts 'this is run 2nd' }) do
  SmokeSignals.handle(lambda {|c| puts 'this is run 1st' }) do
    begin
      SmokeSignals::Condition.new.signal
      puts 'this is run 3rd because no handlers called rescue or restart'
    ensure
      puts 'this is run last'
    end
  end
end

ensure blocks are executed after handlers, but they are executed before restarts. To see why this design decision was made, consider this example.

def parse_file(filename)
  SmokeSignals.with_restarts(:use_new_filename => lambda {|f| parse_file(f) }) do
    file = nil
    begin
      file = File.open(filename)
      if file.lines.first == '#!/keyword'
        # Parse file
      else
        SmokeSignals::Condition.new.signal!
      end
    ensure
      file.close if file
    end
  end
end

If this function were called and restarted many times, and the stack were not unwound before each restart, then you would have many files open at once. This is why SmokeSignals unwinds the stack before executing restarts, meaning that ensure blocks are run before restarts.

If you like, you can use defs to define restarts. This allows you to use default arguments, etc.

def parse_file(filename)
  SmokeSignals.with_restarts(lambda {
                               def use_new_filename(f)
                                 parse_file(f)
                               end

                               def log_and_abort(logger=Rails.logger)
                                 logger.error("File could not be parsed: #{filename}")
                                 # When called, this will return nil from parse_file.
                                 nil
                               end
                             }) do
    # Do stuff...
  end
end

You can pass arguments to restarts the same way you would when calling Object#send.

SmokeSignals.handle(lambda {|c| c.restart(:log_and_abort, Logger.new(STDOUT)) }) do
  parse_file('foo.txt')
end

Is SmokeSignals a replacement for Ruby exceptions?

Short answer: no, they’re an extension.

Long answer… As shown above, you can achieve all the functionality of exceptions with SmokeSignals.

However, you’re probably using some code that doesn’t know about SmokeSignals and raises exceptions instead. Setting a condition handler will not handle these raised exceptions. They couldn’t because in such a case, restarting would be impossible and rescuing would be a necessity. By the time an exception is handled, the stack has already been unwound.

Thread Safety

This library is thread-safe because each thread has its own handlers and restarts. You cannot signal in one thread and handle it in another thread.

Running Tests

rake test

See Also

Special Thanks

This was inspired in part by dynamic_vars, an implementation of thread-local dynamic bindings in Ruby!

Releases

No releases published

Packages

No packages published

Languages