Converting a Ruby block interface to start/stop methods with a Fiber
One of the small joys of computer programming is reading some code and thinking aha, that’s nice.
I recently encountered this feeling of aha while reading the docs for the playwright-ruby-client’s set up code for Capybara which described a mismatch between the Playwright and RSpec interface:
The challenge is that
Playwright.createandplaywright.chromium.launchare block-scoped APIs — the browser shuts down when the block exits. RSpec hasbefore(:suite)andafter(:suite)but noaround(:suite). A Fiber bridges the gap:start!resumes the fiber until it yields the browser back, andstop!resumes it again so both blocks exit cleanly.
I’ll get to that code in a moment, but to set up the explanation: I have written a lot of code that converts start- and stop-like methods into a block interface. That looks something like this:
def with_resource
resource = ResourceLibrary.start!
yield resource
ensure
ResourceLibrary.stop!
end
# and then used it like
with_resource do |resource|
my_code_does_stuff_with resource
end
…but I can’t remember a time when I’ve gone the other way: when a library only provides a block-style interface and I needed to convert it to distinct start and stop methods.
If I hadn’t seen this playwright-ruby-client documentation, I’d have probably imagined doing it with a Thread and Queues to smuggle the resource out of a block. Something like this:
class ThreadedResourceWrapper
attr_reader :resource
def initialize
@control_queue = Queue.new
@resource_queue = Queue.new
end
def start!
@thread = Thread.new do
ResourceLibrary.open do |the_resource|
@resource_queue.push(the_resource)
@control_queue.pop # Pause the thread
end
end
@resource = @resource_queue.pop # Smuggle out the resource
end
def stop!
@control_queue.push(:stop) # Resume the thread to end the block
@thread.join
@resource = nil
end
end
Instead, I’m impressed with using a simple Fiber (blocking, no scheduler) to effectively do the same thing, but without the (slight) overhead of a separate thread:
class FiberResourceWrapper
attr_reader :resource
def start
@fiber = Fiber.new do
ResourceLibrary.open do |the_resource|
Fiber.yield the_resource
end
end
@resource = @fiber.resume # Resume #1: Run the fiber to smuggle out the resource
end
def stop
@fiber.resume # Resume #2: resume the fiber to end the block
@resource = nil
end
end
Of light commentary: The usage of two Queue objects in the ThreadedResourceWrapper is slightly complicated for managing the execution flow… and I also find the Fiber resume behavior to be slightly confusing (or at least require extra attention) because a Fiber has to be explicitly resume’d after initialization to run at all, unlike a Thread which starts executing immediately (well, implicitly but concurrently)
And lastly, for fun, I imagined how I might use this new Fiber knowledge to patch an around_suite callback into RSpec so I didn’t have to wrap every individual resource:
module RSpecAroundSuite
def around_suite(&block)
fiber = nil
RSpec.configure do |config|
config.before(:suite) do
fiber = Fiber.new do
run_suite = -> { Fiber.yield }
run_suite.define_singleton_method(:run) { call } # allow `suite.run` to be analogous to `example.run`
block.call(run_suite)
end
fiber.resume
end
config.after(:suite) do
fiber.resume
end
end
end
end
RSpec.extend(RSpecAroundSuite)
RSpec.around_suite do |suite|
ResourceLibrary.open do |the_resource|
$resource = the_resource # assign it to somewhere accessible
suite.run
end
end