Module: RactorRailsShim

Defined in:
lib/ractor_rails_shim.rb,
lib/ractor_rails_shim/check.rb,
lib/ractor_rails_shim/version.rb,
lib/ractor_rails_shim/patches/core.rb,
lib/ractor_rails_shim/patches/rack.rb,
lib/ractor_rails_shim/version_check.rb,
lib/ractor_rails_shim/patches/devise.rb,
lib/ractor_rails_shim/patches/warden.rb,
lib/ractor_rails_shim/patches/openssl.rb,
lib/ractor_rails_shim/patches/kaminari.rb,
lib/ractor_rails_shim/patches/rubygems.rb,
lib/ractor_rails_shim/patches/propshaft.rb,
lib/ractor_rails_shim/patches/action_view.rb,
lib/ractor_rails_shim/patches/orm_adapter.rb,
lib/ractor_rails_shim/patches/url_helpers.rb,
lib/ractor_rails_shim/patches/activerecord.rb,
lib/ractor_rails_shim/patches/rails_module.rb,
lib/ractor_rails_shim/patches/route_helpers.rb,
lib/ractor_rails_shim/patches/active_support.rb,
lib/ractor_rails_shim/patches/make_shareable.rb,
lib/ractor_rails_shim/patches/mattr_accessor.rb,
lib/ractor_rails_shim/patches/action_dispatch.rb,
lib/ractor_rails_shim/patches/class_attribute.rb,
lib/ractor_rails_shim/patches/action_controller.rb,
lib/ractor_rails_shim/patches/execution_wrapper.rb,
lib/ractor_rails_shim/patches/zeitwerk_registry.rb,
lib/ractor_rails_shim/patches/polymorphic_routes.rb,
lib/ractor_rails_shim/patches/active_model_attribute.rb,
lib/ractor_rails_shim/patches/active_record_model_schema.rb

Overview

ActiveRecord caches a number of values in class-level ivars via @ivar ||= (ModelSchema::ClassMethods). Several of these hold UNshareable values (AttributeSet::Builder, AttributeSet::YAMLEncoder, Hashes of Attribute objects) or take arguments, so neither pre-warming nor Ractor.make_shareable makes them safe for worker Ractors: a worker reading the class ivar hits Ractor::IsolationError (unshareable value) and writing it (the ||=) hits "can not set instance variables of classes/modules by non-main Ractors".

We redirect these specific caches to per-Ractor storage (ActiveSupport::IsolatedExecutionState), keyed by the class. Each worker Ractor computes and keeps its OWN copy — it never touches the shared class ivar, so there is no cross-boundary (unshareable) value and no class-ivar write. The values are deterministic from the schema/connection, so per-Ractor caching is behavior-preserving. In the main Ractor this behaves identically (compute once, cache).

Defined Under Namespace

Modules: ActiveModelAttributePatch, ActiveModelAttributeRegistrationPatch, ActiveRecordAttributesPatch, ActiveRecordModelSchemaPatch, Version Classes: ArWorkerInitWrapper, Check, UnsupportedVersionError, WorkerApp

Constant Summary collapse

VERSION =
"0.2.0"
KEYS =

The keys under which each global is stored in IsolatedExecutionState. Namespaced to avoid collisions with Rails' own uses of IES.

{
  application: :ractor_rails_shim_application,
  app_class: :ractor_rails_shim_app_class,
  cache: :ractor_rails_shim_cache,
  logger: :ractor_rails_shim_logger,
  env: :ractor_rails_shim_env,
  backtrace_cleaner: :ractor_rails_shim_backtrace_cleaner
}.freeze
CLASS_ATTRIBUTES =

Registry of every class_attribute definition the shim's redefine patch has seen. Each entry is [owner_name, namespaced_name (Symbol), key (Symbol)] so that at prepare_for_ractors! time we can capture the main-ractor value of each attribute, make it shareable, and expose it as a read-only fallback for worker Ractors (whose own IES slot is empty). Without this, framework class config (ActionController::Base.config, etc.) is per-Ractor-nil in workers and request dispatch dies (e.g. default_static_extension -> config is nil). The fallback is ONLY read by workers; the main ractor keeps its own live (possibly mutable) value in its IES slot, untouched.

The fallback table itself is built once, at prepare_for_ractors! time, and made shareable; workers read it via a constant (RactorRailsShim::SHAREABLE_FALLBACK).

[]
ABSTRACT_REGISTRY =

Shareable registry: controller class → abstract? boolean. Populated at prepare_for_ractors! time by scanning all AbstractController::Base descendants for their @abstract ivar. Workers read this via the patched AbstractController::Base.abstract? (per-class values can't live in per-Ractor IES). Made shareable (frozen) at prepare time.

Ractor.make_shareable({})
MATTR_DEFAULTS =

Runtime registry: mattr_accessor IES key → default value. Written at mattr-definition time (boot, in main). The mattr reader (string-eval'd) looks the default up here by key — we CANNOT inline arbitrary default values into the eval'd method body (a Logger's .inspect is #<Logger:...>, invalid Ruby). Read by workers when both their IES slot and SHAREABLE_FALLBACK are empty. NOT made shareable (some defaults like Logger are intrinsically unshareable); workers only reach here if the value is a simple shareable literal (Symbol/String/Integer), in which case reading the constant is fine because... actually it IS a constant holding an unshareable Hash → worker read would raise. So this registry is ONLY safe to read from workers for shareable defaults. We guard the reader to only consult it for defaults that are Ractor.shareable?.

{}
CLASS_ATTR_VALUES =

class_attribute default values, keyed by IES key. Written at class_attribute-definition time (boot, in main). The class_attribute reader falls back to this in the MAIN ractor when the IES slot is empty (which it is on non-boot threads — IES is thread-local, and Puma's request threads have empty slots). NOT made shareable (values may be mutable Hashes/Arrays); only safe to read from the main ractor. Workers use SHAREABLE_FALLBACK (built at prepare_for_ractors! time) instead.

{}
SHAREABLE_MATTR_DEFAULTS =

Shareable subset of MATTR_DEFAULTS: only defaults that are Ractor.shareable? (so workers can read the constant safely). Written at mattr-definition time (boot, in main, before workers spawn); frozen + made shareable at prepare_for_ractors! time.

{}
SHAREABLE_CONSTANTS =

Registry of constant path strings ("A::B::C") whose values are mutable Arrays/Hashes/Sets that need to be made shareable (deep-frozen) at boot. Each per-concern file concats its own constants into this array. Users can add their own via RactorRailsShim.shareable_constants << "MyGem::LIST".

[]
SHAREABLE_CLASS_IVARS =

Registry of [ClassName, :ivar] pairs: class-level instance variables whose values are mutable (Hashes/Arrays/objects) and must be made Ractor-shareable (deep-frozen) at boot so worker Ractors can read them. Unlike SHAREABLE_CONSTANTS (top-level constants), these are class instance variables (e.g. ActiveSupport::Editor.@editors, Warden::Strategies.@strategies) that hold unshareable values and are read during request dispatch.

[]
VIEW_CONTEXT_REGISTRY =

Shareable registry: controller class → its built view_context_class. Populated at prepare_for_ractors! time by calling view_context_class on each loaded controller in main (build_view_context_class uses Class.new... blocks → un-shareable Proc from a worker). Made shareable.

Ractor.make_shareable({})
SHAREABLE_FALLBACK =

Frozen, shareable fallback table for class_attribute / mattr_accessor values. Built once at prepare_for_ractors! time from the main ractor's live values (class_attribute IES slot / mattr @@sym), each made shareable via callable-replacement + make_shareable. Workers read this via the RactorRailsShim::SHAREABLE_FALLBACK constant when their own IES slot is empty. Values that can't be made shareable are skipped (workers see nil for those and must set their own).

Ractor.make_shareable({})
PATCH_VERSIONS =

Versions each patch was tested against. Populated by the install_* methods as they register themselves. A patch applies to a runtime Rails version only if the runtime segment matches one of its tested entries. This is the "load different patches for different Rails versions" infrastructure: to add 7.x support, write version-specific variants and tag them here. Defined on the module (not the singleton class) so it's readable from outside as RactorRailsShim::PATCH_VERSIONS.

{}
SUPPORTED_RUBY =
RactorRailsShim::Version::SUPPORTED_RUBY
SUPPORTED_RAILS =
"8.1"
FILES_LOC =

Source-location constant used by make_app_shareable!'s proc-replacement graph traversal (moved here from make_shareable.rb so the Rack concern's pieces live together).

"/rack/files.rb".freeze
DEVISE_SCOPE_LOC =

Source-location constant for the Devise scope constraint Proc (moved from make_shareable.rb so the Devise-related callable lives with the Devise patch).

"/devise/rails/routes.rb".freeze
SHAREABLE_COMPILED_MODULE =

Shareable, mutable module that holds compiled template methods (e.g. _app_views_...). ActionView attaches compiled template methods to compiled_method_container; the default returns a per-class container, which isolates the shared application layout's compiled method to whichever controller first rendered it (so other controllers raise NoMethodError on the layout). Routing every view_context_class to this ONE shared module makes compiled methods available to all controllers/workers. It is a plain Module (shareable without freezing, so workers can still define methods on it).

Module.new
AR_PRIMARY_KEYS_SHAREABLE =

Shareable snapshot of each AR model class's primary_key, captured at prepare time. Workers read this instead of the raw @primary_key class ivar (which is initialized to PRIMARY_KEY_NOT_SET, a BasicObject that can't be made shareable). Populated by _share_model_classes! in the main ractor.

Ractor.make_shareable({})
PgBindBlock =

Shareable callable that replaces Arel::Visitors::PostgreSQL::BIND_BLOCK (a Proc proc { |i| "$#{i}" }). Callable cross-Ractor.

Ractor.make_shareable(Object.new.tap do |o|
  def o.call(i); "$#{i}"; end
  def o.to_proc; method(:call).to_proc; end
end)
SqlBindBlock =

Shareable callable that replaces Arel::Visitors::ToSql::BIND_BLOCK (a Proc proc { "?" }). Callable cross-Ractor.

Ractor.make_shareable(Object.new.tap do |o|
  def o.call(_i = nil); "?"; end
  def o.to_proc; method(:call).to_proc; end
end)
AR_CONFIGURATIONS_SNAPSHOT =

Shareable snapshot of ActiveRecord::Base.configurations at prepare_for_ractors! time. Workers read this to establish their own connection pools with the same db config. Made shareable (frozen).

nil
AR_CONFIGURATIONS_SHAREABLE =

Shareable (deep-frozen) copy of ActiveRecord::Base.configurations (the DatabaseConfigurations object) captured at prepare time. Workers read this instead of the raw @@configurations class variable, which a non-main Ractor cannot access.

nil
AR_DB_CONFIG_HANDLERS_SHAREABLE =

Shareable (deep-frozen) copy of DatabaseConfigurations.db_config_handlers (an Array of shareable handler Procs) captured at prepare time. Workers read this instead of the per-Ractor class instance variable.

nil
AR_QUERY_TRANSFORMERS_SHAREABLE =

Shareable (deep-frozen) copy of ActiveRecord.query_transformers (an Array of transformer classes/objects) captured at prepare time. Workers read this instead of the per-Ractor class instance variable.

nil
SSL_LOC =

Source-location constants used by make_app_shareable!'s proc-replacement graph traversal (moved here from make_shareable.rb so each concern's pieces live together).

"/active_dispatch/middleware/ssl.rb".freeze
"/session/cookie_store.rb".freeze
MAPPER_LOC =
"/action_dispatch/routing/mapper.rb".freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

._abstract_registryObject

Accessor for the abstract-controller registry (written by abstract! in main, read by abstract? in workers). Reassigned to a shareable frozen Hash at prepare_for_ractors! time.



110
111
112
# File 'lib/ractor_rails_shim/patches/core.rb', line 110

def _abstract_registry
  @_abstract_registry
end

._view_context_fallbackObject

Returns the value of attribute _view_context_fallback.



112
113
114
# File 'lib/ractor_rails_shim/patches/core.rb', line 112

def _view_context_fallback
  @_view_context_fallback
end

._view_context_registryObject

Returns the value of attribute _view_context_registry.



111
112
113
# File 'lib/ractor_rails_shim/patches/core.rb', line 111

def _view_context_registry
  @_view_context_registry
end

.thread_mode=(value) ⇒ Object (writeonly)

Sets the attribute thread_mode

Parameters:

  • value

    the value to set the attribute thread_mode to.



153
154
155
# File 'lib/ractor_rails_shim/patches/core.rb', line 153

def thread_mode=(value)
  @thread_mode = value
end

.version_policyObject

Policy for version mismatches. One of :warn (default), :strict, :off. Set before install:

RactorRailsShim.version_policy = :strict

Defaults to :warn when never explicitly set.



118
119
120
# File 'lib/ractor_rails_shim/patches/core.rb', line 118

def version_policy
  @version_policy || :warn
end

Class Method Details

._build_shareable_fallback!Object

Build the shareable fallback for every class_attribute / mattr_accessor value the shim has rerouted. For each registered attribute we:

1. Read the main-ractor value (from its IES slot, which `redefine`
 seeded at class_attribute-definition time).
2. Make it shareable (deep-freeze + callable-replacement for any Procs
 it holds — same technique as make_app_shareable!, applied to the
 config sub-graph).
3. Store under the IES key in a frozen Hash on RactorRailsShim, which
 is readable from every Ractor (it's a constant).

Workers' class_attribute readers fall back to this when their own IES slot is nil. Must run in the main Ractor. Idempotent.



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 204

def _build_shareable_fallback!
  return if @fallback_built
  @fallback_built = true

    fallback = {}
    CLASS_ATTRIBUTES.each do |(owner_name, attr_name, ies_key, default_val)|
      # Skip the Rails logger — it's intrinsically unshareable (IO + Mutex +
      # mutable formatter) and workers build their own per-Ractor logger
      # via the patched reader. Trying to make it shareable would freeze the
      # IO, breaking logging in main too.
      next if owner_name == "Rails" && attr_name == :logger
      val = ActiveSupport::IsolatedExecutionState[ies_key]
      # For class_attribute values whose IES slot was never written but
      # whose definition-time DEFAULT was mutated in place during boot
      # (e.g. AbstractController::Base's `config`, whose default
      # ActiveSupport::OrderedOptions is filled with the real nested config
      # by railties), the live value lives in the main-Ractor
      # CLASS_ATTR_VALUES store, NOT in IES. Read it there so workers get
      # the real value instead of the empty definition-time default.
      if val.nil? && Ractor.main?
        val = RactorRailsShim::CLASS_ATTR_VALUES[ies_key]
      end
    # For mattr_accessor: the value may have been written to @@sym after
    # define-time (e.g. by an initializer). Read it from there if the IES
    # slot is nil (the seed only set the default; the live value may differ).
    if val.nil? && owner_name && attr_name.is_a?(Symbol)
      begin
        owner_mod = owner_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil
        if owner_mod && owner_mod.class_variable_defined?("@@#{attr_name}")
          val = owner_mod.class_variable_get("@@#{attr_name}")
        end
      rescue => e
        # ignore — best-effort read
      end
    end
    # For raw class ivars (PathRegistry, etc.): read @<attr_name> in main.
    if val.nil? && owner_name && attr_name.is_a?(Symbol)
      begin
        owner_mod = owner_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil
        if owner_mod && owner_mod.instance_variable_defined?("@#{attr_name}")
          val = owner_mod.instance_variable_get("@#{attr_name}")
        end
      rescue => e
        # ignore — best-effort read
      end
    end
    # For the Rails module accessors (owner_name == "Rails"): the value
    # may live in the @ivar (set by Rails' own writer via super, or
    # lazy-init'd by Rails' own reader) rather than in IES. Read it via
    # the actual accessor in main, which materializes the lazy-init value.
    if val.nil? && owner_name == "Rails" && defined?(::Rails)
      begin
        val = ::Rails.public_send(attr_name) if ::Rails.respond_to?(attr_name, false)
      rescue => e
        # ignore — best-effort read
      end
    end

    shareable_val = nil
    # Try the live value first.
    if !val.nil?
      shareable_val = _try_make_shareable(val, owner_name, attr_name)
    end
    # If the live value couldn't be shared (e.g. __callbacks holds
    # self-capturing Procs), fall back to the definition-time default.
    # For a frozen shared app this is correct: boot-time callbacks already
    # ran in main; workers treat them as already-run (empty/no-op). The
    # default is dup'd if it's a mutable container (Hash/Array) so each
    # entry in the fallback is an independent shareable copy.
    if shareable_val.nil? && !default_val.nil?
      shareable_val = _try_make_shareable(_shareable_copy(default_val), owner_name, attr_name, default: true)
    end

    fallback[ies_key] = shareable_val if shareable_val
  end
  fallback.freeze
  Ractor.make_shareable(fallback)

  # Make the shareable mattr-defaults subset shareable too (workers read
  # it via the constant). Frozen + reassigned via const_set.
  SHAREABLE_MATTR_DEFAULTS.freeze
  Ractor.make_shareable(SHAREABLE_MATTR_DEFAULTS)

  # Reassign the constants with the built (shareable) tables. const_set
  # warns "already initialized constant" — silence that one warning.
  verbose, $VERBOSE = $VERBOSE, nil
  begin
    const_set(:SHAREABLE_FALLBACK, fallback)
    const_set(:SHAREABLE_MATTR_DEFAULTS, SHAREABLE_MATTR_DEFAULTS)
  ensure
    $VERBOSE = verbose
  end
  fallback
end

._capture_ar_configurations!Object



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 156

def _capture_ar_configurations!
  return if @_ar_configs_captured
  @_ar_configs_captured = true
  return unless defined?(::ActiveRecord::Base)

  begin
    # Build a plain Hash snapshot of every db config keyed by
    # [env_name][config_name] => config_hash. Use
    # ActiveRecord::Base.configurations.configs_for (returns DbConfig
    # objects with .name, .env_name, .configuration_hash) rather than
    # Rails.application.config.database_configuration (a legacy Hash whose
    # shape differs between single-config apps (flat Hash = the config
    # itself) and multi-config apps (nested Hash of name => config)).
    cfgs = ::ActiveRecord::Base.configurations
    snapshot = {}
    if cfgs.respond_to?(:configs_for)
      cfgs.configs_for.each do |dc|
        env_name = dc.env_name
        name = dc.name || "primary"
        hash = dc.configuration_hash
        next unless hash.is_a?(::Hash)
        snapshot[env_name] ||= {}
        snapshot[env_name][name] = hash.reject { |_k, v| v.nil? }
      end
    end
    # Fallback: legacy database_configuration Hash (env => { name => config }
    # OR env => flat config). Used if configs_for is unavailable.
    if snapshot.empty?
      raw = ::Rails.application.config.database_configuration rescue {}
      raw.each do |env_name, env_configs|
        next unless env_configs.is_a?(::Hash)
        snapshot[env_name] ||= {}
        if env_configs.key?("adapter") || env_configs.key?(:adapter)
          # Flat config: the env value IS the "primary" config itself.
          snapshot[env_name]["primary"] = env_configs.reject { |_k, v| v.nil? }
        else
          # Nested: env => { name => config }
          env_configs.each do |name, config|
            next unless config.is_a?(::Hash)
            snapshot[env_name][name] = config.reject { |_k, v| v.nil? }
          end
        end
      end
    end
    snapshot.freeze
    Ractor.make_shareable(snapshot)
    verbose, $VERBOSE = $VERBOSE, nil
    begin
      const_set(:AR_CONFIGURATIONS_SNAPSHOT, snapshot)
    ensure
      $VERBOSE = verbose
    end
  rescue => e
    # Best-effort; if we can't capture configs, workers won't be able
    # to auto-init connections. They can call init_worker_ar_connections!
    # manually with explicit configs.
  end
end

._check_version_supportObject

The shim's patches target specific Rails 8.1 class layouts and Ruby 4.0 Ractor semantics. On other versions, the patches may silently miss or break things. Behavior on mismatch is governed by version_policy:

:warn   (default) print a warning to $stderr, proceed anyway
:strict raise RactorRailsShim::UnsupportedVersionError
:off    silent (for advanced users / experimentation)

Ruby mismatch always warns (Ractor semantics are not stable across majors); Rails mismatch uses the policy. This is real version detection (Gem::Version-based), not a string-prefix compare, so pre-release and patch versions sort correctly.



433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# File 'lib/ractor_rails_shim/patches/core.rb', line 433

def _check_version_support
  @version_policy ||= :warn
  unless RactorRailsShim::Version.supported_ruby?
    msg = "ractor-rails-shim: Ruby #{RUBY_VERSION} — shim developed " \
          "against Ruby #{SUPPORTED_RUBY}. Ractor semantics may differ; " \
          "the shim may break. Proceeding anyway."
    _version_mismatch(msg)
  end
  if RactorRailsShim::Version.rails &&
     !RactorRailsShim::Version.supported_rails?
    rv = ::Rails::VERSION::STRING
    msg = "ractor-rails-shim: Rails #{rv} — shim developed against " \
          "Rails #{RactorRailsShim::Version::TESTED_RAILS.join(", ")}. " \
          "Class layouts (class_attribute, callbacks, PathRegistry, etc.) " \
          "may differ; patches may miss blockers. Proceeding anyway. " \
          "Set RactorRailsShim.version_policy = :strict to make this " \
          "fatal; :off to silence."
    _version_mismatch(msg)
  end
end

._collect_controller_classes(app) ⇒ Object



830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 830

def _collect_controller_classes(app)
  classes = []
  begin
    router = (app.respond_to?(:routes) ? app.routes : nil) || (defined?(::Rails) && ::Rails.application && ::Rails.application.routes)
    router.routes.each do |r|
      c = r.defaults[:controller] rescue nil
      next unless c && c.respond_to?(:camelize)
      klass = "#{c.camelize}Controller".safe_constantize rescue nil
      classes << klass if klass
    end
  rescue
    nil
  end
  begin
    if defined?(::ApplicationController) && ::ApplicationController.respond_to?(:descendants)
      classes.concat(::ApplicationController.descendants)
    end
  rescue
    nil
  end
  classes.compact.uniq
end

._collect_procs(app) ⇒ Object



558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 558

def _collect_procs(app)
  seen = {}
  procs = []
  stack = [[app, "app", nil, nil]]
  until stack.empty?
    o, _path, parent, ivar = stack.pop
    next if o.equal?(nil)
    # Skip BasicObject subclasses that don't respond to is_a?/object_id
    # (e.g. ActiveSupport::Callbacks::CallTemplate internals). Must guard
    # BEFORE calling is_a? — BasicObject doesn't define it.
    next unless _introspectable?(o)
    if o.is_a?(Proc)
      procs << [o, _path, parent, ivar]
      next
    end
    next if seen[o.object_id]
    seen[o.object_id] = true
    next if o.is_a?(Mutex) || o.is_a?(Monitor)
    begin
      o.instance_variables.each do |iv|
        begin; v = o.instance_variable_get(iv); rescue; next; end
        stack << [v, "#{_path}.#{iv}", o, iv] if v
      end
    rescue => e
      # Some objects (BasicObject) don't support instance_variables
    end
    if o.is_a?(Array)
      o.each_with_index { |e, i| stack << [e, "#{_path}[#{i}]", o, nil] if e }
    elsif o.is_a?(Hash)
      o.each do |k, val|
        stack << [k, "#{_path}.key", o, nil] if k
        stack << [val, "#{_path}[#{k.inspect}]", o, nil] if val
      end
      dp = o.default_proc
      procs << [dp, "#{_path}.default_proc", o, :__default_proc__] if dp
    end
  end
  procs
end

._devise_mapping_replacement(proc_obj, _parent) ⇒ Object

Build a shareable replacement for a Devise scope constraint. The original Proc (devise/rails/routes.rb:363) does:

request.env["devise.mapping"] = Devise.mappings[scope]
true

The scope is captured in the Proc's binding. We call the original Proc once in main with a mock request to capture the mapping, then make it shareable and wrap it in a DeviseMappingCallable.



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/ractor_rails_shim/patches/warden.rb', line 177

def _devise_mapping_replacement(proc_obj, _parent)
  mock_env = { "devise.mapping" => nil }
  mock_req = Struct.new(:env).new(mock_env)
  begin
    proc_obj.call(mock_req)
  rescue
  end
  mapping = mock_env["devise.mapping"]
  if mapping
    mapping = _devise_mapping_snapshot(mapping)
  end
  if mapping
    DeviseMappingCallable.new(mapping)
  else
    CallableConst.new(true)
  end
end

._find_files_server(mw) ⇒ Object

Find the Rack::Files (asset) server in the middleware chain, used by make_app_shareable! when replacing the Rack::Head#@app lambda (whose binding receiver is the Rack::Files instance). Moved here from make_shareable.rb so the Rack concern's pieces live together.



149
150
151
152
153
154
155
156
157
158
# File 'lib/ractor_rails_shim/patches/rack.rb', line 149

def _find_files_server(mw)
  cur = mw
  while cur
    if cur.class.name == "ActionDispatch::Static"
      return cur.instance_variable_get(:@file_server)
    end
    cur = cur.instance_variable_get(:@app) rescue nil
  end
  nil
end

._freeze_active_record_class_ivars!Object

Freeze (make Ractor-shareable) every instance variable on every ActiveRecord model class in the MAIN Ractor, before the graph is frozen. Many AR model classes cache unshareable values in class-level ivars (@pending_attribute_ modifications, @column_defaults, @symbol_column_to_string_name_hash, @yaml_encoder, @dangerous_attribute_methods, ...). A worker Ractor cannot read an unshareable class-ivar value (Ractor::IsolationError) nor set one. Freezing them in main (where setting is allowed) yields shareable values that workers read without writing. AR Type objects freeze cleanly, so this is behavior-preserving; per-request code never mutates model class ivars.



609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
# File 'lib/ractor_rails_shim/patches/core.rb', line 609

def _freeze_active_record_class_ivars!
  return unless defined?(::ActiveRecord::Base)
  models = [::ActiveRecord::Base] + (::ActiveRecord::Base.descendants rescue [])
  models.each do |klass|
    # NOTE: do NOT skip abstract classes (e.g. a primary_abstract_class
    # ApplicationRecord). Workers recurse into them via
    # apply_pending_attribute_modifications, so their class ivars must also
    # be shareable.
    klass.instance_variables.each do |ivar|
      val = klass.instance_variable_get(ivar)
      next if val.nil? || Ractor.shareable?(val)
      begin
        Ractor.make_shareable(val)
      rescue
        nil
      end
    end
  end
end

._freeze_class_ivars!(owner) ⇒ Object

Make every unshareable class ivar on owner shareable (deep-freeze) and write it back. A class ivar holding a shareable value is readable from a worker Ractor. Monitor/Mutex->NoOpLock; Concurrent::Map->frozen Hash; values that can't be frozen (Procs, TypeMap) are left as-is.



521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 521

def _freeze_class_ivars!(owner)
  begin
    owner.instance_variables.each do |iv|
      v = owner.instance_variable_get(iv) rescue nil
      next unless v
      next if Ractor.shareable?(v)
      replacement = _shareable_ivar_replacement(v)
      next unless replacement
      begin
        owner.instance_variable_set(iv, replacement)
      rescue => e
        # frozen owner — leave as-is
      end
    end
  rescue => e
  end
end

._freeze_declared_callbacks!Object



748
749
750
751
752
753
754
755
756
757
758
759
760
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 748

def _freeze_declared_callbacks!
  table = (@declared_callbacks || {})
  # Deep-freeze (make shareable) so worker Ractors can read the constant.
  # Entries are Hashes of Symbols/booleans/nil/Arrays — all natively
  # shareable. A non-frozen constant raises Ractor::IsolationError when a
  # worker reads it.
  begin
    Ractor.make_shareable(table)
    RactorRailsShim.const_set(:SHAREABLE_DECLARED_CALLBACKS, table)
  rescue
    nil
  end
end

._freeze_global_class_ivars!Object

Freeze (make Ractor-shareable) unshareable class-level ivars on GLOBAL classes (Time/Date timezone caches, I18n locale caches, ...) in the MAIN Ractor, before the graph is frozen. Unlike model classes, these are shared singletons whose class ivars (e.g. Time's @zone_default / @zone_cache) hold unshareable values that a worker Ractor would otherwise fail to read (Ractor::IsolationError). Freezing them in main yields shareable values.



635
636
637
638
639
640
641
642
643
644
645
646
647
648
# File 'lib/ractor_rails_shim/patches/core.rb', line 635

def _freeze_global_class_ivars!
  classes = %w[Time Date DateTime I18n].filter_map { |n| Object.const_get(n) rescue nil }
  classes.each do |klass|
    klass.instance_variables.each do |ivar|
      val = klass.instance_variable_get(ivar)
      next if val.nil? || Ractor.shareable?(val)
      begin
        Ractor.make_shareable(val)
      rescue
        nil
      end
    end
  end
end

._freeze_global_constants!Object

Replace GLOBAL constants that hold non-shareable values (e.g. Time/Date/DateTime::DATE_FORMATS contain Proc values) with frozen, shareable equivalents so worker Ractors can read them. Proc-valued format entries are dropped (to_fs falls back to to_s for those formats). This is done in the MAIN Ractor, where const_set is allowed.



655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# File 'lib/ractor_rails_shim/patches/core.rb', line 655

def _freeze_global_constants!
  constants = %w[Time Date DateTime].filter_map do |n|
    mod = Object.const_get(n) rescue nil
    mod.is_a?(Module) ? [mod, :DATE_FORMATS] : nil
  end
  constants.each do |mod, name|
    next unless mod.const_defined?(name, false)
    val = mod.const_get(name, false)
    next if Ractor.shareable?(val)
    shareable = if val.is_a?(Hash)
      h = {}
      val.each { |k, v| h[k] = v if Ractor.shareable?(v) }
      h.freeze
    elsif val.is_a?(Array)
      val.select { |v| Ractor.shareable?(v) }.freeze
    else
      val
    end
    begin
      mod.const_set(name, shareable)
    rescue
      nil
    end
  end
end

._freeze_journey_visitors!Object

Journey's routing visitors are stored as instance singletons in class constants (e.g. ActionDispatch::Journey::Visitors::Each::INSTANCE). Worker Ractors read these constants while recognizing routes (Node#eachEach::INSTANCE.accept, Path::Pattern#matchoffsetsnode.each), and a non-frozen instance is NOT a shareable object → Ractor::IsolationError: can not access non-shareable objects in constant ...::Each::INSTANCE by non-main Ractor. The visitor instances are stateless, so freezing them makes them shareable with no behavior change. The same applies to the DISPATCH_CACHE Hashes the visitor accept/visit dispatch through. These constants are NOT reachable from the frozen app graph (Ractor.make_shareable never touches them), so we must freeze them explicitly here (in main, before workers spawn).



684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 684

def _freeze_journey_visitors!
  return unless defined?(::ActionDispatch::Journey::Visitors)
  v = ::ActionDispatch::Journey::Visitors
  [[:Each, :INSTANCE], [:String, :INSTANCE], [:Dot, :INSTANCE]].each do |klass, const|
    mod = v.const_get(klass) rescue nil
    next unless mod && mod.const_defined?(const)
    inst = mod.const_get(const)
    inst.freeze if inst.respond_to?(:freeze) && !inst.frozen?
  end
  [[:Visitor, :DISPATCH_CACHE], [:FunctionalVisitor, :DISPATCH_CACHE]].each do |klass, const|
    mod = v.const_get(klass) rescue nil
    next unless mod && mod.const_defined?(const)
    cache = mod.const_get(const)
    cache.freeze if cache.respond_to?(:freeze) && !cache.frozen?
  end
  # GTG::Builder::DUMMY_END_NODE is a non-shareable instance referenced when
  # a worker Ractor rebuilds the route simulator (e.g. if the warmed
  # @simulator cache is missing on the frozen graph). Make it Ractor-shareable
  # (deep-freeze) so workers can read the constant without
  # Ractor::IsolationError. It is a stateless dummy node, so this is
  # behavior-preserving.
  if defined?(::ActionDispatch::Journey::GTG::Builder) &&
     ::ActionDispatch::Journey::GTG::Builder.const_defined?(:DUMMY_END_NODE)
    node = ::ActionDispatch::Journey::GTG::Builder.const_get(:DUMMY_END_NODE)
    Ractor.make_shareable(node) rescue nil
  end
end

._freeze_messages_constants!Object

ActiveSupport::Messages::Metadata holds non-shareable Array constants (ENVELOPE_SERIALIZERS / TIMESTAMP_SERIALIZERS) of serializer Modules, used by MessageEncryptor during flash/session cookie serialization. A worker Ractor reading these constants (e.g. on redirect_to, which encrypts a flash message) raises Ractor::IsolationError. The Arrays are shareable once frozen (their elements are Modules), so deep-freeze and const_set the shareable copy back so workers read a shareable constant.



688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
# File 'lib/ractor_rails_shim/patches/core.rb', line 688

def _freeze_messages_constants!
  # Load ActiveSupport::MessagePack in the MAIN Ractor FIRST. metadata.rb
  # registers an `ActiveSupport.on_load(:message_pack)` callback that mutates
  # ENVELOPE_SERIALIZERS / TIMESTAMP_SERIALIZERS via `<<`. If that callback
  # first fires inside a worker Ractor (which happens the first time a
  # cookie's `detect_format` probes MessagePackWithFallback.dumped?, because
  # it lazily requires "active_support/message_pack"), it runs against the
  # already-frozen arrays below and raises
  #   FrozenError: can't modify frozen Array
  # Loading here makes the callback fire once, in main, against the
  # non-frozen arrays; load hooks never fire again in workers.
  # Gem::LoadError is a ScriptError (not StandardError), so a bare
  # `rescue nil` on the require would NOT catch a missing msgpack gem.
  begin
    require "active_support/message_pack"
  rescue LoadError
    return
  end

  mod = (Object.const_get(:ActiveSupport) rescue nil)&.const_get(:Messages, false) rescue nil
  mod = mod&.const_get(:Metadata, false) rescue nil
  return unless mod.is_a?(Module)
  %i[ENVELOPE_SERIALIZERS TIMESTAMP_SERIALIZERS].each do |name|
    next unless mod.const_defined?(name, false)
    val = mod.const_get(name, false)
    next if Ractor.shareable?(val)
    shareable = Ractor.make_shareable(val) rescue val
    begin
      mod.const_set(name, shareable)
    rescue
      nil
    end
  end
end

._freeze_mime_negotiation!Object

ActionDispatch::Http::MimeNegotiation holds module-level constants (e.g. RESCUABLE_MIME_FORMAT_ERRORS, an Array of exception classes) that are referenced from the request path (params_readable? -> rescue * RESCUABLE_MIME_FORMAT_ERRORS). These Arrays are non-frozen, hence non-shareable, so a worker Ractor raises Ractor::IsolationError when it reads them. Freeze the mutable constant-containing modules so workers can read shareable copies. Regexp/Class constants are already shareable; only the wrapping Array/Hash need freezing.



782
783
784
785
786
787
788
789
790
791
792
793
794
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 782

def _freeze_mime_negotiation!
  return unless defined?(::ActionDispatch::Http::MimeNegotiation)
  mod = ::ActionDispatch::Http::MimeNegotiation
  mod.constants.each do |name|
    c = mod.const_get(name) rescue nil
    next unless c.is_a?(::Array) || c.is_a?(::Hash)
    next if c.frozen?
    c.freeze
    ::Ractor.make_shareable(c) rescue nil
  end
rescue => e
  warn "[ractor-rails-shim] _freeze_mime_negotiation!: #{e.class}: #{e.message}"
end

._freeze_shareable_class_ivars!Object

Capture each controller's OWN declared process_action symbol filters (before_action / after_action) so worker Ractors can replay them.

WHY NOT READ __callbacks: Rails 8.1.3 under Ruby 4.0.5 (with Devise 5.0.4) has an eager-load class_attribute callback-chain leak — a parent controller's __callbacks accumulates every subclass's filters (and vice-versa), so ApplicationController.__callbacks[:process_action] ends up carrying Devise's require_no_authentication AND PostsController's set_post. Reading __callbacks therefore yields a corrupted, unshareable chain. The app is genuinely broken in eager-load (production) mode even without the shim.

Instead we intercept ActiveSupport::Callbacks.set_callback during eager load (see _install_callback_declaration_capture!) and record, per declaring controller class, the symbolic filters IT declares (kind, filter, only/except). This captures the truth regardless of the leak. The patched run_callbacks replays these per controller, walking ancestors for inheritance.

We only capture SYMBOL filters (the common before_action :set_post form). Proc/lambda filters are skipped — they are self-capturing and cannot be replayed safely in a worker (known limitation; symbolic filters cover the typical case, including Devise's :authenticate_user! / :require_no_authentication). Stored in RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS keyed by the controller class object_id (stable across Ractors since classes are shared). Make Ractor-shareable the class instance variables listed in SHAREABLE_CLASS_IVARS (e.g. ActiveSupport::Editor.@editors, Warden::Strategies.@strategies). Worker Ractors read these during request dispatch; an unshareable value raises Ractor::IsolationError. We deep-freeze the value and write it back so workers read the shareable copy. Also pre-touch any memoizing accessor so workers don't try to write the ivar lazily (which would raise FrozenError on the frozen class).



721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 721

def _freeze_shareable_class_ivars!
  SHAREABLE_CLASS_IVARS.each do |(class_name, ivar)|
    mod = class_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil
    next unless mod && mod.instance_variable_defined?(ivar)
    val = mod.instance_variable_get(ivar)
    next if val.nil?
    begin
      Ractor.make_shareable(val)
      mod.instance_variable_set(ivar, val) rescue nil
    rescue
      nil
    end
  end
  # Pre-touch memoizing accessors so workers short-circuit instead of
  # writing the (now frozen) ivar on first read.
  begin
    ::ActiveSupport::Editor.current if defined?(::ActiveSupport::Editor)
  rescue
    nil
  end
  begin
    ::Warden::Strategies._strategies if defined?(::Warden::Strategies)
  rescue
    nil
  end
end

._generate_ar_attribute_methods!Object

Force AR attribute-method generation for every loaded model in the MAIN Ractor. See the call site in make_app_shareable! for why; without this a worker Ractor dies with Ractor::IsolationError on the first model instantiation (GeneratedAttributeMethods::LOCK is a non-shareable Monitor).



496
497
498
499
500
501
502
503
504
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 496

def _generate_ar_attribute_methods!
  return unless defined?(::ActiveRecord::Base)
  ::ActiveRecord::Base.descendants.each do |klass|
    next unless klass.respond_to?(:define_attribute_methods)
    klass.define_attribute_methods
  rescue StandardError
    nil
  end
end

._install_abstract_controller_patchObject

Patch AbstractController::Base.controller_path to not write/read the action_methods, clear_action_methods!, abstract!, abstract?, and _prefixes to route through IES or use the shareable fallback.



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 49

def _install_abstract_controller_patch
  return if @abstract_controller_patched
  @abstract_controller_patched = true
  _register_patch :abstract_controller, "8.1"
  return unless defined?(::AbstractController::Base)
  ac = ::AbstractController::Base

  # Populate the shareable abstract registry from every loaded controller
  # class's @abstract ivar (set by abstract! / inherited at boot). Workers
  # read this via the patched abstract? (per-class values can't live in
  # per-Ractor IES).
  registry = {}
  ac.descendants.each do |klass|
    begin
      registry[klass] = klass.instance_variable_get(:@abstract) if klass.instance_variable_defined?(:@abstract)
    rescue => e
      # ignore — best-effort
    end
  end
  registry[ac] = ac.instance_variable_get(:@abstract) if ac.instance_variable_defined?(:@abstract)
  registry.freeze
  Ractor.make_shareable(registry)
  self._abstract_registry = registry
  ac.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def controller_path
      cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_controller_path_cache] ||= {})
      v = cache[self]
      return v if v
      if Ractor.main? && instance_variable_defined?(:@controller_path)
        v = @controller_path
        cache[self] = v
        return v
      end
      computed = anonymous? ? nil : name.delete_suffix("Controller").underscore
      cache[self] = computed
      computed
    end

    # action_methods: `@action_methods ||= public_instance_methods(true) -
    # internal_methods).map(&:name).to_set` — raw class-ivar lazy init.
    # The value is a Set of Symbols (shareable once frozen). Route through
    # IES; workers compute it from public_instance_methods (no ivar read)
    # and cache in their own slot. Read per-request during dispatch.
    def action_methods
      cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_action_methods_cache] ||= {})
      v = cache[self]
      return v if v
      if Ractor.main? && instance_variable_defined?(:@action_methods)
        v = @action_methods
        cache[self] = v
        return v
      end
      methods = public_instance_methods(true) - internal_methods
      methods.map!(&:name)
      computed = methods.to_set
      cache[self] = computed
      computed
    end

    def clear_action_methods!
      if Ractor.main?
        @action_methods = nil
      end
      ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_action_methods_cache] = nil
    end

    # abstract! / abstract / abstract? — raw class ivar (@abstract), a
    # per-CLASS boolean. IES is per-Ractor (single value), so we can't use a
    # single IES key for all classes. Instead use a shareable registry
    # (Hash class→bool) built at prepare_for_ractors! time. Workers read
    # the registry; main reads its live @abstract ivar (set by abstract!
    # / inherited). `internal_methods` loops on abstract?.
    def abstract!
      RactorRailsShim._abstract_registry[self] = true if Ractor.main?
      @abstract = true if Ractor.main?
    end

    def abstract
      if Ractor.main? && instance_variable_defined?(:@abstract)
        @abstract
      else
        (RactorRailsShim._abstract_registry || RactorRailsShim::ABSTRACT_REGISTRY)[self] || false
      end
    end
    alias_method :abstract?, :abstract
  RUBY

  # Patch ActionView::ViewPaths::ClassMethods#_prefixes (overrides any
  # Base version). Original: `@_prefixes ||= begin; return local_prefixes
  # if superclass.abstract?; local_prefixes + superclass._prefixes; end`.
  # @_prefixes is a per-CLASS class ivar (workers can't read). Recurse
  # using the patched abstract? and cache in a per-Ractor Hash by class.
  if defined?(::ActionView::ViewPaths::ClassMethods)
    vp = ::ActionView::ViewPaths::ClassMethods
    vp.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def _prefixes
        cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_vp_prefixes_cache] ||= {})
        v = cache[self]
        return v if v
        if Ractor.main? && instance_variable_defined?(:@_prefixes)
          v = @_prefixes
          cache[self] = v
          return v
        end
        computed = if superclass.respond_to?(:abstract?) && superclass.abstract?
          local_prefixes
        elsif superclass.respond_to?(:_prefixes)
          local_prefixes + superclass._prefixes
        else
          local_prefixes
        end
        cache[self] = computed
        computed
      end
    RUBY
  end

  # AbstractController::UrlFor::ClassMethods#action_methods ALSO has a
  # `@action_methods ||= ...` lazy init (it overrides Base.action_methods
  # to subtract route helper names). Patch it the same way.
  if defined?(::AbstractController::UrlFor::ClassMethods)
    url_for_cm = ::AbstractController::UrlFor::ClassMethods
    url_for_cm.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def action_methods
        cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_url_for_action_methods_cache] ||= {})
        v = cache[self]
        return v if v
        if Ractor.main? && instance_variable_defined?(:@action_methods)
          v = @action_methods
          cache[self] = v
          return v
        end
        # NOTE: the original reads `@action_methods ||= if _routes; super -
        # _routes.named_routes.helper_names; else; super; end`. But
        # `_routes` is a singleton method defined via `define_method` with
        # a block (route_set.rb:610), capturing the defining Ractor's
        # binding → "defined with an un-shareable Proc in a different
        # Ractor" when called from a worker. Instead, read the route set
        # directly from the shareable Rails.application (frozen, shared).
        base = super
        routes = Ractor.main? ? (respond_to?(:_routes) ? _routes : nil) : (defined?(::Rails) && ::Rails.application ? ::Rails.application.routes : nil)
        computed = if routes
          base - routes.named_routes.helper_names
        else
          base
        end
        cache[self] = computed
        computed
      end
    RUBY
  end
