Skip to content

Instantly share code, notes, and snippets.

@mitchellh
Created September 15, 2012 02:32
Show Gist options
  • Save mitchellh/3726130 to your computer and use it in GitHub Desktop.
Save mitchellh/3726130 to your computer and use it in GitHub Desktop.
require "fiber"
module Middleware
# This is a basic runner for middleware stacks based on Ruby fibers.
# By using fibers rather than recursion, the stack is not deepened, and
# therefore cannot stack overflow. This allows middleware sequences of
# thousands to be used without crashing Ruby.
#
# This runner is much more complicated than the normal runner because it
# has to do a lot of work to _simulate_ a call stack. For example, one of
# the benefits of middleware sequences is how nicely exceptions can be used
# as an error handling mechanism since exceptions naturally propagate
# outwards. This doesn't work at all in fibers since there is no call
# stack. This runner does some hacky things to simulate this. Another
# example is return values. Middleware can technically use the return
# values of calling the next middleware. With fibers, this doesn't just
# naturally work and we have to work around it.
#
# That being said, this runner passes all the unit tests that the
# normal runner does and is therefore fully API compatible.
class FiberRunner
# Build a new middleware runner with the given middleware
# stack.
#
# Note: This class usually doesn't need to be used directly.
# Instead, take a look at using the {Builder} class, which is
# a much friendlier way to build up a middleware stack.
#
# @param [Array] stack An array of the middleware to run.
def initialize(stack)
# We take the stack and build a proper array of callables.
@callables = build_callables(stack)
end
# Run the middleware stack with the given state bag.
#
# @param [Object] env The state to pass into as the initial
# environment data. This is usual a hash of some sort.
def call(env)
# Turn the callables into fibers.
fibers = build_fibers(@callables)
# This will keep track of the fibers that we successfully executed
# during the first pass, up to the `yield` (if there is one)
called = []
# This will hold some state so we can simulate call stacks.
state = nil
# Start each fiber in order.
fibers.each do |fib|
# Start the fiber with our environment. This will run it up to the
# point where the "@app.call" is called, which does a `Fiber.yield`.
# Or, if that is never called, it will just end.
result = fib.resume(env)
# If an exception is raised, we need to track that state and then
# halt the fiber chain, and immediately begin cleaning out the called
# fibers to simulate exception propagation.
if result && result[0] == :exception
state = result
break
end
# If the fiber ended, then we reached the end and we don't run
# the next middleware.
break if !fib.alive?
# Otherwise, mark that we called it so we'll properly resume later
called.unshift(fib)
end
# Go through every called fiber and finish it out
called.each do |fib|
state = fib.resume(state)
end
# If we STILL are tracking an exception, then it was raised OUT of
# the middleware stack, and we raise it here.
raise state[1] if state && state[0] == :exception
end
protected
# When middlewares call "@app.call" to call the next middleware, this
# is the actual method invoked. This yields the fiber and does other
# things to simulate call-stack behavior for fibers.
def next_middleware(env)
# TODO: Verify only called once
# Yield control. When the fiber is resumed, the runner will tell us
# some state so we can simulate a call stack.
state = Fiber.yield
if state
# If there is an exception then raise it
raise state[1] if state[0] == :exception
# If there is an upstream result then return that
return state[1] if state[0] == :result
end
end
# This takes a stack of middlewares and initializes them in a way
# that each middleware properly calls the next middleware.
def build_callables(stack)
next_middleware = self.method(:next_middleware)
# Go through the stack of middlewares and convert them into callable
# objects.
stack.map do |middleware|
# Unpack the actual item
klass, args, block = middleware
# Default the arguments to an empty array. Otherwise in Ruby 1.8
# a `nil` args will actually pass `nil` into the class. Not what
# we want!
args ||= []
if klass.is_a?(Class)
# If the klass actually is a class, then instantiate it with
# the app and any other arguments given.
klass.new(next_middleware, *args, &block)
elsif klass.respond_to?(:call)
# Make it a lambda which calls the item then forwards up
# the chain.
lambda do |env|
klass.call(env)
next_middleware.call(env)
end
else
raise "Invalid middleware, doesn't respond to `call`: #{action.inspect}"
end
end
end
# This takes an array of callables and turns them into fibers that
# are ready for execution.
#
# @param [Array] callables An array of middleware-compliant callables.
# @return [Array] An array of fibers which when started will call the
# respective callable.
def build_fibers(callables)
callables.map do |callable|
Fiber.new do |env|
begin
[:result, callable.call(env)]
rescue Exception => e
# Any exceptions raised in the called are raised here. Since
# we're using fibers, exceptions don't work as they do when
# recursing through functions, but we want to simulate that,
# so we do some fun things here.
[:exception, e]
end
end
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment