Ruby/Rails

Code Reloading for Rack Apps

By
published

I work almost exclusively on Ruby on Rails applications. The product we’re building at Super Good isn’t a Rails app, though; it’s a Ruby application built directly on top of Rack. While this means the application is very stripped down and fast, which is great for the end product, it also means we don’t get a lot of things for free from our framework.

Code reloading is one of those things. Modern Rails is integrated with Zeitwerk, a general purpose code loader for Ruby that supports reloading. Zeitwerk works great as a loader on its own, but it doesn’t do everything you need for reloading a web applications.

Zeitwerk will reload code when you tell it to, but you need to decide when to tell it to. Reloading also isn’t thread-safe. You need to ensure that you aren’t trying to reload code across different threads, which is very important if we’re using a web server like Puma, which makes use of both threads and processes.

Let’s dive into how we can use Zeitwerk, the listen gem, and concurrent-ruby to get the same kind of reloading that Rails provides, but in a simple Rack application.

This is not a complete Rack app tutorial.

This tutorial focuses on the trickier bits of getting code reloading working in Rack apps. Very little attention is paid to the specifics of how you might structure your codebase, and there’s nothing about Rack itself in here.

A Basic Rack App

First, we’re going to need a few gems. You’ll need all the following in your Gemfile:

gem "concurrent-ruby", require: false
gem "listen"
gem "puma"
gem "rack"
gem "zeitwerk"

You can swap out Puma for your preferred web server, but for this tutorial we’ll use Puma since it requires that we handle both threading and forking properly.

You can structure your app however you like, but I like to have a file called config/environment.rb that loads up the application environment. This way we have a single file that can be loaded when booting up consoles, tests, tasks, and the web server that ensures a consistent environment. For this app, we’ll assume it looks something like this:

ENV["RACK_ENV"] ||= "development"

require 'rubygems'
require 'bundler'

Bundler.require(:default, ENV["RACK_ENV"])

$LOAD_PATH << File.join(File.dirname(__FILE__), "../lib")

require "my_app"

We haven’t defined our app, so let’s create lib/my_app.rb:

MyApp = Rack::Builder.new do
    run -> (env) {
      [200, {Content_Type: "text/plain"}, ["Hello, World!"]]
    }
end

We’re using Rack::Builder here because any non-trivial application will need to use a bunch of different middleware. This is where we can configure those.

Finally, every Rack app needs a config.ru file. Ours should look like this:

require_relative './config/environment'

run MyApp

With all this in place, we have a working app that serves the classic “Hello, World!” message, but there’s no code reloading in place.

Once Upon a Time

Before we get into implementing code reloading, let’s build a little utility class. Put this in lib/once.rb and require it in config/environment.rb.

class Once
  def initialize(&block)
    @block = block
    @mutex = Mutex.new
  end

  def call
    @mutex&.synchronize do
      return unless @mutex

      @block.call

      @mutex = nil
    end
  end
end

This class let’s you create an object that encapsulates a bit of code and only ever lets it run once, even if it’s called across multiple threads.

o = Once.new do
  puts "I should only happen once"
end

100.times.map { Thread.new { o.call } }.each(&:join)
# This prints the message only once.

We’ll explain why we need this later.

Lock and Load

Let’s assume that we’re going to put our reloadable code in a src folder at the root of our application. You can swap it for app or place_where_the_code_goes or whatever you want.

For starters, let’s move the code responsible for actually serving requests into that folder. Create a src/foo.rb that looks like this:

module Foo
  def self.call(env)
    [200, {Content_Type: "text/plain"}, ["Welcome to Foo!"]]
  end
end

Now, update lib/my_app.rb to call this instead:

MyApp = Rack::Builder.new do
  run Foo
end

In the future you’ll probably replace Foo with your app’s router, but for now we just need a callable object that conforms to the Rack API that we can change to verify reloading is working.

Currently, we’re not loading Foo, so our app is broken. Time to draw the rest of the fucking owl. Let’s build out the most complex piece of this setup, the code loader. Put this in lib/code_loader.rb and require it from the environment file:

require "concurrent/atomic/read_write_lock"

class CodeLoader
  def initialize(path:, enable_reloading:)
    @loader = Zeitwerk::Loader.new

    @loader.push_dir path
    @loader.enable_reloading if enable_reloading
    @loader.setup

    @reload_lock = Concurrent::ReadWriteLock.new

    if @loader.reloading_enabled?
      @start_loading = Once.new do
        Listen.to(*@loader.dirs) do
          @dirty = true
        end.start
      end
    else
      @loader.eager_load
    end
  end

  attr_reader :reload_lock

  def reload!
    @start_loading&.call

    return unless @dirty

    reload_lock.with_write_lock do
      @dirty = false
      @loader.reload
    end
  end

  def reloading_enabled?
    @loader.reloading_enabled?
  end
end