end

._install_action_controller_controller_name_patchObject

Patch ActionController::Metal.controller_name (a class method). It memoizes its computed String in a lazy class ivar (@controller_name ||=), which a worker Ractor cannot write. Route the cache through IsolatedExecutionState keyed by the class name so each Ractor builds its own copy; the computation is deterministic from the class name.



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 207

def _install_action_controller_controller_name_patch
  return if @action_controller_controller_name_patched
  @action_controller_controller_name_patched = true
  _register_patch :action_controller_controller_name, "8.1"
  return unless defined?(::ActionController::Metal)
  ::ActionController::Metal.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def controller_name
      key = :"ractor_rails_shim_controller_name_\#{name}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v if v
      cn = (name.demodulize.delete_suffix("Controller").underscore unless anonymous?)
      ActiveSupport::IsolatedExecutionState[key] = cn
      cn
    end
  RUBY
end

._install_action_dispatch_http_url_patchObject

ActionDispatch::Http::URL reads the tld_length class variable DIRECTLY (@@tld_length) in normalize_host and in the default-parameter of domain/subdomains/subdomain. Class variables are not readable from a non-main Ractor, so a worker raises "Ractor::IsolationError: can not access class variables ... @@tld_length". The shim routes the mattr_accessor :tld_length READER through IES, but the literal @@tld_length references bypass that reader. Replace them with the accessor method (which the shim's mattr_accessor rewrite makes worker-safe). domain/subdomains/subdomain live in the Url module mixed into ActionDispatch::Request, so patch that module too.



806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 806

def _install_action_dispatch_http_url_patch
  return if @action_dispatch_http_url_patched
  @action_dispatch_http_url_patched = true
  _register_patch :action_dispatch_http_url, "8.1"
  return unless defined?(::ActionDispatch::Http::URL)

  url = ::ActionDispatch::Http::URL
  # normalize_host is a module_function: build_host_url calls the
  # MODULE-LEVEL copy, so redefining the instance method alone leaves the
  # original (@@tld_length-reading) one in place. Patch the singleton
  # (module-level) method instead.
  url.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def normalize_host(_host, options)
      return _host unless named_host?(_host)
      tld_length = options[:tld_length] || tld_length()
      subdomain  = options.fetch :subdomain, true
      domain     = options[:domain]
      host = +""
      if subdomain == true
        return _host if domain.nil?
        host << extract_subdomains_from(_host, tld_length).join(".")
      elsif subdomain
        host << subdomain.to_param
      end
      host << "." unless host.empty?
      host << (domain || extract_domain_from(_host, tld_length))
      host
    end
  RUBY

  if defined?(::ActionDispatch::Http::URL::Url)
    ::ActionDispatch::Http::URL::Url.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def domain(tld_length = tld_length())
        ActionDispatch::Http::URL.extract_domain(host, tld_length)
      end
      def subdomains(tld_length = tld_length())
        ActionDispatch::Http::URL.extract_subdomains(host, tld_length)
      end
      def subdomain(tld_length = tld_length())
        ActionDispatch::Http::URL.extract_subdomain(host, tld_length)
      end
    RUBY
  end
end

._install_action_dispatch_mounted_helpers_patchObject

Patch ActionDispatch::Routing::RouteSet::MountedHelpers#main_app (and its _main_app worker). main_app is define_method-ed at boot capturing the MAIN ractor's RouteSet + url_helpers in its block binding, so calling it from a worker Ractor raises "defined with an un-shareable Proc in a different Ractor". Devise's _devise_route_context calls send(:main_app) to get the route context for its url helpers. Redefine via string eval, building the RoutesProxy from the shareable RouteSet (RactorRailsShim::SHAREABLE_ROUTES) so workers get a valid context.



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 183

def _install_action_dispatch_mounted_helpers_patch
  return if @mounted_helpers_patched
  @mounted_helpers_patched = true
  _register_patch :mounted_helpers, "8.1"
  return unless defined?(::ActionDispatch::Routing::RouteSet::MountedHelpers)
  mh = ::ActionDispatch::Routing::RouteSet::MountedHelpers
  return unless mh.method_defined?(:main_app)
  mh.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def _main_app
      ::ActionDispatch::Routing::RoutesProxy.new(
        RactorRailsShim::SHAREABLE_ROUTES,
        _routes_context,
        RactorRailsShim::SHAREABLE_ROUTES.url_helpers,
        nil
      )
    end
    def main_app
      @_main_app ||= _main_app
    end
  RUBY
end

._install_action_dispatch_routing_patchObject

Patch ActionDispatch::Routing::RouteSet URL generation. The named route helpers (post_path, session_path, ...) are generated at boot in the main Ractor by RouteSet#add, which captures the PATH / UNKNOWN lambda constants (route_set.rb:349-350) into each helper's url_strategy ivar. Those lambdas were defined in the main Ractor, so calling them from a worker Ractor raises RuntimeError: defined with an un-shareable Proc in a different Ractor. Replace them with shareable Callable objects (Plain old objects with a #call method, made shareable via Ractor.make_shareable) that delegate to ActionDispatch::Http::URL (module methods, callable from any Ractor).



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 215

def _install_action_dispatch_routing_patch
  return if @action_dispatch_routing_patched
  @action_dispatch_routing_patched = true
  _register_patch :action_dispatch_routing, "8.1"
  return unless defined?(::ActionDispatch::Routing::RouteSet)
  return unless defined?(::ActionDispatch::Http::URL)

  # Shareable Callable replacements for the PATH / UNKNOWN lambda constants.
  unless RactorRailsShim.const_defined?(:AVPathStrategy)
    RactorRailsShim.const_set(:AVPathStrategy,
      Ractor.make_shareable(Object.new.tap do |o|
        def o.call(options)
          ActionDispatch::Http::URL.path_for(options)
        end
      end))
  end
  unless RactorRailsShim.const_defined?(:AVUnknownStrategy)
    RactorRailsShim.const_set(:AVUnknownStrategy,
      Ractor.make_shareable(Object.new.tap do |o|
        def o.call(options)
          ActionDispatch::Http::URL.url_for(options)
        end
      end))
  end

  rs = ::ActionDispatch::Routing::RouteSet
  # `RouteSet::PATH` / `RouteSet::UNKNOWN` (route_set.rb:349-350) are lambdas
  # defined in the main Ractor. They are referenced as default parameter
  # values (`def url_for(..., url_strategy = UNKNOWN, ...)`) and inside
  # `path_for`/`define_url_helper`. Reading those constants from a worker
  # Ractor raises `Ractor::IsolationError: can not access non-shareable
  # objects in constant ...UNKNOWN`. Replace them with the shareable
  # Callable objects (which perform the identical `ActionDispatch::Http::URL`
  # lookups) so workers read a shareable constant instead of an unshareable
  # lambda. Behaviour is unchanged in main (same `#call(options)` contract).
  unless rs.const_defined?(:PATH) && Ractor.shareable?(rs.const_get(:PATH))
    verbose = $VERBOSE
    $VERBOSE = nil
    rs.const_set(:PATH, RactorRailsShim::AVPathStrategy)
    rs.const_set(:UNKNOWN, RactorRailsShim::AVUnknownStrategy)
    $VERBOSE = verbose if defined?(verbose)
  end
  # `ActionDispatch::Journey::Router::Utils::ENCODER` (`UriEncoder.new`) and
  # its sibling constants (`DEC2HEX`, `EMPTY`, `US_ASCII`, the unreserved/
  # segment regexes, ...) are referenced by `escape_path`/`escape_segment`,
  # which the journey URL formatter invokes while building a path in a worker
  # Ractor. An unfrozen object/array/string held in a constant is unshareable,
  # so workers reading it raise IsolationError. Freeze each constant in place
  # (via `Ractor.make_shareable`) so the shareable-frozen values are readable
  # from any Ractor.
  if defined?(::ActionDispatch::Journey::Router::Utils)
    utu = ::ActionDispatch::Journey::Router::Utils
    utu.constants.each do |c|
      begin
        v = utu.const_get(c)
        Ractor.make_shareable(v) if v && !Ractor.shareable?(v)
      rescue
        nil
      end
    end
  end
  # `RouteSet::RESERVED_OPTIONS` (route_set.rb:838) is a mutable Array used
  # as a default parameter value in `url_for`/`path_for`. A non-frozen Array
  # is unshareable, so workers reading the constant raise IsolationError.
  # Freeze it in place so the constant becomes shareable.
  begin
    Ractor.make_shareable(rs.const_get(:RESERVED_OPTIONS))
  rescue
    nil
  end
  # Warm the lazy (memoized) caches on every Journey route and its
  # Path::Pattern BEFORE `make_app_shareable!` deep-freezes them. Several of
  # these caches are filled with `||=` (e.g. `Route#parts`,
  # `Route#required_parts`, `Route#required_defaults`,
  # `Path::Pattern#requirements_for_missing_keys_check`, `#to_regexp`,
  # `#offsets`, `#required_names`, `#optional_names`). They are computed
  # deterministically, but assigning the memoized ivar on a frozen object
  # from a worker Ractor raises FrozenError. Computing them here (in main,
  # while the objects are still mutable) populates the ivars so the frozen,
  # shared copies already hold the values and workers only read them.
  if Ractor.main? && defined?(::Rails) && ::Rails.application
    begin
      rset = ::Rails.application.routes
      all = []
      all.concat(rset.named_routes.send(:routes).values) rescue nil
      all.concat(rset.set.routes) rescue nil
      all.uniq.each do |route|
        next unless route.respond_to?(:path)
        route.parts rescue nil
        route.required_parts rescue nil
        route.required_defaults rescue nil
        p = route.path
        p.requirements_for_missing_keys_check rescue nil
        p.to_regexp rescue nil
        p.offsets rescue nil
        p.required_names rescue nil
        p.optional_names rescue nil
      end
    rescue
      nil
    end
  end
  # Capture the (shareable) RouteSet so workers can build URLs without
  # calling `#_routes` — which is `define_method(:_routes) { @_routes ||
  # routes }` (route_set.rb:612), a block capturing the main Ractor's
  # `routes` reference. Calling that block from a worker raises
  # "defined with an un-shareable Proc in a different Ractor". We stash the
  # RouteSet as a shareable constant and point `_routes` at it.
  if Ractor.main?
    begin
      routes = Rails.application.routes if defined?(::Rails) && ::Rails.application
      unless routes.nil?
        verbose = $VERBOSE
        $VERBOSE = nil
        RactorRailsShim.const_set(:SHAREABLE_ROUTES, routes) unless RactorRailsShim.const_defined?(:SHAREABLE_ROUTES)
      end
    rescue
      nil
    ensure
      $VERBOSE = verbose if defined?(verbose)
    end
  end

  # RouteSet#url_for receives url_strategy (the captured PATH/UNKNOWN
  # lambda) and calls `url_strategy.call options` internally. Coerce a
  # non-shareable strategy to the shareable Callable before delegating.
  unless rs.method_defined?(:url_for_without_shim)
    rs.alias_method(:url_for_without_shim, :url_for)
  end
  rs.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def url_for(options, route_name = nil, url_strategy = UNKNOWN, method_name = nil, reserved = RESERVED_OPTIONS)
      url_strategy = RactorRailsShim::AVUnknownStrategy unless Ractor.shareable?(url_strategy)
      url_for_without_shim(options, route_name, url_strategy, method_name, reserved)
    end
  RUBY

  # OptimizedUrlHelper#call invokes `url_strategy.call options` DIRECTLY
  # (route_set.rb:228) without going through url_for, so the coercion above
  # doesn't cover it. Redefine it to call the shareable strategy Callable
  # (the one passed in by our redefined helper methods), replicating the
  # original body exactly otherwise.
  ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper::OptimizedUrlHelper.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def call(t, method_name, args, inner_options, url_strategy)
      if args.size == arg_size && !inner_options && optimize_routes_generation?(t)
        options = t.url_options.merge @options
        path = optimized_helper(args)
        path << "/" if options[:trailing_slash] && !path.end_with?("/")
        options[:path] = path
        original_script_name = options.delete(:original_script_name)
        script_name = t._routes.find_script_name(options)
        if original_script_name
          script_name = original_script_name + script_name
        end
        options[:script_name] = script_name
        strat = Ractor.shareable?(url_strategy) ? url_strategy : RactorRailsShim::AVPathStrategy
        strat.call(options)
      else
        super
      end
    end
  RUBY

  # The base (non-optimized) `UrlHelper#call` (route_set.rb:278) is hit
  # whenever a helper is generated as a plain `UrlHelper` (e.g. our re-run
  # loop) or when optimization is skipped. The original reads `t.url_options`
  # and `t._routes`, both of which assume `t` is a controller/view context
  # whose `_routes`/`url_options` are reachable from a worker. In practice
  # `t` may be the `NamedRouteCollection` (helpers proxy) or any object that
  # lacks these. Route both through the shareable RouteSet / url-options
  # snapshot, falling back to `t`'s own accessors only when it actually
  # provides them (real controller/view). This makes path generation
  # (host-independent) work from any Ractor regardless of `t`.
  ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def call(t, method_name, args, inner_options, url_strategy)
      begin
        controller_options = t.url_options
      rescue
        controller_options = RactorRailsShim::URL_OPTIONS_DEFAULTS || {}
      end
      options = controller_options.merge @options
      hash = handle_positional_args(controller_options, inner_options || {}, args, options, @segment_keys)
      begin
        routes = t._routes
      rescue
        routes = RactorRailsShim::SHAREABLE_ROUTES
      end
      routes.url_for(hash, route_name, url_strategy, method_name)
    end
  RUBY

  # Named route helpers (`post_path`, `post_url`, ...) are generated by
  # `NamedRouteCollection#define_url_helper` (route_set.rb:333) via
  # `mod.define_method(name) { |*args| ... helper.call(...) }` — a BLOCK that
  # captures the `helper` object (an OptimizedUrlHelper holding the route)
  # and the `url_strategy` lambda (PATH/UNKNOWN, both defined in main).
  # Calling that block from a worker Ractor raises
  # "defined with an un-shareable Proc in a different Ractor" before any
  # code runs. Patch `define_url_helper` to (a) make the helper shareable
  # via `Ractor.make_shareable` (deep-freeze; routes are read-only after
  # boot) and stash it in a shareable Hash keyed by name, and (b) define the
  # method with a STRING (no captured binding) that references the Hash and
  # the shareable strategy Callable directly.
  unless RactorRailsShim.const_defined?(:URL_HELPERS)
    RactorRailsShim.const_set(:URL_HELPERS, {})
  end
  nrc = ::ActionDispatch::Routing::RouteSet::NamedRouteCollection
  unless nrc.method_defined?(:define_url_helper_without_shim)
    nrc.alias_method(:define_url_helper_without_shim, :define_url_helper)
  end
  nrc.define_method(:define_url_helper) do |mod, name, helper, url_strategy|
    begin
      # Detach the helper from the live route object before deep-freezing
      # it for cross-Ractor sharing. The non-optimized UrlHelper#call only
      # needs @options / @segment_keys / @route_name to build the options
      # hash and then delegates to `t._routes.url_for(route_name, ...)`,
      # which looks the route up in the (shareable) RouteSet by name. The
      # @route reference would pull the whole route graph into the freeze,
      # freezing objects that make_app_shareable! must still be able to
      # mutate (e.g. Devise route constraints) -> FrozenError.
      if helper.respond_to?(:instance_variable_get)
        helper.instance_variable_set(:@route, nil) rescue nil
        opts = helper.instance_variable_get(:@options)
        helper.instance_variable_set(:@options, opts.dup.freeze) rescue nil
        segs = helper.instance_variable_get(:@segment_keys)
        helper.instance_variable_set(:@segment_keys, segs.dup.freeze) rescue nil
      end
      helper = Ractor.make_shareable(helper)
    rescue
      nil
    end
    RactorRailsShim::URL_HELPERS[name] = helper
    strategy_const = url_strategy.equal?(::ActionDispatch::Routing::RouteSet::PATH) ?
      "AVPathStrategy" : "AVUnknownStrategy"
    mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def #{name}(*args)
        last = args.last
        options = \\
          case last
          when ::Hash
            args.pop
          when ::ActionController::Parameters
            args.pop.to_h
          end
        RactorRailsShim::URL_HELPERS[#{name.inspect}].call(
          self, #{name.inspect}, args, options, RactorRailsShim::#{strategy_const})
      end
    RUBY
  end

  # Re-run the (now patched) helper generation for every route already
  # drawn at boot, so the helpers the app actually uses are worker-safe.
  # Routes are drawn during `Rails.application.initialize!`, which runs
  # before `prepare_for_ractors!`, so the originals are still block-based.
  if Ractor.main? && defined?(::Rails) && ::Rails.application
    begin
      named = ::Rails.application.routes.named_routes
      path_mod = named.instance_variable_get(:@path_helpers_module)
      url_mod = named.instance_variable_get(:@url_helpers_module)
      named.send(:routes).each do |route_name, route|
        # Build the helper directly via `UrlHelper.new` (NOT `UrlHelper.create`,
        # which calls `optimize_helper?` -> `route.glob?` -> `route.path.ast.glob?`
        # and `route.path.ast` is nil by the time routes are finalized post-boot).
        helper = ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper.new(
          route, route.defaults, route_name)
        named.define_url_helper(path_mod, :"#{route_name}_path", helper, ::ActionDispatch::Routing::RouteSet::PATH) if path_mod
        named.define_url_helper(url_mod, :"#{route_name}_url", helper, ::ActionDispatch::Routing::RouteSet::UNKNOWN) if url_mod
      end
      verbose = $VERBOSE
      $VERBOSE = nil
      RactorRailsShim.const_set(:URL_HELPERS, Ractor.make_shareable(RactorRailsShim::URL_HELPERS)) unless Ractor.shareable?(RactorRailsShim::URL_HELPERS)
    rescue
      nil
    ensure
      $VERBOSE = verbose if defined?(verbose)
    end
  end

  # `ActionController::UrlFor#url_options` (action_controller/metal/url_for.rb:45)
  # builds its option hash from `request.host` / `request.optional_port` /
  # `request.protocol` / `request.path_parameters` and merges in
  # `default_url_options`. The controller instance rendered in a worker Ractor
  # has a `request` built from the shared Rack env, but the values it returns
  # (and the `default_url_options` class value, an unshareable Hash stored as a
  # class ivar on ActionController::Base) cannot be read/called from a worker
  # without raising Ractor isolation errors. For path-only helpers (the common
  # case in views) the host/port/protocol are irrelevant, and `default_url_options`
  # is the same deterministic value everywhere, so capture it once in main as a
  # shareable snapshot and have workers use it directly, skipping the
  # request-derived portion.
  unless RactorRailsShim.const_defined?(:URL_OPTIONS_DEFAULTS)
    begin
      if Ractor.main? && defined?(::ActionController::Base)
        defaults = ::ActionController::Base.default_url_options
        defaults = defaults.dup.freeze if defaults.respond_to?(:freeze)
        RactorRailsShim.const_set(:URL_OPTIONS_DEFAULTS, Ractor.make_shareable(defaults))
      end
    rescue
      nil
    end
  end
  if defined?(::ActionController::UrlFor)
    ::ActionController::UrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def url_options
        return super if Ractor.main?
        @_url_options ||= begin
          opts = (RactorRailsShim::URL_OPTIONS_DEFAULTS || {}).dup
          begin
            req = request if respond_to?(:request)
            if req
              opts[:host] = req.host if opts[:host].nil? && req.respond_to?(:host)
              opts[:protocol] = req.protocol if opts[:protocol].nil? && req.respond_to?(:protocol)
              opts[:port] = req.port if opts[:port].nil? && req.respond_to?(:port)
              opts[:_recall] = req.path_parameters if req.respond_to?(:path_parameters)
            end
          rescue
            nil
          end
          opts.freeze
        end
      end
    RUBY
  end

  # NOTE: the block-based `_routes` accessors that break workers are now
  # fixed at their source by `_install_url_helpers_patch` (patches/
  # url_helpers.rb), which intercepts `Module#redefine_singleton_method`
  # / `Module#define_method` for `:_routes` and replaces the main-Ractor
  # block with a string-eval'd method returning `Rails.application.routes`.
  # No per-class enumeration needed.
end

._install_action_view_field_type_patchObject

Patch ActionView::Helpers::Tags::TextField.field_type (and the subclasses EmailField/PasswordField/... that inherit it). The original memoizes its computed String in a lazy class ivar (@field_type ||= name...). The class ivar is per-subclass and unshareable-writable from a worker Ractor. Route the cache through IsolatedExecutionState keyed by the class name so each Ractor builds its own copy; the computation is deterministic.



544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 544

def _install_action_view_field_type_patch
  return if @action_view_field_type_patched
  @action_view_field_type_patched = true
  _register_patch :action_view_field_type, "8.1"
  return unless defined?(::ActionView::Helpers::Tags::TextField)
  tf = ::ActionView::Helpers::Tags::TextField
  tf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def field_type
      key = :"ractor_rails_shim_field_type_\#{name}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v if v
      ft = name.split("::").last.sub("Field", "").downcase
      ActiveSupport::IsolatedExecutionState[key] = ft
      ft
    end
  RUBY
end

._install_action_view_partial_path_patchObject

Patch ActionView::AbstractRenderer::ObjectRendering#partial_path. The original reads PREFIXED_PARTIAL_NAMES — a Concurrent::Map constant (nested Concurrent::Maps) — and writes a nested entry via PREFIXED_PARTIAL_NAMES[@context_prefix][path] ||= .... Concurrent::Map is intrinsically unshareable (it refuses #freeze), so a worker Ractor cannot read the constant NOR write to it. Redefine the method to use a per-Ractor Hash via IsolatedExecutionState (each Ractor builds its own cache from merge_prefix_into_object_path, which is deterministic).



514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 514

def _install_action_view_partial_path_patch
  return if @action_view_partial_path_patched
  @action_view_partial_path_patched = true
  _register_patch :action_view_partial_path, "8.1"
  return unless defined?(::ActionView::AbstractRenderer)
  ::ActionView::AbstractRenderer::ObjectRendering.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def partial_path(object, view)
      object = object.to_model if object.respond_to?(:to_model)
      path = if object.respond_to?(:to_partial_path)
        object.to_partial_path
      else
        raise ArgumentError.new("\#{object.inspect}' is not an ActiveModel-compatible object. It must implement #to_partial_path.")
      end
      if view.prefix_partial_path_with_controller_namespace
        cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_prefixed_partial_names] ||= {})
        cache[@context_prefix] ||= {}
        cache[@context_prefix][path] ||= merge_prefix_into_object_path(@context_prefix, path.dup)
      else
        path
      end
    end
  RUBY
end

._install_action_view_resolver_patchObject

Patch ActionView::FileSystemResolver#_find_all. The original reads the resolver's @unbound_templates cache, which make_app_shareable! rewrites from a Concurrent::Map into a frozen Hash (Concurrent::Map refuses #freeze). The original then calls cache.compute_if_absent (a Concurrent::Map API) on it, which a frozen Hash lacks -> NoMethodError in a worker Ractor. Route the per-virtual-path cache through IsolatedExecutionState instead: each Ractor builds its own mutable Hash (deterministic from disk via unbound_templates_from_path), so the frozen shareable app graph is never mutated.



441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 441

def _install_action_view_resolver_patch
  return if @action_view_resolver_patched
  @action_view_resolver_patched = true
  _register_patch :action_view_resolver, "8.1"
  return unless defined?(::ActionView::FileSystemResolver)
  # Eager-load nested constants referenced below (workers can't autoload).
  if Ractor.main?
    ::ActionView::TemplateDetails rescue nil
    ::ActionView::TemplatePath rescue nil
  end
  ::ActionView::FileSystemResolver.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def _find_all(name, prefix, partial, details, key, locals)
      requested_details = key || ::ActionView::TemplateDetails::Requested.new(**details)
      virtual = ::ActionView::TemplatePath.virtual(name, prefix, partial)
      # Key the cache by resolver path AND virtual path: each resolver
      # (app/views, each gem) has its own @path and its own templates.
      # Keying only by virtual path would let the first resolver poison
      # the cache for all others (e.g. app/views caches [] for
      # devise/sessions/new, hiding the template that lives in the
      # devise gem resolver).
      cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_resolver_cache] ||= {})
      cache_key = [@path, virtual]
      unbound_templates =
        if cache.key?(cache_key)
          cache[cache_key]
        else
          path = ::ActionView::TemplatePath.build(name, prefix, partial)
          tmpls = unbound_templates_from_path(path)
          cache[cache_key] = tmpls
          tmpls
        end
      filter_and_sort_by_details(unbound_templates, requested_details).map do |unbound_template|
        unbound_template.bind_locals(locals)
      end
    end
  RUBY

  # Patch ActionView::Resolver::PathParser#parse. The resolver's
  # @path_parser instance is part of the shareable app graph frozen by
  # make_app_shareable!, and the original method memoizes its compiled
  # regex in `@regex ||= build_path_regex` — assigning @regex on a frozen
  # object raises FrozenError in a worker Ractor. Route the memoization
  # through IsolatedExecutionState keyed by the parser's object_id, so each
  # Ractor compiles its own regex once without mutating the frozen object.
  if defined?(::ActionView::Resolver::PathParser)
    pp = ::ActionView::Resolver::PathParser
    pp_key = :ractor_rails_shim_path_parser_regex
    pp_key_str = pp_key.inspect
    pp.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def parse(path)
        regex = ActiveSupport::IsolatedExecutionState[:"#{pp_key_str}_\#{object_id}"] ||= build_path_regex
        match = regex.match(path)
        path = ::ActionView::TemplatePath.build(match[:action], match[:prefix] || "", !!match[:partial])
        details = ::ActionView::TemplateDetails.new(
          match[:locale]&.to_sym,
          match[:handler]&.to_sym,
          match[:format]&.to_sym,
          match[:variant]&.to_sym
        )
        ::ActionView::Resolver::PathParser::ParsedPath.new(path, details)
      end
    RUBY
  end
end

._install_action_view_safe_join_patchObject

Patch ActionView::Helpers::OutputSafetyHelper#safe_join. Its default separator parameter is sep = $, — a reference to the $ global, which a worker Ractor cannot read (Ractor::IsolationError: can not access global variable $,). The $ global is nil in every normal Rails process, so defaulting to nil reproduces the identical behaviour without touching the global.



568
569
570
571
572
573
574
575
576
577
578
579
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 568

def _install_action_view_safe_join_patch
  return if @action_view_safe_join_patched
  @action_view_safe_join_patched = true
  _register_patch :action_view_safe_join, "8.1"
  return unless defined?(::ActionView::Helpers::OutputSafetyHelper)
  ::ActionView::Helpers::OutputSafetyHelper.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def safe_join(array, sep = nil)
      sep = ERB::Util.unwrapped_html_escape(sep)
      array.flatten.map! { |i| ERB::Util.unwrapped_html_escape(i) }.join(sep).html_safe
    end
  RUBY
end

._install_active_model_attribute_patchObject

See patches/active_model_attribute.rb. When the frozen :ractor graph is built, each model class's _default_attributes template (and the FromDatabase instances within it) is deep-frozen. Attribute#dup_or_share returns self for immutable column types, so a worker's NEW record would share a frozen Attribute and raise FrozenError on first read/write. This patch makes a frozen receiver yield a fresh, mutable Attribute so writes (POST/create) work in workers. No-op in normal (unfrozen) Rails.



547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
# File 'lib/ractor_rails_shim/patches/core.rb', line 547

def _install_active_model_attribute_patch
  return @am_attribute_patched if defined?(@am_attribute_patched) && @am_attribute_patched
  @am_attribute_patched = true
  return unless defined?(::ActiveModel::Attribute)
  ::ActiveModel::Attribute.include(::RactorRailsShim::ActiveModelAttributePatch)
  if defined?(::ActiveModel::AttributeRegistration) &&
     ::ActiveModel::AttributeRegistration.const_defined?(:ClassMethods)
    ::ActiveModel::AttributeRegistration::ClassMethods.prepend(
      ::RactorRailsShim::ActiveModelAttributeRegistrationPatch
    )
  end
  if defined?(::ActiveRecord::Attributes) &&
     ::ActiveRecord::Attributes.const_defined?(:ClassMethods)
    ::ActiveRecord::Attributes::ClassMethods.prepend(
      ::RactorRailsShim::ActiveRecordAttributesPatch
    )
  end
  if defined?(::ActiveRecord::ModelSchema) &&
     ::ActiveRecord::ModelSchema.const_defined?(:ClassMethods)
    ::ActiveRecord::ModelSchema::ClassMethods.prepend(
      ::RactorRailsShim::ActiveRecordModelSchemaPatch
    )
  end
end

._install_active_model_conversion_patchObject

Patch ActiveModel::Conversion::ClassMethods#_to_partial_path to route its lazy class-ivar cache (@_to_partial_path ||= ...) through IsolatedExecutionState. The cache holds a deterministic String derived from model_name, so each Ractor can build its own. Without this, the first render @posts / render post in a worker Ractor writes the class ivar and dies with Ractor::IsolationError: can not set instance variables of classes/modules by non-main Ractors. Seen via ActionView's CollectionRenderer#render_collection_derive_partial -> to_partial_path.



762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 762

def _install_active_model_conversion_patch
  return if @active_model_conversion_patched
  @active_model_conversion_patched = true
  _register_patch :active_model_conversion, "8.1"
  return unless defined?(::ActiveModel::Conversion)
  amc = ::ActiveModel::Conversion
  amc.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    module ClassMethods
      def _to_partial_path
        key = :"ractor_rails_shim_to_partial_path_\#{name}"
        v = ActiveSupport::IsolatedExecutionState[key]
        return v if v
        path = if respond_to?(:model_name)
          "\#{model_name.collection}/\#{model_name.element}"
        else
          element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name))
          collection = ActiveSupport::Inflector.tableize(name)
          "\#{collection}/\#{element}"
        end
        path = path.freeze
        ActiveSupport::IsolatedExecutionState[key] = path
        path
      end
    end
  RUBY
end

._install_active_model_naming_patchObject

(an ActiveModel::Name holding unfrozen, unshareable Strings) on the model class. From a worker Ractor that write raises Ractor::IsolationError ("can not set instance variables of classes/modules by non-main Ractors") and reading the unshareable value raises too. Route the cache through IsolatedExecutionState (keyed by model object_id) so each Ractor builds and keeps its own ActiveModel::Name without touching the shared class ivar.



678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 678

def _install_active_model_naming_patch
  return if @am_naming_patched
  @am_naming_patched = true
  _register_patch :active_model_naming, "8.1"
  return unless defined?(::ActiveModel::Naming)
  mod = ::ActiveModel::Naming
  mod.module_eval do
    def model_name
      if Ractor.main?
        @_model_name ||= _rrs_compute_model_name
      else
        store = (ActiveSupport::IsolatedExecutionState[:rrs_model_names] ||= {})
        store[object_id] ||= _rrs_compute_model_name
      end
    end

    private

    def _rrs_compute_model_name
      namespace = module_parents.detect do |n|
        n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming?
      end
      ::ActiveModel::Name.new(self, namespace)
    end
  end
end

._install_active_record_core_patchObject

Patch ActiveRecord::Core::ClassMethods#arel_table / #predicate_builder / #type_caster. Each memoizes an unshareable value (@arel_table is an Arel::Table, @predicate_builder a PredicateBuilder) on the shared model class. From a worker Ractor the ||= write raises Ractor::IsolationError, and reading the unshareable cached value also raises. Build + cache each per-Ractor via IsolatedExecutionState (keyed by model object_id); main keeps the original class-ivar behavior.



607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 607

