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.)
Ruby 1.8.7 or 1.9. No other gem dependencies.
gem install smoke_signals
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 def
s 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
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.
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.
rake test
- Practical Common Lisp
- Exception Handling – Wikipedia
- Common Lisp: A Tutorial on Conditions and Restarts
This was inspired in part by dynamic_vars, an implementation of thread-local dynamic bindings in Ruby!