Let’s walk through what this does. This class takes two arguments; the location of the code be loaded and whether or not we’ll be reloading it. In its constructor, it instantiates a Zeitwerk loader for that path, optionally setting up reloading. It also creates a read-write lock, before doing one of two things:

  1. If code reloading is enabled, it creates an instance of our Once class to encapsulate the logic for watching for changes to the code on the filesystem.
  2. If code reloading is disabled, it eager loads the code immediately.

The loader has three public methods:

  • CodeLoader#reload_lock exposes the lock that we’ll use to ensure that we don’t serve any requests while a thread is reloading the code and don’t attempt to reload code from multiple threads at the same time.
  • CodeLoader#reloading_enabled? is a predicate method for checking if code reloading is enabled. You’ll see why we need this later.
  • CodeLoader#reload! does the real work. When called, it invokes our Once instance (if defined), ensuring that we’re watching for code changes if necessary. It then bails out if the @dirty flag isn’t set. If code reloading is disabled, this means we always bail out. If code reloading is enabled, then we only continue if something on the filesystem has changed.

    If something on the filesystem has changed, then it acquires the write lock, reloads the code, and resets the @dirty flag.

We can then head over to our config/environment.rb and initialize this loader, saving it to a constant:

MY_APP_LOADER = CodeLoader.new(
  path: File.join(File.dirname(__FILE__), "../src/"),
  enable_reloading: ENV["RACK_ENV"] == "development"
)

This doesn’t get us all the way, though. Nothing is yet calling reload! on our loader. This is important because we don’t want to spin up our Listen thread before Puma forks. When a process forks, threads don’t come along for ride. We’ll call reload from a Rack middleware. Let’s create that middleware in lib/code_loader_middleware.rb:

class CodeLoaderMiddleware
  def initialize(app, loader)
    @app = app
    @loader = loader
  end

  def call(env)
    @loader.reload!

    @loader.reload_lock.with_read_lock {
      @app.call(env)
    }
  end
end

This middleware is much simpler than the loader. It takes an instance of our loader as an argument. On each request it calls reload (which will reload our app’s code if something has changed on disk since the last time it was called), and then acquires the loader’s read lock and serves the request.

As mentioned before, we need that read lock because we may be serving multiple concurrent requests in different threads and one of those requests might be reloading the app’s code. Zeitwerk’s code reloading isn’t thread-safe, so this ensures all requests are served outside of the reloading process.

Require this file in your config/environment.rb, which should now look like this:

ENV["RACK_ENV"] ||= "development"

require 'rubygems'
require 'bundler'

Bundler.require(:default, ENV["RACK_ENV"])

$LOAD_PATH << File.join(File.dirname(__FILE__), "../lib")

require "once"
require "code_loader"
require "code_loader_middleware"

MY_APP_LOADER = CodeLoader.new(
  path: File.join(File.dirname(__FILE__), "../src/"),
  enable_reloading: ENV["RACK_ENV"] == "development"
)

require "my_app"

Next, head over to lib/my_app.rb and use the new middleware:

MyApp = Rack::Builder.new do
  use CodeLoaderMiddleware, MY_APP_LOADER

  run Foo
end

At this point, if everything should work, except that code still isn’t reloading. I’ve intentionally run into one of the biggest gotchas here. MyApp isn’t reloaded and that’s fine, but when it’s instantiated, it captures a reference to the current version of Foo. Code reloading is working, but Rack is still serving this stale version of Foo. We need to ensure that each request references the current value of the Foo constant. This is how we can do that:

MyApp = Rack::Builder.new do
  use CodeLoaderMiddleware, MY_APP_LOADER

  if MY_APP_LOADER.reloading_enabled?
    run -> (env) {
      Foo.call(env)
    }
  else
    run Foo
  end
end

We finally found our use for CodeLoader#reloading_enabled?. By wrapping our app in a Proc when reloading is enabled, we ensure that each request resolves the current value of the Foo constant and serves the latest code. In test and production environments where we aren’t using code reloading, we just run the app directly because there’s no risk of a stale version.

Summary

Let’s review the important pieces:

  • CodeLoader is responsible for setting up Zeitwerk’s code loading, ensuring thread-safe synchronization around the reloading process, and monitoring the filesystem for changes. It eager loads code outside of development so that our code is loaded before Puma forks. This way we take advantage of copy-on-write performance in production.

  • CodeLoaderMiddleware takes an instance of our CodeLoader and tells it to check if code needs to be reloaded before each request, ensuring that development requests always get the latest code. It hooks into a lock on the loader to ensure that we allow ongoing code reloads to finish before serving requests.

With these in place and correctly configured, you can go about defining your application inside the src folder without having to restart your server after every change. If you dig into Rails’ source code, you’ll find that it uses roughly the same approach we’ve used.

A Worse Once Implementation

Credit to John Hawthorn for this one. Ruby’s Regex has an o flag that causes interpolations to be evaluated only once. This allows us to do this:

class Once
  def initialize(&block)
    @block = block
  end

  def call
    /#{@block.call}/o
  end
end

Just because this works does not mean you should do it.