def _install_active_record_core_patch
  return if @ar_core_patched
  @ar_core_patched = true
  _register_patch :active_record_core, "8.1"
  return unless defined?(::ActiveRecord::Core::ClassMethods)
  mod = ::ActiveRecord::Core::ClassMethods
  mod.module_eval do
    def arel_table
      if Ractor.main?
        @arel_table ||= ::Arel::Table.new(table_name, klass: self)
      else
        store = (ActiveSupport::IsolatedExecutionState[:rrs_arel_tables] ||= {})
        store.fetch(object_id) { store[object_id] = ::Arel::Table.new(table_name, klass: self) }
      end
    end

    def predicate_builder
      if Ractor.main?
        @predicate_builder ||= ::ActiveRecord::PredicateBuilder.new(
          ::ActiveRecord::TableMetadata.new(self, arel_table))
      else
        store = (ActiveSupport::IsolatedExecutionState[:rrs_predicate_builders] ||= {})
        store.fetch(object_id) do
          store[object_id] = ::ActiveRecord::PredicateBuilder.new(
            ::ActiveRecord::TableMetadata.new(self, arel_table))
        end
      end
    end

    def type_caster
      if Ractor.main?
        @type_caster ||= ::ActiveRecord::TypeCaster::Map.new(self)
      else
        store = (ActiveSupport::IsolatedExecutionState[:rrs_type_casters] ||= {})
        store.fetch(object_id) { store[object_id] = ::ActiveRecord::TypeCaster::Map.new(self) }
      end
    end
  end
end

._install_active_record_inheritance_patchObject

Patch ActiveRecord::Inheritance::ClassMethods#finder_needs_type_condition?. It memoizes @finder_needs_type_condition (a Symbol) on the shared model class via @ivar ||=. From a worker Ractor that write raises Ractor::IsolationError. Route the value through IsolatedExecutionState (keyed by model object_id); main keeps the original class-ivar behavior.



652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 652

def _install_active_record_inheritance_patch
  return if @ar_inheritance_patched
  @ar_inheritance_patched = true
  _register_patch :active_record_inheritance, "8.1"
  return unless defined?(::ActiveRecord::Inheritance::ClassMethods)
  mod = ::ActiveRecord::Inheritance::ClassMethods
  mod.module_eval do
    def finder_needs_type_condition?
      if Ractor.main?
        :true == (@finder_needs_type_condition ||= descends_from_active_record? ? :false : :true)
      else
        store = (ActiveSupport::IsolatedExecutionState[:rrs_finder_type_cond] ||= {})
        store.fetch(object_id) do
          store[object_id] = descends_from_active_record? ? false : true
        end
      end
    end
  end
end

._install_active_record_model_schema_patchObject

Patch ActiveRecord::ModelSchema::ClassMethods so worker Ractors do not write the @table_name (and related) class ivars on the shared model class. table_name memoizes via reset_table_name unless defined?(@table_name), and reset_table_name calls self.table_name = which writes @table_name/@arel_table/etc. From a worker that write is Ractor::IsolationError. Route the value through IsolatedExecutionState (keyed by model object_id); main keeps the original class-ivar behavior.



557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 557

def _install_active_record_model_schema_patch
  return if @ar_model_schema_patched
  @ar_model_schema_patched = true
  _register_patch :active_record_model_schema, "8.1"
  return unless defined?(::ActiveRecord::ModelSchema::ClassMethods)
  mod = ::ActiveRecord::ModelSchema::ClassMethods
  mod.module_eval do
    def table_name
      if Ractor.main?
        reset_table_name unless defined?(@table_name)
        @table_name
      else
        store = (ActiveSupport::IsolatedExecutionState[:rrs_table_names] ||= {})
        store.fetch(object_id) { store[object_id] = compute_table_name }
      end
    end

    def table_name=(value)
      value = value && value.to_s
      if Ractor.main?
        if defined?(@table_name)
          return if value == @table_name
          reset_column_information if connected?
        end
        @table_name        = value
        @arel_table        = nil
        @sequence_name     = nil unless @explicit_sequence_name
        @predicate_builder = nil
      else
        (ActiveSupport::IsolatedExecutionState[:rrs_table_names] ||= {})[object_id] = value
      end
    end

    def reset_table_name
      if Ractor.main?
        super
      else
        table_name
      end
    end
  end
end

._install_active_support_error_reporter_patchObject

Patch ActiveSupport module's @error_reporter class ivar (defined via singleton_class.attr_accessor :error_reporter in active_support.rb:109) to not read from a worker Ractor. ExecutionWrapper.error_reporter delegates to ActiveSupport.error_reporter, which reads the @error_reporter ivar on the ActiveSupport module. Workers get a fresh ErrorReporter (no subscribers — correct for a read-only shared app where error reporting already ran in main via the Rails.error mechanism). Called per-request via ActionDispatch::Executor middleware.



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 295

