The project is in a healthy, maintained state
ractor-rails-shim makes a Rails application Ractor-safe, so it can run in Ractor mode — serving requests from worker Ractors that share one frozen app graph, instead of forking N separate processes. Rails keeps global state (Rails.application, Rails.cache, Rails.logger, and every config value set via mattr_accessor / class_attribute) in class-level instance variables, which Ruby forbids reading or writing from a non-main Ractor. The shim reroutes those accessors through Ractor-safe storage (ActiveSupport::IsolatedExecutionState, or Ractor.store_if_absent) and patches the handful of raw class-ivar accessors Rails reads per-request. It is verified against a real Rails 8.1 app (Devise, Propshaft, Kaminari, PostgreSQL) served by the kino web server in `kino -m ractor` mode. This is a stopgap: once Rails supports Ractor mode upstream, this gem becomes a no-op and can be removed.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

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.2.5)
  version_check.rb                  # Gem::Version-based Ruby/Rails detection + policy
  fallback_ies.rb                   # thread-local IES shim when ActiveSupport is absent
  check.rb                          # the ractor-rails-check audit (Check.scan / report)
  patches.rb                        # requires all per-concern patch files in order
  patches/
    core.rb                         # module skeleton, registries, install, prepare_for_ractors!, WorkerApp
    make_shareable.rb               # make_app_shareable!, callable/lock replacement, shareable fallback
    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, compiled_method_container
    action_controller.rb            # AbstractController, ActionController name/encoding
    action_dispatch.rb              # ActionDispatch routing, http_url, journey, mounted helpers
    polymorphic_routes.rb           # polymorphic_path(s) URL helpers
    active_support.rb               # Inflector, error_reporter, ExecutionContext, I18n, JSON encoding, Reloader
    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 (worker-safe)
    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
exe/ractor-rails-check              # CLI audit tool
spec/                               # 7 spec files, 61 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)

Per-concern patch files reopen RactorRailsShim's singleton class to add their _install_* methods. patches.rb requires them in dependency order (core.rb first — it defines the module skeleton + registries the rest 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