ractor-rails-shim

A monkey-patch shim that reroutes Rails' class-level instance variable accessors through ActiveSupport::IsolatedExecutionState, which is Ractor-safe. Lets a Rails app run in Ractor mode without forking Rails itself.

Status: proof-of-concept / stopgap. The goal is for Rails to do this upstream, at which point this gem becomes a no-op and can be removed.

Current status (v0.2.5): on a full Rails 8.1 app (Devise 5, Propshaft, Kaminari, PG) under official Ruby 4.0.6, worker Ractors serve every routable action — GET /up, full ERB view rendering, Devise sign-in/sign-out (CSRF issuance and validation), and authenticated Devise writes (POST /posts → 302, row persisted). A worker Ractor dispatches GET /upHTTP 200 via RactorRailsShim.make_app_shareable!. The shim builds a shareable fallback table for framework class config (class_attribute / mattr_accessor values) and patches the raw class-ivar accessors Rails reads per-request (ExecutionWrapper.active_key, Notifications.notifier, Inflections, PathRegistry, I18n, AbstractController lazy ivars, Rack::Request/Utils, ExecutionContext, etc.). 61 unit specs across 7 spec files pass (no Rails dependency); an integration spec dispatches GET /up → 200 in a worker Ractor.

No patched Ruby or kino required. Official Ruby 4.0.6 ships the frozen-iseq call-cache fix (#22075) and the cross-ractor env-string fix, so the SIGBUS crashes that previously required the DDKatch patched Ruby/kino forks are resolved in core. Both SIGBUS classes were the only reason a patched build was ever needed; on 4.0.6 the shim runs kino -m ractor on the official kino gem (0.1.3) under sustained read and write load (verified: 0 transport failures / 0 server errors across /up, GET /posts, POST /posts in the benchmark matrix).

Requirements

  • Ruby >= 4.0.6 — the shim relies on Ruby 4.0's Ractor semantics and Ractor.make_shareable, plus the frozen-iseq call-cache fix (#22075) and the cross-ractor env-string fix that both shipped in 4.0.6. It will not work (and refuses to install) on earlier Ruby versions.
  • Rails ~> 8.1 — tested against Rails 8.1.x class layouts. Other versions are not yet supported (see Version compatibility below).

Ractor-mode server: as of this writing, the only Ruby web server that can run a Rails app in Ractor mode is kino (kino -m ractor). Other servers (Puma, Falcon, …) run Rails in processes or threads, not Ractors. This shim was developed and tested against a real Rails 8.1 app served by kino; that is the configuration it is verified against.

kino: the Ractor-mode server is kino (kino -m ractor). On official Ruby 4.0.6 the upstream kino gem (0.1.3) works as-is — no fork or patch is required. (Historically a DDKatch/kino fork carried a per-ractor env-string cache fix; that fix landed in official 4.0.6, so the fork is obsolete.)

Threaded servers (Puma/Falcon/Thin/Webrick): set RactorRailsShim.thread_mode = true (or ENV["SERVER"] to one of puma|falcon|thin|webrick|thread*) and the shim installs a minimal patch set — only the class_attribute isolation fix + nil-safe callback replay. The per-Ractor IES-routing patches are skipped because IES is empty on a thread server's request threads and would break the app; Rails' own class globals are thread-safe and used as-is. Use this when you want the callback-correctness fixes without running in Ractor mode.

Benchmarks: throughput/latency/memory of this shim + the test app under kino :ractor vs Puma vs Falcon are documented in ractor-rails-shim-test-app/BENCHMARKS.md.

Repository layout

lib/ractor_rails_shim.rb                  # entry point — autoload-install if Rails is loaded
lib/ractor_rails_shim/
  version.rb                              # VERSION constant (currently 0.3.0)
  loader.rb                               # pure require hub — one file, one job (POODR §1)
  patches.rb                              # backward-compat redirect → loader.rb

  foundation/                             # Layer 2: no internal deps
    funnel.rb                             #   debug-aware exception-swallowing role
    registry.rb                           #   canonical home for the nine shared registries
    storage.rb                            #   pluggable key-value store contract (IES / ThreadLocal)
    storage_strategy.rb                   #   composed Ractor/Thread strategy for class_attribute
    ies_accessor.rb                       #   3-tier IES lookup pattern generator
    const_reassign.rb                     #   $VERBOSE-suppressed const_set utility
    version_check.rb                      #   Gem::Version-based Ruby/Rails detection
    version_policy.rb                     #   mismatch policy (:warn/:strict/:off) + patch registry
    run_mode.rb                           #   thread vs Ractor mode decision
    role_defaults.rb                      #   DRY mixin for shared default-proc patterns

  roles/                                  # Layer 3: depend on foundation
    lifecycle.rb                          #   prepare_for_ractors! orchestration
    installer.rb                          #   install orchestrator + framework-patch dispatcher
    install_strategy.rb                   #   per-mode install bodies (Ractor / Thread)
    pre_spawn_steps.rb                    #   shared orchestration steps
    ar_model_walker.rb                    #   ActiveRecord model enumeration
    constant_shareabilizer.rb             #   constant shareability
    shareability_traversal.rb             #   app-graph traversal
    callback_capture.rb                   #   callback-declaration capture
    fallback_builder.rb                   #   shareable-fallback builder
    worker_app.rb                         #   shareable Rack wrapper (per-worker init)
    worker_app_factory.rb                 #   shareable-Rack-app factory
    app_shareabilizer.rb                  #   make_app_shareable! orchestrator
    freezers.rb                           #   freeze/warm sub-domain (6 freezer roles)
    logger_io_neutralizer.rb              #   logger IO detachment
    fallback_ies.rb                       #   backward-compat alias → Storage::ThreadLocal
    check.rb                              #   ractor-rails-check audit (Check.scan / report)

  patches/                                # Layer 4: monkey-patches (depend on roles + foundation)
    core.rb                               #   module skeleton, registries, facade delegations
    callables.rb                          #   NoOpProc, Callable, CallableConst, RequestCallable, …
    make_shareable.rb                     #   make_app_shareable!, proc/lock replacement
    rails_module.rb                       #   Rails.application / Rails.cache / Rails.logger / Rails.env …
    mattr_accessor.rb                     #   Module#mattr_accessor / cattr_accessor macro rewrite
    class_attribute.rb                    #   ActiveSupport class_attribute macro rewrite
    zeitwerk_registry.rb                  #   Zeitwerk::Registry class ivars
    route_helpers.rb                      #   ActionDispatch::Routing::RouteSet#generate_url_helpers
    url_helpers.rb                        #   URL helper singleton + module fixes
    execution_wrapper.rb                  #   ActiveSupport::ExecutionWrapper + callback replay
    rack.rb                               #   Rack::Request / Rack::Utils
    action_view.rb                        #   PathRegistry, LookupContext, Template handlers
    action_controller.rb                  #   AbstractController, ActionController name/encoding
    action_dispatch.rb                    #   ActionDispatch routing, http_url, journey
    polymorphic_routes.rb                 #   polymorphic_path(s) URL helpers
    active_support.rb                     #   Inflector, error_reporter, ExecutionContext, I18n, JSON
    i18n.rb                               #   I18n locale/fallback patches
    warden.rb                             #   Warden hooks / strategies / serializer
    devise.rb                             #   Devise url_helpers / authenticatable / failure_app
    active_model_attribute.rb             #   ActiveModel::Attribute dup_or_share for frozen graphs
    active_record_model_schema.rb         #   AR ModelSchema reload_schema_from_cache
    activerecord.rb                       #   AR connection handler, configurations, query caches
    kaminari.rb                           #   Kaminari config
    propshaft.rb                          #   Propshaft asset server
    orm_adapter.rb                        #   orm_adapter
    openssl.rb                            #   OpenSSL digest cache
    rubygems.rb                           #   Rubygems msgpack pre-check
    hash_compute_if_absent.rb             #   Hash#compute_if_absent for Concurrent::Map replacement

exe/ractor-rails-check                    # CLI audit tool
spec/                                     # 724 unit tests (no Rails dep) + integration spec
script/make_test_app.sh                   # build the minimal Rails 8.1 test app (CI uses this)
script/make_full_test_app.sh              # build the full-featured test app (Devise/PG/Kaminari)
.github/workflows/ci.yml                  # unit job + integration job (GET /up → 200 in a worker Ractor)

Architecture: layered, POODR-aligned design

The directory structure mirrors the dependency layers:

Layer 1: Entry        ractor_rails_shim.rb, loader.rb
Layer 2: Foundation   foundation/  (no internal deps)
Layer 3: Roles        roles/       (depend on foundation)
Layer 4: Patches      patches/     (depend on roles + foundation)

The gem follows Practical Object-Oriented Design in Ruby (POODR) principles:

  • SRP (§1): Every file defines exactly one concern. loader.rb is a pure require hub (no logic). lifecycle.rb owns prepare_for_ractors! orchestration. Role objects (Funnel, Registry, CallbackCapture, etc.) each own one responsibility.

  • Dependencies (§2): Role objects receive collaborators via configure seams (keyword arguments), defaulting to facade lookups. No role reaches past its own namespace by name — the composition root (Installer) is the one place allowed to resolve by name.

  • Interfaces (§3): The Storage contract ([], []=, key?, delete) is implemented by Storage::IES and Storage::ThreadLocal. The StorageStrategy contract (lookup, store, replay_callbacks?, replay_callbacks!) is implemented by StorageStrategy::Ractor and StorageStrategy::Thread.

  • Duck Typing (§4): ShareabilityTraversal uses a CONTAINER_WALKERS dispatch table (Hash → lambda) instead of is_a? chains. Lock detection uses respond_to?(:synchronize) instead of is_a?(Mutex).

  • Composition (§5): AppShareabilizer composes 15 collaborators via the configure seam. FallbackBuilder uses a ValueLookup chain of responsibility. Installer selects between InstallStrategy::Ractor and InstallStrategy::Thread.

  • Roles (§6): Modules as roles (Funnel, ARModelWalker, ConstantShareabilizer, CallbackCapture, etc.) — each extend RoleDefaults for DRY default-proc patterns.

Per-concern patch files reopen RactorRailsShim's singleton class to add their _install_* methods. loader.rb requires them in dependency order (core.rb first — it defines the module skeleton + constants that others reference). Each _install_* method is idempotent (guarded by its own @*_patched flag), so install, prepare_for_ractors!, and make_app_shareable! can all call the full set safely.

Why

Rails stores global state in class-level instance variables:

class Rails
  class << self
    attr_accessor :app_class, :cache, :logger
    def application; @application ||= ...; end
  end
end

From a non-main Ractor, these reads/writes raise Ractor::IsolationError:

can not get unshareable values from instance variables of classes/modules
from non-main Ractors

This is the primary blocker preventing Rails from running in Ractor mode (see the kino project's doc/rails-on-ractors.md for the full diagnosis). The same pattern blocks Hanami, Padrino, Sinatra, and most Ruby web frameworks.

How it works

The shim reroutes the accessor methods through ActiveSupport::IsolatedExecutionState, which is thread-local storage (Thread.current[:key]). Each Ractor has its own threads, so each Ractor gets its own slot automatically — verified on Ruby 4.0.6. Rails already uses this primitive for ActiveRecord::ConnectionHandling.connection_handler (for thread safety), so the pattern is proven in production.

Two storage paths, depending on the state's shape:

path primitive sharing mutability use case
per-Ractor IsolatedExecutionState / Ractor.store_if_absent none (each Ractor own copy) mutable connection pools, per-Ractor config, logger
shareable Ractor.make_shareable + constant one copy, by reference frozen (read-only) route tables, frozen config, templates

The shim uses the per-Ractor path by default. For shareable state, use Ractor.make_shareable directly — that's not this gem's job.

Load order. install may be called either before or after Rails is defined — the normal config/boot.rb path calls it before require "rails". The mattr_accessor macro patch applies regardless; the Rails-module accessor patch (Rails.application, Rails.env, ...) defers via a TracePoint(:class) load hook that fires when module Rails opens.

Version compatibility

The shim targets specific Rails class layouts (8.1.x) and Ruby Ractor semantics (4.0.x). At install it runs a real Gem::Version-based check (not a string compare) and applies a configurable policy on mismatch:

RactorRailsShim.version_policy = :strict  # raise on untested versions
RactorRailsShim.version_policy = :warn    # default: warn to $stderr, proceed
RactorRailsShim.version_policy = :off     # silent (experimentation)

Under :strict a mismatch raises RactorRailsShim::UnsupportedVersionError. Each patch registers its tested Rails versions in RactorRailsShim::PATCH_VERSIONS; query what applied to your runtime with:

RactorRailsShim.applicable_patches
# => { applied: [:mattr_accessor, :rails_module, ...], skipped: [{name: ...}] }

Adding Rails 7.x support: the version-gated registry is the extension point — write version-specific patch variants, tag them in PATCH_VERSIONS, and the dispatcher applies only matching patches. (7.x is not yet supported; only 8.1 is tested today.)

Install

Add to your Gemfile:

gem "ractor-rails-shim", group: :production

Then install early in boot, before Rails.application is first accessed:

# config/boot.rb
require "bundler/setup"
require "ractor_rails_shim"
RactorRailsShim.install

After Rails is fully booted (after Rails.application.initialize!) and before spawning worker Ractors, call prepare_for_ractors! to make the remaining unshareable constants (e.g. Rails::Railtie::ABSTRACT_RAILTIES, which loads after module Rails opens) shareable:

# config/environment.rb, or wherever you boot your app before spawning workers
Rails.application.initialize!
RactorRailsShim.prepare_for_ractors!

To share the whole app across worker Ractors (the :ractor mode path), call make_app_shareable! — it replaces every self-capturing Proc in the app graph with a callable object, every Mutex/Monitor with a no-op lock, and every Concurrent::Map with a frozen Hash, then calls Ractor.make_shareable:

Rails.application.initialize!
app = RactorRailsShim.make_app_shareable!(Rails.application)
# app is now frozen and Ractor.shareable? — pass it to worker Ractors:
worker = Ractor.new(app) { |a| a.call(env) }

Note: make_app_shareable! is production-only (the app becomes read-only). It also detaches the logger IO from the app graph: app.config.logger (the broadcast target holding the real $stdout/$stderr IO) is swapped for a frozen no-op BroadcastLogger so Ractor.make_shareable(app) doesn't freeze the process's real IOs, and the main ractor's Rails.logger (the per-Ractor module accessor, not in the app graph) is re-pointed at a fresh live BroadcastLogger$stderr so main keeps logging. Worker ractors build their own per-Ractor Rails.logger (a BroadcastLogger$stderr). To fix unshareable constants in your own app/gems, add them to the registry before prepare_for_ractors!:

RactorRailsShim.shareable_constants << "MyGem::MUTABLE_LIST"
RactorRailsShim.prepare_for_ractors!

Or from a Rails console / runner for a quick check:

require "ractor_rails_shim"
RactorRailsShim.install

Audit your app

bundle exec ractor-rails-check
# or scoped:
bundle exec ractor-rails-check --rails   # only Rails framework modules
bundle exec ractor-rails-check --app     # only app + gems

Reports class ivars holding unshareable values — the ones that would raise Ractor::IsolationError from a worker Ractor. Example:

ractor-rails-shim check: 23 class-ivar blocker(s) found

=== Rails framework (8) ===
  Rails@application = nil
  Rails@app_class = String
  Rails@cache = NilClass
  ...

=== app + gems (15) ===
  Devise@config = Devise::Config
  Sidekiq@options = Hash
  ...

hints:
  - require "ractor_rails_shim" and call RactorRailsShim.install before
    Rails.application is first accessed (early in config/boot.rb)
  - class-var (@@foo) blockers from mattr_accessor/cattr_accessor are rerouted
    by the shim automatically once installed
  - raw class-ivar (@foo) blockers are NOT fixed by the shim; patch the gem
    or use Ractor.make_shareable + a constant for shareable state
  - for per-Ractor mutable state use Ractor.store_if_absent(key) { default }

What this fixes

  • Rails.application, Rails.app_class, Rails.cache, Rails.logger, Rails.env, Rails.backtrace_cleaner — rerouted through IsolatedExecutionState.
  • Module.mattr_accessor / cattr_accessor — the macro is rewritten so all ~150 call sites in Rails inherit the fix without individual edits. Pass shareable: true to opt an accessor into the shareable-by-reference path instead.
  • Class.class_attribute (ActiveSupport) — the macro is rewritten so executor, check, and every other class_attribute-defined accessor routes through IsolatedExecutionState via string-eval'd methods (no captured binding). Without this, a worker Ractor calling app.reloader.executor = ... during boot raises "defined with an un-shareable Proc in a different Ractor".
  • Zeitwerk::Registry class ivars (@loaders, @mutex, @autoloads, etc.) — routed through IsolatedExecutionState so a worker Ractor can create autoloaders (each Ractor gets its own registry).
  • Unshareable constants (Rails::Railtie::ABSTRACT_RAILTIES, ActiveSupport::EnvironmentInquirer::DEFAULT_ENVIRONMENTS, etc.) — made shareable once at boot via Ractor.make_shareable + const_set. Add your own via RactorRailsShim.shareable_constants << "MyGem::CONST" then call prepare_for_ractors!.
  • Framework class config fallback (SHAREABLE_FALLBACK) — at make_app_shareable! time the main ractor's live class_attribute / mattr_accessor values are captured, made shareable (callable-replacement for any Procs), and exposed as a read-only fallback. Worker readers return it when their per-Ractor IES slot is empty — this is what fixes ActionController::Base.config being nil in workers.
  • Raw class-ivar/cvar accessors Rails reads per-request (the long tail beyond mattr_accessor) — patched individually: ExecutionWrapper.active_key, ActiveSupport::Notifications.notifier, ActiveSupport.error_reporter, ActiveSupport::ExecutionContext (after_change_callbacks / nestable), ActiveSupport::Inflector::Inflections, ActionView::PathRegistry, ActionView::LookupContext + DetailsKey (view_context_class built per-controller in main & shared via VIEW_CONTEXT_REGISTRY), ActionView::Template::Handlers, AbstractController::Base (controller_path / action_methods / abstract / _prefixes via per-Ractor Hash caches keyed by class), AbstractController::UrlFor#action_methods (sidesteps the unshareable _routes define_method-block), ActionController::ParameterEncoding#action_encoding_template, Rack::Request (forwarded_priority / x_forwarded_proto_priority), Rack::Utils (default_query_parser / multipart limits), ActionDispatch::Request.parameter_parsers, I18n::Config (default_locale / locale) + I18n.fallbacks + I18n::Locale::Tag.implementation.
  • ActiveSupport::Callbacks#run_callbacks — made nil-safe so a worker Ractor whose __callbacks couldn't be shared (frozen, self-capturing-Proc callback chains) treats callbacks as empty. Correct for a read-only shared app where boot-time callbacks already ran in main.

What this does NOT fix

  • Gems. Every gem your app depends on (Devise, Sidekiq, redis-rb, pg, etc.) has its own class ivars. The shim's mattr_accessor rewrite helps gems that use that macro, but gems using raw @ivar ||= ... need their own patches. The --check script surfaces these.
  • App code holding mutable state in closures (cache = {}; ->(env){ cache[...] }). The shim can't see into closures; --check finds class ivars only.
  • define_method-block methods. A handful of Rails methods are defined via define_method with a block capturing the defining Ractor (e.g. ActionDispatch::Routing::RouteSet's _routes singleton helper, ActionView::Base.with_empty_template_cache's compiled_method_container). The shim works around these by building the affected classes (e.g. view_context_class) in the main Ractor and sharing them, or by reading the route set directly from the shared Rails.application. More complex apps may hit additional define_method-block call sites that need similar treatment.

Limitations

  • Per-Ractor means N copies. Each Ractor gets its own Rails.application, Rails.cache, etc. — same shape as forking N processes, but cheaper (no heap duplication). For large read-only state, use Ractor.make_shareable instead.
  • The mattr_accessor rewrite is broad. It reroutes all mattr_accessor-defined accessors through IsolatedExecutionState, including ones that were legitimately class-global. This may change semantics for code that sets a value in main Ractor expecting worker Ractors to see it. Audit with --check and use shareable: true for accessors that should be shared.
  • Fragile across Rails versions. This is a monkey-patch. Rails releases that touch rails.rb or mattr_accessor may break it. When upstream fixes it, delete the gem.

When to delete this gem

This shim is a stopgap. Delete it (remove from the Gemfile) when Rails natively supports Ractor mode — i.e. when all of these land upstream:

  1. Class-level instance variables / class variables backing mattr_accessor and class_attribute are migrated to IsolatedExecutionState (or equivalent ractor-safe storage). Rails already does this for ActiveRecord::ConnectionHandling.connection_handler, proving the pattern.
  2. The Zeitwerk::Registry class ivars route through ractor-safe storage.
  3. Unshareable constants (EnvironmentInquirer::DEFAULT_ENVIRONMENTS, etc.) are made shareable (deep-frozen) at boot.
  4. The 7 self-capturing Procs in the app graph are restructured to not capture self — e.g. the Rack::Files head lambda, ActionDispatch::SSL exclude proc, CookieStore same-site proc, the message-verifier secret generator, and the routes-reloader blocks. These block Ractor.make_shareable(Rails.application) today.
  5. Initializer blocks (Rails::Initializable::Initializer#block) are shareable (defined as methods, not closures) so per-Ractor boot works.

Until then, the shim is required. A simple canary: with the gem removed, Ractor.make_shareable(Rails.application) fails (it raises on a Mutex or a "Proc's self is not shareable"). When that call succeeds unshimmed, the gem is obsolete. See UPSTREAM_ISSUE.md for the full blocker map and the proposed incremental upstream merge plan.

Publishing (maintainers)

The gemspec is publish-ready (metadata, MFA required, CHANGELOG packaged). To release a new version:

# Bump lib/ractor_rails_shim/version.rb and add a CHANGELOG entry, then:
gem build ractor-rails-shim.gemspec
gem push ractor-rails-shim-<version>.gem

CI (.github/workflows/ci.yml) gates merges: unit specs (no Rails) + an integration job that builds the minimal Rails 8.1 app and dispatches GET /up in a worker Ractor. Don't publish from a red build.

License

MIT