def _install_active_support_error_reporter_patch
  return if @error_reporter_patched
  @error_reporter_patched = true
  _register_patch :error_reporter, "8.1"
  return unless defined?(::ActiveSupport)
  er_key = :ractor_rails_shim_active_support_error_reporter
  er_key_str = er_key.inspect
  ::ActiveSupport.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def error_reporter
      v = ActiveSupport::IsolatedExecutionState[#{er_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@error_reporter)
        @error_reporter
      else
        built = ActiveSupport::ErrorReporter.new
        ActiveSupport::IsolatedExecutionState[#{er_key_str}] = built
        built
      end
    end
    def error_reporter=(val)
      ActiveSupport::IsolatedExecutionState[#{er_key_str}] = val
      @error_reporter = val if Ractor.main?
      val
    end
  RUBY
end

._install_activerecord_configurations_patchObject

Patch ActiveRecord::Core.configurations / configurations= to route the raw @@configurations class variable (which a non-main Ractor cannot read or write) through IsolatedExecutionState, with a shareable (deep-frozen) fallback for worker Ractors. Connection establishment in a worker (ConnectionHandler#establish_connection -> resolve_pool_config -> ActiveRecord::Base.configurations) otherwise dies on the class variable. Captured in the main Ractor at prepare/make-shareable time.



796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 796

def _install_activerecord_configurations_patch
  return if @ar_configurations_patched
  @ar_configurations_patched = true
  _register_patch :activerecord_configurations, "8.1"
  return unless defined?(::ActiveRecord::Core)

  if Ractor.main?
    begin
      cfg = ::ActiveRecord::Base.configurations
      cfg = Ractor.make_shareable(cfg) if cfg
      if cfg
        verbose, $VERBOSE = $VERBOSE, nil
        begin
          const_set(:AR_CONFIGURATIONS_SHAREABLE, cfg)
        ensure
          $VERBOSE = verbose
        end
      end
    rescue => e
      # best-effort
    end
  end

  key = :ractor_rails_shim_ar_configurations
  key_str = key.inspect
  # ActiveRecord::Base gets `configurations` via ActiveSupport::Concern's
  # `class_methods` (it copies the method onto Base's singleton class), so
  # patching Core.singleton_class alone is not enough — redefine on Base's
  # singleton class directly. `@@configurations` resolves through the
  # include chain (Core's class var) in the main ractor; the worker branch
  # never touches the class var.
  ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def configurations
      v = ActiveSupport::IsolatedExecutionState[#{key_str}]
      return v unless v.nil?
      if Ractor.main?
        ActiveRecord::Core.class_variable_get(:@@configurations)
      else
        RactorRailsShim::AR_CONFIGURATIONS_SHAREABLE
      end
    end
    def configurations=(config)
      ActiveSupport::IsolatedExecutionState[#{key_str}] = config
      ActiveRecord::Core.class_variable_set(:@@configurations, config) if Ractor.main?
    end
  RUBY
end

._install_activerecord_connection_handler_patchObject

Patch ActiveRecord::Base to route default_connection_handler through IES, and ensure connection_handler returns the per-Ractor handler. In the main ractor, falls back to the original default_connection_handler (set at core.rb:248). In workers, falls back to nil (correct — workers must call init_worker_ar_connections! to establish their own handler). Also patches retrieve_connection / connected? / connection_pool to tolerate nil handler (raise a clear error message instead of NoMethodError on nil).



1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1266

def _install_activerecord_connection_handler_patch
  return if @ar_conn_handler_patched
  @ar_conn_handler_patched = true
  _register_patch :activerecord_connection_handler, "8.1"
  return unless defined?(::ActiveRecord::ConnectionHandling)

  # Capture configs at install time if AR is already loaded (main ractor).
  _capture_ar_configurations! if Ractor.main?

  # Capture the main ractor's default_connection_handler value BEFORE we
  # override the method below (the override shadows the original
  # class_attribute reader). The class_attribute reader (already patched
  # by the shim) routes through IES, so read the value now. Store it in
  # CLASS_ATTR_VALUES so the patched reader can find it, and seed IES so
  # connection_handler finds it immediately.
  dch_key = :ractor_rails_shim_ar_default_connection_handler
  dch_key_str = dch_key.inspect
  if Ractor.main?
    begin
      orig_handler = ::ActiveRecord::Base.default_connection_handler
      if orig_handler
        RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__] = orig_handler
        ActiveSupport::IsolatedExecutionState[dch_key] = orig_handler
      end
    rescue => e
      # Best-effort
    end
  end

  # Patch default_connection_handler to route through IES.
  # The class_attribute reader for default_connection_handler is already
  # patched by the shim (it's in the known-unshareable skip list → nil
  # in workers). We override the class method to return the per-Ractor
  # handler if set, then fall back to the original (main only) or nil.
  ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def default_connection_handler
      v = ActiveSupport::IsolatedExecutionState[#{dch_key_str}]
      return v unless v.nil?
      if Ractor.main?
        cv = RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__]
        return cv if cv
      end
      nil
    end
    def default_connection_handler=(val)
      ActiveSupport::IsolatedExecutionState[#{dch_key_str}] = val
    end
  RUBY

  # Also ensure connection_handler (which Rails already routes through IES
  # at core.rb:132-138) works. Rails' implementation:
  #   def self.connection_handler
  #     ActiveSupport::IsolatedExecutionState[:active_record_connection_handler] || default_connection_handler
  #   end
  # This is already correct — if the worker sets the IES key via
  # init_worker_ar_connections!, connection_handler returns it. If not,
  # it falls back to default_connection_handler (nil in workers).
  #
  # We just need to make sure connection_pool / retrieve_connection
  # give a clear error message when the handler is nil (instead of
  # NoMethodError: undefined method `retrieve_connection_pool' for nil).
  ::ActiveRecord::ConnectionHandling.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def connection_pool
      handler = connection_handler
      unless handler
        raise ActiveRecord::ConnectionNotEstablished,
          "No connection handler for Ractor \#{Ractor.current.object_id}. " \
          "Call RactorRailsShim.init_worker_ar_connections! in each worker " \
          "Ractor before serving requests."
      end
      handler.retrieve_connection_pool(connection_specification_name, role: current_role, shard: current_shard, strict: true)
    end
    def retrieve_connection
      handler = connection_handler
      unless handler
        raise ActiveRecord::ConnectionNotEstablished,
          "No connection handler for Ractor \#{Ractor.current.object_id}. " \
          "Call RactorRailsShim.init_worker_ar_connections! in each worker " \
          "Ractor before serving requests."
      end
      handler.retrieve_connection(connection_specification_name, role: current_role, shard: current_shard)
    end
    def connected?
      handler = connection_handler
      return false unless handler
      handler.connected?(connection_specification_name, role: current_role, shard: current_shard)
    end
  RUBY
end

._install_activerecord_db_config_handlers_patchObject

Patch ActiveRecord::DatabaseConfigurations.db_config_handlers (a singleton_class.attr_accessor, i.e. a class instance variable on the DatabaseConfigurations class) to route through IES with a shareable fallback. The value is an Array of adapter-registered handler Procs (register_db_config_handler { |env,name,url,config| ... }). Class instance variables are per-Ractor, so a worker's slot is empty even if main set one — and the worker cannot read main's. The handler Procs themselves CAN be made shareable (verified: the sqlite3 handler captures only the shareable HashConfig constant), so we deep-freeze the Array + each Proc in main and expose it as a shareable constant the worker reads. ConnectionHandler#establish_connection -> resolve_pool_config -> DatabaseConfigurations#resolve -> build_db_config_from_hash calls these Procs, so they must be shareable AND callable cross-Ractor.



857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 857

def _install_activerecord_db_config_handlers_patch
  return if @ar_dbch_patched
  @ar_dbch_patched = true
  _register_patch :activerecord_db_config_handlers, "8.1"
  return unless defined?(::ActiveRecord::DatabaseConfigurations)

  if Ractor.main?
    begin
      handlers = ::ActiveRecord::DatabaseConfigurations.db_config_handlers
      # Make each handler Proc shareable (freezes its binding). A shareable
      # Proc is callable from any Ractor.
      handlers.each { |h| Ractor.make_shareable(h) rescue nil }
      shareable = Ractor.make_shareable(handlers.dup)
      verbose, $VERBOSE = $VERBOSE, nil
      begin
        const_set(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable)
      ensure
        $VERBOSE = verbose
      end
    rescue => e
      # best-effort
    end
  end

  key = :ractor_rails_shim_ar_db_config_handlers
  key_str = key.inspect
  ::ActiveRecord::DatabaseConfigurations.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def db_config_handlers
      v = ActiveSupport::IsolatedExecutionState[#{key_str}]
      return v unless v.nil?
      if Ractor.main?
        @db_config_handlers
      else
        RactorRailsShim::AR_DB_CONFIG_HANDLERS_SHAREABLE
      end
    end
    def db_config_handlers=(val)
      ActiveSupport::IsolatedExecutionState[#{key_str}] = val
      @db_config_handlers = val if Ractor.main?
    end
  RUBY
end

._install_activerecord_deduplicable_patchObject

Patch Deduplicable::ClassMethods#registry to route the lazy class instance variable @registry through IES. registry returns @registry ||= {} — a mutable Hash used to deduplicate column metadata objects. It's called during schema introspection (Post.all -> columns -> new_column_from_field -> fetch_type_metadata -> Deduplicable.new -> deduplicate -> registry). The class instance variable write fails from a non-main Ractor. Fix: route through IES so each Ractor builds its own registry Hash.



1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1015

def _install_activerecord_deduplicable_patch
  return if @ar_deduplicable_patched
  @ar_deduplicable_patched = true
  _register_patch :activerecord_deduplicable, "8.1"
  return unless defined?(::ActiveRecord::ConnectionAdapters::Deduplicable)

  ::ActiveRecord::ConnectionAdapters::Deduplicable::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def registry
      key = :"ractor_rails_shim_dedup_registry_\#{name || object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v unless v.nil?
      h = {}
      ActiveSupport::IsolatedExecutionState[key] = h
      h
    end
  RUBY
end

._install_activerecord_delegation_patchObject

Patch ActiveRecord::Delegation.uncacheable_methods to route the lazy class instance variable @uncacheable_methods through IES.

uncacheable_methods is a class method on the Delegation module: `@uncacheable_methods ||= (delegated_classes.flat_map(&:public_instance_methods)

  • Relation.public_instance_methods).to_set.freeze. It's read during method_missingon relation delegate classes (e.g. when Kaminari callsPost.page(1).per(10)perisn't a standard Relation method, soClassSpecificRelation#method_missingchecksuncacheable_methods` to decide whether to delegate). The class instance variable write fails from a non-main Ractor (Ractor::IsolationError).

Fix: route through IES so each Ractor computes + caches its own Set. The computation is deterministic (same delegated_classes everywhere). String-eval'd (no captured binding), callable from any Ractor.



1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1405

def _install_activerecord_delegation_patch
  return if @ar_delegation_patched
  @ar_delegation_patched = true
  _register_patch :activerecord_delegation, "8.1"
  return unless defined?(::ActiveRecord::Delegation)

  ::ActiveRecord::Delegation.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def uncacheable_methods
      key = :ractor_rails_shim_ar_uncacheable_methods
      v = ActiveSupport::IsolatedExecutionState[key]
      return v unless v.nil?
      result = (
        delegated_classes.flat_map(&:public_instance_methods) - ActiveRecord::Relation.public_instance_methods
      ).to_set.freeze
      ActiveSupport::IsolatedExecutionState[key] = result
      result
    end
  RUBY
end

._install_activerecord_find_by_cache_patchObject

ActiveRecord::Base#cached_find_by_statement reads @find_by_statement_cache[connection.prepared_statements] (a Hash whose values are Concurrent::Maps) and calls cache.compute_if_absent(key). Concurrent::Map is unshareable, so make_app_shareable! replaces the maps with frozen Hashes whose values end up nil — and Hash has no compute_if_absent anyway. In a worker Ractor this raises NoMethodError: undefined method 'compute_if_absent' for nil, breaking find / find_by / take (but not where, which doesn't use the cache). Fix: in non-main Ractors, build the per-find-statement cache in IsolatedExecutionState (per-Ractor, mutable) keyed by the model class and connection.prepared_statements. Main keeps the original Concurrent::Map-backed behavior via super.



1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1488

def _install_activerecord_find_by_cache_patch
  return if @activerecord_find_by_cache_patched
  @activerecord_find_by_cache_patched = true
  _register_patch :activerecord_find_by_cache, "8.1"
  return unless defined?(::ActiveRecord::Base)

  ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def cached_find_by_statement(connection, key, &block)
      return super if Ractor.main?
      cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_find_by_cache_\#{object_id}"] ||= {})
      prepared = connection.prepared_statements
      sub = (cache[prepared] ||= {})
      if sub.key?(key)
        sub[key]
      else
        sub[key] = ::ActiveRecord::StatementCache.create(connection, &block)
      end
    end
  RUBY
end

._install_activerecord_migration_patchObject

Patch ActiveRecord::Migrator.migrations_paths (a singleton attr_accessor reading the @migrations_paths class ivar) and ActiveRecord::Migration::CheckPending (the dev pending-migration middleware) to be Ractor-safe. In dev, CheckPending runs on every request and reads Migrator.migrations_paths + mutates its own @watcher / @needs_check ivars on the (frozen, shared) middleware instance. Route the class ivar and the instance ivars through IsolatedExecutionState so each worker reads the main Ractor's migrations paths and builds its own watcher. This keeps the dev pending-migration guard working under kino :ractor instead of stripping the middleware.



1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1642

def _install_activerecord_migration_patch
  return if @activerecord_migration_patched
  @activerecord_migration_patched = true
  _register_patch :activerecord_migration, "8.1"
  return unless defined?(::ActiveRecord::Migrator)

  mig = ::ActiveRecord::Migrator
  mp_key = :ractor_rails_shim_migrator_migrations_paths
  mp_key_str = mp_key.inspect
  mig.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def migrations_paths
      v = ActiveSupport::IsolatedExecutionState[#{mp_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@migrations_paths)
        v = @migrations_paths
        ActiveSupport::IsolatedExecutionState[#{mp_key_str}] = v
        v
      else
        ["db/migrate"].freeze
      end
    end
    def migrations_paths=(val)
      ActiveSupport::IsolatedExecutionState[#{mp_key_str}] = val
      @migrations_paths = val if Ractor.main?
      val
    end
  RUBY

  return unless defined?(::ActiveRecord::Migration::CheckPending)
  cp = ::ActiveRecord::Migration::CheckPending
  w_key = :ractor_rails_shim_check_pending_watcher
  nc_key = :ractor_rails_shim_check_pending_needs_check
  w_key_str = w_key.inspect
  nc_key_str = nc_key.inspect
  cp.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def call(env)
      @mutex.synchronize do
        watcher = ActiveSupport::IsolatedExecutionState[#{w_key_str}]
        if watcher.nil?
          watcher = ActiveSupport::IsolatedExecutionState[#{w_key_str}] = build_watcher do
            ActiveSupport::IsolatedExecutionState[#{nc_key_str}] = true
            ::ActiveRecord::Migration.check_pending_migrations
            ActiveSupport::IsolatedExecutionState[#{nc_key_str}] = false
          end
        end
        needs_check = ActiveSupport::IsolatedExecutionState[#{nc_key_str}]
        needs_check = true if needs_check.nil?
        if needs_check
          watcher.execute
        else
          watcher.execute_if_updated
        end
      end
      @app.call(env)
    end
  RUBY
end

._install_activerecord_model_classes_patchObject

Register + run the model-class shareability patch (Blocker: AR model class lazy class-ivar initialization from workers).



541
542
543
544
545
546
547
548
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 541

def _install_activerecord_model_classes_patch
  return if @ar_model_classes_patched
  @ar_model_classes_patched = true
  _register_patch :activerecord_model_classes, "8.1"
  return unless defined?(::ActiveRecord::Base)
   if Ractor.main?
   if Ractor.main?
end

._install_activerecord_model_schema_patchObject

Patch ActiveRecord::ModelSchema::ClassMethods lazy class-ivar caches (symbol_column_to_string, content_columns, column_defaults) to route through IsolatedExecutionState. Each Ractor builds its own cache (deterministic from columns/columns_hash, which are warmed in main and read-only in workers). Without this, the first worker call that misses the cache tries to WRITE the class ivar (@symbol_column_to_string_name_hash ||= ...) and dies with Ractor::IsolationError: can not set instance variables of classes/modules by non-main Ractors. Seen via Devise's clean_up_passwords -> respond_to? -> symbol_column_to_string.



714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 714

def _install_activerecord_model_schema_patch
  return if @ar_model_schema_symbol_patched
  @ar_model_schema_symbol_patched = true
  _register_patch :activerecord_model_schema, "8.1"
  return unless defined?(::ActiveRecord::ModelSchema)

  ::ActiveRecord::ModelSchema::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def symbol_column_to_string(name_symbol)
      key = :"ractor_rails_shim_symbol_column_to_string_\#{self.name}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v[name_symbol] if v
      hash = column_names.index_by(&:to_sym)
      ActiveSupport::IsolatedExecutionState[key] = hash
      hash[name_symbol]
    end

    def content_columns
      key = :"ractor_rails_shim_content_columns_\#{self.name}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v if v
      cols = columns.reject do |c|
        c.name == primary_key ||
        c.name == inheritance_column ||
        c.name.end_with?("_id", "_count")
      end.freeze
      ActiveSupport::IsolatedExecutionState[key] = cols
      cols
    end

    def column_defaults
      key = :"ractor_rails_shim_column_defaults_\#{self.name}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v if v
      defaults = _default_attributes.deep_dup.to_hash.freeze
      ActiveSupport::IsolatedExecutionState[key] = defaults
      defaults
    end
  RUBY
end

._install_activerecord_module_attrs_patchObject

Patch ActiveRecord module-level singleton_class.attr_accessor attributes (schema_cache_ignored_tables, database_cli, etc.) to route through IES with shareable fallbacks. These are class instance variables on the ActiveRecord module that workers can't read/write. Each is an Array or Hash of simple literals, so they can be deep-frozen and shared.



959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 959

def _install_activerecord_module_attrs_patch
  return if @ar_module_attrs_patched
  @ar_module_attrs_patched = true
  _register_patch :activerecord_module_attrs, "8.1"
  return unless defined?(::ActiveRecord)

  # [method_name, const_name] pairs. The const holds the shareable snapshot.
  attrs = [
    [:schema_cache_ignored_tables, :AR_SCHEMA_CACHE_IGNORED_TABLES_SHAREABLE],
    [:database_cli, :AR_DATABASE_CLI_SHAREABLE],
  ]

  attrs.each do |method_name, const_name|
    if Ractor.main?
      begin
        val = ::ActiveRecord.public_send(method_name)
        shareable = Ractor.make_shareable(val.is_a?(::Array) ? val.dup : val)
        verbose, $VERBOSE = $VERBOSE, nil
        begin
          const_set(const_name, shareable)
        ensure
          $VERBOSE = verbose
        end
      rescue => e
        # best-effort
      end
    end

    key = :"ractor_rails_shim_ar_#{method_name}"
    key_str = key.inspect
    const_str = const_name.to_s
    ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def #{method_name}
        v = ActiveSupport::IsolatedExecutionState[#{key_str}]
        return v unless v.nil?
        if Ractor.main?
          @#{method_name}
        else
          RactorRailsShim::#{const_str}
        end
      end
      def #{method_name}=(val)
        ActiveSupport::IsolatedExecutionState[#{key_str}] = val
        @#{method_name} = val if Ractor.main?
      end
    RUBY
  end
end

._install_activerecord_pool_config_patchObject

Patch ActiveRecord::ConnectionAdapters::PoolConfig#initialize to skip writing to the INSTANCES ObjectSpace::WeakMap registry in non-main Ractors. This is the first wall a worker hits when establishing a connection (ConnectionHandler#establish_connection -> resolve_pool_config -> PoolConfig.new -> INSTANCES[self] = self).

INSTANCES is a private_constant ObjectSpace::WeakMap. A WeakMap is intrinsically unshareable (it can't be frozen / made shareable), and a non-main Ractor cannot access the constant at all (Ractor::IsolationError: "can not access non-shareable objects in constant ... by non-main ractor").

The registry is ONLY used by the class methods discard_pools! and disconnect_all! (which iterate all pool configs to disconnect/reload). Those are called during reloading (dev) and explicit disconnect — never in a read-only production worker serving requests. So skipping the registry write in workers is safe: workers manage their own per-Ractor handler + pools, and the main ractor's registry stays intact for reload.

We redefine initialize via string eval (no captured binding) so it's callable from any Ractor. The body replicates the original exactly except the final INSTANCES[self] = self is guarded by Ractor.main?. The private INSTANCES constant is accessible via constant lookup because the method is defined on PoolConfig itself.



1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1090

def _install_activerecord_pool_config_patch
  return if @ar_pool_config_patched
  @ar_pool_config_patched = true
  _register_patch :activerecord_pool_config, "8.1"
  return unless defined?(::ActiveRecord::ConnectionAdapters::PoolConfig)

  ::ActiveRecord::ConnectionAdapters::PoolConfig.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def initialize(connection_class, db_config, role, shard)
      super()
      @server_version = nil
      self.connection_descriptor = connection_class
      @db_config = db_config
      @role = role
      @shard = shard
      @pool = nil
      INSTANCES[self] = self if Ractor.main?
    end
  RUBY
end

._install_activerecord_primary_key_patchObject

Patch ActiveRecord::AttributeMethods::PrimaryKey#primary_key and #composite_primary_key? to not read the PRIMARY_KEY_NOT_SET constant.

The original code: reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key) reads the constant on every call. PRIMARY_KEY_NOT_SET is a BasicObject (can't be frozen, can't be made shareable), so reading the constant from a worker Ractor raises Ractor::IsolationError — even if @primary_key is already set to the real value.

Fix: replace the sentinel check with a shareable-snapshot lookup. At _share_model_classes! time, each model's primary_key is warmed in main and stored in AR_PRIMARY_KEYS_SHAREABLE (a frozen Hash). The patched primary_key method checks IES first (per-Ractor), then the shareable snapshot, then falls back to the original logic in the main ractor. Workers never read the constant. String-eval'd (no captured binding).



1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1440

def _install_activerecord_primary_key_patch
  return if @ar_primary_key_patched
  @ar_primary_key_patched = true
  _register_patch :activerecord_primary_key, "8.1"
  return unless defined?(::ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods)

  mod = ::ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods
  mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def primary_key
      key = :"ractor_rails_shim_pk_\#{name || object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v unless v.nil?
      if Ractor.main?
        reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
        v = @primary_key
        ActiveSupport::IsolatedExecutionState[key] = v
        v
      else
        RactorRailsShim::AR_PRIMARY_KEYS_SHAREABLE[name]
      end
    end
    def composite_primary_key?
      key = :"ractor_rails_shim_pk_\#{name || object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v.is_a?(::Array) unless v.nil?
      if Ractor.main?
        reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
        @primary_key.is_a?(::Array)
      else
        pk = RactorRailsShim::AR_PRIMARY_KEYS_SHAREABLE[name]
        pk.is_a?(::Array)
      end
    end
  RUBY
end

._install_activerecord_query_constraints_patchObject

Patch Persistence::ClassMethods#query_constraints_list and #has_query_constraints? to route the lazy @query_constraints_list class ivar through IES. query_constraints_list does @query_constraints_list ||= <computation> — the class ivar write fails from a non-main Ractor. Called during Post.first -> ordered_relation -> _order_columns.



1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1038

def _install_activerecord_query_constraints_patch
  return if @ar_query_constraints_patched
  @ar_query_constraints_patched = true
  _register_patch :activerecord_query_constraints, "8.1"
  return unless defined?(::ActiveRecord::Persistence::ClassMethods)

  ::ActiveRecord::Persistence::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def query_constraints_list
      key = :"ractor_rails_shim_qcl_\#{name || object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v unless v.nil?
      result = if base_class? || primary_key != base_class.primary_key
        primary_key if primary_key.is_a?(::Array)
      else
        base_class.query_constraints_list
      end
      ActiveSupport::IsolatedExecutionState[key] = result
      result
    end
    def has_query_constraints?
      key = :"ractor_rails_shim_qcl_\#{name || object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return !v.nil? unless v.nil?
      result = query_constraints_list
      !result.nil?
    end
  RUBY
end

._install_activerecord_query_logs_patchObject

Patch ActiveRecord::QueryLogs#tag_content. It reads the @handlers and @formatter class ivars (populated from config.active_record.query_log_tags during boot) on every SQL statement, so a worker Ractor raises Ractor::IsolationError. cached_comment is a thread_mattr_accessor (already Ractor-safe), so only the handlers/formatter need handling. Capture a shareable snapshot in main (snapshot_query_logs!, post-boot) and have workers build the comment from it — query-log tags then work in workers exactly as in dev's main Ractor.



1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1547

def _install_activerecord_query_logs_patch
  return if @query_logs_patched
  @query_logs_patched = true
  _register_patch :query_logs, "8.1"
  return unless defined?(::ActiveRecord::QueryLogs)
  ql = ::ActiveRecord::QueryLogs
  # tag_content is defined as a SINGLETON method on ActiveRecord::QueryLogs
  # (not an instance method), so alias the singleton method (not an
  # instance one) and fall back to it for the main Ractor / when the
  # snapshot is unavailable.
  ql.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    alias_method :__ractors_original_tag_content, :tag_content
    def tag_content(connection)
      return __ractors_original_tag_content(connection) if Ractor.main?
      snap = ::RactorRailsShim::QUERY_LOGS_SNAPSHOT
      return __ractors_original_tag_content(connection) unless snap
      format = snap[:format]
      formatter = case format
        when :sqlcommenter then ::ActiveRecord::QueryLogs::SQLCommenter
        else ::ActiveRecord::QueryLogs::LegacyFormatter
      end
      return nil unless formatter
      context = ActiveSupport::ExecutionContext.to_h
      context[:connection] ||= connection
      pairs = snap[:handlers].filter_map do |(key, kind, data)|
        val = case kind
          when :get_key then context[key]
          when :identity then data
          else nil
        end
        formatter.format(key, val) unless val.nil?
      end
      formatter.join(pairs)
    end
  RUBY
end

._install_activerecord_query_transformers_patchObject

Patch ActiveRecord.query_transformers to route through IES with a shareable fallback. query_transformers is a singleton_class .attr_accessor (a class instance variable on the ActiveRecord module) holding an Array of transformer objects (e.g. ActiveRecord::QueryLogs). DatabaseStatements#preprocess_query reads it on every query: ActiveRecord.query_transformers.each { |t| t.call(sql, self) }.

Class instance variables are per-Ractor, so a worker's @query_transformers is nil (set in main at boot via self.query_transformers = [], then << QueryLogs in the railtie). The transformer objects themselves ARE shareable (they're Classes/modules), so we deep-freeze the Array in main and expose it as a shareable constant the worker reads. Same pattern as _install_activerecord_db_config_handlers_patch.



913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 913

def _install_activerecord_query_transformers_patch
  return if @ar_query_transformers_patched
  @ar_query_transformers_patched = true
  _register_patch :activerecord_query_transformers, "8.1"
  return unless defined?(::ActiveRecord)

  if Ractor.main?
    begin
      transformers = ::ActiveRecord.query_transformers
      transformers.each { |t| Ractor.make_shareable(t) rescue nil }
      shareable = Ractor.make_shareable(transformers.dup)
      verbose, $VERBOSE = $VERBOSE, nil
      begin
        const_set(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable)
      ensure
        $VERBOSE = verbose
      end
    rescue => e
      # best-effort
    end
  end

  key = :ractor_rails_shim_ar_query_transformers
  key_str = key.inspect
  ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def query_transformers
      v = ActiveSupport::IsolatedExecutionState[#{key_str}]
      return v unless v.nil?
      if Ractor.main?
        @query_transformers
      else
        RactorRailsShim::AR_QUERY_TRANSFORMERS_SHAREABLE
      end
    end
    def query_transformers=(val)
      ActiveSupport::IsolatedExecutionState[#{key_str}] = val
      @query_transformers = val if Ractor.main?
    end
  RUBY
end

._install_activerecord_quoting_cache_patchObject

Patch the adapter quoting caches (QUOTED_COLUMN_NAMES / QUOTED_TABLE_NAMES) to use per-Ractor storage instead of the unshareable Concurrent::Map constants.

Each adapter (SQLite3, MySQL, PostgreSQL) defines these as Concurrent::Map.new constants in its Quoting module, and the quote_column_name / quote_table_name class methods lazily populate them via MAP[name] ||= <quoting_logic>.freeze. Concurrent::Map is intrinsically unshareable (no #freeze), so a worker Ractor cannot access the constant at all (Ractor::IsolationError: "can not access non-shareable objects in constant ..."). This is the fourth wall a worker hits: during Post.count -> Arel traversal -> quote_table_name -> QUOTED_TABLE_NAMES[name] ||= ....

Fix: redefine quote_column_name / quote_table_name on each adapter's Quoting::ClassMethods module to use a per-Ractor Hash cache (stored in IES, keyed by the adapter class name). Each Ractor builds its own mutable cache on first access. The quoting logic is replicated per adapter (it's simple, stable string manipulation). String-eval'd (no captured binding), callable from any Ractor.



1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1212

def _install_activerecord_quoting_cache_patch
  return if @ar_quoting_patched
  @ar_quoting_patched = true
  _register_patch :activerecord_quoting_cache, "8.1"

  # [module_path, column_logic, table_logic] per adapter.
  adapters = [
    ["ActiveRecord::ConnectionAdapters::SQLite3::Quoting",
     %q{%Q("#{name.to_s.gsub('"', '""')}").freeze},
     %q{%Q("#{name.to_s.gsub('"', '""').gsub(".", "\".\"")}").freeze}],
    ["ActiveRecord::ConnectionAdapters::MySQL::Quoting",
     %q{"`#{name.to_s.gsub('`', '``')}`".freeze},
     %q{"`#{name.to_s.gsub('`', '``').gsub(".", "`.`")}`".freeze}],
    ["ActiveRecord::ConnectionAdapters::PostgreSQL::Quoting",
     %q{::PG::Connection.quote_ident(name.to_s).freeze},
     %q{::ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(name.to_s).quoted.freeze}],
  ]

  adapters.each do |mod_path, column_logic, table_logic|
    mod = begin
      mod_path.split("::").inject(Object) { |ns, n| ns.const_get(n, false) }
    rescue
      nil
    end
    next unless mod
    klass_mod = mod.const_get(:ClassMethods, false) rescue next
    next unless klass_mod.method_defined?(:quote_column_name)

    klass_mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def quote_column_name(name)
        key = :"ractor_rails_shim_quoted_cols_\#{self.name}"
        cache = ActiveSupport::IsolatedExecutionState[key]
        cache ||= (ActiveSupport::IsolatedExecutionState[key] = {})
        cache[name] ||= (#{column_logic})
      end

      def quote_table_name(name)
        key = :"ractor_rails_shim_quoted_tables_\#{self.name}"
        cache = ActiveSupport::IsolatedExecutionState[key]
        cache ||= (ActiveSupport::IsolatedExecutionState[key] = {})
        cache[name] ||= (#{table_logic})
      end
    RUBY
  end
end

._install_activerecord_reaper_patchObject

Patch ConnectionPool::Reaper#run to no-op in non-main Ractors.

ConnectionPool#initialize (connection_pool.rb:307) calls @reaper.run, which calls Reaper.register_pool (a class method). register_pool reads/writes the Reaper class's instance variables (@mutex, @pools, background reaper thread. Class instance variables are off-limits to non-main Ractors (Ractor::IsolationError), so this is the second wall a worker hits during establish_connection -> ConnectionPool.new.

The reaper is a background maintenance thread that periodically reaps dead-thread connections, flushes idle connections, and keepalives stale ones. In a worker Ractor this is neither safe (can't share the reaper thread or its class-ivar registry across Ractors) nor essential: each Ractor owns its own connection pool, and when the Ractor exits its pool is garbage-collected with it. Connection health for long-lived workers can be addressed later with a per-Ractor reaper if needed; for now, no-op'ing registration unblocks connection establishment.

register_pool is only called from Reaper#run, so patching run to return early in non-main Ractors fully prevents the class-ivar access and the thread spawn. The pool itself still functions normally.



1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1132

def _install_activerecord_reaper_patch
  return if @ar_reaper_patched
  @ar_reaper_patched = true
  _register_patch :activerecord_reaper, "8.1"
  return unless defined?(::ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper)

  ::ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def run
      return unless frequency && frequency > 0
      return unless Ractor.main?
      self.class.register_pool(pool, frequency)
    end
  RUBY
end

._install_activerecord_relation_delegate_cache_patchObject

Blockers 3: ActiveRecord model classes cache relation-delegate classes in the @relation_delegate_cache class instance variable (set in DelegateCache#initialize_relation_delegate_cache, activerecord/relation/delegation.rb:31-44). The cache is a plain (mutable) Hash mapping each delegated class (ActiveRecord::Relation, etc.) to an anonymous delegate Class. From a worker Ractor, reading the class ivar raises Ractor::IsolationError ("can not get unshareable values from instance variables of classes/modules from non-main Ractors (@relation_delegate_cache from Post)") — even a plain Post.page(1).

The delegate Classes themselves ARE shareable (verified: Ractor.shareable?(Post::ActiveRecord_Relation) == true). Only the enclosing Hash is mutable (unshareable). So we deep-freeze the cache (Ractor.make_shareable) in the main Ractor at prepare/make-shareable time. A class ivar whose value is shareable is readable from a worker Ractor (unlike class variables, which always raise). Freezing is safe: the cache is populated once per class at load time and never mutated afterwards (each relation-delegate Class is const_set as a private constant on the model class).



282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 282

def _install_activerecord_relation_delegate_cache_patch
  return if @ar_rdc_patched
  @ar_rdc_patched = true
  _register_patch :activerecord_relation_delegate_cache, "8.1"
  return unless defined?(::ActiveRecord::Base)

  mod = ::ActiveRecord::Delegation::DelegateCache
  mod.module_eval do
    # The default implementation reads the delegate class from the
    # `@relation_delegate_cache` *class instance variable* — an unshareable
    # Hash populated lazily on the model class. From a worker Ractor that
    # value is unreadable (Ractor::IsolationError: "can not get unshareable
    # values from instance variables ... (@relation_delegate_cache from
    # Post)"). `initialize_relation_delegate_cache` ALSO const_sets each
    # delegate class onto the model (e.g. `Post::ActiveRecord_Relation`),
    # and that constant is shareable. So read the delegate class via the
    # constant instead of the class ivar — Ractor-safe with no per-worker
    # rebuild.
    def relation_delegate_class(klass)
      const_get(klass.name.gsub("::", "_"))
    end

    # Keep building + const_setting the delegate classes (shareable), but
    # stop stashing them in the unshareable `@relation_delegate_cache` ivar.
    # The ivar is left a frozen empty Hash so any (now-unused) reader of it
    # is still Ractor-safe.
    def initialize_relation_delegate_cache
      @relation_delegate_cache = {}.freeze
      ::ActiveRecord::Delegation.delegated_classes.each do |k|
        delegate = Class.new(k) { include ::ActiveRecord::Delegation::ClassSpecificRelation }
        include_relation_methods(delegate)
        mangled_name = k.name.gsub("::", "_")
        const_set mangled_name, delegate
        private_constant mangled_name
      end
    end
  end

   if Ractor.main?
end

._install_activerecord_serialize_cast_value_patchObject

Patch ActiveModel::Type::SerializeCastValue::ClassMethods #serialize_cast_value_compatible? to route the lazy class instance variable @serialize_cast_value_compatible through IES.

This method lazily caches a boolean: return @x if defined?(@x); @x = <computation>. It's called during type-map initialization (every adapter's initialize_type_map creates type objects whose constructors eagerly call this to precompute the value). The class instance variable write fails from a non-main Ractor (Ractor::IsolationError: "can not set instance variables of classes/modules by non-main Ractors").

Fix: route through IES so each Ractor computes + caches its own value. The computation is deterministic (compares ancestor positions of two methods), so per-Ractor recomputation yields the same result. Each including class gets its own IES key (keyed by self.name). String-eval'd (no captured binding), callable from any Ractor.



1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1372

def _install_activerecord_serialize_cast_value_patch
  return if @ar_serialize_cast_patched
  @ar_serialize_cast_patched = true
  _register_patch :activerecord_serialize_cast_value, "8.1"
  return unless defined?(::ActiveModel::Type::SerializeCastValue)

  ::ActiveModel::Type::SerializeCastValue::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def serialize_cast_value_compatible?
      key = :"ractor_rails_shim_scv_\#{name || object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v unless v.nil?
      result = ancestors.index(instance_method(:serialize_cast_value).owner) <= ancestors.index(instance_method(:serialize).owner)
      ActiveSupport::IsolatedExecutionState[key] = result
      result
    end
  RUBY
end

._install_activerecord_transaction_callbacks_patchObject

In the shared :ractor graph, ActiveRecord model classes can end up with a nil __callbacks (the class_attribute value can't be made shareable when it holds unshareable callback Procs, so the shim's shareable fallback returns nil). has_transactional_callbacks? calls the generated _rollback_callbacks / _commit_callbacks / _before_commit_callbacks readers, which do __callbacks[:kind] directly — bypassing the run_callbacks_with_nil_safe guard. With a nil __callbacks that raises NoMethodError: undefined method '[]' for nil, breaking every save / update / destroy (they run inside a transaction). Guard it: a nil / empty callback table means no transactional callbacks.



1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1710

def _install_activerecord_transaction_callbacks_patch
  return if @activerecord_transaction_callbacks_patched
  @activerecord_transaction_callbacks_patched = true
  _register_patch :activerecord_transaction_callbacks, "8.1"
  return unless defined?(::ActiveRecord::Base)
  ar = ::ActiveRecord::Base
  ar.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def has_transactional_callbacks?
      cb = __callbacks
      return false unless cb
      !((cb[:rollback] || []).empty?) ||
        !((cb[:commit] || []).empty?) ||
        !((cb[:before_commit] || []).empty?)
    end
  RUBY
end

._install_all_framework_patchesObject

Public API: run after Rails.application.initialize! and BEFORE spawning worker Ractors. Makes every registered constant shareable (deep-freeze). Constants that didn't exist at install time (e.g. Rails::Railtie, loaded after module Rails opens) get fixed here. Idempotent; safe to call multiple times. Must run in the main Ractor.

NOTE: this does NOT build the framework-config shareable fallback. That step is folded into make_app_shareable! because some class_attribute / mattr_accessor values reference the app graph, and making them shareable must happen AFTER the app itself is already frozen (otherwise the app gets frozen prematurely and precompute/proc-replacement can't mutate it). If you call prepare_for_ractors! standalone (without make_app_shareable!), worker Ractors will see nil for framework config values that couldn't be shared without freezing the app — set them explicitly per worker, or use make_app_shareable!. Shared list of every framework patch install method. Both prepare_for_ractors! (pre-worker boot) and make_app_shareable! (post-boot, pre-freeze) call this, so the two no longer duplicate the literal _install_* list. Each _install_* is idempotent (guarded by its own @*_patched flag), so calling the full set at either point is a no-op for already-applied patches. Duplicates from the original prepare_for_ractors! list (_install_csrf_reset_patch, _install_messages_serializer_patch, _install_activerecord_delegation_patch) are collapsed here.



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/ractor_rails_shim/patches/core.rb', line 328

def _install_all_framework_patches
  _install_rack_request_patch
  _install_inflector_patch
  _install_module_introspection_patch
  _install_parameter_encoding_patch
  _install_path_registry_patch
  _install_action_view_resolver_patch
  _install_action_view_partial_path_patch
  _install_action_view_field_type_patch
  _install_action_view_safe_join_patch
  _install_abstract_controller_patch
  _install_action_controller_controller_name_patch
  _install_flash_helpers_patch
  _install_csrf_reset_patch
  _install_active_support_error_reporter_patch
  _install_lookup_context_patch
  _install_i18n_patch
  _install_i18n_backend_patch
  _install_i18n_interpolation_patch
  _install_messages_serializer_patch
  _install_template_handlers_patch
  _install_execution_context_patch
  _install_request_parameter_parsers_patch
  _install_query_parser_patch
  _install_rack_utils_patch
  _install_log_subscriber_patch
  _install_local_cache_patch
  _install_reloader_patch
  _install_exception_wrapper_patch
  _install_action_dispatch_routing_patch
  _install_action_dispatch_mounted_helpers_patch
  _install_action_dispatch_http_url_patch
  _install_journey_routes_patch
  _install_warden_hooks_patch
  _install_warden_strategies_patch
  _install_devise_failure_app_patch
  _install_activerecord_connection_handler_patch
  _install_activerecord_configurations_patch
  _install_activerecord_db_config_handlers_patch
  _install_activerecord_query_transformers_patch
  _install_activerecord_module_attrs_patch
  _install_activerecord_deduplicable_patch
  _install_activerecord_pool_config_patch
  _install_activerecord_reaper_patch
  _install_arel_visitor_dispatch_cache_patch
  _install_arel_bind_block_patch
  _install_activerecord_quoting_cache_patch
  _install_activerecord_serialize_cast_value_patch
  _install_activerecord_delegation_patch
  _install_activerecord_primary_key_patch
  _install_activerecord_query_constraints_patch
  _install_activerecord_relation_delegate_cache_patch
  _install_activerecord_model_classes_patch
  _install_active_model_naming_patch
  _install_active_record_core_patch
  _install_active_record_inheritance_patch
  _install_active_record_model_schema_patch
  _install_activerecord_model_schema_patch
  _install_openssl_digest_patch
  _install_caching_key_generator_patch
  _install_active_model_conversion_patch
  _install_activerecord_find_by_cache_patch
  _install_activerecord_migration_patch
  _install_activerecord_transaction_callbacks_patch
  _install_activerecord_query_logs_patch
  _install_kaminari_config_patch
  _install_propshaft_patch
  _install_devise_url_helpers_patch
  _install_devise_authenticatable_patch
  _install_polymorphic_routes_patch
  _install_orm_adapter_patch
  _install_warden_serializer_patch
  _install_json_encoding_patch
  _install_active_model_attribute_patch
  _install_hash_compute_if_absent_patch
end

._install_arel_bind_block_patchObject



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 85

def _install_arel_bind_block_patch
  return if @arel_bind_block_patched
  @arel_bind_block_patched = true
  _register_patch :arel_bind_block, "8.1"

  # PostgreSQL: proc { |i| "$#{i}" }
  if defined?(::Arel::Visitors::PostgreSQL)
    ::Arel::Visitors::PostgreSQL.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def bind_block
        RactorRailsShim::PgBindBlock
      end
    RUBY
  end

  # ToSql (base): proc { "?" }
  if defined?(::Arel::Visitors::ToSql)
    ::Arel::Visitors::ToSql.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def bind_block
        RactorRailsShim::SqlBindBlock
      end
    RUBY
  end
end

._install_arel_visitor_dispatch_cache_patchObject

Patch Arel::Visitors::Visitor.dispatch_cache to route through IES.

dispatch_cache is a class method that lazily initializes a class instance variable: @dispatch_cache ||= Hash.new { |hash, klass| ... } .compare_by_identity. The cache maps AST node classes to visit method symbols (e.g. Arel::Nodes::SelectStatement -> :visit_Arel_Nodes_SelectStatement). It's read on every Arel traversal (every query) and written on cache-miss (default proc) and on method-not-found fallback (visit() rescue).

The class instance variable @dispatch_cache can't be read or written by a non-main Ractor (Ractor::IsolationError). The Hash also has a default Proc (which is intrinsically unshareable), so the value can't be frozen+shared. This is the third wall a worker hits during the first query: Post.count -> adapter creation -> arel_visitor -> ToSql#initialize -> Visitor#initialize -> get_dispatch_cache -> self.class.dispatch_cache -> @dispatch_cache ||= ....

Fix: route through per-class IES. Each Ractor builds its own mutable cache (with its own default proc) on first access. The key includes self.name so each visitor subclass (ToSql, SQLite3, etc.) gets its own cache — necessary because the method-not-found fallback (dispatch[object.class] = dispatch[superklass]) resolves differently per visitor class. The main ractor's existing @dispatch_cache (if any) is left orphaned; new visitors created after the patch use the IES cache. String-eval'd (no captured binding), callable from any Ractor.



1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1172

def _install_arel_visitor_dispatch_cache_patch
  return if @arel_visitor_patched
  @arel_visitor_patched = true
  _register_patch :arel_visitor_dispatch_cache, "8.1"
  return unless defined?(::Arel::Visitors::Visitor)

  ::Arel::Visitors::Visitor.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def dispatch_cache
      key = :"ractor_rails_shim_arel_dispatch_\#{name || object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v if v
      cache = Hash.new do |hash, klass|
        hash[klass] = :"visit_\#{(klass.name || "").gsub("::", "_")}"
      end.compare_by_identity
      ActiveSupport::IsolatedExecutionState[key] = cache
      cache
    end
  RUBY
end

._install_caching_key_generator_patchObject

Patch ActiveSupport::CachingKeyGenerator#generate_key. Its @cache_keys ivar is a Concurrent::Map; make_app_shareable! rewrites Concurrent::Map ivars into FROZEN Hashes (see make_shareable.rb), so a worker Ractor's @cache_keys[args.join("|")] ||= ... write raises FrozenError. The cache is pure memoization keyed by (generator, args), so route it through IsolatedExecutionState (one mutable cache per Ractor). The inner OpenSSL::Digest lambda patch.



937
938
939
940
941
942
943
944
945
946
947
948
949
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 937

def _install_caching_key_generator_patch
  return if @caching_key_generator_patched
  @caching_key_generator_patched = true
  _register_patch :caching_key_generator, "8.1"
  return unless defined?(::ActiveSupport::CachingKeyGenerator)
  ::ActiveSupport::CachingKeyGenerator.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def generate_key(*args)
      store = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_caching_key_generator] ||= {})
      key = "\#{object_id}|\#{args.join("|")}"
      store.fetch(key) { store[key] = @key_generator.generate_key(*args) }
    end
  RUBY
end

._install_callback_declaration_capture!Object

Install an interceptor on ActiveSupport::Callbacks.set_callback that records, per declaring class, every symbolic :process_action filter it declares. This must run BEFORE eager load (so declarations are captured as they happen) — install wires it via ActiveSupport.on_load(:active_support).



779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 779

def _install_callback_declaration_capture!
  return if @callback_capture_installed
  @callback_capture_installed = true
  # ActiveSupport::Callbacks may not be loaded yet at on_load(:active_support)
  # time (it's required lazily). Require it so the ClassMethods module with
  # set_callback exists before we alias it.
  require "active_support/callbacks" rescue nil
  mod = (defined?(::ActiveSupport::Callbacks) &&
         ::ActiveSupport::Callbacks.const_defined?(:ClassMethods)) ?
        ::ActiveSupport::Callbacks::ClassMethods : nil
  return unless mod && mod.method_defined?(:set_callback)
  mod.alias_method(:_rrs_orig_set_callback, :set_callback)
  mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def set_callback(name, *filters, &block)
      if name == :process_action && filters.length >= 2 && filters[0].is_a?(Symbol)
        kind = filters[0]
        filter = filters[1]
        if filter.is_a?(Symbol) &&
           self.is_a?(::Class) &&
           self.ancestors.include?(::AbstractController::Base)
          # Rails converts `only:`/`except:` into an ActionFilter object
          # stored in the callback's `:if`/`:unless` options (NOT a bare
          # `:only` key). Read the constraint back from the ActionFilter's
          # @conditional_key (:only/:except) and @actions (a Set of action
          # name Strings).
          opts = filters.find { |f| f.is_a?(Hash) }
          only = nil
          except = nil
          if opts
            [opts[:if], opts[:unless]].each do |arr|
              next unless arr.is_a?(Array)
              arr.each do |af|
                ck = af.instance_variable_get(:@conditional_key) rescue nil
                acts = af.instance_variable_get(:@actions) rescue nil
                next unless ck && acts
                acts = acts.to_a.map(&:to_sym) if acts.respond_to?(:to_a)
                only = acts if ck == :only
                except = acts if ck == :except
              end
            end
          end
          ::RactorRailsShim._record_declared_callback(
            self.object_id, kind, filter, only, except)
        end
      end
      _rrs_orig_set_callback(name, *filters, &block)
    end
  RUBY
  @callback_capture_installed = true
end

._install_callbacks_nil_safe_patchObject

Patch ActiveSupport::Inflector::Inflections to not read @en_instance / @instance class ivars from a worker Ractor. The inflections instance holds rules (Arrays/Hashes of Strings) populated at boot; for a frozen shared app it's read-only. Workers share the main-ractor's inflections instance via a shareable fallback (made shareable in place). instance / instance_or_fallback are called per-request during routing (camelize). Patch ActiveSupport::Callbacks#run_callbacks to tolerate a nil __callbacks (the case in worker Ractors whose class_attribute fallback couldn't be made shareable because callback chains hold frozen, self-capturing Procs). For a frozen, read-only shared app the boot-time callbacks (ExecutionContext push/pop, CurrentAttributes clear) already ran in the main Ractor at boot; worker Ractors don't need to re-run them per request (CurrentAttributes/ExecutionContext are thread-local, hence per-Ractor, and start empty in a fresh worker). When __callbacks is nil, run_callbacks just yields the block — matching the empty-chain fast path in the original. Moved here from execution_wrapper.rb (it patches ActiveSupport::Callbacks, not ExecutionWrapper).



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 46

def _install_callbacks_nil_safe_patch
  return if @callbacks_nil_safe_patched
  @callbacks_nil_safe_patched = true
  _register_patch :callbacks_nil_safe, "8.1"
  return unless defined?(::ActiveSupport::Callbacks)
  ::ActiveSupport::Callbacks.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def run_callbacks_with_nil_safe(kind, type = nil)
      kind = kind.to_sym
      if RactorRailsShim.thread_mode? && kind == :process_action &&
         defined?(::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS)
        # Thread (Puma/Falcon) mode: the eager-load class_attribute leak
        # corrupts __callbacks, so ALWAYS replay the captured symbolic
        # filters (ignoring __callbacks entirely). Serving happens in the
        # main Ractor, where the worker-style empty-__callbacks path
        # below never triggers.
        table = ::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS
        action = (self.action_name rescue nil)
        action = action.to_sym if action
        entries = []
        k = self.class
        while k && k <= ::ActionController::Base
          rec = table[k.object_id]
          entries = rec + entries if rec
          k = k.superclass
        end
        unless entries.empty?
          applies = lambda do |e|
            next false unless (e[:kind] == :before || e[:kind] == :after)
            in_only = e[:only].nil? || (action && e[:only].include?(action))
            not_except = e[:except].nil? || !(action && e[:except].include?(action))
            in_only && not_except
          end
          result = nil
          halted = false
          entries.each do |e|
            next unless e[:kind] == :before && applies.call(e)
            send(e[:filter]) if respond_to?(e[:filter], true)
            if respond_to?(:performed?) ? performed? : response_body
              halted = true
              break
            end
          end
          result = (yield if block_given?) unless halted
          entries.each do |e|
            next unless e[:kind] == :after && applies.call(e)
            send(e[:filter]) if respond_to?(e[:filter], true)
          end
          return result
        end
      end
      callbacks = __callbacks[kind] if __callbacks
      if callbacks.nil? || callbacks.empty?
        # In a worker Ractor, class_attribute-backed `__callbacks`
        # falls back to the empty default (see class_attribute.rb /
        # make_shareable.rb), so controller `before_action` /
        # `after_action` filters are normally SKIPED. For
        # `:process_action` we replay the captured SYMBOL filters
        # (see make_shareable!#_capture_controller_callbacks!)
        # so actions that depend on a before_action (e.g. `set_post`
        # loading `@post`) render correctly. Proc/lambda filters
        # are not captured (self-capturing, unshareable) and are
        # skipped — a known limitation.
        if (RactorRailsShim.thread_mode? || !Ractor.main?) && kind.to_sym == :process_action &&
           defined?(::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS)
          table = ::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS
          action = (self.action_name rescue nil)
          action = action.to_sym if action
          # Reconstruct this controller's callback list by walking its
          # ancestors (superclass-first). Each class recorded ONLY the
          # filters IT declared (via the set_callback interceptor), which
          # bypasses the eager-load class_attribute leak that otherwise
          # pollutes __callbacks.
          entries = []
          k = self.class
          while k && k <= ::ActionController::Base
            rec = table[k.object_id]
            entries = rec + entries if rec
            k = k.superclass
          end
          unless entries.empty?
            # A captured symbolic filter applies to this action unless
            # constrained by `only:`/`except:`. `kind` (before/after)
            # decides whether it wraps the action pre- or post-yield.
            applies = lambda do |e|
              next false unless (e[:kind] == :before || e[:kind] == :after)
              in_only = e[:only].nil? || (action && e[:only].include?(action))
              not_except = e[:except].nil? || !(action && e[:except].include?(action))
              in_only && not_except
            end
            result = nil
            halted = false
            entries.each do |e|
              next unless e[:kind] == :before && applies.call(e)
              send(e[:filter]) if respond_to?(e[:filter], true)
              # A before-filter that renders/redirects performs — halt the
              # chain (as the real ActiveSupport::Callbacks machinery
              # does) so the action is NOT run. Otherwise an action that
              # depends on a performed before_action (e.g. Devise's
              # verify_signed_out_user redirect) would render twice.
              if respond_to?(:performed?) ? performed? : response_body
                halted = true
                break
              end
            end
            result = (yield if block_given?) unless halted
            entries.each do |e|
              next unless e[:kind] == :after && applies.call(e)
              send(e[:filter]) if respond_to?(e[:filter], true)
            end
            return result
          end
        end
        yield if block_given?
      else
        run_callbacks_without_nil_safe(kind, type) { yield if block_given? }
      end
    end
    alias_method :run_callbacks_without_nil_safe, :run_callbacks
    alias_method :run_callbacks, :run_callbacks_with_nil_safe
  RUBY
end

._install_csrf_reset_patchObject

In the shared :ractor graph, Devise's engine controllers (e.g. Devise::SessionsController) end up with a nil csrf_token_storage_strategy at request time — the value is dropped when make_app_shareable! deep-freezes the app (RequestForgeryProtection sets it only on ActionController::Base.config, and the per-controller frozen config copy loses it). A worker then raises NoMethodError on reset_csrf_token during reset_session (logout / sign_out). Guard the reset so a missing strategy is a no-op — reset_session regenerates the session id anyway, discarding the CSRF token.



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 232

def _install_csrf_reset_patch
  return if @csrf_reset_patched
  @csrf_reset_patched = true
  _register_patch :csrf_reset, "8.1"
  return unless defined?(::ActionController::RequestForgeryProtection)
  rfp = ::ActionController::RequestForgeryProtection
  rfp.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def reset_csrf_token(request) # :doc:
      request.env.delete(CSRF_TOKEN)
      strat = csrf_token_storage_strategy
      strat.reset(request) if strat
    end
  RUBY

  # `csrf_token_storage_strategy` and `forgery_protection_strategy` are
  # both delegated to the controller `config` (a class_attribute). In the
  # shared :ractor graph the per-controller frozen `config` copy loses
  # them, so a worker reads nil and CSRF token handling raises
  # ("undefined method 'fetch' for nil" / "undefined method 'new' for
  # nil"). Default to the standard SessionStore / Exception strategies so
  # token ISSUANCE and VALIDATION work in workers.
  #
  # NOTE: prepending to RequestForgeryProtection (a module) would NOT
  # reach the controllers, because ActionController::Base already
  # *included* it before this patch runs. Prepend to the BASE CLASS so
  # every controller (incl. Devise subclasses) picks up the default.
  # The defaults are referenced via their constant paths (not captured
  # locals) because `def` bodies do not close over enclosing locals.
  ::ActionController::Base.prepend(Module.new do
    def csrf_token_storage_strategy
      super || ::ActionController::RequestForgeryProtection::SessionStore.new
    end

    def forgery_protection_strategy
      super || ::ActionController::RequestForgeryProtection::ProtectionMethods::Exception
    end

    # Delegated to `config` (a class_attribute) which loses its value when
    # make_app_shareable! deep-freezes the shared graph, so a worker reads
    # nil. With a nil param key, form_authenticity_param reads params[nil]
    # and CSRF VALIDATION rejects every POST (even with a valid token),
    # because the token is carried under the real key (:authenticity_token).
    # Default to the standard param name so validation can find the token.
    def request_forgery_protection_token
      super || :authenticity_token
    end
  end)
end

._install_devise_authenticatable_patchObject

Devise::Models::Authenticatable::ClassMethods#devise_parameter_filter memoizes @devise_parameter_filter ||= Devise::ParameterFilter.new(...) on the MODEL CLASS. A worker Ractor cannot set an instance variable on a shared class/module, so calling it raises "can not set instance variables of classes/modules by non-main Ractors". Route the memoized filter through IsolatedExecutionState (per-Ractor), keyed by the (shared, stable) class object_id. case_insensitive_keys / strip_whitespace_keys are class_attribute values the shim already routes through IES, so they read fine in a worker.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/ractor_rails_shim/patches/devise.rb', line 85

def _install_devise_authenticatable_patch
  return if @devise_authenticatable_patched
  @devise_authenticatable_patched = true
  _register_patch :devise_authenticatable, "5.0"
  return unless defined?(::Devise::Models::Authenticatable::ClassMethods)
  ::Devise::Models::Authenticatable::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def devise_parameter_filter
      key = :"ractor_rails_shim_devise_param_filter_\#{object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v unless v.nil?
      f = Devise::ParameterFilter.new(case_insensitive_keys, strip_whitespace_keys)
      ActiveSupport::IsolatedExecutionState[key] = f
      f
    end
  RUBY
end

._install_devise_failure_app_patchObject

Devise::FailureApp.call memoizes its Rack endpoint in a class-level ivar (@respond ||= action(:respond)). A worker Ractor cannot set instance variables on a shared class/module, so calling it raises "can not set instance variables of classes/modules by non-main Ractors". Route the memoized endpoint through IsolatedExecutionState (per-Ractor), so each worker builds and caches its own copy without mutating the shared class.



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/ractor_rails_shim/patches/devise.rb', line 48

def _install_devise_failure_app_patch
  return if @devise_failure_app_patched
  @devise_failure_app_patched = true
  _register_patch :devise_failure_app, "5.0"
  return unless defined?(::Devise::FailureApp)
  ::Devise::FailureApp.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1
    def call(env)
      respond = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_devise_failure_respond] ||= action(:respond)
      respond.call(env)
    end
  RUBY
  # Devise::FailureApp#relative_url_root reads `config.action_controller
  # .try(:relative_url_root)`. `config.action_controller` is a
  # Rails::Railtie::Configuration whose `relative_url_root` is NOT a real
  # method, so `try` triggers `method_missing`, which reads the `@@options`
  # class variable — unreadable from a worker Ractor. `Rails.application
  # .config.relative_url_root` IS a real method on
  # Rails::Application::Configuration (no method_missing, no class var), so
  # use just that; for the common case (no relative root) it returns nil.
  if defined?(::Devise::FailureApp)
    ::Devise::FailureApp.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def relative_url_root
        @relative_url_root ||= Rails.application.config.relative_url_root
      end
    RUBY
  end
end

._install_devise_url_helpers_patchObject



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/ractor_rails_shim/patches/devise.rb', line 102

def _install_devise_url_helpers_patch
  return if @devise_url_helpers_patched
  @devise_url_helpers_patched = true
  _register_patch :devise_url_helpers, "5.0"
  return unless defined?(::Devise::Controllers::UrlHelpers)

  mod = ::Devise::Controllers::UrlHelpers

  # Patch Devise.mappings FIRST (before the snapshot below) so every
  # subsequent read — including the snapshot — does NOT trigger a route
  # reload. Routes are fully drawn by Rails.application.initialize! before
  # prepare_for_ractors! runs, so @@mappings is already populated in main;
  # workers read the shareable snapshot instead. The ORIGINAL
  # Devise.mappings calls reload_routes_unless_loaded, which during
  # prepare collapses the RouteSet to a single railtie route (rails/info)
  # that then gets frozen into the shared app graph — breaking routing for
  # every worker Ractor.
  if defined?(::Devise) && ::Devise.respond_to?(:mappings)
    ::Devise.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def mappings
        return RactorRailsShim::DEVISE_MAPPINGS if !Ractor.main? && RactorRailsShim.const_defined?(:DEVISE_MAPPINGS)
        ::Devise.class_variable_get(:@@mappings)
      end
    RUBY
  end

  # Capture a shareable snapshot of Devise.mappings in MAIN (after routes
  # are drawn). The real Mapping objects hold an unshareable failure-app
  # lambda and a default-proc Hash, so we can't share them directly; build
  # a DeviseMappingSnapshot per scope (see make_shareable.rb). Workers read
  # this via the patched Devise.mappings reader.
  if Ractor.main? && defined?(::Devise)
    begin
      h = ::Devise.mappings
      snap = {}
      h.each { |scope, mapping| snap[scope] = _devise_mapping_snapshot(mapping) }
      snap = Ractor.make_shareable(snap) rescue nil
      const_set(:DEVISE_MAPPINGS, snap) if snap
    rescue
      nil
    end
  end

  # Redefine each generated helper via string eval (no captured binding).
  # Replicate the EXACT body of Devise::Controllers::UrlHelpers.generate_helpers!
  # (lib/devise/controllers/url_helpers.rb): the helper does NOT call the
  # alias method on the context — it reconstructs the REAL route helper name
  # from `action` + `scope` + `module_name` (e.g. alias `session_path` ->
  # real `user_session_path`) and sends THAT to the context. Bake in
  # `action`/`module_name`/`path_or_url` as literals; `scope` is resolved
  # per-call from the argument, so interpolate it at runtime (\\#{scope}).
  routes = ::Devise::URL_HELPERS.slice(*(::Devise.mappings.values.map(&:used_helpers).flatten.uniq))
  routes.each do |module_name, actions|
    [:path, :url].each do |path_or_url|
      actions.each do |action|
        action_prefix = action ? "#{action}_" : ""
        method = :"#{action_prefix}#{module_name}_#{path_or_url}"
        mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
          def #{method}(resource_or_scope, *args)
            scope = Devise::Mapping.find_scope!(resource_or_scope)
            router_name = Devise.mappings[scope].router_name
            context = router_name ? send(router_name) : _devise_route_context
            context.send("#{action_prefix}\#{scope}_#{module_name}_#{path_or_url}", *args)
          end
        RUBY
      end
    end
  end
end

._install_exception_wrapper_patchObject

Patch ActionDispatch::ExceptionWrapper instance methods that read @@rescue_responses / @@rescue_templates class variables directly (bypassing the mattr_accessor reader the shim already reroutes through IES). Workers can't read class vars; route through the class method.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 61

def _install_exception_wrapper_patch
  return if @exception_wrapper_patched
  @exception_wrapper_patched = true
  _register_patch :exception_wrapper, "8.1"
  return unless defined?(::ActionDispatch::ExceptionWrapper)
  ::ActionDispatch::ExceptionWrapper.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def rescue_template
      self.class.rescue_templates[exception_class_name]
    end
    def status_code
      ActionDispatch::Response.rack_status_code(self.class.rescue_responses[exception_class_name])
    end
    def rescue_response?
      self.class.rescue_responses.key?(exception.class.name)
    end
  RUBY
  # Also patch the class method (status_code_for_exception) that reads
  # @@rescue_responses directly — called by ActionController::Instrumentation
  # at request time. Route through the mattr reader (which the shim already
  # reroutes through IES).
  ::ActionDispatch::ExceptionWrapper.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def status_code_for_exception(class_name)
      ActionDispatch::Response.rack_status_code(rescue_responses[class_name])
    end
  RUBY
end

._install_execution_context_patchObject

class ivar and nestable are read/written per-request. Route through IES; workers get empty arrays (correct for a read-only shared app where ExecutionContext is per-Ractor and starts empty in a fresh worker).



771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 771

def _install_execution_context_patch
  return if @exec_context_patched
  @exec_context_patched = true
  _register_patch :execution_context, "8.1"
  return unless defined?(::ActiveSupport::ExecutionContext)
  ec = ::ActiveSupport::ExecutionContext
  acb_key = :ractor_rails_shim_exec_context_after_change_callbacks
  nest_key = :ractor_rails_shim_exec_context_nestable
  acb_key_str = acb_key.inspect
  nest_key_str = nest_key.inspect
  ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def after_change_callbacks
      v = ActiveSupport::IsolatedExecutionState[#{acb_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@after_change_callbacks)
        v = @after_change_callbacks
        ActiveSupport::IsolatedExecutionState[#{acb_key_str}] = v
        v
      else
        arr = []
        ActiveSupport::IsolatedExecutionState[#{acb_key_str}] = arr
        arr
      end
    end
    def after_change(&block)
      after_change_callbacks << block
    end
    def nestable
      v = ActiveSupport::IsolatedExecutionState[#{nest_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@nestable)
        v = @nestable
        ActiveSupport::IsolatedExecutionState[#{nest_key_str}] = v
        v
      else
        false
      end
    end
    def nestable=(val)
      ActiveSupport::IsolatedExecutionState[#{nest_key_str}] = val
      @nestable = val if Ractor.main?
      val
    end
  RUBY
  # Rewrite the methods that read @after_change_callbacks directly.
  ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def set(**options)
      options.symbolize_keys!
      keys = options.keys
      store = record.store
      previous_context = if block_given?
        keys.zip(store.values_at(*keys)).to_h
      end
      store.merge!(options)
      after_change_callbacks.each(&:call)
      if block_given?
        begin
          yield
        ensure
          store.merge!(previous_context)
          after_change_callbacks.each(&:call)
        end
      end
    end
    def []=(key, value)
      record.store[key.to_sym] = value
      after_change_callbacks.each(&:call)
    end
  RUBY
end

._install_flash_helpers_patchObject

Patch the flash-type helper methods (notice, alert, ...) defined by ActionController::Metal::Flash#add_flash_types via define_method(type) { request.flash[type] }. That block is compiled in the MAIN Ractor, so calling it from a worker Ractor raises "defined with an un-shareable Proc in a different Ractor". Redefine each flash type as a string-eval'd method (no captured binding) so it is callable from any Ractor. Called at prepare_for_ractors! time, after the controllers are loaded and the types are known.



289
290
291
292
293
294
295
296
297
298
299
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 289

def _install_flash_helpers_patch
  return if @flash_helpers_patched
  @flash_helpers_patched = true
  _register_patch :flash_helpers, "8.1"
  return unless defined?(::ActionController::Base)
  types = ::ActionController::Base._flash_types rescue []
  types.each do |type|
    ::ActionController::Base.class_eval "def #{type}; request.flash[#{type.inspect}]; end"
    ::ActionController::Base.send(:private, type) if ::ActionController::Base.private_method_defined?(type) rescue nil
  end
end

._install_hash_compute_if_absent_patchObject

The shim's make_app_shareable! replaces Concurrent::Map instance variables (which are not Ractor-shareable) with plain Hashes so workers can read them. But Rails code calls Concurrent::Map#compute_if_absent on these caches (e.g. ActiveModel::AttributeMethods' attribute_method_patterns_ cache). Plain Hash lacks that method, so we add a compatible definition. For a MUTABLE cache the shim freezes the replaced Hash (so it is shareable across workers); mutating it would raise FrozenError. So when the receiver is frozen we route the store to per-Ractor IES keyed by the Hash identity and the key — giving each worker its own cache entry without mutating the shared object. Semantics otherwise match Concurrent::Map.



582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
# File 'lib/ractor_rails_shim/patches/core.rb', line 582

def _install_hash_compute_if_absent_patch
  return @hash_compute_if_absent_patched if defined?(@hash_compute_if_absent_patched) && @hash_compute_if_absent_patched
  @hash_compute_if_absent_patched = true
  return if ::Hash.method_defined?(:compute_if_absent)
  ::Hash.prepend(Module.new do
    def compute_if_absent(key)
      if frozen?
        ies_key = :"rrs_cia_#{object_id}_#{key}"
        ActiveSupport::IsolatedExecutionState[ies_key] ||= yield(key)
      elsif key?(key)
        self[key]
      else
        self[key] = yield(key)
      end
    end
  end)
end

._install_i18n_backend_patchObject

Patch I18n::Backend::Simple::Implementation#translations and #store_translations. The original uses a MUTEX-backed Concurrent::Hash default block (MUTEX is a non-shareable constant on the module), so a worker Ractor that builds its own (per the patched I18n::Config#backend) backend and lazy-loads translations hits "can not access non-shareable objects in constant ...MUTEX". Worker-local backends are single-threaded (a Ractor serializes its requests), so drop the mutex and use a plain Hash. Applied at prepare_for_ractors! time (after the i18n backend class is loaded).



702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 702

def _install_i18n_backend_patch
  return if @i18n_backend_patched
  @i18n_backend_patched = true
  _register_patch :i18n_backend, "8.1"
  return unless defined?(::I18n::Backend::Simple::Implementation)
  impl = ::I18n::Backend::Simple::Implementation
  impl.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def translations(do_init: false)
      init_translations if do_init && !initialized?
      @translations ||= {}
    end
    def store_translations(locale, data, options = {})
      if ::I18n.enforce_available_locales &&
         ::I18n.available_locales_initialized? &&
         !::I18n.locale_available?(locale)
        return data
      end
      locale = locale.to_sym
      translations[locale] ||= {}
      data = ::I18n::Utils.deep_symbolize_keys(data) unless options.fetch(:skip_symbolize_keys, false)
      ::I18n::Utils.deep_merge!(translations[locale], data)
    end
  RUBY
end

._install_i18n_interpolation_patchObject

Patch I18n.interpolate_hash. It reads INTERPOLATION_PATTERNS_CACHE — a constant Hash with a default proc (unshareable) — to fetch the compiled interpolation Regexp. A worker Ractor cannot read that constant, raising "can not access non-shareable objects in constant I18n::INTERPOLATION_PATTERNS_CACHE by non-main ractor". Route the cache through IsolatedExecutionState so each Ractor compiles its own Regexp once.



733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 733

def _install_i18n_interpolation_patch
  return if @i18n_interpolation_patched
  @i18n_interpolation_patched = true
  _register_patch :i18n_interpolation, "8.1"
  return unless defined?(::I18n)
  ::I18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def interpolate_hash(string, values)
      patterns = config.interpolation_patterns
      cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_interp_cache] ||= {})
      pattern = cache[patterns] ||= ::Regexp.union(patterns)
      interpolated = false

      interpolated_string = string.gsub(pattern) do |match|
        interpolated = true

        if match == '%%'
          '%'
        else
          key = ($1 || $2 || match.tr("%{}", "")).to_sym
          value = if values.key?(key)
                    values[key]
                  else
                    config.missing_interpolation_argument_handler.call(key, values, string)
                  end
          value = value.call(values) if value.respond_to?(:call)
          $3 ? sprintf("%#{$3}", value) : value
        end
      end

      interpolated ? interpolated_string : string
    end
  RUBY
end

._install_i18n_patchObject

Patch I18n::Config's class-variable-backed accessors (default_locale, locale, backend, etc.) to not read @@cvars from a worker Ractor. I18n defines these manually (@@default_locale ||= :en), not via cattr_accessor, so the shim's mattr rewrite doesn't catch them. The values are frozen Symbols / shareable config objects; route the frequently-read ones (default_locale, locale) through IES with the same default. Read per-request during view lookup (LookupContext details).



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 329

def _install_i18n_patch
  return if @i18n_patched
  @i18n_patched = true
  _register_patch :i18n, "8.1"
  return unless defined?(::I18n::Config)
  cfg = ::I18n::Config
  dl_key = :ractor_rails_shim_i18n_default_locale
  l_key = :ractor_rails_shim_i18n_locale
  av_key = :ractor_rails_shim_i18n_available_locales
  avs_key = :ractor_rails_shim_i18n_available_locales_set
  dl_key_str = dl_key.inspect
  l_key_str = l_key.inspect
  av_key_str = av_key.inspect
  avs_key_str = avs_key.inspect
  cfg.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def default_locale
      v = ActiveSupport::IsolatedExecutionState[#{dl_key_str}]
      return v unless v.nil?
      if Ractor.main? && defined?(@@default_locale)
        cv = @@default_locale
        ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = cv
        return cv
      end
      ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = :en
      :en
    end
    def default_locale=(locale)
      v = locale && locale.to_sym
      ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = v
      @@default_locale = v if Ractor.main?
      v
    end
    def locale
      v = ActiveSupport::IsolatedExecutionState[#{l_key_str}]
      return v unless v.nil?
      default_locale
    end
    def locale=(locale)
      v = locale && locale.to_sym
      ActiveSupport::IsolatedExecutionState[#{l_key_str}] = v
      v
    end
    # available_locales is read per-request during view template lookup
    # (ActionView::Resolver::PathParser#build_path_regex). The original
    # reads the @@available_locales class variable, which a worker Ractor
    # cannot access (Ractor::IsolationError). Route it through IES. In
    # main we mirror the class var; in a worker we default to [:en] WITHOUT
    # delegating to backend.available_locales (that path reads the @@backend
    # / @@load_path class vars, which are also unreadable from a worker).
    # The template-regex path only needs the list of locale symbols, and
    # [:en] is the documented I18n default — correct for apps that don't
    # set config.i18n.available_locales explicitly.
    def available_locales
      v = ActiveSupport::IsolatedExecutionState[#{av_key_str}]
      return v unless v.nil?
      if Ractor.main?
        if defined?(@@available_locales) && (cv = @@available_locales)
          ActiveSupport::IsolatedExecutionState[#{av_key_str}] = cv
          return cv
        end
        al = backend.available_locales
        al = al.freeze if al.respond_to?(:freeze) && !al.frozen?
        ActiveSupport::IsolatedExecutionState[#{av_key_str}] = al
        return al
      end
      al = [:en].freeze
      ActiveSupport::IsolatedExecutionState[#{av_key_str}] = al
      al
    end
    def available_locales=(locales)
      v = Array(locales).map { |l| l.to_sym }
      v = nil if v.empty?
      ActiveSupport::IsolatedExecutionState[#{av_key_str}] = v
      @@available_locales = v if Ractor.main?
      v
    end
    def available_locales_set
      v = ActiveSupport::IsolatedExecutionState[#{avs_key_str}]
      return v unless v.nil?
      if Ractor.main? && defined?(@@available_locales_set) && (cv = @@available_locales_set)
        ActiveSupport::IsolatedExecutionState[#{avs_key_str}] = cv
        return cv
      end
      s = available_locales.inject(Set.new) { |set, locale| set << locale.to_s << locale.to_sym }
      ActiveSupport::IsolatedExecutionState[#{avs_key_str}] = s
      s
    end
    def available_locales_initialized?
      !!(ActiveSupport::IsolatedExecutionState[#{av_key_str}])
    end
    # enforce_available_locales is read during every I18n.translate (the
    # Label/tag translation path in views). The original reads the
    # @@enforce_available_locales class variable, which a worker Ractor
    # cannot access (Ractor::IsolationError). Route it through IES; in main
    # we mirror the class var, in a worker we default to `true` (the
    # documented I18n default).
    def enforce_available_locales
      v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce]
      return v unless v.nil?
      if Ractor.main? && defined?(@@enforce_available_locales)
        cv = @@enforce_available_locales
        ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = cv
        return cv
      end
      ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = true
      true
    end
    def enforce_available_locales=(val)
      v = !!val
      ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = v
      @@enforce_available_locales = v if Ractor.main?
      v
    end
    # backend reads the @@backend class variable (which a worker Ractor
    # cannot access). The backend holds the loaded translations. Because the
    # translation data can contain Procs (e.g. `number.nth.ordinals` in
    # ActiveSupport's en locale), the whole backend cannot be deep-frozen
    # and shared. Instead, each worker builds its OWN backend instance (of
    # the same class as the main backend, so fallbacks etc. are preserved)
    # and lazy-loads translations from the shareable +load_path+ (see the
    # patched `load_path`/`load_path=`). The worker-local backend is mutable
    # (its @interpolations Proc is created in the worker, so it's fine).
    def backend
      if Ractor.main?
        @@backend ||= ::I18n::Backend::Simple.new
      else
        key = :ractor_rails_shim_i18n_backend
        b = ActiveSupport::IsolatedExecutionState[key]
        return b if b
        cls = (RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS) && RactorRailsShim::I18N_BACKEND_CLASS) || ::I18n::Backend::Simple
        b = cls.new
        ActiveSupport::IsolatedExecutionState[key] = b
        b
      end
    end
    def backend=(value)
      @@backend = value
    end
    # load_path reads the @@load_path class variable (unreadable from a
    # worker). Capture the (shareable) list of translation file paths in
    # main; workers reload translations from disk via these paths.
    def load_path
      v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path]
      return v unless v.nil?
      if Ractor.main?
        lp = (defined?(@@load_path) && @@load_path) || []
        lp = lp.dup.freeze if lp.respond_to?(:freeze) && !lp.frozen?
        ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path] = lp
        lp
      else
        RactorRailsShim.const_defined?(:I18N_LOAD_PATH) ? RactorRailsShim::I18N_LOAD_PATH : []
      end
    end
    def load_path=(lp)
      lp = Array(lp)
      ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path] = lp
      @@load_path = lp if Ractor.main?
      lp
    end
    # default_separator / exception_handler / missing_interpolation_argument_handler
    # / interpolation_patterns each read a @@ class variable unreadable from a
    # worker. Route them through IES; main mirrors the class var, workers use
    # the documented default (each default is worker-local and shareable-safe:
    # a String, a fresh ExceptionHandler, a fresh lambda, or the frozen
    # DEFAULT_INTERPOLATION_PATTERNS constant).
    def default_separator
      v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep]
      return v unless v.nil?
      if Ractor.main?
        cv = defined?(@@default_separator) ? @@default_separator : "."
        ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep] = cv
        cv
      else
        "."
      end
    end
    def default_separator=(separator)
      ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep] = separator
      @@default_separator = separator if Ractor.main?
      separator
    end
    def exception_handler
      v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc]
      return v unless v.nil?
      if Ractor.main?
        cv = defined?(@@exception_handler) ? @@exception_handler : ::I18n::ExceptionHandler.new
        ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc] = cv
        cv
      else
        ::I18n::ExceptionHandler.new
      end
    end
    def exception_handler=(handler)
      ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc] = handler
      @@exception_handler = handler if Ractor.main?
      handler
    end
    def missing_interpolation_argument_handler
      v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss]
      return v unless v.nil?
      if Ractor.main?
        cv = defined?(@@missing_interpolation_argument_handler) ? @@missing_interpolation_argument_handler : lambda do |missing_key, provided_hash, string|
            raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string)
          end
        ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss] = cv
        cv
      else
        lambda do |missing_key, provided_hash, string|
            raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string)
          end
      end
    end
    def missing_interpolation_argument_handler=(handler)
      ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss] = handler
      @@missing_interpolation_argument_handler = handler if Ractor.main?
      handler
    end
    def interpolation_patterns
      v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip]
      return v unless v.nil?
      if Ractor.main?
        cv = defined?(@@interpolation_patterns) ? @@interpolation_patterns : ::I18n::DEFAULT_INTERPOLATION_PATTERNS.dup
        ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip] = cv
        cv
      else
        ::I18n::DEFAULT_INTERPOLATION_PATTERNS
      end
    end
    def interpolation_patterns=(patterns)
      ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip] = patterns
      @@interpolation_patterns = patterns if Ractor.main?
      patterns
    end
  RUBY

  # Capture the I18n backend class and shareable load paths in MAIN after
  # the app has initialized. Workers build their own backend instance of
  # the captured class and reload translations from the captured load paths
  # (see `I18n::Config#backend` / `#load_path`). Eager-load the backend
  # class so the constant is globally defined for worker Ractors.
  if Ractor.main? && defined?(::I18n)
    begin
      # Eager-load I18n classes/constants in main so they are globally
      # defined for worker Ractors (which cannot autoload).
      ::I18n::Backend::Simple rescue nil
      ::I18n::ExceptionHandler rescue nil
      ::I18n::MissingInterpolationArgument rescue nil
      ::I18n::DEFAULT_INTERPOLATION_PATTERNS rescue nil
      backend = ::I18n.backend
      backend.translate(:en, "") rescue nil
      backend.available_locales rescue nil
      const_set(:I18N_BACKEND_CLASS, backend.class) unless RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS)
      raw_lp = (backend.respond_to?(:instance_variable_get) && backend.instance_variable_get(:@load_path)) ||
                (::I18n.respond_to?(:load_path) && ::I18n.load_path) || []
      shareable_lp = Ractor.make_shareable(Array(raw_lp).dup) rescue Array(raw_lp).map(&:to_s).freeze
      const_set(:I18N_LOAD_PATH, shareable_lp) unless RactorRailsShim.const_defined?(:I18N_LOAD_PATH)
    rescue
      nil
    end
  end

  # Patch I18n.fallbacks (a singleton method on the I18n module) to not
  # read the @@fallbacks class variable from a worker Ractor. It already
  # uses Fiber/Thread-local storage with @@fallbacks as the fallback;
  # route the @@fallbacks read through IES so workers build their own
  # I18n::Locale::Fallbacks. Called per-request via LookupContext details.
  if defined?(::I18n)
    i18n = ::I18n
    fb_key = :ractor_rails_shim_i18n_fallbacks
    fb_key_str = fb_key.inspect
    i18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def fallbacks
        v = ActiveSupport::IsolatedExecutionState[#{fb_key_str}]
        return v unless v.nil?
        if Ractor.main? && defined?(@@fallbacks)
          cv = @@fallbacks
          if cv
            ActiveSupport::IsolatedExecutionState[#{fb_key_str}] = cv
            return cv
          end
        end
        built = I18n::Locale::Fallbacks.new
        ActiveSupport::IsolatedExecutionState[#{fb_key_str}] = built
        built
      end
    RUBY

    # I18n::Locale::Tag.implementation — manual @@implementation ||= Simple.
    # The value is a module (shareable). Route through IES.
    if defined?(::I18n::Locale::Tag)
      tag = ::I18n::Locale::Tag
      tag_key = :ractor_rails_shim_i18n_tag_implementation
      tag_key_str = tag_key.inspect
      tag.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
        def implementation
          v = ActiveSupport::IsolatedExecutionState[#{tag_key_str}]
          return v unless v.nil?
          if Ractor.main? && defined?(@@implementation)
            cv = @@implementation
            ActiveSupport::IsolatedExecutionState[#{tag_key_str}] = cv
            return cv
          end
          ActiveSupport::IsolatedExecutionState[#{tag_key_str}] = I18n::Locale::Tag::Simple
          I18n::Locale::Tag::Simple
        end
      RUBY
    end

    # I18n::Base#normalize_key reads the @@normalized_key_cache class
    # variable (a double-nested Hash with default procs — unshareable, and
    # unreadable from a worker). It's a pure performance cache, so route it
    # through IsolatedExecutionState: each Ractor builds its own nested
    # cache via I18n.new_double_nested_cache and reads/writes it locally.
    if defined?(::I18n::Base)
      nk_key = :ractor_rails_shim_i18n_normalized_key_cache
      nk_key_str = nk_key.inspect
      ::I18n::Base.module_eval <<-RUBY, __FILE__, __LINE__ + 1
        def normalize_key(key, separator)
          cache = ActiveSupport::IsolatedExecutionState[#{nk_key_str}]
          cache ||= (ActiveSupport::IsolatedExecutionState[#{nk_key_str}] = ::I18n.new_double_nested_cache)
          cache[separator][key] ||=
            case key
            when Array
              key.flat_map { |k| normalize_key(k, separator) }
            else
              keys = key.to_s.split(separator)
              keys.delete('')
              keys.map! do |k|
                case k
                when /\A[-+]?([1-9]\d*|0)\z/ # integer
                  k.to_i
                when 'true'
                  true
                when 'false'
                  false
                else
                  k.to_sym
                end
              end
              keys
            end
        end
      RUBY
    end

    # I18n.reserved_keys_pattern memoizes its compiled regex in a lazy class
    # ivar (@reserved_keys_pattern) which a worker Ractor cannot write.
    # Route the cache through IsolatedExecutionState.
    if defined?(::I18n)
      rkp_key = :ractor_rails_shim_i18n_reserved_keys_pattern
      rkp_key_str = rkp_key.inspect
      ::I18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
        def reserved_keys_pattern
          v = ActiveSupport::IsolatedExecutionState[#{rkp_key_str}]
          return v if v
          pat = /(?<!%)%\\{(#{::I18n::RESERVED_KEYS.join("|")})\\}/
          ActiveSupport::IsolatedExecutionState[#{rkp_key_str}] = pat
          pat
        end
      RUBY
    end
  end
end

._install_inflector_patchObject



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 201

def _install_inflector_patch
  return if @inflector_patched
  @inflector_patched = true
  _register_patch :inflector, "8.1"
  return unless defined?(::ActiveSupport::Inflector::Inflections)
  inf = ::ActiveSupport::Inflector::Inflections
  en_key = :ractor_rails_shim_inflections_en
  inst_key = :ractor_rails_shim_inflections_instance
  en_key_str = en_key.inspect
  inst_key_str = inst_key.inspect
  inf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def instance(locale = :en)
      if locale == :en
        v = ActiveSupport::IsolatedExecutionState[#{en_key_str}]
        return v unless v.nil?
        if Ractor.main?
          existing = instance_variable_get(:@__en_instance__) if instance_variable_defined?(:@__en_instance__)
          ActiveSupport::IsolatedExecutionState[#{en_key_str}] = existing
          return existing || new.tap { |i| instance_variable_set(:@__en_instance__, i) }
        end
        fb = RactorRailsShim::SHAREABLE_FALLBACK[#{en_key_str}]
        return fb if fb
        built = new
        ActiveSupport::IsolatedExecutionState[#{en_key_str}] = built
        built
      else
        h = ActiveSupport::IsolatedExecutionState[#{inst_key_str}] ||= (Ractor.main? ? (instance_variable_defined?(:@__instance__) ? instance_variable_get(:@__instance__) : Concurrent::Map.new) : Concurrent::Map.new)
        h[locale] ||= new
      end
    end

    def instance_or_fallback(locale)
      return instance(locale) if locale == :en
      h = ActiveSupport::IsolatedExecutionState[#{inst_key_str}]
      if h && h.key?(locale)
        return h[locale]
      end
      if Ractor.main? && instance_variable_defined?(:@__instance__)
        iv = instance_variable_get(:@__instance__)
        return iv[locale] if iv && iv.key?(locale)
      end
      instance(locale)
    end
  RUBY
  # Register so _build_shareable_fallback! captures the :en inflections
  # instance (made shareable) for workers.
  CLASS_ATTRIBUTES << ["ActiveSupport::Inflector::Inflections", :__en_instance__, en_key, nil]
  # Materialize the :en instance into IES in main so the fallback builder
  # can read + share it.
  inf.instance(:en) if Ractor.main?
end

._install_journey_routes_patchObject

Make Journey route recognition work under kino :ractor.

The shared app graph (frozen + made shareable by make_app_shareable!) already carries the routes' ast and the GTG simulator. The simulator, however, is normally UNshareable because TransitionTable seeds @memos with a default-Proc Hash (Hash.new { |h,k| h[k] = [] }), and because its @memos holds the per-route Route objects whose constraint Procs can't cross Ractor boundaries. The default Proc is the only thing we can fix; the Route constraint Procs are instead made shareable by make_app_shareable! when it freezes the whole graph (its proc-replacement pass rewrites them).

So the plan:

1. Patch TransitionTable to use a plain Hash + `add_memo` using `||= []`
 (behavior-identical, but shareable once Route memos are frozen).
2. Warm + cache `@ast` / `@simulator` on the live Routes object AFTER
 make_app_shareable!'s route precompute (which reloads/resets the
 routes) and BEFORE Ractor.make_shareable freezes the graph. Once
 frozen into the shared graph, worker Ractors read the cached ivars
 via the ORIGINAL Routes#ast/#simulator (no per-worker rebuild — a
 rebuild would have to read the frozen Route memos AND reuse several
 non-shareable class constants, which is fragile).

We deliberately do NOT override ast/simulator: the original methods read the cached ivars, which is exactly what workers need.



570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 570

def _install_journey_routes_patch
  return if @journey_routes_patched
  @journey_routes_patched = true
  _register_patch :journey_routes, "8.1"
  return unless defined?(::ActionDispatch::Journey::Routes)

  # Patch TransitionTable to drop its default-Proc @memos. Must run before
  # the simulator is warmed (in _warm_journey_routes!, called from
  # make_app_shareable! after the route precompute).
  if defined?(::ActionDispatch::Journey::GTG::TransitionTable)
    tt = ::ActionDispatch::Journey::GTG::TransitionTable
    tt.class_eval <<-RUBY, __FILE__, __LINE__ + 1
      def initialize
        @stdparam_states = {}
        @regexp_states   = {}
        @string_states   = {}
        @accepting       = {}
        @memos           = {}
      end
      def add_memo(idx, memo)
        (@memos[idx] ||= []) << memo
      end
    RUBY
  end

  # Make Routes#ast / #simulator tolerant of a FROZEN receiver. The shim
  # warms + caches these ivars on the live (unfrozen) graph before freezing,
  # but if the cache is missing on the frozen shared object (e.g. routes
  # were re-drawn after warming, or warming was skipped), the original
  # `@simulator ||= build` raise FrozenError in a worker Ractor. When frozen,
  # build and RETURN the value without memoizing — the build is read-only
  # over the frozen route nodes, so it's safe and stays worker-local.
  routes = ::ActionDispatch::Journey::Routes
  routes.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def ast
      return @ast if defined?(@ast) && @ast
      built = ::ActionDispatch::Journey::Nodes::Or.new(anchored_routes.map(&:ast))
      frozen? ? built : (@ast ||= built)
    end

    def simulator
      return @simulator if defined?(@simulator) && @simulator
      gtg = ::ActionDispatch::Journey::GTG::Builder.new(ast).transition_table
      built = ::ActionDispatch::Journey::GTG::Simulator.new(gtg)
      frozen? ? built : (@simulator ||= built)
    end
  RUBY

  # Path::Pattern memoizes several ivars via `||=` (@re, @offsets,
  # @required_names, @optional_names, @requirements_for_missing_keys_check).
  # On a frozen shared graph the `||=` assignment raises FrozenError in a
  # worker Ractor. Make them frozen-tolerant: return the cached value if
  # present, otherwise build and RETURN it without memoizing (the build is
  # read-only over the frozen ast/requirements). This keeps workers correct
  # even if warming skipped a pattern.
  if defined?(::ActionDispatch::Journey::Path::Pattern)
    ::ActionDispatch::Journey::Path::Pattern.class_eval <<-RUBY, __FILE__, __LINE__ + 1
      def required_names
        return @required_names if defined?(@required_names) && @required_names
        built = names - optional_names
        frozen? ? built : (@required_names ||= built)
      end

      def optional_names
        return @optional_names if defined?(@optional_names) && @optional_names
        built = spec.find_all(&:group?).flat_map { |g| g.find_all(&:symbol?) }.map(&:name).uniq
        frozen? ? built : (@optional_names ||= built)
      end

      def to_regexp
        return @re if defined?(@re) && @re
        built = regexp_visitor.new(@separators, @requirements).accept(spec)
        frozen? ? built : (@re ||= built)
      end

      def requirements_for_missing_keys_check
        return @requirements_for_missing_keys_check if defined?(@requirements_for_missing_keys_check) && @requirements_for_missing_keys_check
        built = requirements.transform_values { |regex| /\A#\{regex\}\Z/ }
        frozen? ? built : (@requirements_for_missing_keys_check ||= built)
      end

      def offsets
        return @offsets if defined?(@offsets) && @offsets
        built = begin
          offs = [0]
          spec.find_all(&:symbol?).each do |node|
            node = node.to_sym
            if @requirements.key?(node)
              re = /#\{Regexp.union(@requirements[node])\}|/
              offs.push((re.match("").length - 1) + offs.last)
            else
              offs << offs.last
            end
          end
          offs
        end
        frozen? ? built : (@offsets ||= built)
      end
    RUBY
  end
end

._install_json_encoding_patchObject

Patch ActiveSupport::JSON::Encoding. The module memoizes two encoders in class ivars (@encoder_without_options / @encoder_without_escape) inside json_encoder=, and exposes json_encoder as a attr_reader (so it too reads the @json_encoder class ivar). A worker Ractor cannot read any of these module ivars, raising Ractor::IsolationError ("can not get unshareable values from instance variables of classes/modules from non-main Ractors"). Capture the encoder CLASS in main (on assignment) into a shareable constant, then build a per-Ractor encoder instance via IsolatedExecutionState instead of reading the module ivars.



1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 1028

def _install_json_encoding_patch
  return if @json_encoding_patched
  @json_encoding_patched = true
  _register_patch :json_encoding, "8.1"
  return unless defined?(::ActiveSupport::JSON::Encoding)
  enc = ::ActiveSupport::JSON::Encoding
  ec_key = :ractor_rails_shim_json_encoder
  ec_key_str = ec_key.inspect
  ecn_key = :ractor_rails_shim_json_encoder_no_escape
  ecn_key_str = ecn_key.inspect
  enc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def json_encoder=(encoder)
      RactorRailsShim.const_set(:JSON_ENCODER_CLASS, encoder) if Ractor.main? && defined?(RactorRailsShim)
      @json_encoder = encoder if Ractor.main?
      encoder
    end
    def json_encoder
      RactorRailsShim::JSON_ENCODER_CLASS
    end
    def encode_without_options(value)
      encoder = ActiveSupport::IsolatedExecutionState[#{ec_key_str}]
      encoder ||= (ActiveSupport::IsolatedExecutionState[#{ec_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new)
      encoder.encode(value)
    end
    def encode_without_escape(value)
      encoder = ActiveSupport::IsolatedExecutionState[#{ecn_key_str}]
      encoder ||= (ActiveSupport::IsolatedExecutionState[#{ecn_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new(escape: false))
      encoder.encode(value)
    end
  RUBY
  # The JSON encoding constants (HTML_ENTITIES_REGEX etc.) live on
  # ActiveSupport::JSON::Encoding but are NOT Ractor-shareable in Ruby 4.0
  # (Regexp.union / frozen-string Hash return false for Ractor.shareable?).
  # Deep-freeze + replace them here (in main, during prepare_for_ractors!)
  # so worker Ractors can read them when the encoder escapes HTML. Belt and
  # suspenders alongside the SHAREABLE_CONSTANTS registration.
  if Ractor.main?
    %w[ESCAPED_CHARS HTML_ENTITIES_REGEX FULL_ESCAPE_REGEX JS_SEPARATORS_REGEX].each do |name|
      next unless enc.const_defined?(name, false)
      v = enc.const_get(name, false)
      unless Ractor.shareable?(v)
        begin
          enc.const_set(name, Ractor.make_shareable(v))
        rescue
          nil
        end
      end
    end
  end
  # Make sure the constant exists on RactorRailsShim so worker references
  # resolve. It is set on the first `json_encoder=` call during init; seed a
  # default here so even a direct call before init is safe.
  unless RactorRailsShim.const_defined?(:JSON_ENCODER_CLASS)
    RactorRailsShim.const_set(:JSON_ENCODER_CLASS, ::ActiveSupport::JSON::Encoding::JSONGemEncoder)
  end
end

._install_kaminari_config_patchObject



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/ractor_rails_shim/patches/kaminari.rb', line 21

def _install_kaminari_config_patch
  return if @kaminari_patched
  @kaminari_patched = true
  _register_patch :kaminari_config, "8.1"
  return unless defined?(::Kaminari)

  # In the main ractor, capture the config object and make it shareable.
  shareable_config = nil
  if Ractor.main?
    begin
      cfg = ::Kaminari.instance_variable_get(:@_config) rescue nil
      if cfg
        begin
          Ractor.make_shareable(cfg)
          shareable_config = cfg
        rescue => e
          # If the config can't be made shareable (unlikely — it's all
          # integers/symbols/nil), build a fresh one with defaults.
          shareable_config = Ractor.make_shareable(::Kaminari::Config.new)
        end
      else
        shareable_config = Ractor.make_shareable(::Kaminari::Config.new)
      end
    rescue => e
      # Best-effort
    end
  end
  shareable_config ||= Ractor.make_shareable(::Kaminari::Config.new) rescue nil

  # Store the shareable config as a constant so workers can read it.
  if shareable_config
    verbose, $VERBOSE = $VERBOSE, nil
    begin
      const_set(:KAMINARI_SHAREABLE_CONFIG, shareable_config)
    ensure
      $VERBOSE = verbose
    end
  end

  k_key = :ractor_rails_shim_kaminari_config
  k_key_str = k_key.inspect
  ::Kaminari.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def config
      v = ActiveSupport::IsolatedExecutionState[#{k_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@_config)
        @_config
      else
        RactorRailsShim::KAMINARI_SHAREABLE_CONFIG
      end
    end

    def config=(val)
      ActiveSupport::IsolatedExecutionState[#{k_key_str}] = val
    end
  RUBY

  # Register so the shareable fallback builder knows about it.
  CLASS_ATTRIBUTES << ["Kaminari", :config, k_key, nil] if shareable_config
end

._install_local_cache_patchObject

Patch ActiveSupport::Cache::Strategy::LocalCache#local_cache_key. The original memoizes the key in a @local_cache_key ivar on the store:

`@local_cache_key ||= "...".to_sym`

When the store is part of the frozen, shared Rails.application graph (deep-frozen by make_app_shareable! for kino :ractor mode), a worker Ractor writing that ivar raises FrozenError. The key is a pure function of the store's class + object_id (both stable for the shared object), so compute it deterministically each call — no ivar write. The key still addresses LocalCacheRegistry, which is already Ractor-safe (it uses IsolatedExecutionState), so each Ractor keeps its own local cache.



915
916
917
918
919
920
921
922
923
924
925
926
927
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 915

def _install_local_cache_patch
  return if @local_cache_patched
  @local_cache_patched = true
  _register_patch :local_cache, "8.1"
  return unless defined?(::ActiveSupport::Cache::Strategy::LocalCache)
  lc = ::ActiveSupport::Cache::Strategy::LocalCache
  lc.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def local_cache_key
      str = "\#{self.class.name.underscore}_local_cache_\#{object_id}".gsub(/[\\/-]/, "_")
      str.to_sym
    end
  RUBY
end

._install_log_subscriber_patchObject

Patch ActiveSupport::LogSubscriber.logger — a raw class ivar with lazy init (@logger ||= Rails.logger) that's WRITTEN at request teardown via flush_all!. Workers can't write class ivars → IsolationError. Route through IES; workers get Rails.logger (which the shim already routes through IES) so it resolves to the worker's own per-Ractor logger.



847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 847

def _install_log_subscriber_patch
  return if @log_subscriber_patched
  @log_subscriber_patched = true
  _register_patch :log_subscriber, "8.1"
  return unless defined?(::ActiveSupport::LogSubscriber)
  ls = ::ActiveSupport::LogSubscriber
  key = :ractor_rails_shim_log_subscriber_logger
  key_str = key.inspect
  ls.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def logger
      v = ActiveSupport::IsolatedExecutionState[#{key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@logger)
        @logger
      elsif defined?(::Rails) && ::Rails.respond_to?(:logger)
        ::Rails.logger
      end
    end

    def logger=(val)
      ActiveSupport::IsolatedExecutionState[#{key_str}] = val
    end
  RUBY
end

._install_lookup_context_patchObject



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 67

def _install_lookup_context_patch
  return if @lookup_context_patched
  @lookup_context_patched = true
  _register_patch :lookup_context, "8.1"
  return unless defined?(::ActionView::LookupContext)
  # Force autoload of the nested ActionView::Template constants that the
  # patched details_cache_key references (Template::Types,
  # TemplateDetails::Requested). Constants are global, so defining them
  # here (main ractor) makes them visible to worker ractors, which cannot
  # autoload. Without this, the first template render in a worker dies on
  # `NameError: uninitialized constant ActionView::Template::TemplateDetails`.
  if Ractor.main? && defined?(::ActionView::Template)
    ::ActionView::Template::Types rescue nil
    ::ActionView::TemplateDetails rescue nil
  end
  lc = ::ActionView::LookupContext
  key = :ractor_rails_shim_lookup_context_registered_details
  key_str = key.inspect
  lc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def registered_details
      v = ActiveSupport::IsolatedExecutionState[#{key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@registered_details)
        @registered_details
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] || []
      end
    end
    def registered_details=(val)
      ActiveSupport::IsolatedExecutionState[#{key_str}] = val
      @registered_details = val if Ractor.main?
      val
    end
  RUBY
  CLASS_ATTRIBUTES << ["ActionView::LookupContext", :registered_details, key, []]

  # Redefine default_#{name} methods. register_detail (line 25 of
  # lookup_context.rb) defines these via Accessors.define_method(:"default_#{name}", &block)
  # — a Proc from the main ractor. Calling them from a worker raises
  # "defined with an un-shareable Proc in a different Ractor".
  # Trigger: when Accept: */* is sent, request.formats returns [Mime::ALL],
  # and LookupContext#formats= (the override at line 263) does
  # `values.concat(default_formats) if values.delete "*/*"` — Mime::ALL
  # compares == to "*/*", so delete removes it and default_formats is called.
  # Fix: call each block once in main, make the result shareable, and
  # redefine the method via string eval (no captured binding).
  accessors = ::ActionView::LookupContext::Accessors
  ::ActionView::LookupContext.registered_details.each do |name|
    block = accessors::DEFAULT_PROCS[name]
    next unless block
    begin
      value = block.call
    rescue
      next
    end
    begin
      value = Ractor.make_shareable(value)
    rescue
      value = value.dup.freeze rescue value
    end
    const_name = "SHIM_DEFAULT_#{name.upcase}_VALUE"
    verbose, $VERBOSE = $VERBOSE, nil
    accessors.const_set(const_name, value)
    $VERBOSE = verbose
    accessors.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def default_#{name}
        ::ActionView::LookupContext::Accessors::#{const_name}
      end
    RUBY
  end

  # Patch ActionView::Rendering::ClassMethods#view_context_class to read
  # from a shareable registry (populated in main) instead of building via
  # Class.new{...} blocks (un-shareable Proc from a worker). The built
  # class is made shareable; workers read it via the registry.
  if defined?(::ActionView::Rendering::ClassMethods)
    rcm = ::ActionView::Rendering::ClassMethods

    # `ActionView::Base.with_empty_template_cache` is patched separately and
    # EARLY (see _install_with_empty_template_cache_patch, installed via
    # ActiveSupport.on_load(:action_view) before eager load) because the
    # framework's original uses `define_method(:compiled_method_container)
    # { subclass }` — a block/Proc captured in the main Ractor that raises
    # "defined with an un-shareable Proc in a different Ractor" when a
    # worker calls it. In production `DetailsKey.view_context_class` calls
    # with_empty_template_cache during eager load, so the patch MUST be in
    # place before then.
    _install_with_empty_template_cache_patch if defined?(::ActionView::Base)

    # Build the per-controller view_context_class registry in main. We call
    # the ORIGINAL build_view_context_class directly (bypassing Rails'
    # inherit_view_context_class? short-circuit, which otherwise makes
    # subclasses reuse ActionController::Base's class built with a nil
    # `_routes` and thus NO route url_helpers). `routes` is forced to the
    # shareable Rails.application.routes when the controller's own `_routes`
    # is nil, so named helpers (new_post_path, etc.) are always present.
    if Ractor.main?
      registry = {}
      ::AbstractController::Base.descendants.each do |ctrl|
        begin
          routes = ctrl.respond_to?(:_routes) ? ctrl._routes : nil
          routes = ::Rails.application.routes if routes.nil? && ::Rails.respond_to?(:application) && ::Rails.application
          vcc = rcm.instance_method(:build_view_context_class).bind(ctrl).call(
            ::ActionView::LookupContext::DetailsKey.view_context_class,
            ctrl.respond_to?(:supports_path?) ? ctrl.supports_path? : true,
            routes,
            ctrl.respond_to?(:_helpers) ? ctrl._helpers : nil
          )
          registry[ctrl] = vcc if vcc
        rescue => e
          # skip controllers that can't build (e.g. abstract)
        end
      end
      registry.delete_if { |_, v| v.nil? }
      registry.freeze
      begin
        Ractor.make_shareable(registry)
        self._view_context_registry = registry
      rescue => e
        # If the registry can't be made shareable, leave it — workers fall
        # back to the empty-cache base.
      end
    end

    rcm.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def view_context_class
        return @view_context_class if Ractor.main? && instance_variable_defined?(:@view_context_class) && @view_context_class

        if Ractor.main?
          @view_context_class ||= build_view_context_class(
            ActionView::LookupContext::DetailsKey.view_context_class,
            supports_path?,
            _routes,
            _helpers
          )
          return @view_context_class
        end

        # Worker Ractor. The frozen shared controller class carries a
        # memoized @view_context_class built in main (in the registry) with
        # its proper route url_helpers + controller helpers. Look it up by
        # controller class; fall back to the shareable fallback class (built
        # in main with Rails.application.routes) for any controller not
        # present in the registry at prepare time.
        vcc = RactorRailsShim._view_context_registry[self]
        return vcc if vcc
        RactorRailsShim._view_context_fallback
      end

      # `inherit_view_context_class?` (action_view/rendering.rb:52) compares
      # `superclass._helpers` — and `_helpers` is defined via a block
      # (redefine_singleton_method) that cannot run in a worker Ractor
      # ("defined with an un-shareable Proc in a different Ractor"). Return
      # false so each controller builds its own view_context_class (the
      # normal Rails behaviour whenever _routes/_helpers differ), avoiding
      # the `_helpers` comparison entirely. Behaviour is identical in the
      # main Ractor (the inherited class would be equivalent).
      def inherit_view_context_class?
        false
      end
    RUBY
  end
  vcc_key = :ractor_rails_shim_lookup_context_view_context_class
  vcc_key_str = vcc_key.inspect
  CLASS_ATTRIBUTES << ["ActionView::LookupContext::DetailsKey", :view_context_class, vcc_key, nil]
  # Build it now in main and stash in IES so the fallback builder picks it up.
  if Ractor.main? && defined?(::ActionView::Base)
    ActiveSupport::IsolatedExecutionState[vcc_key] = ::ActionView::LookupContext::DetailsKey.view_context_class
    # Shareable fallback view_context_class for any controller not present in
    # the registry at prepare time. Subclasses ActionView::Base, so it
    # inherits the per-class compiled_method_container (self.class) and the
    # route url_helpers. build_view_context_class is a ClassMethods method on
    # controllers, so invoke it via the unbound method bound to
    # ActionController::Base.
    fallback = rcm.instance_method(:build_view_context_class).bind(::ActionController::Base).call(
      ::ActionView::LookupContext::DetailsKey.view_context_class,
      true,
      (defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application) ? ::Rails.application.routes : nil,
      nil
    )
    Ractor.make_shareable(fallback) rescue nil
    self._view_context_fallback = fallback
  end

  dk = ::ActionView::LookupContext::DetailsKey
  dk_key = :ractor_rails_shim_lookup_context_details_keys
  dc_key = :ractor_rails_shim_lookup_context_digest_cache
  dk_key_str = dk_key.inspect
  dc_key_str = dc_key.inspect
  vcc_key_str2 = vcc_key.inspect
  dk.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def view_context_class
      v = ActiveSupport::IsolatedExecutionState[#{vcc_key_str2}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@view_context_class)
        v = @view_context_class
        ActiveSupport::IsolatedExecutionState[#{vcc_key_str2}] = v
        v
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{vcc_key_str2}]
      end
    end
    def details_keys
      v = ActiveSupport::IsolatedExecutionState[#{dk_key_str}]
      return v if v
      if Ractor.main? && instance_variable_defined?(:@details_keys)
        v = @details_keys
      else
        v = Concurrent::Map.new
      end
      ActiveSupport::IsolatedExecutionState[#{dk_key_str}] = v
      v
    end
    def digest_cache(details)
      dc = (ActiveSupport::IsolatedExecutionState[#{dc_key_str}] ||= Concurrent::Map.new)
      dc[details_cache_key(details)] ||= Concurrent::Map.new
    end
    def details_cache_key(details)
      details_keys.fetch(details) do
        if formats = details[:formats]
          unless ::ActionView::Template::Types.valid_symbols?(formats)
            details = details.dup
            details[:formats] &= ::ActionView::Template::Types.symbols
          end
        end
        details_keys[details] ||= ::ActionView::TemplateDetails::Requested.new(**details)
      end
    end
  RUBY
end

._install_messages_serializer_patchObject

Patch ActiveSupport::Messages::SerializerWithFallback. Its SERIALIZERS constant is a Hash of serializer modules — but the Hash itself is not Ractor-shareable, so a worker Ractor reading it raises "can not access non-shareable objects in constant ...SERIALIZERS". The individual serializer modules ARE shareable, so route the lookup through IsolatedExecutionState (a per-Ractor cache of the same module references, which workers can read). .load resolves the fallback serializer the same way.



959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 959

def _install_messages_serializer_patch
  return if @messages_serializer_patched
  @messages_serializer_patched = true
  _register_patch :messages_serializer, "8.1"
  return unless defined?(::ActiveSupport::Messages::SerializerWithFallback)
  swf = ::ActiveSupport::Messages::SerializerWithFallback
  swf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def serializer_for(format)
      if Ractor.main?
        SERIALIZERS.fetch(format)
      else
        (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_serializers] ||= {
          marshal: ::ActiveSupport::Messages::SerializerWithFallback::MarshalWithFallback,
          json: ::ActiveSupport::Messages::SerializerWithFallback::JsonWithFallback,
          json_allow_marshal: ::ActiveSupport::Messages::SerializerWithFallback::JsonWithFallbackAllowMarshal,
          message_pack: ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallback,
          message_pack_allow_marshal: ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallbackAllowMarshal,
        })[format]
      end
    end

    def [](format)
      if format.to_s.include?("message_pack") && !defined?(::ActiveSupport::MessagePack)
        require "active_support/message_pack"
      end
      serializer_for(format)
    end
  RUBY
  swf.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def load(dumped)
      format = detect_format(dumped)
      if format == self.format
        _load(dumped)
      elsif format && fallback?(format)
        payload = { serializer: self.format, fallback: format, serialized: dumped }
        ActiveSupport::Notifications.instrument("message_serializer_fallback.active_support", payload) do
          payload[:deserialized] = serializer_for(format)._load(dumped)
        end
      else
        raise "Unsupported serialization format"
      end
    end
  RUBY

  # MessagePackWithFallback#available? lazily memoizes `@available` directly
  # on the module. When a worker Ractor first deserializes a cookie,
  # SerializerWithFallback#load -> detect_format -> MessagePackWithFallback
  # .dumped? -> available? tries to SET that ivar, raising
  #   Ractor::IsolationError: can not set instance variables of
  #   classes/modules by non-main Ractors
  # Replace the ivar memoization with a pure constant check. The module is
  # shareable and ActiveSupport::MessagePack resolves to a shareable class,
  # so this is safe from any Ractor.
  ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallback.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def available?
      defined?(::ActiveSupport::MessagePack)
    end
  RUBY
end

._install_module_introspection_patchObject

Patch Module#module_parent_name so a worker Ractor does not write the @parent_name class ivar on a shared (non-frozen) module. The default memoizes @parent_name ||= ... on first use; when that first use happens in a worker it writes a class ivar on a shared module, which raises Ractor::IsolationError ("can not set instance variables of classes/modules by non-main Ractors"). Route the per-worker cache through IsolatedExecutionState (keyed by module object_id); main keeps the original class-ivar behavior.



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 261

def _install_module_introspection_patch
  return if @module_introspection_patched
  @module_introspection_patched = true
  _register_patch :module_introspection, "8.1"
  return unless defined?(::Module)
  ::Module.module_eval do
    def module_parent_name
      if defined?(@parent_name)
        @parent_name
      else
        name = self.name
        return if name.nil?

        parent_name = name =~ /::[^:]+\z/ ? -$` : nil
        if Ractor.main?
          @parent_name = parent_name unless frozen?
        else
          store = (ActiveSupport::IsolatedExecutionState[:rrs_module_parent_names] ||= {})
          store[object_id] ||= parent_name
        end
        parent_name
      end
    end
  end
end

._install_notifications_notifier_patchObject

Patch ActiveSupport::Notifications.notifier to not read the @notifier class ivar from a worker Ractor. The original is attr_accessor :notifier with @notifier = Fanout.new set at module load — a raw class ivar holding a Fanout (which has a Mutex + subscriber Procs, both unshareable). Workers get their own per-Ractor Fanout (no subscribers — instrumentation is a no-op in workers, which is correct for a read-only shared app where log subscribers already ran in main). notifier is read by instrumenter (per-request via Rails::Rack::Logger).

Moved here from execution_wrapper.rb (it patches ActiveSupport

Notifications, not ExecutionWrapper).



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 178

def _install_notifications_notifier_patch
  return if @notifications_notifier_patched
  @notifications_notifier_patched = true
  _register_patch :notifications_notifier, "8.1"
  return unless defined?(::ActiveSupport::Notifications)
  notif = ::ActiveSupport::Notifications
  nkey = :ractor_rails_shim_notifications_notifier
  nkey_str = nkey.inspect
  notif.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def notifier
      v = ActiveSupport::IsolatedExecutionState[#{nkey_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@notifier)
        @notifier
      else
        built = ActiveSupport::Notifications::Fanout.new
        ActiveSupport::IsolatedExecutionState[#{nkey_str}] = built
        built
      end
    end
  RUBY
end

._install_openssl_digest_patchObject



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/ractor_rails_shim/patches/openssl.rb', line 29

def _install_openssl_digest_patch
  return if @openssl_digest_patched
  @openssl_digest_patched = true
  _register_patch :openssl_digest, "8.1"
  return unless defined?(::OpenSSL::Digest)

  algos = %w(MD4 MD5 RIPEMD160 SHA1 SHA224 SHA256 SHA384 SHA512)
  algos.each do |name|
    klass_name = name.tr("-", "_")
    next unless ::OpenSSL::Digest.const_defined?(klass_name)
    klass = ::OpenSSL::Digest.const_get(klass_name)
    next unless klass.is_a?(::Class)

    # Replace the lambda-based `initialize` (super(name, data)) with a
    # string-eval'd method that holds no captured Proc.
    klass.class_eval "def initialize(data = nil); super(#{name.inspect}, data); end"

    # Replace the singleton `digest`/`hexdigest` blocks the same way.
    klass.singleton_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
      def digest(data)
        new.digest(data)
      end
      def hexdigest(data)
        new.hexdigest(data)
      end
    RUBY
  end
end

._install_orm_adapter_patchObject



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/ractor_rails_shim/patches/orm_adapter.rb', line 14

def _install_orm_adapter_patch
  return if @orm_adapter_patched
  @orm_adapter_patched = true
  _register_patch :orm_adapter, "8.1"
  return unless defined?(::OrmAdapter::ToAdapter)

  ::OrmAdapter::ToAdapter.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def to_adapter
      key = :"ractor_rails_shim_orm_adapter_\#{object_id}"
      v = ActiveSupport::IsolatedExecutionState[key]
      return v unless v.nil?
      adapter = self::OrmAdapter.new(self)
      ActiveSupport::IsolatedExecutionState[key] = adapter
      adapter
    end
  RUBY
end

._install_parameter_encoding_patchObject

Patch ActionController::ParameterEncoding::ClassMethods#action_encoding_template to not read @_parameter_encodings (a raw class ivar) from a worker Ractor. The default is an empty-ish Hash; for a frozen shared app workers get an empty frozen Hash (no per-action param encodings — correct for apps that don't declare parameter_encoding, e.g. the health controller).



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 25

def _install_parameter_encoding_patch
  return if @param_encoding_patched
  @param_encoding_patched = true
  _register_patch :parameter_encoding, "8.1"
  return unless defined?(::ActionController::ParameterEncoding)
  pe = ::ActionController::ParameterEncoding::ClassMethods
  pe.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def action_encoding_template(action)
      enc = if Ractor.main?
        instance_variable_defined?(:@_parameter_encodings) ? @_parameter_encodings : nil
      else
        ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_param_encodings]
      end
      if enc && enc.has_key?(action.to_s)
        enc[action.to_s]
      end
    end
  RUBY
end

._install_path_registry_patchObject

Patch ActionView::PathRegistry to not read its raw class ivars (@view_paths_by_class, @file_system_resolvers) from a worker Ractor. These are populated at boot (view paths registered by the app). For a frozen shared app they're read-only; workers read them via the shareable fallback (built from main's values, made shareable). get_view_paths is called per-request during view lookup; all_file_system_resolvers is called by the exception backtrace builder.



398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 398

def _install_path_registry_patch
  return if @path_registry_patched
  @path_registry_patched = true
  _register_patch :path_registry, "8.1"
  return unless defined?(::ActionView::PathRegistry)
  pr = ::ActionView::PathRegistry
  vpc_key = :ractor_rails_shim_path_registry_view_paths_by_class
  fsr_key = :ractor_rails_shim_path_registry_file_system_resolvers
  vpc_key_str = vpc_key.inspect
  fsr_key_str = fsr_key.inspect
  pr.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def get_view_paths(klass)
      h = ActiveSupport::IsolatedExecutionState[#{vpc_key_str}]
      h = (Ractor.main? ? (instance_variable_defined?(:@view_paths_by_class) ? instance_variable_get(:@view_paths_by_class) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{vpc_key_str}]) if h.nil?
      h[klass] || get_view_paths(klass.superclass)
    end

    def set_view_paths(klass, paths)
      h = ActiveSupport::IsolatedExecutionState[#{vpc_key_str}] ||= (Ractor.main? ? (instance_variable_defined?(:@view_paths_by_class) ? instance_variable_get(:@view_paths_by_class) : {}) : {})
      h[klass] = paths
      instance_variable_set(:@view_paths_by_class, h) if Ractor.main?
    end

    def all_file_system_resolvers
      h = ActiveSupport::IsolatedExecutionState[#{fsr_key_str}]
      h = (Ractor.main? ? (instance_variable_defined?(:@file_system_resolvers) ? instance_variable_get(:@file_system_resolvers) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{fsr_key_str}]) if h.nil?
      h.values
    end
  RUBY
  # Register so the fallback builder captures + shares these.
  CLASS_ATTRIBUTES << ["ActionView::PathRegistry", :view_paths_by_class, vpc_key, {}]
  CLASS_ATTRIBUTES << ["ActionView::PathRegistry", :file_system_resolvers, fsr_key, {}]
end

._install_polymorphic_routes_patchObject



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/ractor_rails_shim/patches/polymorphic_routes.rb', line 21

def _install_polymorphic_routes_patch
  return if @polymorphic_routes_patched
  @polymorphic_routes_patched = true
  _register_patch :polymorphic_routes, "8.1"
  return unless defined?(::ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder)
  hmb = ::ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder
  hmb.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def get(action, type)
      type = type.to_sym
      build action, type
    end

    def url
      build nil, "url"
    end

    def path
      build nil, "path"
    end
  RUBY
  hmb.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def handle_model_call(target, record)
      if mapping = polymorphic_mapping(target, record) rescue nil
        mapping.call(target, [record], suffix == "path")
      else
        method, args = handle_model(record)
        target.public_send(method, *args)
      end
    end
  RUBY

  # The module-level `polymorphic_path` / `polymorphic_url` (called by
  # `form_with` when it infers the URL from a model) invoke
  # `mapping.call` directly. A custom `resolve` mapping Proc is built in
  # the main Ractor and is un-shareable, so calling it from a worker
  # Ractor raises "defined with an un-shareable Proc in a different
  # Ractor". Rescue that and fall through to the (worker-safe)
  # HelperMethodBuilder path, which derives the route from the record's
  # model name — the same fallback the original code uses when no mapping
  # is registered at all.
  pm = ::ActionDispatch::Routing::PolymorphicRoutes
  pm.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def polymorphic_path(record_or_hash_or_array, options = {})
      if ::Hash === record_or_hash_or_array
        options = record_or_hash_or_array.merge(options)
        record  = options.delete :id
        return polymorphic_path record, options
      end

      if mapping = (polymorphic_mapping(record_or_hash_or_array) rescue nil)
        begin
          return mapping.call(self, [record_or_hash_or_array, options], true)
        rescue ::Ractor::Error
        end
      end

      opts   = options.dup
      action = opts.delete :action
      type   = :path

      HelperMethodBuilder.polymorphic_method self,
                                             record_or_hash_or_array,
                                             action,
                                             type,
                                             opts
    end

    def polymorphic_url(record_or_hash_or_array, options = {})
      if ::Hash === record_or_hash_or_array
        options = record_or_hash_or_array.merge(options)
        record  = options.delete :id
        return polymorphic_url record, options
      end

      if mapping = (polymorphic_mapping(record_or_hash_or_array) rescue nil)
        begin
          return mapping.call(self, [record_or_hash_or_array, options], false)
        rescue ::Ractor::Error
        end
      end

      opts   = options.dup
      action = opts.delete :action
      type   = opts.delete(:routing_type) || :url

      HelperMethodBuilder.polymorphic_method self,
                                             record_or_hash_or_array,
                                             action,
                                             type,
                                             opts
    end
  RUBY
end

._install_propshaft_patchObject



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/ractor_rails_shim/patches/propshaft.rb', line 27

def _install_propshaft_patch
  return if @propshaft_patched
  @propshaft_patched = true
  _register_patch :propshaft, "1.3"
  return unless defined?(::Propshaft::LoadPath)

  lp = ::Propshaft::LoadPath
  lp.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def asset_paths_by_type(content_type)
      cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_type_\#{object_id}"] ||= {})
      if cache.key?(content_type)
        cache[content_type]
      else
        cache[content_type] = extract_logical_paths_from(assets.select { |a| a.content_type == ::Mime::EXTENSION_LOOKUP[content_type] })
      end
    end

    def asset_paths_by_glob(glob)
      cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_glob_\#{object_id}"] ||= {})
      if cache.key?(glob)
        cache[glob]
      else
        cache[glob] = extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(glob) })
      end
    end
  RUBY

  if lp.method_defined?(:asset_paths_by_path)
    lp.class_eval <<-RUBY, __FILE__, __LINE__ + 1
      def asset_paths_by_path(path)
        cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_path_\#{object_id}"] ||= {})
        if cache.key?(path)
          cache[path]
        else
          cache[path] = extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(path) })
        end
      end
    RUBY
  end
end

._install_query_parser_patchObject

ActionDispatch::QueryParser.each_pair returns enum_for(:each_pair, s, separator) when called without a block. The Enumerator wraps a Proc that was compiled in the main Ractor, so when a worker Ractor iterates it (pairs.each inside ParamBuilder#from_pairs) Ruby raises "defined with an un-shareable Proc in a different Ractor". Redefine it to materialize into a plain (shareable) frozen Array using a block-free loop, so worker Ractors can parse form/query pairs without crossing Ractor boundaries.



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 124

def _install_query_parser_patch
  return if @query_parser_patched
  @query_parser_patched = true
  return unless defined?(::ActionDispatch::QueryParser)
  ::ActionDispatch::QueryParser.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1
    def each_pair(s, separator = nil)
      return _materialized_pairs(s, separator) unless block_given?
      s ||= ""
      splitter =
        if separator
          ::ActionDispatch::QueryParser::COMMON_SEP[separator] || /[#{separator}] */n
        else
          ::ActionDispatch::QueryParser::DEFAULT_SEP
        end
      s.split(splitter).each do |part|
        next if part.empty?
        k, v = part.split("=", 2)
        k = URI.decode_www_form_component(k)
        v &&= URI.decode_www_form_component(v)
        yield k, v
      end
      nil
    end

    def _materialized_pairs(s, separator)
      s ||= ""
      splitter =
        if separator
          ::ActionDispatch::QueryParser::COMMON_SEP[separator] || /[#{separator}] */n
        else
          ::ActionDispatch::QueryParser::DEFAULT_SEP
        end
      parts = s.split(splitter)
      result = []
      i = 0
      while i < parts.length
        part = parts[i]
        i += 1
        if part.empty?
          next
        end
        kv = part.split("=", 2)
        k = URI.decode_www_form_component(kv[0])
        v = kv[1] && URI.decode_www_form_component(kv[1])
        result << [k, v]
      end
      result.freeze
    end
  RUBY
end

._install_rack_request_patchObject

Patch Rack::Request's class-level attr_accessors (forwarded_priority, x_forwarded_proto_priority) to not read @ivars from a worker Ractor. The values are frozen-Symbol Arrays (shareable); route the cache through IES with the same default in workers. Read per-request via ActionDispatch::RemoteIp. Applied at prepare_for_ractors! time (after Rails boots, so Rack is loaded).



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/ractor_rails_shim/patches/rack.rb', line 46

def _install_rack_request_patch
  return if @rack_request_patched
  @rack_request_patched = true
  _register_patch :rack_request, "8.1"
  return unless defined?(::Rack::Request)
  req = ::Rack::Request
  fp_key = :ractor_rails_shim_rack_forwarded_priority
  xp_key = :ractor_rails_shim_rack_x_forwarded_proto_priority
  fp_key_str = fp_key.inspect
  xp_key_str = xp_key.inspect
  req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def forwarded_priority
      v = ActiveSupport::IsolatedExecutionState[#{fp_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@forwarded_priority)
        @forwarded_priority
      else
        [:forwarded, :x_forwarded]
      end
    end
    def forwarded_priority=(val)
      ActiveSupport::IsolatedExecutionState[#{fp_key_str}] = val
      @forwarded_priority = val if Ractor.main?
      val
    end
    def x_forwarded_proto_priority
      v = ActiveSupport::IsolatedExecutionState[#{xp_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@x_forwarded_proto_priority)
        @x_forwarded_proto_priority
      else
        [:proto, :scheme]
      end
    end
    def x_forwarded_proto_priority=(val)
      ActiveSupport::IsolatedExecutionState[#{xp_key_str}] = val
      @x_forwarded_proto_priority = val if Ractor.main?
      val
    end
  RUBY
end

._install_rack_utils_patchObject

Patch Rack::Utils singleton attr_accessors (default_query_parser, multipart_total_part_limit, multipart_file_limit) to not read @ivars from a worker Ractor. The values are shareable once frozen (QueryParser, Integers). Route through IES; workers read the shareable fallback. default_query_parser is read per-request during POST parsing.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/ractor_rails_shim/patches/rack.rb', line 93

def _install_rack_utils_patch
  return if @rack_utils_patched
  @rack_utils_patched = true
  _register_patch :rack_utils, "8.1"
  return unless defined?(::Rack::Utils)
  u = ::Rack::Utils
  dqp_key = :ractor_rails_shim_rack_utils_default_query_parser
  mtp_key = :ractor_rails_shim_rack_utils_multipart_total_part_limit
  mfl_key = :ractor_rails_shim_rack_utils_multipart_file_limit
  dqp_key_str = dqp_key.inspect
  mtp_key_str = mtp_key.inspect
  mfl_key_str = mfl_key.inspect
  u.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def default_query_parser
      v = ActiveSupport::IsolatedExecutionState[#{dqp_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@default_query_parser)
        v = @default_query_parser
        ActiveSupport::IsolatedExecutionState[#{dqp_key_str}] = v
        v
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{dqp_key_str}] || ::Rack::QueryParser::QueryParser.make_default(32)
      end
    end
    def multipart_total_part_limit
      v = ActiveSupport::IsolatedExecutionState[#{mtp_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@multipart_total_part_limit)
        v = @multipart_total_part_limit
        ActiveSupport::IsolatedExecutionState[#{mtp_key_str}] = v
        v
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{mtp_key_str}] || 128
      end
    end
    def multipart_file_limit
      v = ActiveSupport::IsolatedExecutionState[#{mfl_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@multipart_file_limit)
        v = @multipart_file_limit
        ActiveSupport::IsolatedExecutionState[#{mfl_key_str}] = v
        v
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{mfl_key_str}] || 64
      end
    end
  RUBY
  CLASS_ATTRIBUTES << ["Rack::Utils", :default_query_parser, dqp_key, nil]
  CLASS_ATTRIBUTES << ["Rack::Utils", :multipart_total_part_limit, mtp_key, nil]
  CLASS_ATTRIBUTES << ["Rack::Utils", :multipart_file_limit, mfl_key, nil]
end

._install_reloader_patchObject

Patch ActiveSupport::Reloader#check! / #reloaded!. These are CLASS

methods that memoize @should_reload in a class ivar. ActionDispatch

Executor#call runs Reloader.run! -> check! on EVERY request, so a worker Ractor writing that class ivar raises Ractor::IsolationError ("can not set instance variables of classes/modules by non-main Ractors"). Route the flag through IsolatedExecutionState so each Ractor has its own. With reloading disabled (config.enable_reloading = false, the right setting for a frozen, shared kino :ractor graph) check.call is lambda { false }, so workers compute false (no reload) — but the write must still be Ractor-safe.



882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 882

def _install_reloader_patch
  return if @reloader_patched
  @reloader_patched = true
  _register_patch :reloader, "8.1"
  return unless defined?(::ActiveSupport::Reloader)
  rl = ::ActiveSupport::Reloader
  key = :ractor_rails_shim_reloader_should_reload
  key_str = key.inspect
  rl.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def check!
      v = ActiveSupport::IsolatedExecutionState[#{key_str}]
      return v unless v.nil?
      result = check.call
      ActiveSupport::IsolatedExecutionState[#{key_str}] = result
      result
    end

    def reloaded!
      ActiveSupport::IsolatedExecutionState[#{key_str}] = false
    end
  RUBY
end

._install_request_parameter_parsers_patchObject

Patch ActionDispatch::Request.parameter_parsers (singleton attr_reader backed by @parameter_parsers) to not read the class ivar from a worker Ractor. The value is a Hash of MIME-type → parser (lambdas). Route through IES; workers read the shareable fallback (the boot-time parsers, made shareable). Read per-request during parameter parsing.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 93

def _install_request_parameter_parsers_patch
  return if @request_param_parsers_patched
  @request_param_parsers_patched = true
  _register_patch :request_parameter_parsers, "8.1"
  return unless defined?(::ActionDispatch::Request)
  req = ::ActionDispatch::Request
  pp_key = :ractor_rails_shim_request_parameter_parsers
  pp_key_str = pp_key.inspect
  req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def parameter_parsers
      v = ActiveSupport::IsolatedExecutionState[#{pp_key_str}]
      return v unless v.nil?
      if Ractor.main? && instance_variable_defined?(:@parameter_parsers)
        v = @parameter_parsers
        ActiveSupport::IsolatedExecutionState[#{pp_key_str}] = v
        v
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{pp_key_str}] || ActionDispatch::Request::DEFAULT_PARSERS
      end
    end
  RUBY
  CLASS_ATTRIBUTES << ["ActionDispatch::Request", :parameter_parsers, pp_key, nil]
end

._install_template_handlers_patchObject



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 298

def _install_template_handlers_patch
  return if @template_handlers_patched
  @template_handlers_patched = true
  _register_patch :template_handlers, "8.1"
  return unless defined?(::ActionView::Template::Handlers)
  # Eager-load the handler classes in main so workers don't need to
  # autoload them (workers can't autoload).
  if Ractor.main?
    ::ActionView::Template::Handlers::Raw rescue nil
    ::ActionView::Template::Handlers::ERB rescue nil
    ::ActionView::Template::Handlers::Html rescue nil
    ::ActionView::Template::Handlers::Builder rescue nil
  end
  h = ::ActionView::Template::Handlers
  th_key = :ractor_rails_shim_av_template_handlers
  dth_key = :ractor_rails_shim_av_default_template_handlers
  th_key_str = th_key.inspect
  dth_key_str = dth_key.inspect
  # The handler registry lives in class variables (@@template_handlers,
  # @@default_template_handlers, @@template_extensions) whose values are
  # mutable Hashes holding handler instances — and an unshareable `:ruby`
  # lambda. A worker Ractor cannot read these class vars. Route the
  # registry through IsolatedExecutionState: each Ractor builds its own
  # handler map (the defaults are deterministic), and in main we seed from
  # the live class var (capturing any custom handlers gems registered at
  # boot). The `:ruby` lambda makes the map unshareable, so the old
  # SHAREABLE_FALLBACK approach (which skips unshareable values) left
  # workers with an empty map. These are instance methods (Handlers is
  # extended into ActionView::Template, and the render path calls them on
  # the Template instance), so they must be defined on the module itself,
  # not just the singleton class.
  h.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def self._ractor_rails_shim_handlers
      map = ActiveSupport::IsolatedExecutionState[#{th_key_str}]
      return map unless map.nil?
      if Ractor.main?
        cv = class_variable_get(:@@template_handlers) rescue nil
        cv = nil if cv && cv.empty?
        if cv
          ActiveSupport::IsolatedExecutionState[#{th_key_str}] = cv
          return cv
        end
      end
      built = {
        raw: ::ActionView::Template::Handlers::Raw.new,
        erb: ::ActionView::Template::Handlers::ERB.new,
        html: ::ActionView::Template::Handlers::Html.new,
        builder: ::ActionView::Template::Handlers::Builder.new,
        ruby: ->(_, source) { source },
      }
      ActiveSupport::IsolatedExecutionState[#{th_key_str}] = built
      built
    end

    def self._ractor_rails_shim_persist(map)
      ActiveSupport::IsolatedExecutionState[#{th_key_str}] = map
      class_variable_set(:@@template_handlers, map) if Ractor.main?
    end

    def self.extensions
      self._ractor_rails_shim_handlers.keys
    end

    def registered_template_handler(extension)
      extension && ::ActionView::Template::Handlers._ractor_rails_shim_handlers[extension.to_sym]
    end

    def handler_for_extension(extension)
      registered_template_handler(extension) || ::ActionView::Template::Handlers::ERB.new
    end

    def template_handler_extensions
      ::ActionView::Template::Handlers._ractor_rails_shim_handlers.keys.map(&:to_s).sort
    end

    def register_template_handler(*extensions, handler)
      map = ::ActionView::Template::Handlers._ractor_rails_shim_handlers.dup
      extensions.each { |ext| map[ext.to_sym] = handler }
      ::ActionView::Template::Handlers._ractor_rails_shim_persist(map)
    end

    def unregister_template_handler(*extensions)
      map = ::ActionView::Template::Handlers._ractor_rails_shim_handlers.dup
      extensions.each { |ext| map.delete(ext.to_sym) }
      ::ActionView::Template::Handlers._ractor_rails_shim_persist(map)
    end

    def register_default_template_handler(extension, klass)
      register_template_handler(extension, klass)
    end
  RUBY
end

._install_warden_hooks_patchObject



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/ractor_rails_shim/patches/warden.rb', line 27

def _install_warden_hooks_patch
  return if @warden_patched
  @warden_patched = true
  _register_patch :warden_hooks, "8.1"
  return unless defined?(::Warden::Hooks)
  ::Warden::Hooks.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def _after_set_user
      if Ractor.main? && instance_variable_defined?(:@_after_set_user)
        @_after_set_user
      else
        []
      end
    end
    def _before_failure
      if Ractor.main? && instance_variable_defined?(:@_before_failure)
        @_before_failure
      else
        []
      end
    end
    def _after_failed_fetch
      if Ractor.main? && instance_variable_defined?(:@_after_failed_fetch)
        @_after_failed_fetch
      else
        []
      end
    end
    def _before_logout
      if Ractor.main? && instance_variable_defined?(:@_before_logout)
        @_before_logout
      else
        []
      end
    end
    def _on_request
      if Ractor.main? && instance_variable_defined?(:@_on_request)
        @_on_request
      else
        []
      end
    end
  RUBY
end

._install_warden_serializer_patchObject

Warden registers per-scope session serializers with Warden::SessionSerializer.send(:define_method, method_name, &block) (warden-1.2.9/lib/warden/manager.rb:71). The block is created while the app boots in the main Ractor, so the resulting method is Ractor-bound: invoking it (e.g. user_serialize) from a worker Ractor raises "defined with an un-shareable Proc in a different Ractor".

Devise's block body is simply mapping.to.serialize_into_session(record). We re-register the serializers as plain def methods (which are NOT Ractor-bound) that delegate to the model class's own serialize_into_session / serialize_from_session class methods — both worker-safe — so the chain is callable from any worker Ractor.



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/ractor_rails_shim/patches/warden.rb', line 115

def _install_warden_serializer_patch
  return if @warden_serializer_patched
  @warden_serializer_patched = true
  _register_patch :warden_serializer, "8.1"
  return unless defined?(::Warden::SessionSerializer)

  if defined?(::Devise) && ::Devise.respond_to?(:mappings)
    ::Devise.mappings.each do |scope, mapping|
      model = mapping.to
      ::Warden::SessionSerializer.class_eval <<-RUBY, __FILE__, __LINE__ + 1
        def #{scope}_serialize(record)
          #{model}.serialize_into_session(record)
        end

        def #{scope}_deserialize(*keys)
          # Devise's serialize_into_session returns [[id], salt]. The key
          # passed by Warden::SessionSerializer#fetch is that value, but in
          # the kino :ractor worker the per-request session that Warden's
          # serializer sees can hold an extra wrapping layer
          # ([[[id], salt]]). Flatten so serialize_from_session(key, salt)
          # always receives exactly two arguments.
          #{model}.serialize_from_session(*keys.flatten)
        end
      RUBY
    end
  end

  ::Warden::SessionSerializer.class_eval do
    unless method_defined?(:serialize)
      def serialize(user)
        user
      end
    end

    unless method_defined?(:deserialize)
      def deserialize(key)
        key
      end
    end
  end
end

._install_warden_strategies_patchObject

Patch Warden::Strategies#_strategies. The strategy registry is a lazy class ivar (@strategies ||= {}) on the Warden::Strategies module; a worker Ractor reading it raises "can not get unshareable values from instance variables of classes/modules from non-main Ractors" (Devise's current_user / user_signed_in? in a layout hits Warden::Strategies -> _strategies). Capture the (shareable) registry in main and expose it via a constant that workers read.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/ractor_rails_shim/patches/warden.rb', line 78

def _install_warden_strategies_patch
  return if @warden_strategies_patched
  @warden_strategies_patched = true
  _register_patch :warden_strategies, "8.1"
  return unless defined?(::Warden::Strategies)
  ::Warden::Strategies.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def _strategies
      if Ractor.main?
        @strategies ||= {}
      else
        RactorRailsShim::SHAREABLE_WARDEN_STRATEGIES || {}
      end
    end
  RUBY
  if Ractor.main?
    begin
      strat = ::Warden::Strategies.instance_variable_get(:@strategies)
      strat = Ractor.make_shareable(strat) if strat && !Ractor.shareable?(strat)
      RactorRailsShim.const_set(:SHAREABLE_WARDEN_STRATEGIES, strat) unless RactorRailsShim.const_defined?(:SHAREABLE_WARDEN_STRATEGIES)
    rescue
      nil
    end
  end
end

._install_with_empty_template_cache_patchObject

Patch ActionView::Base.with_empty_template_cache (action_view/base.rb:204) to a block-free def. The original defines compiled_method_container (instance + singleton) via define_method(&block) — an un-shareable Proc compiled in the main Ractor that raises "defined with an un-shareable Proc in a different Ractor" when a worker calls it. We also route compiled template methods through ONE shared SHAREABLE_COMPILED_MODULE so the application layout (and Devise shared partials) compile once and are visible to every controller / worker Ractor.

Installed EARLY via ActiveSupport.on_load(:action_view) (see core.rb install) so it is in place before production eager load calls DetailsKey.view_context_class -> with_empty_template_cache. Idempotent.



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 43

def _install_with_empty_template_cache_patch
  return if @with_empty_template_cache_patched
  return unless defined?(::ActionView::Base) && Ractor.main?
  @with_empty_template_cache_patched = true
  _register_patch :with_empty_template_cache, "8.1"
  ::ActionView::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def with_empty_template_cache
      subclass = Class.new(self) do
        include RactorRailsShim::SHAREABLE_COMPILED_MODULE
        def compiled_method_container
          RactorRailsShim::SHAREABLE_COMPILED_MODULE
        end
        def self.compiled_method_container
          RactorRailsShim::SHAREABLE_COMPILED_MODULE
        end
        def inspect
          "#<ActionView::Base:\#{'%#016x' % (object_id << 1)}>"
        end
      end
      subclass
    end
  RUBY
end

._introspectable?(o) ⇒ Boolean

BasicObject (and its subclasses) don't define respond_to?, so calling o.respond_to? on one raises NoMethodError. Use this to safely test whether an object can be introspected (is_a?, instance_variables, ...).

Returns:

  • (Boolean)


552
553
554
555
556
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 552

def _introspectable?(o)
  o.respond_to?(:is_a?)
rescue NoMethodError
  false
end

._make_value_shareable(val) ⇒ Object

Best-effort shareable replacement for a constant value. Monitor/Mutex become a NoOpLock (never contended post-boot). BasicObject instances (used as sentinel sentinels, e.g. PRIMARY_KEY_NOT_SET) can't be frozen (BasicObject has no #freeze method) — replace with a frozen Symbol. Everything else is deep-frozen via Ractor.make_shareable; if that fails (e.g. a Proc, or a Concurrent::Map / TypeMap holding Procs — both intrinsically unshareable and needing upstream Rails changes), returns nil and the constant is left as-is (the worker will raise a clear IsolationError on read).



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/ractor_rails_shim/patches/core.rb', line 277

def _make_value_shareable(val)
  if (val.is_a?(::Monitor) rescue false) || (val.is_a?(::Mutex) rescue false)
    Ractor.make_shareable(NoOpLock.new)
  elsif !(val.respond_to?(:freeze) rescue false)
    # BasicObject subclasses don't have #freeze/#respond_to? (Kernel not
    # included). Replace with a frozen Symbol sentinel — it's compared
    # with `equal?`, and a frozen Symbol is always shareable.
    Ractor.make_shareable(:"__shim_unshareable_sentinel__")
  else
    begin
      Ractor.make_shareable(val)
    rescue => e
      nil
    end
  end
end

._neutralize_logger_io!(app) ⇒ Object

Detach the logger IO from the app graph so Ractor.make_shareable(app) doesn't freeze the process's real $stdout/$stderr. The app-instance logger (app.config.logger) holds an IO in its logdev; freezing it would silence main-ractor logging and break minitest/server output.

Strategy: replace app.config.logger (and any broadcast target reachable from the app) with a frozen, shareable no-op BroadcastLogger (no IO). Then re-point the MAIN ractor's Rails.logger (the per-Ractor module accessor, NOT in the app graph) at a fresh live BroadcastLogger writing to $stderr — which is NOT reachable from the frozen app, so it stays mutable. Workers already build their own per-Ractor Rails.logger in the patched reader, so they're unaffected.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 140

def _neutralize_logger_io!(app)
  # A frozen, shareable no-op BroadcastLogger (no broadcasts → no IO) to
  # swap in for the app-instance logger graph.
  noop_logger = ::ActiveSupport::BroadcastLogger.new
  noop_logger.freeze
  Ractor.make_shareable(noop_logger)

  # Replace the app-instance logger + any IO reachable from the app graph.
  seen = {}
  stack = [app]
  until stack.empty?
    o = stack.pop
    next if o.equal?(nil) || seen[o.object_id]
    seen[o.object_id] = true
    begin
      o.instance_variables.each do |iv|
        begin; v = o.instance_variable_get(iv); rescue; next; end
        if iv == :@logger
          # Replace the app-instance / config logger with the no-op (so the
          # frozen app graph holds no live IO). Best-effort; rescue if the
          # owner is frozen.
          o.instance_variable_set(iv, noop_logger) rescue nil
        elsif v.is_a?(::IO) && (v == $stdout || v == $stderr || v == STDOUT || v == STDERR)
          # Any stray IO reference → a shareable no-op sink.
          sink = NoOpLogDev.new
          sink.freeze
          Ractor.make_shareable(sink)
          o.instance_variable_set(iv, sink) rescue nil
        elsif v
          stack << v
        end
      end
    rescue => e
      # BasicObject or frozen objects don't support instance_variables
    end
    if o.is_a?(Array); o.each { |e| stack << e if e }
    elsif o.is_a?(Hash); o.each { |_, val| stack << val if val }
    end
  end

  # Re-point the MAIN ractor's Rails.logger at a fresh live logger (not
  # reachable from the frozen app) so main keeps logging after the app is
  # made shareable. $stderr is each-Ractor-local and not in the app graph,
  # so it stays mutable. Use the same shape Rails uses (BroadcastLogger
  # broadcasting to a Logger writing $stderr).
  if Ractor.main? && defined?(::Rails)
    live = ::ActiveSupport::BroadcastLogger.new(::Logger.new($stderr))
    ::Rails.logger = live
  end
end

._patch_rails_module_body(mod) ⇒ Object



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 61

def _patch_rails_module_body(mod)
  k = KEYS

  # Register the Rails module accessors in CLASS_ATTRIBUTES so the
  # shareable-fallback builder captures their main-ractor values at
  # prepare_for_ractors! / make_app_shareable! time and exposes them to
  # worker Ractors (e.g. Rails.logger is read per-request by
  # Rails::Rack::SilenceRequest). `application` is NOT registered — workers
  # get the shared app via Ractor.new(app), not via Rails.application.
  CLASS_ATTRIBUTES << ["Rails", :logger,        k[:logger],        nil]
  CLASS_ATTRIBUTES << ["Rails", :cache,         k[:cache],         nil]
  CLASS_ATTRIBUTES << ["Rails", :backtrace_cleaner, k[:backtrace_cleaner], nil]
  CLASS_ATTRIBUTES << ["Rails", :app_class,     k[:app_class],     nil]

  # We PREPEND a module onto Rails.singleton_class rather than redefine
  # the methods directly, because Rails defines its own `application`,
  # `env`, etc. LATER in rails.rb (`class << self; def application; ...`).
  # A direct module_eval redefinition would be clobbered when Rails'
  # own `def` runs afterward. A prepended module sits in front of the
  # singleton class in the method lookup chain and survives a later
  # `def` on the same class — so our IES-routed reader stays in front.
  # We call `super` to fall back to Rails' original method for the
  # main-ractor lazy-init path (which reads the @application ivar —
  # safe in the main ractor).
  patch = Module.new
  patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    # application: IES first; main ractor falls back to Rails' own
    # lazy init via super. Worker ractors return nil (their own IES
    # slot is empty until they boot their own app).
    def application
      v = ActiveSupport::IsolatedExecutionState[#{k[:application].inspect}]
      return v unless v.nil?
      if Ractor.main?
        super
      else
        RactorRailsShim.const_defined?(:SHAREABLE_APP) ? RactorRailsShim::SHAREABLE_APP : nil
      end
    end

    def application=(val)
      ActiveSupport::IsolatedExecutionState[#{k[:application].inspect}] = val
      super if Ractor.main?
      val
    end

    # Simple accessors: app_class, cache, logger, backtrace_cleaner.
    # Workers fall back to the shareable fallback (built from main's
    # value at make_app_shareable! time) when their own IES slot is empty.
    def app_class
      v = ActiveSupport::IsolatedExecutionState[#{k[:app_class].inspect}]
      return v unless v.nil?
      return super if Ractor.main?
      RactorRailsShim::SHAREABLE_FALLBACK[#{k[:app_class].inspect}]
    end
    def app_class=(val)
      ActiveSupport::IsolatedExecutionState[#{k[:app_class].inspect}] = val
      super if Ractor.main?
      val
    end

    def cache
      v = ActiveSupport::IsolatedExecutionState[#{k[:cache].inspect}]
      return v unless v.nil?
      return super if Ractor.main?
      RactorRailsShim::SHAREABLE_FALLBACK[#{k[:cache].inspect}]
    end
    def cache=(val)
      ActiveSupport::IsolatedExecutionState[#{k[:cache].inspect}] = val
      super if Ractor.main?
      val
    end

    def logger
      v = ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}]
      return v unless v.nil?
      return super if Ractor.main?
      # Loggers are intrinsically mutable (formatters hold tag stacks,
      # logdevs hold IO + Mutex) and can't be shared read-only. Build a
      # per-worker ActiveSupport::BroadcastLogger (which mixes in
      # LoggerSilence#silence, used by Rails::Rack::SilenceRequest)
      # writing to $stderr (each Ractor has its own $stderr stream) and
      # cache it in IES so subsequent reads return the same instance.
      built = ActiveSupport::BroadcastLogger.new(Logger.new($stderr))
      ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}] = built
      built
    end
    def logger=(val)
      ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}] = val
      super if Ractor.main?
      val
    end

    def backtrace_cleaner
      v = ActiveSupport::IsolatedExecutionState[#{k[:backtrace_cleaner].inspect}]
      return v unless v.nil?
      return super if Ractor.main?
      RactorRailsShim::SHAREABLE_FALLBACK[#{k[:backtrace_cleaner].inspect}]
    end
    def backtrace_cleaner=(val)
      ActiveSupport::IsolatedExecutionState[#{k[:backtrace_cleaner].inspect}] = val
      super if Ractor.main?
      val
    end

    # env: worker ractors build their own EnvironmentInquirer from ENV
    # (no @ _env ivar to read). Main ractor falls back to super, which
    # lazily builds and caches in @_env.
    def env
      v = ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}]
      return v unless v.nil?
      if Ractor.main?
        super
      else
        built = ActiveSupport::EnvironmentInquirer.new(
          ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development"
        )
        ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}] = built
        built
      end
    end

    def env=(val)
      v = ActiveSupport::EnvironmentInquirer.new(val)
      ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}] = v
      super if Ractor.main?
      v
    end
  RUBY
  mod.singleton_class.prepend(patch)
end

._precompute_lazy_ivars(app) ⇒ Object

--- graph traversal helpers ---



484
485
486
487
488
489
490
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 484

def _precompute_lazy_ivars(app)
  app.env_config
  app.app_env_config rescue nil
  app.routes.url_helpers rescue nil
  app.routes.named_routes rescue nil
  app.routes.helpers rescue nil
end

._precompute_propshaft!(app) ⇒ Object

Warm every Propshaft Assembly/LoadPath lazy ivar in MAIN (before the app is frozen). Warmed ivars are truthy, so the ||= memoization in workers short-circuits instead of attempting to assign onto a frozen object. Assets::by_path is itself memoized; warming assets populates it so workers only read the frozen cache.



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/ractor_rails_shim/patches/propshaft.rb', line 73

def _precompute_propshaft!(app)
  return unless Ractor.main?
  assets = app.assets rescue nil
  return unless assets
  assets.load_path rescue nil
  assets.compilers rescue nil
  assets.resolver rescue nil
  assets.prefix rescue nil
  assets.processor rescue nil
  # Build the asset map (LoadPath#assets -> assets_by_path) so the inner
  # cache is populated and frozen before workers read it. Then warm each
  # Asset's lazy ivars (@content_type, @digest) in MAIN — they are memoized
  # via `||=` and the Asset objects live inside the frozen, shared app
  # graph, so a worker reading them would raise FrozenError. Warming here
  # (while still mutable) lets the `||=` short-circuit in workers.
  assets.load_path.assets.each do |asset|
    asset.content_type rescue nil
    asset.digest rescue nil
  end
  # Warm the resolver manifest cache. Propshaft::Resolver::Static#manifest
  # memoizes `@manifest ||= Propshaft::Manifest.from_path(...)`; the
  # resolver is frozen in the shared graph, so without warming, a worker
  # rendering a `stylesheet_link_tag` (or reading asset integrity) for a
  # PRECOMPILED manifest raises FrozenError ("can't modify frozen
  # Propshaft::Resolver::Static"). `#manifest` is private — invoke via
  # send to trigger the `||=` in MAIN (while still mutable) so workers only
  # read the frozen, cached value.
  resolver = assets.resolver rescue nil
  if resolver.respond_to?(:manifest, true)
    begin
      resolver.send(:manifest)
    rescue
      nil
    end
  end
end

._record_declared_callback(klass_id, kind, filter, only, except) ⇒ Object

Record a single declared symbolic filter. Called from the set_callback interceptor during eager load (main Ractor only).



764
765
766
767
768
769
770
771
772
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 764

def _record_declared_callback(klass_id, kind, filter, only, except)
  @declared_callbacks ||= {}
  (@declared_callbacks[klass_id] ||= []) << {
    kind: kind,
    filter: filter,
    only: (only.freeze if only),
    except: (except.freeze if except)
  }
end

._register_patch(name, *tested_segments) ⇒ Object

Record that a patch was developed/tested against the given Rails version segments. Called by each install_* method. This populates PATCH_VERSIONS so applicable_patches can report what applied to the runtime. To add support for a new version, add the segment here (after writing/testing the variant) — no other wiring needed.



488
489
490
491
# File 'lib/ractor_rails_shim/patches/core.rb', line 488

def _register_patch(name, *tested_segments)
  existing = PATCH_VERSIONS[name] || []
  PATCH_VERSIONS[name] = (existing + tested_segments).uniq
end

._replace_locks_and_concurrent_maps!(app) ⇒ Object

NOTE: _devise_mapping_replacement (Devise scope constraint → DeviseMappingCallable) now lives in warden.rb; _find_files_server (Rack::Files target for the asset stack) now lives in rack.rb.



656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 656

def _replace_locks_and_concurrent_maps!(app)
  seen = {}
  stack = [[app, "app", nil, nil]]
  until stack.empty?
    o, _p, _parent, _ivar = stack.pop
    next if o.equal?(nil)
    next unless _introspectable?(o)
    next if seen[o.object_id]
    seen[o.object_id] = true
    next if o.is_a?(Mutex) || o.is_a?(Monitor)
    begin
      o.instance_variables.each do |iv|
        begin; v = o.instance_variable_get(iv); rescue; next; end
        if v.is_a?(Mutex) || v.is_a?(Monitor)
          o.instance_variable_set(iv, NoOpLock.new) rescue nil
        elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map)
          hash_copy = {}
          v.each_pair { |k, val| hash_copy[k] = val }
          o.instance_variable_set(iv, hash_copy) rescue nil
        elsif v
          stack << [v, "#{_p}.#{iv}", o, iv]
        end
      end
    rescue => e
      # BasicObject or frozen objects don't support instance_variables
    end
    if o.is_a?(Array); o.each_with_index { |e, i| stack << [e, "#{_p}[#{i}]", o, nil] if e }
    elsif o.is_a?(Hash); o.each { |k, val| stack << [k, "#{_p}.key", o, nil] if k; stack << [val, "#{_p}[#{k.inspect}]", o, nil] if val }
    end
  end
end

._replace_one_proc(proc_obj, parent, ivar, mw) ⇒ Object



598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 598

def _replace_one_proc(proc_obj, parent, ivar, mw)
  src = proc_obj.source_location&.first || ""
  replacement =
    if src.end_with?(SSL_LOC) && ivar == :@exclude
      redirect = parent.instance_variable_get(:@redirect)
      CallableConst.new(!redirect)
    elsif src.end_with?(FILES_LOC) && ivar == :@app
      # The lambda is `Rack::Files#initialize`'s `lambda { |env| get env }`,
      # stored as `Rack::Head#@app`. Its `self` (binding receiver) is the
      # `Rack::Files` instance that defines `get` — NOT the `Rack::Head`
      # that holds it. Use the binding receiver as the callable target so
      # the worker calls `Rack::Files#get(env)` (the original behavior).
      # Fall back to the middleware-chain search if the receiver can't be
      # resolved (e.g. frozen/unavailable binding).
      receiver = proc_obj.binding.receiver rescue nil
      files_server = receiver if receiver && receiver.respond_to?(:get)
      files_server ||= _find_files_server(mw)
      files_server ||= parent
      Callable.new(files_server, :get)
    elsif src.end_with?(COOKIE_LOC)
      RequestCallable.new(:cookies_same_site_protection)
    elsif src.end_with?(DEVISE_SCOPE_LOC)
      _devise_mapping_replacement(proc_obj, parent)
    elsif src.end_with?(MAPPER_LOC) && ivar == :@strategy
      line = proc_obj.source_location[1]
      line == 32 ? StrategyServe.new : StrategyCall.new
    else
      NoOpProc.new
    end

  if ivar == :__default_proc__
    # The parent Hash may already be frozen (e.g. by an earlier
    # shareability pass on AR internals). A frozen Hash can't have its
    # default cleared, but a frozen Hash with a default_proc is still
    # unshareable — Ractor.make_shareable(parent) later will replace it
    # wholesale if needed. Just skip here when frozen.
    begin
      parent.default = nil
    rescue FrozenError, RuntimeError
      # frozen Hash — leave the default_proc; make_shareable handles it.
    end
  elsif ivar
    parent.instance_variable_set(ivar, replacement) rescue nil
  elsif parent.is_a?(Array)
    idx = parent.index(proc_obj)
    if idx then parent[idx] = replacement
    else parent.each_with_index { |e, i| parent[i] = replacement if e.equal?(proc_obj) }
    end
  elsif parent.is_a?(Hash)
    key = parent.key(proc_obj)
    parent[key] = replacement if key rescue nil
  end
end

._replace_unshareable_procs!(app) ⇒ Object

Replace every Proc in the app graph with a callable/no-op object. Multiple passes because the same Proc object can live in many containers (e.g. deprecation behaviors shared across deprecators). Doesn't dedup procs — must replace every occurrence.



540
541
542
543
544
545
546
547
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 540

def _replace_unshareable_procs!(app)
  mw = (app.instance_variable_get(:@app) rescue nil)
  3.times do
    procs = _collect_procs(app)
    break if procs.empty?
    procs.each { |proc_obj, _path, parent, ivar| _replace_one_proc(proc_obj, parent, ivar, mw) }
  end
end

._share_active_record_internals!Object

ActiveRecord's internal helper classes (the *Clause classes used while building a relation's Arel) cache a frozen "empty" singleton in a class instance variable via @empty ||= new(...).freeze (e.g. ActiveRecord::Relation::WhereClause#empty). Reading that class ivar from a worker Ractor raises Ractor::IsolationError if the value isn't shareable. Fix: warm .empty in the main Ractor (populating @empty with its frozen singleton), then make every class ivar on these helper classes shareable. Idempotent; must run in the main Ractor. ActiveRecord's internal helper classes/modules hold class instance variables that a worker Ractor reads during query building / connection establishment (e.g. ActiveRecord::ConnectionAdapters.@adapters — a Hash of adapter_name => [class_name, path]; the *Clause classes' @empty frozen singleton). A class ivar whose VALUE is shareable IS readable from a worker Ractor (unlike class variables), so we deep-freeze each value in the main Ractor and write it back (Monitor/Mutex->NoOpLock, Concurrent::Map->frozen Hash, etc. via _shareable_ivar_replacement).

TARGETED: only specific leaf registries (not a broad ActiveRecord::* sweep). A broad sweep also freezes AR-railtie initializer Collections reachable from the app graph, which breaks make_app_shareable's proc-replacement (frozen containers can't be mutated to swap Procs).



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 488

def 
  return unless defined?(::ActiveRecord::Base)

  # 1. Warm + freeze the *Clause `.empty` singletons.
  ObjectSpace.each_object(Class) do |c|
    n = c.name
    next unless n && n.start_with?("ActiveRecord::Relation::") &&
                n.end_with?("Clause")
    begin
      c.empty if c.respond_to?(:empty)
    rescue => e
    end
    _freeze_class_ivars!(c)
  end

  # 2. ConnectionAdapters.@adapters (String => [class_name, path]) — read
  #    by `ConnectionAdapters.resolve` during establish_connection.
  if defined?(::ActiveRecord::ConnectionAdapters)
    _freeze_class_ivars!(::ActiveRecord::ConnectionAdapters)
  end

  # 3. ActiveRecord::Type — holds @default_value (a lazy singleton Value
  #    used as a fallback type). Warm it and freeze the class ivar.
  if defined?(::ActiveRecord::Type)
    ::ActiveRecord::Type.default_value rescue nil
    _freeze_class_ivars!(::ActiveRecord::Type)
  end
end

._share_model_classes!Object

ActiveRecord model classes lazily initialize many class instance variables on first use (e.g. @table_name, @arel_table, @predicate_builder, @columns_hash, @attribute_methods_module) via @ivar ||= compute. The computation is deterministic, but it WRITES the class ivar — which a worker Ractor cannot do (Ractor::IsolationError: "can not set instance variables of classes/modules by non-main Ractors").

Fix: in the main Ractor, warm every model class by running representative queries (count / first / page), which populates all the lazy class ivars with their shareable-or-not values. Then make every class ivar's VALUE shareable (deep-freeze via Ractor.make_shareable) and write it back while the class is still mutable in main. A class ivar holding a shareable value is readable from a worker Ractor, and the worker's ||= short-circuits (no write). Idempotent; must run in the main Ractor after eager_load.



359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 359

def 
  return unless defined?(::ActiveRecord::Base)

  classes = [::ActiveRecord::Base]
  classes.concat(::ActiveRecord::Base.descendants) rescue nil
  classes.each do |klass|
    # Warm the class's lazy ivars by actually exercising the query paths
    # the workers will hit. Main has a working connection handler, so this
    # populates exactly the ivars a real query touches.
    # Warm the class's lazy ivars by actually exercising the query paths
    # the workers will hit. Main has a working connection handler, so this
    # populates exactly the ivars a real query touches. Each call is
    # isolated: a failure in one must not skip the rest (e.g. the private
    # `relation` method or a cold connection must not prevent `table_name`
    # from being set).
    warm_calls = [
      -> { klass.connection_pool if klass.respond_to?(:connection_pool) },
      -> { klass.send(:reset_primary_key) if klass.respond_to?(:reset_primary_key, true) },
      -> { klass.table_name },
      -> { klass.arel_table },
      -> { klass.columns_hash },
      -> { klass.attribute_names },
      -> { klass.attribute_types },
      -> { klass.predicate_builder },
      -> { klass.defined_enums if klass.respond_to?(:defined_enums) },
      -> { klass.send(:relation) if klass.respond_to?(:relation, true) },
      -> { klass.count rescue nil },
      -> { klass.first rescue nil },
      -> { klass.send(:query_constraints_list) if klass.respond_to?(:query_constraints_list, true) },
      -> { if defined?(::Kaminari) && klass.respond_to?(:page)
             klass.page(1).to_a rescue nil
           end },
    ]
    warm_calls.each { |c| begin; c.call; rescue => e; end }

    # Make every class ivar shareable and write it back. The class is
    # still mutable here (in main), so the write is allowed.
    begin
      klass.instance_variables.each do |iv|
        v = klass.instance_variable_get(iv) rescue nil
        next unless v
        next if Ractor.shareable?(v)
        replacement = _shareable_ivar_replacement(v)
        next unless replacement
        begin
          klass.instance_variable_set(iv, replacement)
        rescue => e
          # frozen owner — leave as-is
        end
      end
    rescue => e
      # BasicObject / frozen owners
    end
  end

  # Capture each model's primary_key into a shareable snapshot. Workers
  # read this instead of the raw @primary_key class ivar (which starts as
  # PRIMARY_KEY_NOT_SET, a BasicObject that can't be made shareable).
  begin
    pk_map = {}
    classes.each do |klass|
      n = klass.name
      next unless n
      pk = klass.primary_key rescue next
      pk_map[n] = pk if pk
    end
    shareable = Ractor.make_shareable(pk_map)
    verbose, $VERBOSE = $VERBOSE, nil
    begin
      const_set(:AR_PRIMARY_KEYS_SHAREABLE, shareable)
    ensure
      $VERBOSE = verbose
    end
  rescue => e
    # best-effort
  end
end

._share_relation_delegate_caches!Object

Make every loaded AR model class's @relation_delegate_cache shareable. Idempotent; must run in the main Ractor after eager_load so that all model classes (and their caches) exist.



326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 326

def 
  return unless defined?(::ActiveRecord::Base)
  classes = [::ActiveRecord::Base]
  classes.concat(::ActiveRecord::Base.descendants) rescue nil
  classes.each do |klass|
    cache = klass.instance_variable_get(:@relation_delegate_cache) rescue nil
    next unless cache
    next if Ractor.shareable?(cache)
    begin
      klass.instance_variable_set(:@relation_delegate_cache,
        Ractor.make_shareable(cache))
    rescue => e
      # Best-effort: if a cache holds an unshareable delegate class we
      # can't freeze, skip it. The worker will then hit a clear error on
      # the first relation method and we can patch that class specifically.
    end
  end
end

._shareable_copy(val) ⇒ Object

Return a fresh copy of a mutable default container (Hash/Array) so the fallback entry is independent. Frozen/shareable defaults pass through.



332
333
334
335
336
337
338
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 332

def _shareable_copy(val)
  case val
  when Hash then val.dup
  when Array then val.dup
  else val
  end
end

._shareable_ivar_replacement(v) ⇒ Object

  • Monitor/Mutex -> NoOpLock (never contended post-boot)
  • Concurrent::Map -> frozen Hash
  • else -> Ractor.make_shareable; if that fails (statement caches etc.), a frozen empty container of the same kind so the worker reads a shareable value (cold cache in workers; slower, correct). Returns nil if no replacement can be made.


443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 443

def _shareable_ivar_replacement(v)
  if v.is_a?(::Monitor) || v.is_a?(::Mutex)
    Ractor.make_shareable(NoOpLock.new)
  elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map)
    h = {}
    begin
      v.each_pair { |k, val| h[k] = val }
    rescue => e
    end
    Ractor.make_shareable(h)
  else
    begin
      Ractor.make_shareable(v)
    rescue => e
      case v
      when ::Hash then Ractor.make_shareable({})
      when ::Array then Ractor.make_shareable([])
      when ::Set then Ractor.make_shareable(::Set.new)
      else nil
      end
    end
  end
end

._try_make_shareable(val, owner_name, attr_name, default: false) ⇒ Object

Best-effort attempt to make val shareable (callable-replacement for Procs + lock-replacement + make_shareable). Returns the shareable val, or nil if it can't be made shareable. On failure, emits a warning (unless default: — defaults are expected to sometimes be unshareable, so we skip the noise).



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 304

def _try_make_shareable(val, owner_name, attr_name, default: false)
  # __callbacks and validators hold callback chains / validator instances
  # with self-capturing Procs that can NEVER be made shareable. This is
  # expected: workers correctly treat callbacks as already-run (the
  # nil-safe run_callbacks patch yields the block directly). Skip the
  # attempt entirely — don't waste cycles traversing the graph, and don't
  # emit warnings for known-expected failures.
  attr_sym = attr_name.to_s
  return nil if attr_sym.end_with?("__callbacks") ||
                attr_sym.end_with?("__validators") ||
                attr_sym.end_with?("default_connection_handler")

  begin
    _replace_unshareable_procs!(val)
    _replace_locks_and_concurrent_maps!(val)
    Ractor.make_shareable(val)
    val
  rescue => e
    unless default
      warn "ractor-rails-shim: could not make attribute " \
           "#{owner_name}##{attr_name} shareable (#{e.class}: #{e.message[0,80]}); workers will fall back to default or nil"
    end
    nil
  end
end

._version_mismatch(message) ⇒ Object

Apply the configured policy to a mismatch message.



455
456
457
458
459
460
461
462
463
464
# File 'lib/ractor_rails_shim/patches/core.rb', line 455

def _version_mismatch(message)
  case version_policy
  when :strict
    raise UnsupportedVersionError, message
  when :off
    # silent
  else
    warn message
  end
end

._warm_active_record_class_caches!Object

Warm ActiveRecord model classes' lazily-computed, shareable class-ivar memoizations in the MAIN Ractor, BEFORE the graph is frozen. Methods like the timestamp_attribute_* helpers cache frozen Arrays of strings (shareable once warmed), so pre-populating them here lets workers read via ||= without ever setting the class ivar. (Class ivars holding unshareable values are handled by _freeze_active_record_class_ivars!.)



729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
# File 'lib/ractor_rails_shim/patches/core.rb', line 729

def _warm_active_record_class_caches!
  return unless defined?(::ActiveRecord::Base)
  models = [::ActiveRecord::Base] + (::ActiveRecord::Base.descendants rescue [])
  warmers = %i[
    timestamp_attributes_for_create_in_model
    timestamp_attributes_for_update_in_model
    all_timestamp_attributes_in_model
    sequence_name
    columns
    column_names
    attribute_names
    column_defaults
    symbol_column_to_string_name_hash
    returning_columns_for_insert
    yaml_encoder
    attribute_types
  ]
  models.each do |klass|
    next if klass.abstract_class?
    warmers.each do |m|
      next unless klass.respond_to?(m, true)
      begin
        klass.send(m)
      rescue
        nil
      end
    end
  end
end

._warm_attribute_method_patterns!Object

Build + freeze ActiveModel's per-class attribute_method_patterns_cache (and attribute_method_matchers) in the MAIN Ractor for every loaded model. These are lazy class ivars populated on the first respond_to? call; they hold an Array of [Regexp, Symbol] pairs (shareable elements) but the Array itself is mutable and therefore NOT Ractor-shareable. A worker Ractor reading the ivar raises Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules. redirect_to @post calls Post#respond_to?(:to_model) in the worker, which reads this cache, so the write-path 302 redirect dies. Building it in MAIN (where it is reachable) and freezing the Array makes it shareable; the cache is never mutated after build (attribute_method_patterns_matching only does .select on it), so freezing is safe.



519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 519

def _warm_attribute_method_patterns!
  return unless defined?(::ActiveRecord::Base)
  ::ActiveRecord::Base.descendants.each do |klass|
    next unless klass.respond_to?(:attribute_method_patterns_cache, true)
    begin
      cache = klass.send(:attribute_method_patterns_cache)
      cache.freeze if cache
      if klass.respond_to?(:attribute_method_matchers, true)
        matchers = klass.send(:attribute_method_matchers)
        matchers.freeze if matchers
      end
    rescue StandardError
      nil
    end
  end
end

._warm_journey_routes!Object

Warm + cache @ast / @simulator on the live Routes graph. Called from make_app_shareable! AFTER the route precompute (which resets the routes) and BEFORE Ractor.make_shareable freezes the graph. Must run in the main Ractor. Idempotent (caches on the mutable object, then frozen in place).



744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 744

def _warm_journey_routes!
  return unless Ractor.main?
  _freeze_journey_visitors!
  _freeze_mime_negotiation!
  begin
    rs = ::Rails.application.routes rescue nil
    # Navigate to the ActionDispatch::Journey::Routes object — the one that
    # is frozen into the shared graph and read (via Router#simulator) on
    # every request. Rails wraps it in a RouteSet (and sometimes a
    # LazyRouteSet), neither of which defines #simulator, so calling
    # `rs.routes.simulator` would silently NoMethodError and leave @simulator
    # uncached — forcing worker Ractors to rebuild the simulator (and hit
    # Ractor::IsolationError on GTG constants). Descend through #routes until
    # we reach the Journey::Routes instance.
    routes = rs
    while routes.respond_to?(:routes) && !routes.is_a?(::ActionDispatch::Journey::Routes)
      nxt = routes.routes
      break if nxt.equal?(routes)
      routes = nxt
    end
    if routes.is_a?(::ActionDispatch::Journey::Routes)
      routes.ast
      routes.simulator
      _warm_path_patterns!(routes)
    end
  rescue => e
    # best-effort
  end
end

._warm_path_patterns!(routes) ⇒ Object

Pre-compute every Journey::Path::Pattern's lazy memoized ivars (@required_names, @optional_names, @offsets, @re, @requirements_for_missing_keys_check) on the LIVE (unfrozen) pattern, before Ractor.make_shareable freezes the graph. Path::Pattern#match (called on every request during route recognition) memoizes @offsets via @offsets ||= ...; on a frozen pattern that write raises FrozenError. By computing it now (and caching into the frozen object), the worker reads the cached value and never writes. We deliberately do NOT call the built-in eager_load!, which sets @ast = nil (the @ast/@spec are still read by requirements_anchored? and must survive).



722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 722

def _warm_path_patterns!(routes)
  return unless routes.respond_to?(:routes)
  routes.routes.each do |r|
    p = r.respond_to?(:path) ? r.path : nil
    next unless p
    begin
      p.required_names
      p.optional_names
      p.send(:offsets)
      p.to_regexp
      p.requirements_for_missing_keys_check if p.respond_to?(:requirements_for_missing_keys_check)
    rescue
      # best-effort — a pattern we can't warm will fall back to its own
      # (non-frozen) copy if one exists; ignore unusual shapes.
    end
  end
end

.applicable_patchesObject

Report which registered patches apply to the runtime Rails version (and which were skipped because they're untested on it). Useful for diagnostics and CI. Returns a Hash: { applied: [...], skipped: [...] }.



469
470
471
472
473
474
475
476
477
478
479
480
481
# File 'lib/ractor_rails_shim/patches/core.rb', line 469

def applicable_patches
  seg = RactorRailsShim::Version.rails_segment
  applied = []
  skipped = []
  PATCH_VERSIONS.each do |name, tested|
    if seg.nil? || tested.include?(seg)
      applied << name
    else
      skipped << { name: name, tested: tested, runtime: seg }
    end
  end
  { applied: applied, skipped: skipped }
end

.autoload_install!Object

Require this gem and the patches auto-install IF Rails is loaded. If Rails isn't loaded yet, install is deferred to the first call of install (call it from config/boot.rb before Rails.application).



25
26
27
# File 'lib/ractor_rails_shim.rb', line 25

def autoload_install!
  install if defined?(::Rails) && !installed?
end

.capture_app_constantsObject

Capture a frozen name -> object map for every constant the application's Zeitwerk loaders manage. Runs in the MAIN Ractor, after eager load, where all app constants are defined. The map travels to worker Ractors, which use it to (re)bind the constant names into their own namespaces.

Why this is needed: a Ractor boundary does NOT share top-level constant names — only the class/module objects reachable from the frozen shared app graph cross the boundary. A worker Ractor therefore sees RactorRailsShim, ActiveRecord, ApplicationRecord, the controllers, etc. (objects reachable from the app), but NOT the application's own model constants (e.g. Post): the object is in the graph, but its name is not bound in the worker, so PostsController#index's Post reference raises NameError. Rebinding the captured names fixes it without re-running autoloading (which is itself impossible in a worker, since Zeitwerk::Loader.new raises IsolationError off the main Ractor).



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
# File 'lib/ractor_rails_shim/patches/core.rb', line 508

def capture_app_constants
  map = {}
  return map unless defined?(::Rails) && Rails.respond_to?(:autoloaders)
  [Rails.autoloaders.main, Rails.autoloaders.once].each do |loader|
    next unless loader.respond_to?(:all_expected_cpaths)
    begin
      loader.all_expected_cpaths.values.each do |cpath|
        obj = Object.const_get(cpath) rescue next
        begin
          Ractor.make_shareable(obj) unless Ractor.shareable?(obj)
        rescue
          next
        end
        map[cpath] = obj if Ractor.shareable?(obj)
      end
    rescue => e
      warn "[ractor_rails_shim] capture_app_constants: #{e.class}: #{e.message}"
    end
  end
  map.freeze
end

.do_install_shareable_constantsObject

Run after Rails is fully booted (after Rails.application.initialize!) and BEFORE spawning worker Ractors. Re-attempts to make every registered constant shareable; constants that didn't exist at install time (e.g. Rails::Railtie, loaded after module Rails opens) get fixed here. Safe to call multiple times; already-shareable constants are no-ops.

This MUST run in the main Ractor (const_set writes the constant table). Public wrapper is prepare_for_ractors! above.



234
235
236
# File 'lib/ractor_rails_shim/patches/core.rb', line 234

def do_install_shareable_constants
  shareable_constants.each { |path| make_constant_shareable(path) }
end

.fix_url_helpers_singleton_routesObject

The module-level singleton _routes (set via redefine_singleton_method in the gem's included block) is still a define_method block. It is not on the request hot-path, but make it shareable anyway. Called after the module has been included into ActionView/ActionController (i.e. from prepare_for_ractors!).



106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/ractor_rails_shim/patches/route_helpers.rb', line 106

def fix_url_helpers_singleton_routes
  return unless defined?(::Rails) && ::Rails.application
  return unless ::Rails.application.routes.respond_to?(:url_helpers)
  mod = ::Rails.application.routes.url_helpers
  return unless mod.respond_to?(:singleton_class)
  mod.singleton_class.class_eval do
    def _routes
      ::Rails.application.routes
    end
  end
rescue
end

.init_worker_ar_connections!Object

Worker-Ractor hook: create a fresh ConnectionHandler and establish connections from the captured configurations snapshot. Call this in each worker Ractor before serving requests:

Ractor.new(app) do |a|
RactorRailsShim.init_worker_ar_connections!
a.call(env)
end

Idempotent: safe to call multiple times (subsequent calls are no-ops once the handler is established). Uses Ractor.store_if_absent semantics via IES.



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 227

def init_worker_ar_connections!
  return if Ractor.main?
  return unless defined?(::ActiveRecord::Base)

  key = :active_record_connection_handler
  existing = ActiveSupport::IsolatedExecutionState[key]
  return if existing

  # Establish a fresh, per-Ractor connection handler + pool from the
  # captured configurations snapshot. We call ConnectionHandler#establish_connection
  # DIRECTLY (not ActiveRecord::Base.establish_connection, which writes
  # the `@resolved_config` class ivar from the worker -> IsolationError).
  # ConnectionHandler#establish_connection reads ActiveRecord::Base.configurations
  # (now IES-routed by _install_activerecord_configurations_patch) inside
  # resolve_pool_config, so it works from a worker. Best-effort per config.
  snapshot = AR_CONFIGURATIONS_SNAPSHOT
  if snapshot && !snapshot.empty?
    env = ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development"
    env_configs = snapshot[env] || snapshot.values.first || {}

    handler = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
    env_configs.each do |_name, config|
      begin
        handler.establish_connection(config,
          owner_name: ::ActiveRecord::Base,
          role: ::ActiveRecord::Base.current_role || :writing,
          shard: ::ActiveRecord::Base.current_shard || :default)
      rescue => e
        # Best-effort: if one connection fails, continue with others.
      end
    end

    ActiveSupport::IsolatedExecutionState[key] = handler
  end
end

.installObject



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/ractor_rails_shim/patches/core.rb', line 155

def install
  _check_version_support
  @thread_mode = !!(ENV["SERVER"] && ENV["SERVER"] =~ /puma|falcon|thin|webrick|thread/i) unless defined?(@thread_mode)

  if thread_mode?
    # Minimal install for thread (Puma/Falcon) servers: only the
    # class_attribute isolation fix + nil-safe callback replay. The other
    # patches route framework globals through per-Ractor IES, which is
    # empty on Puma's request threads and would break the app, so they are
    # skipped; the original Rails globals are thread-safe and used as-is.
    install_class_attribute
    install_execution_wrapper
    # Capture each controller's OWN declared before_action/after_action
    # filters at declaration time (during eager load) by intercepting
    # ActiveSupport::Callbacks.set_callback. This must be installed BEFORE
    # eager load so declarations are captured as they happen — the
    # class_attribute callback chain is corrupted by an eager-load leak
    # under Ruby 4.0.5 + Rails 8.1.3 + Devise, so reading __callbacks later
    # yields a wrong, unshareable chain. Install requires active_support/
    # callbacks to be loaded, so require it first; install runs before the
    # app's eager_load, so every controller declaration is captured.
    require "active_support/callbacks" rescue nil
    _install_callback_declaration_capture!
  else
    install_mattr_accessor
    install_class_attribute
    install_zeitwerk_registry
    install_rubygems
    install_rails_module
    install_shareable_constants
    install_execution_wrapper
    require "active_support/callbacks" rescue nil
    _install_callback_declaration_capture!
    # Patch ActionView::Base.with_empty_template_cache EARLY (before eager
    # load) so production's DetailsKey.view_context_class uses the block-free
    # version. The framework's original defines compiled_method_container via
    # define_method(&block) — an un-shareable Proc that breaks worker
    # Ractors. on_load fires as soon as ActionView is required, well before
    # the app's eager_load.
    ActiveSupport.on_load(:action_view) do
      RactorRailsShim._install_with_empty_template_cache_patch
    end
  end
  @installed = true
  true
end

.install_class_attributeObject



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 23

def install_class_attribute
  _register_patch :class_attribute, "8.1"
  return if @class_attr_patched
  @class_attr_patched = true
  if defined?(::ActiveSupport::ClassAttribute)
    patch_class_attribute!
  else
    # Defer until ActiveSupport::ClassAttribute loads. A TracePoint(:class)
    # fires when `module ClassAttribute` opens. One-shot.
    @ca_tp = TracePoint.new(:class) do |trace|
      if defined?(::ActiveSupport::ClassAttribute) && !@ca_patched
        @ca_tp.disable
        patch_class_attribute!
      end
    end
    @ca_tp.enable
  end
end

.install_execution_wrapperObject



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/ractor_rails_shim/patches/execution_wrapper.rb', line 17

def install_execution_wrapper
  return if @exec_wrapper_patched
  @exec_wrapper_patched = true
  _register_patch :execution_wrapper, "8.1"
  if defined?(::ActiveSupport::ExecutionWrapper)
    patch_execution_wrapper!
  else
    @ew_tp = TracePoint.new(:class) do |trace|
      if defined?(::ActiveSupport::ExecutionWrapper) && !@exec_wrapper_registry_patched
        @ew_tp.disable
        patch_execution_wrapper!
      end
    end
    @ew_tp.enable
  end
end

.install_mattr_accessorObject



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/ractor_rails_shim/patches/mattr_accessor.rb', line 9

def install_mattr_accessor
  _register_patch :mattr_accessor, "8.1"
  return if @mattr_patched
  @mattr_patched = true

  ::Module.prepend(Module.new {
    # The prepended module's body is evaluated in the main ractor at
    # prepend time; the methods it defines are callable from any ractor
    # because they're defined via string eval (no captured binding).
    # But mattr_accessor itself runs at app boot in the main ractor, and
    # the per-accessor redefinition must also use string eval.
    #
    # IMPORTANT: Rails' mattr_accessor/cattr_accessor stores values in
    # CLASS VARIABLES (@@sym), not class instance variables (@sym). The
    # default value is written via class_variable_set("@@sym", default).
    # Class variables are also subject to Ractor::IsolationError from
    # non-main ractors (verified on Ruby 4.0.5), so we route through IES
    # the same way — but the main-ractor fallback must read @@sym, and
    # the seed must run in the main ractor at define-time (via super).
    def mattr_accessor(*syms, instance_reader: true, instance_writer: true,
                       instance_accessor: true, default: nil, **kwargs, &block)
      shareable = kwargs[:shareable]
      mod_name = name

      # Compute the default value the same way Rails does, so we can
      # seed worker-ractor IES slots with it (workers can't read @@sym).
      # The block form is evaluated once here (in main ractor) like Rails.
      sym_default = block_given? && default.nil? ? yield : default

      super # define the methods via the original path (sets @@sym)

      syms.each do |sym|
        key = :"ractor_rails_shim_mattr_#{mod_name}_#{sym}"
        key_str = key.inspect
        cv = "@@#{sym}"
        cv_str = cv.inspect

        # Register so _build_shareable_fallback! can capture the main-ractor
        # value (read from @@sym) at prepare_for_ractors! time. The label
        # is just for diagnostics. The default is stored too so the
        # fallback builder can use it when the live value can't be shared.
        RactorRailsShim::CLASS_ATTRIBUTES << [mod_name, sym, key, sym_default]
        # Store the default in a runtime registry (NOT inlined into the
        # eval'd method body — arbitrary objects like Logger have invalid
        # `.inspect` output). The reader looks it up by key.
        RactorRailsShim::MATTR_DEFAULTS[key] = sym_default
        # If the default is shareable, add to the shareable subset. We
        # rebuild the constant as a new frozen shareable Hash each time
        # (so workers can read the constant even before prepare_for_ractors!
        # runs — e.g. unit tests). const_set warns "already initialized
        # constant"; silence it.
        if sym_default && Ractor.shareable?(sym_default)
          h = RactorRailsShim::SHAREABLE_MATTR_DEFAULTS.dup
          h[key] = sym_default
          h.freeze
          Ractor.make_shareable(h)
          verbose, $VERBOSE = $VERBOSE, nil
          begin
            RactorRailsShim.const_set(:SHAREABLE_MATTR_DEFAULTS, h)
          ensure
            $VERBOSE = verbose
          end
        end

        # Redefine the class reader via string eval (no captured binding).
        # Class variables are only touched from the main ractor; worker
        # ractors fall back to SHAREABLE_FALLBACK (built from main's @@sym
        # at prepare_for_ractors! time) when their own IES slot is empty.
        # NOTE: we deliberately do NOT inline the default value here —
        # arbitrary objects (e.g. Logger) have invalid `.inspect` output.
        # The fallback builder captures the live value (which may equal
        # the default) at prepare time.
        singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
          def #{sym}
            v = ActiveSupport::IsolatedExecutionState[#{key_str}]
            return v unless v.nil?

            if #{!!shareable}
              if class_variable_defined?(#{cv_str})
                class_variable_get(#{cv_str})
              end
            elsif Ractor.main?
              if class_variable_defined?(#{cv_str})
                class_variable_get(#{cv_str})
              else
                nil
              end
            else
              # Worker: try the shareable fallback (built from main's @@sym
              # at prepare_for_ractors! time). If empty, try the
              # definition-time default (only the shareable subset — the
              # full MATTR_DEFAULTS holds unshareable defaults like Logger
              # which workers can't read via the constant).
              fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}]
              return fb unless fb.nil?
              RactorRailsShim::SHAREABLE_MATTR_DEFAULTS[#{key_str}]
            end
          end

          def #{sym}=(val)
            ActiveSupport::IsolatedExecutionState[#{key_str}] = val
            if Ractor.main?
              class_variable_set(#{cv_str}, val) if class_variable_defined?(#{cv_str})
              class_variable_set(#{cv_str}, val) unless class_variable_defined?(#{cv_str})
            end
            val
          end
        RUBY

        # Instance readers/writers route through IES directly (NOT
        # self.class.#{sym}). Rails' original uses @@sym (a class variable
        # inherited by including classes); the shim routes through IES,
        # so the instance reader must also use IES. Using self.class.sym
        # would fail for mattr_accessor on Modules (e.g.
        # ActionView::Helpers::FormHelper#form_with_generates_ids):
        # self.class is the including class (ActionView::Base), which
        # doesn't have the module's singleton method.
        # Only redefine if instance_accessor is on (matches Rails).
        if instance_reader && instance_accessor
          module_eval <<-RUBY, __FILE__, __LINE__ + 1
            def #{sym}
              v = ActiveSupport::IsolatedExecutionState[#{key_str}]
              return v unless v.nil?
              if Ractor.main?
                self.class.class_variable_defined?(#{cv_str}) ? self.class.class_variable_get(#{cv_str}) : nil
              else
                RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}]
              end
            end
          RUBY
        end
        if instance_writer && instance_accessor
          module_eval <<-RUBY, __FILE__, __LINE__ + 1
            def #{sym}=(val)
              ActiveSupport::IsolatedExecutionState[#{key_str}] = val
              self.class.class_variable_set(#{cv_str}, val) if Ractor.main? && self.class.class_variable_defined?(#{cv_str})
              val
            end
          RUBY
        end
      end
    end

    # cattr_accessor is an alias for mattr_accessor in Rails; route it too.
    if method_defined?(:cattr_accessor, true)
      alias_method :_unshimmed_cattr_accessor, :cattr_accessor
      def cattr_accessor(*args, **kwargs, &block)
        mattr_accessor(*args, **kwargs, &block)
      end
    end
  })
end

.install_rails_load_hookObject

Defer the Rails-module patch until Rails is defined. A TracePoint on :class fires when rails.rb opens module Rails (module bodies fire as :class); once the constant is assigned, we patch once and disable the hook.



39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 39

def install_rails_load_hook
  return if @rails_load_hook_installed
  @rails_load_hook_installed = true

  @rails_tp = TracePoint.new(:class) do |trace|
    if defined?(::Rails) && !@rails_module_patched
      @rails_tp.disable
      patch_rails_module!(::Rails)
    end
  end
  @rails_tp.enable
end

.install_rails_moduleObject

Patch the Rails module's class-level accessors (Rails.application, Rails.env, Rails.cache, etc.) to route through IsolatedExecutionState. Defers via a load hook if Rails isn't defined yet (the config/boot.rb case).



26
27
28
29
30
31
32
33
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 26

def install_rails_module
  _register_patch :rails_module, "8.1"
  if defined?(::Rails)
    patch_rails_module!(::Rails)
  else
    install_rails_load_hook
  end
end

.install_route_helpers_patchObject



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/ractor_rails_shim/patches/route_helpers.rb', line 30

def install_route_helpers_patch
  return if @route_helpers_patched
  @route_helpers_patched = true
  _register_patch :route_helpers, "8.1"
  return unless defined?(::ActionDispatch::Routing::RouteSet)

  rs = ::ActionDispatch::Routing::RouteSet

  # 1. Replace the PATH / UNKNOWN lambdas with shareable Method objects.
  begin
    rs.send(:remove_const, :PATH) if rs.const_defined?(:PATH, false)
  rescue
  end
  rs.const_set(:PATH, ::ActionDispatch::Http::URL.method(:path_for)) rescue nil
  begin
    rs.send(:remove_const, :UNKNOWN) if rs.const_defined?(:UNKNOWN, false)
  rescue
  end
  rs.const_set(:UNKNOWN, ::ActionDispatch::Http::URL.method(:url_for)) rescue nil

  # 2. Named route helpers -> compiled `def` (regular `resources` routes).
  rs.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    def define_url_helper(mod, name, helper, url_strategy)
      const_name = :"RRS_HELPER_\#{name}"
      mod.const_set(const_name, helper)
      strategy = url_strategy.equal?(PATH) ? :PATH : :UNKNOWN
      body = "def " + name.to_s + "(*args)\\n" \
             "  last = args.last\\n" \
             "  options = case last\\n" \
             "    when Hash then args.pop\\n" \
             "    when ActionController::Parameters then args.pop.to_h\\n" \
             "  end\\n" \
             "  ::ActionDispatch::Routing::RouteSet.const_get(" + const_name.inspect + ").call(self, " + name.inspect + ", args, options, ::ActionDispatch::Routing::RouteSet.const_get(" + strategy.inspect + "))\\n" \
             "end"
      mod.module_eval(body, __FILE__, __LINE__ + 1)
    end
  RUBY

  # 3. `direct` / `resolve` helpers -> compiled `def`.
  if defined?(::ActionDispatch::Routing::RouteSet::NamedRouteCollection)
    ::ActionDispatch::Routing::RouteSet::NamedRouteCollection.class_eval <<-RUBY, __FILE__, __LINE__ + 1
      def add_url_helper(name, defaults, &block)
        helper = CustomUrlHelper.new(name, defaults, &block)
        path_name = :"\#{name}_path"
        url_name  = :"\#{name}_url"
        @path_helpers_module.const_set(:"RRS_HELPER_\#{path_name}", helper)
        @url_helpers_module.const_set(:"RRS_HELPER_\#{url_name}", helper)
        pbody = "def " + path_name.to_s + "(*args)\\n  const_get(:\\\"RRS_HELPER_" + path_name.to_s + "\\\").call(self, args, true)\\nend"
        ubody = "def " + url_name.to_s + "(*args)\\n  const_get(:\\\"RRS_HELPER_" + url_name.to_s + "\\\").call(self, args, false)\\nend"
        @path_helpers_module.module_eval(pbody, __FILE__, __LINE__ + 1)
        @url_helpers_module.module_eval(ubody, __FILE__, __LINE__ + 1)
        @path_helpers << path_name
        @url_helpers  << url_name
        self
      end
    RUBY
  end

  # 4. `_routes` / `_generate_paths_by_default` -> compiled `def`.
  rs.class_eval <<-RUBY, __FILE__, __LINE__ + 1
    alias_method :_rrs_orig_generate_url_helpers, :generate_url_helpers
    def generate_url_helpers(supports_path)
      routes = self
      mod = _rrs_orig_generate_url_helpers(supports_path)
      mod.module_eval("def _routes\n  @_routes || ::Rails.application.routes\nend", __FILE__, __LINE__ + 1)
      mod.module_eval("def _generate_paths_by_default\n  " + supports_path.inspect + "\nend", __FILE__, __LINE__ + 1)
      mod
    end
  RUBY
end

.install_rubygemsObject

Installed at install (boot) time. Only redefines the reader; the snapshot is filled in later by snapshot_gem_paths!.



21
22
23
24
25
26
27
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 21

def install_rubygems
  return if @rubygems_patched
  @rubygems_patched = true
  _register_patch :rubygems, "all"
  return unless defined?(::Gem)
  patch_rubygems!
end

.install_shareable_constantsObject



214
215
216
217
218
219
220
221
222
223
# File 'lib/ractor_rails_shim/patches/core.rb', line 214

def install_shareable_constants
  # Called at install time; if ActiveSupport isn't loaded yet, the
  # constants don't exist. We re-run from patch_rails_module! (which
  # fires once Rails — and thus ActiveSupport — is defined). Guarded
  # by @shareable_constants_done so both paths are safe.
  _register_patch :shareable_constants, "8.1"
  return unless defined?(::ActiveSupport)

  do_install_shareable_constants
end

.install_url_helpers_patchObject



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/ractor_rails_shim/patches/url_helpers.rb', line 27

def install_url_helpers_patch
  return if @url_helpers_patched
  @url_helpers_patched = true
  _register_patch :url_helpers, "8.1"
  return unless defined?(::ActiveRecord::Base)

  if defined?(::ActionView::RoutingUrlFor)
    ::ActionView::RoutingUrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      alias_method :_rrs_orig_view_url_for, :url_for
      def url_for(options = nil)
        _rrs_orig_view_url_for(options)
      rescue => e
        raise unless e.message.include?("un-shareable Proc")
        if options.is_a?(Hash) || options.is_a?(ActionController::Parameters)
          full_url_for(options)
        else
          meth = _generate_paths_by_default ? :path : :url
          builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.public_send(meth)
          builder.handle_model_call(self, options)
        end
      end
    RUBY
  end

  if defined?(::ActionDispatch::Routing::UrlFor)
    ::ActionDispatch::Routing::UrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      alias_method :_rrs_orig_full_url_for, :full_url_for
      def full_url_for(options = nil)
        _rrs_orig_full_url_for(options)
      rescue => e
        raise unless e.message.include?("un-shareable Proc")
        if options.is_a?(Hash) || options.is_a?(ActionController::Parameters)
          route_name = options.delete :use_route
          merged = options.to_h.symbolize_keys.reverse_merge!(url_options)
          # `self._routes` is an un-shareable `define_method` block in a
          # worker Ractor, so calling it re-raises the same error. Route the
          # fallback through the worker-safe shareable RouteSet instead.
          if RactorRailsShim.const_defined?(:SHAREABLE_ROUTES)
            RactorRailsShim::SHAREABLE_ROUTES.url_for(merged, route_name)
          else
            _routes.url_for(merged, route_name)
          end
        else
          builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url
          builder.handle_model_call(self, options)
        end
      end
    RUBY
  end
end

.install_zeitwerk_registryObject



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/ractor_rails_shim/patches/zeitwerk_registry.rb', line 17

def install_zeitwerk_registry
  return if @zeitwerk_patched
  @zeitwerk_patched = true
  _register_patch :zeitwerk_registry, "8.1"
  if defined?(::Zeitwerk::Registry)
    patch_zeitwerk_registry!
  else
    # Defer until Zeitwerk loads. A TracePoint(:class) fires when
    # `module Registry` opens. One-shot.
    @zw_tp = TracePoint.new(:class) do |trace|
      if defined?(::Zeitwerk::Registry) && !@zeitwerk_registry_patched
        @zw_tp.disable
        patch_zeitwerk_registry!
      end
    end
    @zw_tp.enable
  end
end

.installed?Boolean

Returns:

  • (Boolean)


202
203
204
# File 'lib/ractor_rails_shim/patches/core.rb', line 202

def installed?
  @installed ||= false
end

.make_app_shareable!(app = Rails.application) ⇒ Object

Public API: make Rails.application shareable across Ractors. Replaces every self-capturing Proc in the app graph with a callable object (no captured binding), every Mutex/Monitor with a NoOpLock, and every Concurrent::Map with a frozen Hash, then calls Ractor.make_shareable. After this, Ractor.new(app) { |a| a.call(env) } works from worker Ractors. Must run in the main Ractor after prepare_for_ractors! and before spawning workers.

WARNING: this MUTATES the app object graph in place (replaces ivars). The app becomes read-only (frozen). Do NOT call if you intend to keep mutating the app (e.g. development reloading). Production-only.

Returns the shareable app. Raises on failure (e.g. if a Proc can't be replaced — add the missing constant to shareable_constants first).



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 45

def make_app_shareable!(app = Rails.application)
  # Shareable constants + Rack::Request + Inflector + ParameterEncoding +
  # PathRegistry + AbstractController + error_reporter + LookupContext +
  # I18n + Template::Handlers + ExecutionContext + Request param parsers.
  do_install_shareable_constants unless @shareable_constants_done
  # Install (or re-run, idempotently) the full framework-patch set. Most
  # are already applied by prepare_for_ractors!; this guarantees every
  # patch is present after full boot even if prepare_for_ractors! ran
  # before some classes were loaded.
  _install_all_framework_patches
  # Pre-compute lazy ivars BEFORE freezing (they mutate the app).
  _precompute_lazy_ivars(app)
  _precompute_propshaft!(app)
  # Force ActiveRecord attribute-method generation in the MAIN Ractor for
  # every loaded model. AR defines these lazily on first instantiation; if
  # left undone, a worker Ractor's first `Post.new` / record load re-enters
  # `define_attribute_methods`, which locks
  # `GeneratedAttributeMethods::LOCK` — a `Monitor` created in the main
  # Ractor and therefore non-shareable — raising Ractor::IsolationError.
  # Generating here (where the Monitor is reachable) sets
  # `@attribute_methods_generated = true` on the shared, frozen classes so
  # workers skip the lock entirely.
  _generate_ar_attribute_methods!
  # Warm + freeze ActiveModel's per-class `attribute_method_patterns_cache`
  # (and `attribute_method_matchers`) in MAIN for every loaded model. See
  # `_warm_attribute_method_patterns!` for why: a worker Ractor reading these
  # lazy class ivars (Array of [Regexp, Symbol], but mutable => unshareable)
  # during `redirect_to @post` -> `respond_to?` raises Ractor::IsolationError.
  _warm_attribute_method_patterns!
  # Capture each controller's OWN declared `process_action` symbol filters
  # (before_action / after_action) into a shareable table so worker
  # Ractors can replay them. The shim routes class_attribute-backed
  # `__callbacks` through IES and seeds workers with the empty default, so
  # controller filters do NOT run in workers by default (see
  # execution_wrapper.rb run_callbacks patch). For GET requests that depend
  # on a before_action (e.g. `set_post` loading `@post`), that breaks
  # rendering. We freeze the declared-filter table captured during eager
  # load (in main, before freeze) and the patched run_callbacks replays
  # them per controller.
  _freeze_declared_callbacks!
  # Warm + cache the routes' @ast / @simulator on the live graph. This MUST
  # run AFTER the route precompute above (which reloads/resets the routes)
  # and BEFORE _replace_unshareable_procs! / Ractor.make_shareable below:
  # the proc-replacement pass rewrites the Route constraint Procs held in
  # the simulator's @memos, and the freeze then shares the whole thing so
  # worker Ractors read the cached, frozen simulator via the original
  # Routes#simulator (no per-worker rebuild). See action_dispatch.rb.
  _freeze_shareable_class_ivars!
  _warm_journey_routes!
  # Neutralize the app's logger IO so Ractor.make_shareable doesn't freeze
  # $stdout/$stderr (freezing STDOUT breaks the process's own output).
  # Workers build their own per-Ractor Rails.logger, so the app-instance
  # logger is unused post-freeze; redirect its logdev to a fresh StringIO
  # sink (which is safely freezable).
  _neutralize_logger_io!(app)
  _replace_unshareable_procs!(app)
  _replace_locks_and_concurrent_maps!(app)
  Ractor.make_shareable(app)
  # Stash the now-shareable app in a constant so worker Ractors can read
  # `Rails.application` (e.g. Propshaft::Helper reads
  # `Rails.application.assets`, and various gems call Rails.application
  # internally). The shared app is frozen (read-only), so returning it
  # from worker Ractors is safe — they only read from it, never mutate.
  if Ractor.main?
    verbose, $VERBOSE = $VERBOSE, nil
    begin
      const_set(:SHAREABLE_APP, app) unless const_defined?(:SHAREABLE_APP)
    ensure
      $VERBOSE = verbose
    end
  end
  # Build the framework-config fallback AFTER the app is frozen. The
  # fallback makes class_attribute / mattr_accessor values shareable; some
  # of those values reference the app graph (e.g. config objects that point
  # back at Rails.application). Doing this after the app is already
  # shareable means Ractor.make_shareable on the config values is a no-op
  # for the app portion (already frozen) — avoiding a "can't modify frozen
  # app" error when precompute wrote to it. (prepare_for_ractors!, which
  # also builds the fallback, is a no-op now via @fallback_built.)
  _build_shareable_fallback!
  app
end

.make_constant_shareable(const_path) ⇒ Object

Resolve a constant path string to a value, and if it exists and is not already shareable, replace it with its shareable (deep-frozen) version. Returns true if the constant was made shareable (or already was); false if it doesn't exist yet (caller may retry).



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/ractor_rails_shim/patches/core.rb', line 242

def make_constant_shareable(const_path)
  owner, name = split_const_path(const_path)
  return false unless owner && name
  return true if owner.const_defined?(name, false) == false

  val = owner.const_get(name, false)
  return true if Ractor.shareable?(val)

  shareable = _make_value_shareable(val)
  return true unless shareable

  # Deep-freeze and reassign. Ractor.make_shareable mutates `val` in
  # place (freezing it and its reachable objects) and returns it.
  # const_set warns "already initialized constant" because Rails'
  # environment_inquirer.rb defined the constant first. The reassign is
  # intentional (we're replacing the mutable value with its frozen
  # shareable twin), so silence that one warning.
  verbose, $VERBOSE = $VERBOSE, nil
  begin
    owner.const_set(name, shareable)
  ensure
    $VERBOSE = verbose
  end
  true
end

.patch_class_attribute!Object

The actual patch. Idempotent. Must run in the main Ractor. redefine is a singleton method on ClassAttribute (defined in class << self), so we prepend onto the singleton class.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 45

def patch_class_attribute!
  return if @ca_patched
  @ca_patched = true
  ::ActiveSupport::ClassAttribute.singleton_class.prepend(Module.new {
    # redefine is called once per attribute at class_attribute-definition
    # time (in the main Ractor). The original defines methods with blocks;
    # we replace with string-eval'd methods that route through IES so
    # they're callable from any Ractor. The default value is seeded into
    # the main Ractor's IES slot immediately (matching original semantics
    # where the reader returns the default until a subclass overrides).
    def redefine(owner, name, namespaced_name, value)
      key = :"ractor_rails_shim_class_attr_#{owner.object_id}_#{namespaced_name}"
      key_str = key.inspect

      # Seed the main Ractor's IES slot with the default. Only seed in
      # main — workers start nil and set their own value via the writer.
      ActiveSupport::IsolatedExecutionState[key] = value if Ractor.main?

      # Also store in CLASS_ATTR_VALUES so the reader can fall back to it
      # in the MAIN ractor on non-boot threads. IES is thread-local: Puma's
      # request threads have empty IES slots, so the reader returns nil
      # without this fallback. This is the bug that breaks normal (non-
      # Ractor) multi-threaded servers — the minimal --minimal app didn't
      # hit it because /up doesn't trigger LogSubscriber.log_levels.
      # CLASS_ATTR_VALUES is NOT shareable (values may be mutable); only
      # safe to read from the main ractor.
      RactorRailsShim::CLASS_ATTR_VALUES[key] = value

      # Register so _build_shareable_fallback! can capture + make shareable
      # at prepare_for_ractors! time. owner.name may be nil for anonymous
      # classes (e.g. spec fixtures); use a stable label in that case.
      # The default value is stored too so the fallback builder can use it
      # when the live value can't be made shareable (e.g. __callbacks holds
      # self-capturing Procs — workers get the empty default, treating
      # boot-time callbacks as already-run, which is correct for a frozen
      # shared app).
      owner_label = owner.respond_to?(:name) ? owner.name : owner.class.name
      owner_label = owner_label || "anon_#{owner.class.name}_#{owner.object_id}"
      RactorRailsShim::CLASS_ATTRIBUTES << [owner_label, namespaced_name, key, value]

      # Always define the namespaced reader/writer on owner's singleton
      # class via string eval (no captured binding). The class_attribute
      # macro itself also defines `def #{name}; #{namespaced_name}; end`
      # via class_eval (string-eval'd, safe) on the owner — that calls our
      # IES-routed namespaced reader/writer. We override BOTH the namespaced
      # and (when owner is a module's singleton) the public name.
      #
      # Worker-Ractor fallback: when the worker's own IES slot is empty
      # (which it is by default — the value lives in main's slot), fall
      # back to the frozen shareable table built at prepare_for_ractors!
      # time. This is read-only and shared across all workers; workers that
      # need their own mutable value call the writer, which writes their
      # IES slot and shadows the fallback.
      target = owner.singleton_class? ? owner : owner.singleton_class
      if RactorRailsShim.thread_mode?
        # Thread (Puma/Falcon) mode: route through a SHARED (process-wide)
        # store keyed by the actual class's object_id, walking ancestors
        # for copy-on-write fallback. This restores per-subclass isolation
        # (lost by the IES-routed variant) without thread-local IES, which
        # is empty on Puma's request threads.
        target.module_eval <<-RUBY, __FILE__, __LINE__ + 1
          def #{namespaced_name}
            self.ancestors.each do |anc|
              v = RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"]
              return v unless v.nil?
            end
            #{namespaced_name.inspect} == :__callbacks ? {} : nil
          end

          def #{namespaced_name}=(new_value)
            RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{self.object_id}_#{namespaced_name}"] = new_value
            new_value
          end
        RUBY

        # When owner is a module's singleton class, also override the
        # public reader `def #{name}` with the shared-store version.
        if owner.singleton_class? && owner.attached_object.is_a?(Module)
          owner.module_eval <<-RUBY, __FILE__, __LINE__ + 1
            def #{name}
              self.ancestors.each do |anc|
                v = RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"]
                return v unless v.nil?
              end
              #{namespaced_name.inspect} == :__callbacks ? {} : nil
            end

            def #{name}=(new_value)
              RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{self.object_id}_#{namespaced_name}"] = new_value
              new_value
            end
          RUBY
        end
       else
        target.module_eval <<-RUBY, __FILE__, __LINE__ + 1
          def #{namespaced_name}
            self.ancestors.each do |anc|
              k = :"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"
              v = ActiveSupport::IsolatedExecutionState[k]
              return v unless v.nil?
              fb = RactorRailsShim::SHAREABLE_FALLBACK[k]
              return fb unless fb.nil?
            end
            RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main?
          end

          def #{namespaced_name}=(new_value)
            ActiveSupport::IsolatedExecutionState[#{key_str}] = new_value
            RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] = new_value if Ractor.main?
            new_value
          end
        RUBY

        # When owner is a module's singleton class, the original also
        # defines a public reader `def #{name} { value }` on owner directly
        # (block-based). Override it with the IES-routed version + fallback.
        if owner.singleton_class? && owner.attached_object.is_a?(Module)
          owner.module_eval <<-RUBY, __FILE__, __LINE__ + 1
            def #{name}
              self.ancestors.each do |anc|
                k = :"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"
                v = ActiveSupport::IsolatedExecutionState[k]
                return v unless v.nil?
                fb = RactorRailsShim::SHAREABLE_FALLBACK[k]
                return fb unless fb.nil?
              end
              RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main?
            end

            def #{name}=(new_value)
              ActiveSupport::IsolatedExecutionState[#{key_str}] = new_value
              RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] = new_value if Ractor.main?
              new_value
            end
          RUBY
        end
      end
    end

    # redefine_method is used by `redefine` internally and by other call
    # sites (rare). The class_attribute path goes through our `redefine`
    # above; keep the original block-based behavior for any other callers
    # so we don't break unrelated code.
    def redefine_method(owner, name, private: false, &block)
      super
    end
  })
end

.patch_execution_wrapper!Object



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/ractor_rails_shim/patches/execution_wrapper.rb', line 34

def patch_execution_wrapper!
  return if @exec_wrapper_registry_patched
  @exec_wrapper_registry_patched = true
  ew = ::ActiveSupport::ExecutionWrapper
  key = :ractor_rails_shim_exec_wrapper_active_key
  key_str = key.inspect
  # active_key returns :"active_execution_wrapper_<object_id>"; a frozen
  # Symbol is shareable. Compute it once per Ractor and cache in IES.
  ew.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def active_key
      v = ActiveSupport::IsolatedExecutionState[#{key_str}]
      return v unless v.nil?
      sym = :"active_execution_wrapper_\#{object_id}"
      ActiveSupport::IsolatedExecutionState[#{key_str}] = sym
      sym
    end
  RUBY

  # Patch ActiveSupport::Callbacks#run_callbacks to tolerate a nil
  # __callbacks (the case in worker Ractors whose class_attribute fallback
  # couldn't be made shareable because callback chains hold frozen,
  # self-capturing Procs). For a frozen, read-only shared app the boot-time
  # callbacks (ExecutionContext push/pop, CurrentAttributes clear) already
  # ran in the main Ractor at boot; worker Ractors don't need to re-run
  # them per request (CurrentAttributes/ExecutionContext are thread-local,
  # hence per-Ractor, and start empty in a fresh worker). When __callbacks
  # is nil, run_callbacks just yields the block — matching the empty-chain
  # fast path in the original. (Method body lives in active_support.rb.)
  _install_callbacks_nil_safe_patch

  # Patch ActiveSupport::Notifications.notifier to not read the @notifier
  # class ivar from a worker Ractor. The original is `attr_accessor
  # :notifier` with `@notifier = Fanout.new` set at module load — a raw
  # class ivar holding a Fanout (which has a Mutex + subscriber Procs,
  # both unshareable). Workers get their own per-Ractor Fanout (no
  # subscribers — instrumentation is a no-op in workers, which is correct
  # for a read-only shared app where log subscribers already ran in main).
  # `notifier` is read by `instrumenter` (per-request via Rails::Rack::Logger).
  # (Method body lives in active_support.rb.)
  _install_notifications_notifier_patch
end

.patch_rails_module!(mod) ⇒ Object

The actual Rails-module patch. Idempotent. Must be called from the main Ractor (it prepends onto Rails.singleton_class).



54
55
56
57
58
59
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 54

def patch_rails_module!(mod)
  return if @rails_module_patched
  @rails_module_patched = true
  do_install_shareable_constants
  _patch_rails_module_body(mod)
end

.patch_rubygems!Object



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 29

def patch_rubygems!
  return if @rubygems_method_patched
  @rubygems_method_patched = true
  gem = ::Gem
  unless gem.singleton_class.method_defined?(:__shim_original_gem_paths)
    gem.singleton_class.alias_method :__shim_original_gem_paths, :paths
  end
  # `def` (not `define_method`) so the method has no captured binding and
  # is callable from any Ractor. `Ractor.main?` + a shareable constant are
  # both Ractor-safe.
  gem.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def paths
      if Ractor.main?
        __shim_original_gem_paths
      else
        ::RactorRailsShim::GEM_PATHS_SNAPSHOT
      end
    end
  RUBY
end

.patch_zeitwerk_registry!Object



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/ractor_rails_shim/patches/zeitwerk_registry.rb', line 36

def patch_zeitwerk_registry!
  return if @zeitwerk_registry_patched
  @zeitwerk_registry_patched = true

  reg = ::Zeitwerk::Registry
  # The ivars Zeitwerk sets at the bottom of registry.rb. Map each to an
  # IES key and a default-builder string (eval'd in the reader when the
  # Ractor's slot is empty). Builders reference Zeitwerk constants by
  # full path so they're resolvable from any Ractor.
  ivars = {
    loaders:                  [:ractor_rails_shim_zw_loaders,    "Zeitwerk::Registry::Loaders.new"],
    gem_loaders_by_root_file: [:ractor_rails_shim_zw_gem,        "{}"],
    autoloads:                [:ractor_rails_shim_zw_autoloads,  "Zeitwerk::Registry::Autoloads.new"],
    explicit_namespaces:      [:ractor_rails_shim_zw_explicit,   "Zeitwerk::Registry::ExplicitNamespaces.new"],
    inceptions:               [:ractor_rails_shim_zw_inceptions, "Zeitwerk::Registry::Inceptions.new"],
    mutex:                    [:ractor_rails_shim_zw_mutex,      "Mutex.new"],
  }

  # Redefine each reader (and the mutex, which is read directly as @mutex)
  # to route through IES with lazy per-Ractor init. Use a PREPENDED module
  # (not direct module_eval on the singleton class) because Zeitwerk's
  # `attr_reader :loaders` etc. run LATER in the module body and would
  # clobber a direct redefinition — same load-order issue as the Rails
  # module accessors. A prepended module stays in front of the lookup chain.
  #
  # In the MAIN ractor, fall back to the existing ivar (set by Zeitwerk at
  # the bottom of registry.rb) so main-ractor state is preserved. Worker
  # ractors lazily build their own via the builder string.
  reader_patch = Module.new
  ivars.each do |ivar, (key, builder)|
    key_str = key.inspect
    ivar_sym = :"@#{ivar}"
    ivar_str = ivar_sym.inspect
    reader_patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def #{ivar}
        v = ActiveSupport::IsolatedExecutionState[#{key_str}]
        return v unless v.nil?
        if Ractor.main?
          existing = instance_variable_get(#{ivar_str}) if instance_variable_defined?(#{ivar_str})
          if existing
            ActiveSupport::IsolatedExecutionState[#{key_str}] = existing
            return existing
          end
        end
        v = #{builder}
        ActiveSupport::IsolatedExecutionState[#{key_str}] = v
        v
      end
    RUBY
  end
  reg.singleton_class.prepend(reader_patch)

  # `conflicting_root_dir?` and `loader_for_gem` read @mutex / @gem_loaders
  # directly via instance_variable_get-ish access (they use @mutex in the
  # method body). Since we redefined the readers, the direct @mutex refs
  # in those methods still hit the ivar. We need to rewrite those two
  # methods to call the reader instead. Easiest: prepend a module that
  # calls self.mutex / self.gem_loaders_by_root_file.
  reg.singleton_class.prepend(Module.new {
    def conflicting_root_dir?(loader, new_root_dir)
      mutex.synchronize do
        loaders.each do |existing_loader|
          next if existing_loader == loader
          existing_loader.__roots.each_key do |existing_root_dir|
            next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir)
            new_root_dir_slash = new_root_dir + '/'
            existing_root_dir_slash = existing_root_dir + '/'
            next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash)
            next if loader.__ignores?(existing_root_dir)
            break if existing_loader.__ignores?(new_root_dir)
            return existing_loader
          end
        end
        nil
      end
    end

    def loader_for_gem(root_file, namespace:, warn_on_extra_files:)
      h = gem_loaders_by_root_file
      h[root_file] ||= Zeitwerk::GemLoader.__new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files)
    end

    def unregister_loader(loader)
      gem_loaders_by_root_file.delete_if { |_, l| l == loader }
    end
  })
end

.prepare_for_ractors!Object



405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/ractor_rails_shim/patches/core.rb', line 405

def prepare_for_ractors!
 do_install_shareable_constants
 RactorRailsShim._freeze_shareable_class_ivars! if RactorRailsShim.respond_to?(:_freeze_shareable_class_ivars!)
 snapshot_gem_paths!
 snapshot_query_logs!
 _install_all_framework_patches
 install_url_helpers_patch
 fix_url_helpers_singleton_routes
 _warm_active_record_class_caches!
 _freeze_active_record_class_ivars!
 _freeze_global_class_ivars!
 _freeze_global_constants!
 _freeze_messages_constants!
end

.shareable_constantsObject

--- Generic constant-sharing utilities (moved from rails_module.rb) ----- These are framework-agnostic; SHAREABLE_CONSTANTS lives here too, so the whole constant-shareability machinery is owned by core.rb.



210
211
212
# File 'lib/ractor_rails_shim/patches/core.rb', line 210

def shareable_constants
  SHAREABLE_CONSTANTS
end

.snapshot_gem_paths!Object

Called from prepare_for_ractors! (post-boot, main Ractor) so the snapshot reflects Bundler-configured gem paths. Also called from install as a safety net so the constant is never undefined.



53
54
55
56
57
58
59
60
61
62
63
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 53

def snapshot_gem_paths!
  return unless defined?(::Gem)
  return if defined?(::RactorRailsShim::GEM_PATHS_SNAPSHOT)
  snap = ::Gem.paths
  begin
    Ractor.make_shareable(snap)
  rescue StandardError
    snap = Ractor.make_shareable(::Gem.path)
  end
  ::RactorRailsShim.const_set(:GEM_PATHS_SNAPSHOT, snap)
end

.snapshot_query_logs!Object

Capture the QueryLogs handlers/formatter as a shareable snapshot for workers (called post-boot, main Ractor, in prepare_for_ractors!).

We deliberately do NOT use Ractor.make_shareable on the raw @handlers objects: a handler may be a ZeroArityHandler wrapping a Proc, a raw lambda/Proc tag, or an IdentityHandler whose value is unshareable — all of which make_shareable raises on, which (the original rescue swallowed) left QUERY_LOGS_SNAPSHOT unset and every worker falling through to the original tag_content -> @handlers read -> Ractor::IsolationError. Instead we build a fresh, guaranteed-shareable structure:

{ format: :legacy|:sqlcommenter,
handlers: [[key, :get_key, nil] | [key, :identity, value], ...] }

Only GetKeyHandler (context key lookup) and IdentityHandler (constant value, when that value itself is shareable) are captured. Proc/lambda handlers can't be expressed cross-Ractor and are dropped in workers (tags that depend on per-request Procs simply don't appear in worker query comments — acceptable; the main Ractor still logs them).



1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1601

def snapshot_query_logs!
  return unless defined?(::ActiveRecord::QueryLogs)
  return if RactorRailsShim.const_defined?(:QUERY_LOGS_SNAPSHOT)
  return unless Ractor.main?
  begin
    ql = ::ActiveRecord::QueryLogs
    format = ql.tags_formatter
    format = :legacy if format == false || format.nil?
    raw_handlers = ql.instance_variable_get(:@handlers) || []
    entries = []
    raw_handlers.each do |key, handler|
      if handler.is_a?(::ActiveRecord::QueryLogs::GetKeyHandler)
        entries << [key, :get_key, nil]
      elsif handler.is_a?(::ActiveRecord::QueryLogs::IdentityHandler)
        value = handler.instance_variable_get(:@value)
        next unless Ractor.shareable?(value)
        entries << [key, :identity, value]
      else
        # ZeroArityHandler (wraps a Proc) or a raw Proc/lambda tag:
        # intrinsically unshareable / depends on a closure; skip in workers.
        next
      end
    end
    snap = { format: format, handlers: entries.freeze }.freeze
    Ractor.make_shareable(snap)
    RactorRailsShim.const_set(:QUERY_LOGS_SNAPSHOT, snap)
  rescue StandardError
    nil
  end
end

.split_const_path(path) ⇒ Object

Split "A::B::C" into [A::B (module), :C]. Returns [nil, nil] if the parent isn't defined.



296
297
298
299
300
301
302
# File 'lib/ractor_rails_shim/patches/core.rb', line 296

def split_const_path(path)
  parts = path.split("::")
  return [Object, parts.first.to_sym] if parts.size == 1
  parent = parts[0...-1].inject(Object) { |ns, n| ns.const_get(n) } rescue nil
  return [nil, nil] unless parent
  [parent, parts.last.to_sym]
end

.thread_mode?Boolean

Install all the patches. Safe to call multiple times (idempotent).

May be called either before or after Rails is loaded:

- If Rails is already defined (e.g. `Bundler.require` ran first), the
Rails module accessors are patched immediately.
- If Rails is not yet defined (the normal `config/boot.rb` case, where
`install` is called before `require "rails"`), a one-shot load hook
defers the Rails-module patch until `rails.rb` is loaded. The
`mattr_accessor` macro patch (a `Module.prepend`) applies
immediately regardless, because it patches the macro itself, not
any Rails constant.

True when the shim should install its THREAD-server (Puma/Falcon) mode instead of the default Ractor (kino) mode. In thread mode Ractor.main? is true, so Rails' own globals (class variables / class ivars) are thread-safe and used as-is; only the class_attribute callback-chain isolation fix and the nil-safe callback replay are installed. The other patches route framework globals through per-Ractor IsolatedExecutionState, which is empty on Puma's request threads and would break the app, so they are skipped.

Set explicitly via RactorRailsShim.thread_mode = true, or implicitly from ENV (puma|falcon|thin|webrick|thread*). Detected in install.

Returns:

  • (Boolean)


148
149
150
151
# File 'lib/ractor_rails_shim/patches/core.rb', line 148

def thread_mode?
  return @thread_mode if defined?(@thread_mode)
  false
end

.worker_app(frozen_app) ⇒ Object

Build the shareable Rack app handed to kino. Captures the application's constants in the main Ractor and wraps the frozen, shareable app in a WorkerApp that rebinds those constants (and initializes the worker's ActiveRecord connection) on the first request served by each worker Ractor. Returns a shareable WorkerApp instance.



535
536
537
538
# File 'lib/ractor_rails_shim/patches/core.rb', line 535

def worker_app(frozen_app)
  bindings = capture_app_constants
  WorkerApp.new(frozen_app, bindings)
end

.worker_ar_init(app) ⇒ Object

Wrap app so every worker Ractor initializes its ActiveRecord connections on first request. Returns a shareable wrapper.



1535
1536
1537
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1535

def worker_ar_init(app)
  ArWorkerInitWrapper.new(app)
end