Module: RactorRailsShim

Defined in:
lib/ractor_rails_shim.rb,
lib/ractor_rails_shim/version.rb,
lib/ractor_rails_shim/roles/check.rb,
lib/ractor_rails_shim/patches/core.rb,
lib/ractor_rails_shim/patches/i18n.rb,
lib/ractor_rails_shim/patches/rack.rb,
lib/ractor_rails_shim/patches/devise.rb,
lib/ractor_rails_shim/patches/warden.rb,
lib/ractor_rails_shim/roles/freezers.rb,
lib/ractor_rails_shim/patches/openssl.rb,
lib/ractor_rails_shim/roles/installer.rb,
lib/ractor_rails_shim/roles/lifecycle.rb,
lib/ractor_rails_shim/patches/kaminari.rb,
lib/ractor_rails_shim/patches/rubygems.rb,
lib/ractor_rails_shim/roles/worker_app.rb,
lib/ractor_rails_shim/foundation/funnel.rb,
lib/ractor_rails_shim/patches/callables.rb,
lib/ractor_rails_shim/patches/propshaft.rb,
lib/ractor_rails_shim/foundation/storage.rb,
lib/ractor_rails_shim/roles/fallback_ies.rb,
lib/ractor_rails_shim/foundation/registry.rb,
lib/ractor_rails_shim/foundation/run_mode.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/roles/ar_model_walker.rb,
lib/ractor_rails_shim/roles/pre_spawn_steps.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/roles/callback_capture.rb,
lib/ractor_rails_shim/roles/fallback_builder.rb,
lib/ractor_rails_shim/roles/install_strategy.rb,
lib/ractor_rails_shim/foundation/ies_accessor.rb,
lib/ractor_rails_shim/patches/action_dispatch.rb,
lib/ractor_rails_shim/patches/class_attribute.rb,
lib/ractor_rails_shim/roles/app_shareabilizer.rb,
lib/ractor_rails_shim/foundation/role_defaults.rb,
lib/ractor_rails_shim/foundation/version_check.rb,
lib/ractor_rails_shim/roles/worker_app_factory.rb,
lib/ractor_rails_shim/foundation/const_reassign.rb,
lib/ractor_rails_shim/foundation/version_policy.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/foundation/storage_strategy.rb,
lib/ractor_rails_shim/roles/logger_io_neutralizer.rb,
lib/ractor_rails_shim/roles/constant_shareabilizer.rb,
lib/ractor_rails_shim/roles/shareability_traversal.rb,
lib/ractor_rails_shim/patches/active_model_attribute.rb,
lib/ractor_rails_shim/patches/hash_compute_if_absent.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 (RactorRailsShim.storage), 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: ARModelWalker, ActionDispatchStrategy, ActiveModelAttributePatch, ActiveModelAttributeRegistrationPatch, ActiveRecordAttributesPatch, ActiveRecordModelSchemaPatch, AppShareabilizer, CallbackCapture, ConstReassign, ConstantShareabilizer, FallbackBuilder, Freezers, Funnel, IESAccessor, InstallStrategy, Installer, Lifecycle, LoggerIONeutralizer, Patches, PreSpawnSteps, Registry, RoleDefaults, RunMode, ShareabilityTraversal, Storage, StorageStrategy, Version, VersionPolicy, WorkerAppFactory Classes: ArWorkerInitWrapper, Callable, CallableConst, Check, DeviseMappingSnapshot, NoOpCond, NoOpLock, NoOpLogDev, NoOpProc, WorkerApp

Constant Summary collapse

VERSION =
"0.3.1"
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 =

The nine shared registries. Issue #35 (Round 4): storage OWNS the mutable registries (Array/Hash) — the facade constants below are the SAME object as Registry's instance variables, so appends are visible through both paths. The frozen (shareable) registries are swapped via _reassign_shareable_const which updates BOTH the facade constant (for the string-eval'd code that reads RactorRailsShim::SHAREABLE_FALLBACK) and Registry (so role objects that read Registry.shareable_fallback see the new value). New code should prefer RactorRailsShim::Registry.

Registry.class_attributes
MATTR_DEFAULTS =
Registry.mattr_defaults
CLASS_ATTR_VALUES =
Registry.class_attr_values
SHAREABLE_MATTR_DEFAULTS =
Registry.shareable_mattr_defaults
SHAREABLE_CONSTANTS =
Registry.shareable_constants
SHAREABLE_CLASS_IVARS =
Registry.shareable_class_ivars
ABSTRACT_REGISTRY =
Registry.abstract_registry
VIEW_CONTEXT_REGISTRY =
Registry.view_context_registry
SHAREABLE_FALLBACK =
Registry.shareable_fallback
PATCH_VERSIONS =

Registry of patch names → tested Rails version segments. Owned by VersionPolicy; the constant here is an alias so the historical RactorRailsShim::PATCH_VERSIONS reference keeps working.

RactorRailsShim::VersionPolicy::PATCH_VERSIONS
UnsupportedVersionError =

Alias for backward compatibility — errors are catchable as either RactorRailsShim::UnsupportedVersionError or RactorRailsShim::VersionPolicy::UnsupportedVersionError.

RactorRailsShim::VersionPolicy::UnsupportedVersionError
SUPPORTED_RUBY =
RactorRailsShim::Version::SUPPORTED_RUBY
SUPPORTED_RAILS =
"8.1"
NON_DISPATCHED_FRAMEWORK_PATCHES =

install*_patch methods called from OTHER install paths, not from the dispatcher. The constant + the dispatcher live on Installer (extracted Issue #13, Step 13.6); kept as a facade delegation so the existing framework_patch_dispatch_spec (which reads the dispatcher's source location) and version_spec keep passing. See Installer for the contract.

RactorRailsShim::Installer::NON_DISPATCHED_FRAMEWORK_PATCHES
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
FallbackIES =
Storage::ThreadLocal
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
EMPTY_CALLBACKS_HASH =

Frozen shared sentinel for the __callbacks class_attribute's missing-slot default. Rails callers index the result (__callbacks[:process_action]), so a missing slot must return an empty Hash rather than nil. A single frozen constant avoids the per-read {} allocation the original reader had. Defined on the module (not the singleton class) so it's readable as RactorRailsShim::EMPTY_CALLBACKS_HASH from the eval'd method bodies. Made Ractor-shareable so worker Ractors can read it too.

Ractor.make_shareable({}.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.



53
54
55
# File 'lib/ractor_rails_shim/patches/core.rb', line 53

def _abstract_registry
  @_abstract_registry
end

._view_context_fallbackObject

Returns the value of attribute _view_context_fallback.



55
56
57
# File 'lib/ractor_rails_shim/patches/core.rb', line 55

def _view_context_fallback
  @_view_context_fallback
end

._view_context_registryObject

Returns the value of attribute _view_context_registry.



54
55
56
# File 'lib/ractor_rails_shim/patches/core.rb', line 54

def _view_context_registry
  @_view_context_registry
end

.storage_strategyObject



168
169
170
171
# File 'lib/ractor_rails_shim/foundation/storage_strategy.rb', line 168

def storage_strategy
  return @storage_strategy if defined?(@storage_strategy)
  RactorRailsShim::RunMode.thread? ? StorageStrategy::Thread : StorageStrategy::Ractor
end

Class Method Details

._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
# 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)
    _reassign_shareable_const(:AR_CONFIGURATIONS_SNAPSHOT, snapshot)
  rescue StandardError => 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

Verify the runtime matches the versions the shim was developed against. 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.



242
243
244
# File 'lib/ractor_rails_shim/patches/core.rb', line 242

def _check_version_support
  RactorRailsShim::VersionPolicy.check_version_support
end

._class_attr_methods(method_name, key_str, missing_default) ⇒ Object

Build the reader/writer pair for one method name. ONE body for both run modes — the selected RactorRailsShim.storage_strategy (set once at install from RunMode.thread?) decides the lookup/store backend. method_name is the def name (namespaced or public); key_str is the inspected IES key Symbol literal (constant across both calls); missing_default is the inlined missing-slot default expression (string of Ruby source — only the Thread strategy consults it; the Ractor strategy relies on IES + SHAREABLE_FALLBACK + CLASS_ATTR_VALUES).



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

def _class_attr_methods(method_name, key_str, missing_default)
  <<~RUBY
    def #{method_name}
      RactorRailsShim.storage_strategy.lookup(self, #{key_str}, #{missing_default})
    end

    def #{method_name}=(new_value)
      RactorRailsShim.storage_strategy.store(self, #{key_str}, new_value)
      new_value
    end
  RUBY
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 StandardError
  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

._devise_mapping_snapshot(mapping) ⇒ Object



160
161
162
# File 'lib/ractor_rails_shim/patches/callables.rb', line 160

def _devise_mapping_snapshot(mapping)
  _swallow("devise mapping snapshot") { DeviseMappingSnapshot.new(mapping) }
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_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.



547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 547

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 StandardError => e
        # frozen owner — leave as-is
      end
    end
  rescue StandardError => e
  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).



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

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)
    _swallow("make journey dummy end node shareable") { Ractor.make_shareable(node) }
  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.



778
779
780
781
782
783
784
785
786
787
788
789
790
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 778

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
    _swallow("make mime negotiation constant shareable") { ::Ractor.make_shareable(c) }
  end
rescue StandardError => e
  warn "[ractor-rails-shim] _freeze_mime_negotiation!: #{e.class}: #{e.message}"
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
201
202
203
204
205
206
207
208
209
# 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 StandardError => 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 = (RactorRailsShim.storage[: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 = (RactorRailsShim.storage[: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
      RactorRailsShim.storage[: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?.
    #
    # The registry is frozen with `Ractor.make_shareable` at install time so
    # it can travel the shared app graph. Mutating a frozen Hash raises
    # FrozenError, so `abstract!` only writes the live ivar (main) and
    # guards the registry write behind a mutability check. `abstract!`
    # after install in main is a no-op on the registry (already captured);
    # callers that need to flip a class to abstract post-install should
    # rebuild the registry (rare — abstract! is a boot-time declaration).
    def abstract!
      reg = RactorRailsShim._abstract_registry
      reg[self] = true if reg && !reg.frozen? && 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 = (RactorRailsShim.storage[: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 = (RactorRailsShim.storage[: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.



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 216

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 = RactorRailsShim.storage[key]
      return v if v
      cn = (name.demodulize.delete_suffix("Controller").underscore unless anonymous?)
      RactorRailsShim.storage[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.



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
843
844
845
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 802

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
# 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 StandardError
        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 StandardError
    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
    _swallow("warm journey routes") do
      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
    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 StandardError
      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 StandardError
        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 StandardError
        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|
    # 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.
    RactorRailsShim::Funnel.swallow("freeze url helper") do
      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)
    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 StandardError
      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 StandardError
      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 StandardError
            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 = RactorRailsShim.storage[key]
      return v if v
      ft = name.split("::").last.sub("Field", "").downcase
      RactorRailsShim.storage[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 = (RactorRailsShim.storage[: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 = (RactorRailsShim.storage[: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 = RactorRailsShim.storage[:"#{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_method_patterns_patchObject

ActiveModel::AttributeMethods::ClassMethods#attribute_method_patterns_cache stores a mutable Concurrent::Map in a CLASS instance variable (@attribute_method_patterns_cache). That ivar is unshareable, so reading it from a worker Ractor raises Ractor::IsolationError ("can not get unshareable values from instance variables of classes/modules from non-main Ractors") — hit on the write path via redirect_to @post -> respond_to? -> matched_attribute_method -> attribute_method_patterns_cache.

Unlike @relation_delegate_cache (populated once, freezable), this map is mutated lazily per method_name (compute_if_absent) during request handling, so it cannot be frozen. Instead route it through Ractor-local storage: each Ractor gets its own Concurrent::Map, shared by all of its threads. The cache content is deterministic (a pure function of the class's attribute_method_patterns), so per-Ractor recomputation is correct.



339
340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 339

def _install_active_model_attribute_method_patterns_patch
  return if @am_amp_patched
  @am_amp_patched = true
  _register_patch :active_model_attribute_method_patterns, "8.1"
  return unless defined?(::ActiveModel::AttributeMethods)

  mod = ::ActiveModel::AttributeMethods::ClassMethods
  mod.module_eval do
    def attribute_method_patterns_cache
      store = Ractor.current[:__am_attribute_method_patterns_cache__] ||= {}
      store[object_id] ||= Concurrent::Map.new(initial_capacity: 4)
    end
  end
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. Delegates to RactorRailsShim::Patches::ActiveModelAttribute.install (extracted Step 22.2, Issue #22). The idempotency flag now lives on the role object. See Patches::ActiveModelAttribute for the contract (the three prepend targets + the ActiveModel::Attribute guard).



311
312
313
# File 'lib/ractor_rails_shim/patches/core.rb', line 311

def _install_active_model_attribute_patch
  Patches::ActiveModelAttribute.install
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.



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 864

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 = RactorRailsShim.storage[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
        RactorRailsShim.storage[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.



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 716

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 = (RactorRailsShim.storage[: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.



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 645

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 = (RactorRailsShim.storage[: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 = (RactorRailsShim.storage[: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 = (RactorRailsShim.storage[: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.



690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 690

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 = (RactorRailsShim.storage[: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.

NOTE on naming: there are TWO ModelSchema-related install methods, with deliberately different scopes:

* `_install_active_record_model_schema_patch`  (this method) — patches
`table_name` / `table_name=` / `reset_table_name` only.
* `_install_activerecord_model_schema_patch`  (below) — patches the
*column-derived* lazy caches `symbol_column_to_string`,
`content_columns` (and previously `column_defaults`, now provided
by the prepended `ActiveRecordModelSchemaPatch`).

Both target ActiveRecord::ModelSchema::ClassMethods but patch disjoint method sets; do not collapse them without auditing every caller.



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 595

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 = (RactorRailsShim.storage[: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
        (RactorRailsShim.storage[: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.



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
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 205

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 = RactorRailsShim.storage[#{er_key_str}]
      return v if RactorRailsShim.storage.key?(#{er_key_str})
      if Ractor.main? && instance_variable_defined?(:@error_reporter)
        @error_reporter
      else
        built = ActiveSupport::ErrorReporter.new
        RactorRailsShim.storage[#{er_key_str}] = built
        built
      end
    end
    def error_reporter=(val)
      RactorRailsShim.storage[#{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.



898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 898

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
        _reassign_shareable_const(:AR_CONFIGURATIONS_SHAREABLE, cfg)
      end
    rescue StandardError => 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 = RactorRailsShim.storage[#{key_str}]
      return v if RactorRailsShim.storage.key?(#{key_str})
      if Ractor.main?
        ActiveRecord::Core.class_variable_get(:@@configurations)
      else
        RactorRailsShim::AR_CONFIGURATIONS_SHAREABLE
      end
    end
    def configurations=(config)
      RactorRailsShim.storage[#{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).



1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1348

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::Registry.class_attr_values[:__ractor_rails_shim_ar_default_connection_handler__] = orig_handler
        RactorRailsShim.storage[dch_key] = orig_handler
      end
    rescue StandardError => 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 = RactorRailsShim.storage[#{dch_key_str}]
      return v if RactorRailsShim.storage.key?(#{dch_key_str})
      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)
      RactorRailsShim.storage[#{dch_key_str}] = val
    end
    # Route the per-Ractor handler through Ractor-local storage. IES is
    # per-thread, so a handler stored on the init thread is invisible to the
    # worker's other threads; Ractor.current is per-Ractor and shared by all
    # threads of the worker. Falls back to IES (legacy) then
    # default_connection_handler (main Ractor only).
    # NOTE: uses nil-sentinel (`unless v.nil?`) rather than key?-based
    # detection because Ractor#[] has no `key?` method (Ractor local
    # storage returns nil for both unset and set-to-nil). Safe here
    # because `connection_handler=` is only ever called with a real
    # ConnectionHandler instance, never nil — so nil unambiguously
    # means "unset, fall through to default_connection_handler".
    def connection_handler
      v = Ractor.current[:active_record_connection_handler]
      return v unless v.nil?
      RactorRailsShim.storage[:active_record_connection_handler] || default_connection_handler
    end
    def connection_handler=(handler)
      Ractor.current[:active_record_connection_handler] = handler
    end
  RUBY

  # Also ensure connection_handler (which Rails already routes through IES
  # at core.rb:132-138) works. Rails' implementation:
  #   def self.connection_handler
  #     RactorRailsShim.storage[: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.



954
955
956
957
958
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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 954

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| _swallow("make ar db config handler shareable") { Ractor.make_shareable(h) } }
      shareable = Ractor.make_shareable(handlers.dup)
      _reassign_shareable_const(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable)
    rescue StandardError => 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 = RactorRailsShim.storage[#{key_str}]
      return v if RactorRailsShim.storage.key?(#{key_str})
      if Ractor.main?
        @db_config_handlers
      else
        RactorRailsShim::AR_DB_CONFIG_HANDLERS_SHAREABLE
      end
    end
    def db_config_handlers=(val)
      RactorRailsShim.storage[#{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.



1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1097

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 = RactorRailsShim.storage[key]
      return v if RactorRailsShim.storage.key?(key)
      h = {}
      RactorRailsShim.storage[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.



1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1506

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 = RactorRailsShim.storage[key]
      return v if RactorRailsShim.storage.key?(key)
      result = (
        delegated_classes.flat_map(&:public_instance_methods) - ActiveRecord::Relation.public_instance_methods
      ).to_set.freeze
      RactorRailsShim.storage[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.



1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1589

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 = (RactorRailsShim.storage[:"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.



1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1743

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 = RactorRailsShim.storage[#{mp_key_str}]
      return v if RactorRailsShim.storage.key?(#{mp_key_str})
      if Ractor.main? && instance_variable_defined?(:@migrations_paths)
        v = @migrations_paths
        RactorRailsShim.storage[#{mp_key_str}] = v
        v
      else
        ["db/migrate"].freeze
      end
    end
    def migrations_paths=(val)
      RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{w_key_str}]
        if watcher.nil?
          watcher = RactorRailsShim.storage[#{w_key_str}] = build_watcher do
            RactorRailsShim.storage[#{nc_key_str}] = true
            ::ActiveRecord::Migration.check_pending_migrations
            RactorRailsShim.storage[#{nc_key_str}] = false
          end
        end
        needs_check = RactorRailsShim.storage[#{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).



567
568
569
570
571
572
573
574
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 567

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.



752
753
754
755
756
757
758
759
760
761
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
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
841
842
843
844
845
846
847
848
849
850
851
852
853
854
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 752

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 = RactorRailsShim.storage[key]
      return v[name_symbol] if v
      hash = column_names.index_by(&:to_sym)
      RactorRailsShim.storage[key] = hash
      hash[name_symbol]
    end

    def content_columns
      key = :"ractor_rails_shim_content_columns_\#{self.name}"
      v = RactorRailsShim.storage[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
      RactorRailsShim.storage[key] = cols
      cols
    end

    # NOTE: `column_defaults` is intentionally NOT redefined here. It is
    # installed by the prepended `ActiveRecordModelSchemaPatch` (see
    # `active_record_model_schema.rb`), which is prepended onto
    # `ModelSchema::ClassMethods` by `_install_active_model_attribute_patch`
    # and therefore sits in front of this module_eval heredoc in the
    # method lookup chain. A second definition here would be dead code and
    # a maintenance trap.

    # `reload_schema_from_cache` (called by `load_schema`/`reload_schema!`)
    # writes a batch of class ivars to `nil` to invalidate schema caches:
    #   @_returning_columns_for_insert, @arel_table, @column_names,
    #   @symbol_column_to_string_name_hash, @content_columns, @column_defaults
    # (ModelSchema) plus @timestamp_attributes_for_create_in_model,
    # @timestamp_attributes_for_update_in_model,
    # @all_timestamp_attributes_in_model (Timestamp#reload_schema_from_cache
    # via super). In a worker Ractor those writes raise
    # Ractor::IsolationError ("can not set instance variables of
    # classes/modules by non-main Ractors"). The shim already routes the
    # *readers* for these caches through IES, so in a worker we only need
    # to clear the IES slots — the next read rebuilds lazily. In main we
    # keep the original class-ivar-clearing behavior.
    def reload_schema_from_cache(recursive = true)
      if Ractor.main?
        super
      else
        # Clear this Ractor's IES slots for the IES-routed schema caches.
        # Use `next` over an explicit list (not `IES.clear`) to avoid
        # wiping unrelated slots. Keys mirror the readers above + the
        # prepended ActiveRecordModelSchemaPatch (active_record_model_schema.rb)
        # + ActiveModelAttributeRegistrationPatch (active_model_attribute.rb).
        name_str = self.name
        [
          :"ractor_rails_shim_symbol_column_to_string_\#{name_str}",
          :"ractor_rails_shim_content_columns_\#{name_str}",
          :"rrs_column_defaults_\#{object_id}",
          :"rrs_attributes_builder_\#{object_id}",
          :"rrs_yaml_encoder_\#{object_id}",
          :"rrs_returning_cols_\#{object_id}",
          :"rrs_default_attributes_\#{object_id}",
          :"rrs_attribute_types_\#{object_id}",
          :"rrs_table_names",
          :"rrs_arel_tables",
          :"rrs_predicate_builders",
          :"rrs_type_casters",
        ].each do |k| RactorRailsShim.storage.delete(k) end
        # The Timestamp subclass override calls `super` (this method); it
        # has already run by the time we get here via the super chain, so
        # its ivar-clears were intercepted by the worker branch of THIS
        # override (no-op). No further action needed.
      end
    end
  RUBY

  # Patch ActiveRecord::Timestamp::ClassMethods#reload_schema_from_cache
  # the same way: in main, keep the original ivar-clearing; in a worker,
  # skip the class-ivar writes (the IES slots are cleared by the
  # ModelSchema#reload_schema_from_cache override above via super).
  if defined?(::ActiveRecord::Timestamp::ClassMethods)
    ::ActiveRecord::Timestamp::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def reload_schema_from_cache(recursive = true)
        if Ractor.main?
          @timestamp_attributes_for_create_in_model = nil
          @timestamp_attributes_for_update_in_model = nil
          @all_timestamp_attributes_in_model = nil
          super
        else
          # Worker: skip the class-ivar writes (would raise
          # IsolationError). The ModelSchema super clears the IES slots.
          super
        end
      end
    RUBY
  end
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.



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
1084
1085
1086
1087
1088
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1046

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)
        _reassign_shareable_const(const_name, shareable)
      rescue StandardError => 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 = RactorRailsShim.storage[#{key_str}]
        return v if RactorRailsShim.storage.key?(#{key_str})
        if Ractor.main?
          @#{method_name}
        else
          RactorRailsShim::#{const_str}
        end
      end
      def #{method_name}=(val)
        RactorRailsShim.storage[#{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.



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_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).



1541
1542
1543
1544
1545
1546
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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1541

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 = RactorRailsShim.storage[key]
      return v if RactorRailsShim.storage.key?(key)
      if Ractor.main?
        reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
        v = @primary_key
        RactorRailsShim.storage[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 = RactorRailsShim.storage[key]
      return v.is_a?(::Array) if RactorRailsShim.storage.key?(key)
      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.



1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1120

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 = RactorRailsShim.storage[key]
      return v if RactorRailsShim.storage.key?(key)
      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
      RactorRailsShim.storage[key] = result
      result
    end
    def has_query_constraints?
      key = :"ractor_rails_shim_qcl_\#{name || object_id}"
      v = RactorRailsShim.storage[key]
      return !v.nil? if RactorRailsShim.storage.key?(key)
      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.



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1648

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.



1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1005

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| _swallow("make ar query transformer shareable") { Ractor.make_shareable(t) } }
      shareable = Ractor.make_shareable(transformers.dup)
      _reassign_shareable_const(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable)
    rescue StandardError => 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 = RactorRailsShim.storage[#{key_str}]
      return v if RactorRailsShim.storage.key?(#{key_str})
      if Ractor.main?
        @query_transformers
      else
        RactorRailsShim::AR_QUERY_TRANSFORMERS_SHAREABLE
      end
    end
    def query_transformers=(val)
      RactorRailsShim.storage[#{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.



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1294

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 StandardError
      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 = RactorRailsShim.storage[key]
        cache ||= (RactorRailsShim.storage[key] = {})
        cache[name] ||= (#{column_logic})
      end

      def quote_table_name(name)
        key = :"ractor_rails_shim_quoted_tables_\#{self.name}"
        cache = RactorRailsShim.storage[key]
        cache ||= (RactorRailsShim.storage[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.



1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1214

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).



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 284

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.



1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1473

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 = RactorRailsShim.storage[key]
      return v if RactorRailsShim.storage.key?(key)
      result = ancestors.index(instance_method(:serialize_cast_value).owner) <= ancestors.index(instance_method(:serialize).owner)
      RactorRailsShim.storage[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.



1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1811

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_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.



1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1254

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 = RactorRailsShim.storage[key]
      return v if v
      cache = Hash.new do |hash, klass|
        hash[klass] = :"visit_\#{(klass.name || "").gsub("::", "_")}"
      end.compare_by_identity
      RactorRailsShim.storage[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.



398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 398

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 = (RactorRailsShim.storage[: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_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).



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/active_support.rb', line 49

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
      strategy = RactorRailsShim.storage_strategy
      if kind == :process_action && defined?(::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS)
        # Thread (Puma/Falcon) mode: replay_callbacks! always replays
        # (replay_callbacks? returns false → block runs).
        # Ractor mode: replays only when __callbacks is empty
        # (replay_callbacks? returns true → skipped when callbacks present).
        callbacks = __callbacks[kind] if __callbacks
        return strategy.replay_callbacks!(self, kind) { (yield if block_given?) } if strategy.replay_callbacks?(callbacks)
      end
      callbacks = __callbacks[kind] if __callbacks
      if callbacks.nil? || callbacks.empty?
        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.



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
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 241

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 = RactorRailsShim.storage[key]
      return v if RactorRailsShim.storage.key?(key)
      f = Devise::ParameterFilter.new(case_insensitive_keys, strip_whitespace_keys)
      RactorRailsShim.storage[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 = RactorRailsShim.storage[: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
171
172
173
174
175
# 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"
  # Generate DeviseMappingSnapshot predicates from Devise::MODULES now that
  # Devise is loaded. Replaces the hardcoded fallback list with the actual
  # module set (incl. any third-party gems added at boot). No-op if
  # Devise::MODULES isn't defined.
  DeviseMappingSnapshot.generate_predicates_from_devise_modules!
  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 = _swallow("make devise mappings shareable") { Ractor.make_shareable(snap) }
      const_set(:DEVISE_MAPPINGS, snap) if snap
    rescue StandardError
      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



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
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 232

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 = RactorRailsShim.storage[#{acb_key_str}]
      return v if RactorRailsShim.storage.key?(#{acb_key_str})
      if Ractor.main? && instance_variable_defined?(:@after_change_callbacks)
        v = @after_change_callbacks
        RactorRailsShim.storage[#{acb_key_str}] = v
        v
      else
        arr = []
        RactorRailsShim.storage[#{acb_key_str}] = arr
        arr
      end
    end
    def after_change(&block)
      after_change_callbacks << block
    end
    def nestable
      v = RactorRailsShim.storage[#{nest_key_str}]
      return v if RactorRailsShim.storage.key?(#{nest_key_str})
      if Ractor.main? && instance_variable_defined?(:@nestable)
        v = @nestable
        RactorRailsShim.storage[#{nest_key_str}] = v
        v
      else
        false
      end
    end
    def nestable=(val)
      RactorRailsShim.storage[#{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.



298
299
300
301
302
303
304
305
306
307
308
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 298

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. See RactorRailsShim::Patches::HashComputeIfAbsent for the full semantics contract (nil-storing, per-Hash IES isolation via object_id). Install the Hash#compute_if_absent patch. Delegates to RactorRailsShim::Patches::HashComputeIfAbsent.install (extracted Step 22.1, Issue #22); kept as a facade method so the existing framework_patch_dispatch auto-discovery (which enumerates _install_*_patch singleton methods), compute_if_absent_spec.rb, and storage_spec.rb keep passing. The idempotency flag now lives on the role object. See Patches::HashComputeIfAbsent for the contract (mutable vs frozen receiver, nil-storing, per-Hash IES isolation via object_id).



331
332
333
# File 'lib/ractor_rails_shim/patches/core.rb', line 331

def _install_hash_compute_if_absent_patch
  Patches::HashComputeIfAbsent.install
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).



390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/ractor_rails_shim/patches/i18n.rb', line 390

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.



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
# File 'lib/ractor_rails_shim/patches/i18n.rb', line 421

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 = (RactorRailsShim.storage[: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).



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
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
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
# File 'lib/ractor_rails_shim/patches/i18n.rb', line 17

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 = RactorRailsShim.storage[#{dl_key_str}]
      return v if RactorRailsShim.storage.key?(#{dl_key_str})
      if Ractor.main? && defined?(@@default_locale)
        cv = @@default_locale
        RactorRailsShim.storage[#{dl_key_str}] = cv
        return cv
      end
      RactorRailsShim.storage[#{dl_key_str}] = :en
      :en
    end
    def default_locale=(locale)
      v = locale && locale.to_sym
      RactorRailsShim.storage[#{dl_key_str}] = v
      @@default_locale = v if Ractor.main?
      v
    end
    def locale
      v = RactorRailsShim.storage[#{l_key_str}]
      return v if RactorRailsShim.storage.key?(#{l_key_str})
      default_locale
    end
    def locale=(locale)
      v = locale && locale.to_sym
      RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{av_key_str}]
      return v if RactorRailsShim.storage.key?(#{av_key_str})
      if Ractor.main?
        if defined?(@@available_locales) && (cv = @@available_locales)
          RactorRailsShim.storage[#{av_key_str}] = cv
          return cv
        end
        al = backend.available_locales
        al = al.freeze if al.respond_to?(:freeze) && !al.frozen?
        RactorRailsShim.storage[#{av_key_str}] = al
        return al
      end
      al = [:en].freeze
      RactorRailsShim.storage[#{av_key_str}] = al
      al
    end
    def available_locales=(locales)
      v = Array(locales).map { |l| l.to_sym }
      v = nil if v.empty?
      RactorRailsShim.storage[#{av_key_str}] = v
      @@available_locales = v if Ractor.main?
      v
    end
    def available_locales_set
      v = RactorRailsShim.storage[#{avs_key_str}]
      return v if RactorRailsShim.storage.key?(#{avs_key_str})
      if Ractor.main? && defined?(@@available_locales_set) && (cv = @@available_locales_set)
        RactorRailsShim.storage[#{avs_key_str}] = cv
        return cv
      end
      s = available_locales.inject(Set.new) { |set, locale| set << locale.to_s << locale.to_sym }
      RactorRailsShim.storage[#{avs_key_str}] = s
      s
    end
    def available_locales_initialized?
      !!(RactorRailsShim.storage[#{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 = RactorRailsShim.storage[:ractor_rails_shim_i18n_enforce]
      return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_enforce)
      if Ractor.main? && defined?(@@enforce_available_locales)
        cv = @@enforce_available_locales
        RactorRailsShim.storage[:ractor_rails_shim_i18n_enforce] = cv
        return cv
      end
      RactorRailsShim.storage[:ractor_rails_shim_i18n_enforce] = true
      true
    end
    def enforce_available_locales=(val)
      v = !!val
      RactorRailsShim.storage[: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 = RactorRailsShim.storage[key]
        return b if b
        cls = (RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS) && RactorRailsShim::I18N_BACKEND_CLASS) || ::I18n::Backend::Simple
        b = cls.new
        RactorRailsShim.storage[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 = RactorRailsShim.storage[:ractor_rails_shim_i18n_load_path]
      return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_load_path)
      if Ractor.main?
        lp = (defined?(@@load_path) && @@load_path) || []
        lp = lp.dup.freeze if lp.respond_to?(:freeze) && !lp.frozen?
        RactorRailsShim.storage[: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)
      RactorRailsShim.storage[: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 = RactorRailsShim.storage[:ractor_rails_shim_i18n_sep]
      return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_sep)
      if Ractor.main?
        cv = defined?(@@default_separator) ? @@default_separator : "."
        RactorRailsShim.storage[:ractor_rails_shim_i18n_sep] = cv
        cv
      else
        "."
      end
    end
    def default_separator=(separator)
      RactorRailsShim.storage[:ractor_rails_shim_i18n_sep] = separator
      @@default_separator = separator if Ractor.main?
      separator
    end
    def exception_handler
      v = RactorRailsShim.storage[:ractor_rails_shim_i18n_exc]
      return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_exc)
      if Ractor.main?
        cv = defined?(@@exception_handler) ? @@exception_handler : ::I18n::ExceptionHandler.new
        RactorRailsShim.storage[:ractor_rails_shim_i18n_exc] = cv
        cv
      else
        ::I18n::ExceptionHandler.new
      end
    end
    def exception_handler=(handler)
      RactorRailsShim.storage[:ractor_rails_shim_i18n_exc] = handler
      @@exception_handler = handler if Ractor.main?
      handler
    end
    def missing_interpolation_argument_handler
      v = RactorRailsShim.storage[:ractor_rails_shim_i18n_miss]
      return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_miss)
      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
        RactorRailsShim.storage[: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)
      RactorRailsShim.storage[:ractor_rails_shim_i18n_miss] = handler
      @@missing_interpolation_argument_handler = handler if Ractor.main?
      handler
    end
    def interpolation_patterns
      v = RactorRailsShim.storage[:ractor_rails_shim_i18n_ip]
      return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_ip)
      if Ractor.main?
        cv = defined?(@@interpolation_patterns) ? @@interpolation_patterns : ::I18n::DEFAULT_INTERPOLATION_PATTERNS.dup
        RactorRailsShim.storage[:ractor_rails_shim_i18n_ip] = cv
        cv
      else
        ::I18n::DEFAULT_INTERPOLATION_PATTERNS
      end
    end
    def interpolation_patterns=(patterns)
      RactorRailsShim.storage[: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 StandardError
      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 = RactorRailsShim.storage[#{fb_key_str}]
        return v if RactorRailsShim.storage.key?(#{fb_key_str})
        if Ractor.main? && defined?(@@fallbacks)
          cv = @@fallbacks
          if cv
            RactorRailsShim.storage[#{fb_key_str}] = cv
            return cv
          end
        end
        built = I18n::Locale::Fallbacks.new
        RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{tag_key_str}]
          return v if RactorRailsShim.storage.key?(#{tag_key_str})
          if Ractor.main? && defined?(@@implementation)
            cv = @@implementation
            RactorRailsShim.storage[#{tag_key_str}] = cv
            return cv
          end
          RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{nk_key_str}]
          cache ||= (RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{rkp_key_str}]
          return v if v
          pat = /(?<!%)%\\{(#{::I18n::RESERVED_KEYS.join("|")})\\}/
          RactorRailsShim.storage[#{rkp_key_str}] = pat
          pat
        end
      RUBY
    end
  end
end

._install_inflector_patchObject



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
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 111

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 = RactorRailsShim.storage[#{en_key_str}]
        return v if RactorRailsShim.storage.key?(#{en_key_str})
        if Ractor.main?
          existing = instance_variable_get(:@__en_instance__) if instance_variable_defined?(:@__en_instance__)
          RactorRailsShim.storage[#{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
        RactorRailsShim.storage[#{en_key_str}] = built
        built
      else
        h = RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{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.



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
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 566

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.



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
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 489

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 = RactorRailsShim.storage[#{ec_key_str}]
      encoder ||= (RactorRailsShim.storage[#{ec_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new)
      encoder.encode(value)
    end
    def encode_without_escape(value)
      encoder = RactorRailsShim.storage[#{ecn_key_str}]
      encoder ||= (RactorRailsShim.storage[#{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.send(:remove_const, name) if enc.const_defined?(name, false)
          enc.const_set(name, Ractor.make_shareable(v))
        rescue StandardError
          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
# 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 StandardError => 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 StandardError => 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
    _reassign_shareable_const(:KAMINARI_SHAREABLE_CONFIG, shareable_config)
  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 = RactorRailsShim.storage[#{k_key_str}]
      return v if RactorRailsShim.storage.key?(#{k_key_str})
      if Ractor.main? && instance_variable_defined?(:@_config)
        @_config
      else
        RactorRailsShim::KAMINARI_SHAREABLE_CONFIG
      end
    end

    def config=(val)
      RactorRailsShim.storage[#{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.



376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 376

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.



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 308

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 = RactorRailsShim.storage[#{key_str}]
      return v if RactorRailsShim.storage.key?(#{key_str})
      if Ractor.main? && instance_variable_defined?(:@logger)
        @logger
      elsif defined?(::Rails) && ::Rails.respond_to?(:logger)
        ::Rails.logger
      end
    end

    def logger=(val)
      RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{key_str}]
      return v if RactorRailsShim.storage.key?(#{key_str})
      if Ractor.main? && instance_variable_defined?(:@registered_details)
        @registered_details
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] || []
      end
    end
    def registered_details=(val)
      RactorRailsShim.storage[#{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 StandardError
      next
    end
    begin
      value = Ractor.make_shareable(value)
    rescue StandardError
      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 StandardError => 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 StandardError => 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)
    RactorRailsShim.storage[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
    )
    fallback = _swallow("make view context fallback shareable") { Ractor.make_shareable(fallback) }
    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 = RactorRailsShim.storage[#{vcc_key_str2}]
      return v if RactorRailsShim.storage.key?(#{vcc_key_str2})
      if Ractor.main? && instance_variable_defined?(:@view_context_class)
        v = @view_context_class
        RactorRailsShim.storage[#{vcc_key_str2}] = v
        v
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{vcc_key_str2}]
      end
    end
    def details_keys
      v = RactorRailsShim.storage[#{dk_key_str}]
      return v if v
      if Ractor.main? && instance_variable_defined?(:@details_keys)
        v = @details_keys
      else
        v = Concurrent::Map.new
      end
      RactorRailsShim.storage[#{dk_key_str}] = v
      v
    end
    def digest_cache(details)
      dc = (RactorRailsShim.storage[#{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.



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
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 420

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
        (RactorRailsShim.storage[: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.



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
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 171

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 = (RactorRailsShim.storage[: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).



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

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 = RactorRailsShim.storage[#{nkey_str}]
      return v if RactorRailsShim.storage.key?(#{nkey_str})
      if Ractor.main? && instance_variable_defined?(:@notifier)
        @notifier
      else
        built = ActiveSupport::Notifications::Fanout.new
        RactorRailsShim.storage[#{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 = RactorRailsShim.storage[key]
      return v if RactorRailsShim.storage.key?(key)
      adapter = self::OrmAdapter.new(self)
      RactorRailsShim.storage[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
        RactorRailsShim.storage[: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 = RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{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 = (RactorRailsShim.storage[:"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 = (RactorRailsShim.storage[:"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 = (RactorRailsShim.storage[:"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 = RactorRailsShim.storage[#{fp_key_str}]
      return v if RactorRailsShim.storage.key?(#{fp_key_str})
      if Ractor.main? && instance_variable_defined?(:@forwarded_priority)
        @forwarded_priority
      else
        [:forwarded, :x_forwarded]
      end
    end
    def forwarded_priority=(val)
      RactorRailsShim.storage[#{fp_key_str}] = val
      @forwarded_priority = val if Ractor.main?
      val
    end
    def x_forwarded_proto_priority
      v = RactorRailsShim.storage[#{xp_key_str}]
      return v if RactorRailsShim.storage.key?(#{xp_key_str})
      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)
      RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{dqp_key_str}]
      return v if RactorRailsShim.storage.key?(#{dqp_key_str})
      if Ractor.main? && instance_variable_defined?(:@default_query_parser)
        v = @default_query_parser
        RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{mtp_key_str}]
      return v if RactorRailsShim.storage.key?(#{mtp_key_str})
      if Ractor.main? && instance_variable_defined?(:@multipart_total_part_limit)
        v = @multipart_total_part_limit
        RactorRailsShim.storage[#{mtp_key_str}] = v
        v
      else
        RactorRailsShim::SHAREABLE_FALLBACK[#{mtp_key_str}] || 128
      end
    end
    def multipart_file_limit
      v = RactorRailsShim.storage[#{mfl_key_str}]
      return v if RactorRailsShim.storage.key?(#{mfl_key_str})
      if Ractor.main? && instance_variable_defined?(:@multipart_file_limit)
        v = @multipart_file_limit
        RactorRailsShim.storage[#{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.



343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 343

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 = RactorRailsShim.storage[#{key_str}]
      return v if RactorRailsShim.storage.key?(#{key_str})
      result = check.call
      RactorRailsShim.storage[#{key_str}] = result
      result
    end

    def reloaded!
      RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{pp_key_str}]
      return v if RactorRailsShim.storage.key?(#{pp_key_str})
      if Ractor.main? && instance_variable_defined?(:@parameter_parsers)
        v = @parameter_parsers
        RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{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
          RactorRailsShim.storage[#{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 },
      }
      RactorRailsShim.storage[#{th_key_str}] = built
      built
    end

    def self._ractor_rails_shim_persist(map)
      RactorRailsShim.storage[#{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 StandardError
      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

._patch_rails_module_body(mod) ⇒ Object



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
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 68

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 = RactorRailsShim.storage[#{k[:application].inspect}]
      return v if RactorRailsShim.storage.key?(#{k[:application].inspect})
      if Ractor.main?
        super
      else
        RactorRailsShim::AppShareabilizer.const_defined?(:SHAREABLE_APP) ? RactorRailsShim::AppShareabilizer::SHAREABLE_APP : nil
      end
    end

    def application=(val)
      RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{k[:app_class].inspect}]
      return v if RactorRailsShim.storage.key?(#{k[:app_class].inspect})
      return super if Ractor.main?
      RactorRailsShim::SHAREABLE_FALLBACK[#{k[:app_class].inspect}]
    end
    def app_class=(val)
      RactorRailsShim.storage[#{k[:app_class].inspect}] = val
      super if Ractor.main?
      val
    end

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

    def logger
      v = RactorRailsShim.storage[#{k[:logger].inspect}]
      return v if RactorRailsShim.storage.key?(#{k[:logger].inspect})
      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))
      RactorRailsShim.storage[#{k[:logger].inspect}] = built
      built
    end
    def logger=(val)
      RactorRailsShim.storage[#{k[:logger].inspect}] = val
      super if Ractor.main?
      val
    end

    def backtrace_cleaner
      v = RactorRailsShim.storage[#{k[:backtrace_cleaner].inspect}]
      return v if RactorRailsShim.storage.key?(#{k[:backtrace_cleaner].inspect})
      return super if Ractor.main?
      RactorRailsShim::SHAREABLE_FALLBACK[#{k[:backtrace_cleaner].inspect}]
    end
    def backtrace_cleaner=(val)
      RactorRailsShim.storage[#{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 = RactorRailsShim.storage[#{k[:env].inspect}]
      return v if RactorRailsShim.storage.key?(#{k[:env].inspect})
      if Ractor.main?
        super
      else
        built = ActiveSupport::EnvironmentInquirer.new(
          ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development"
        )
        RactorRailsShim.storage[#{k[:env].inspect}] = built
        built
      end
    end

    def env=(val)
      v = ActiveSupport::EnvironmentInquirer.new(val)
      RactorRailsShim.storage[#{k[:env].inspect}] = v
      super if Ractor.main?
      v
    end
  RUBY
  mod.singleton_class.prepend(patch)
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 StandardError
      nil
    end
  end
end

._reassign_shareable_const(name, value) ⇒ Object

Reassign a constant on RactorRailsShim with a new shareable value, silencing the "already initialized constant" warning that const_set emits when the constant was previously defined. Centralizes the $VERBOSE-suppressed const_set dance that was repeated at every shareable-constant rebuild site (SHAREABLE_FALLBACK, SHAREABLE_MATTR_DEFAULTS, etc.). The value MUST already be frozen + shareable — this method does not make it so.

Issue #35 (Round 4): for the four frozen registries that Registry owns (SHAREABLE_FALLBACK, SHAREABLE_MATTR_DEFAULTS, ABSTRACT_REGISTRY, VIEW_CONTEXT_REGISTRY), also update the Registry instance variable so role objects reading through Registry.* see the new value. The facade const_set is kept for the string-eval'd code that reads RactorRailsShim::SHAREABLE_*.



110
111
112
113
114
115
116
117
118
119
# File 'lib/ractor_rails_shim/patches/core.rb', line 110

def _reassign_shareable_const(name, value)
  ConstReassign.call(self, name, value)
  case name
  when :SHAREABLE_FALLBACK then Registry.reassign_shareable_fallback(value)
  when :SHAREABLE_MATTR_DEFAULTS then Registry.reassign_shareable_mattr_defaults(value)
  when :ABSTRACT_REGISTRY then Registry.reassign_abstract_registry(value)
  when :VIEW_CONTEXT_REGISTRY then Registry.reassign_view_context_registry(value)
  end
  value
end

._register_for_fallback(mod_name, sym, key, default) ⇒ Object

Register a mattr accessor so _build_shareable_fallback! can capture + make shareable the main-ractor value at prepare_for_ractors! time. The default is stored too so the fallback builder can use it when the live value can't be shared (e.g. __callbacks holds self-capturing Procs).



174
175
176
# File 'lib/ractor_rails_shim/patches/mattr_accessor.rb', line 174

def _register_for_fallback(mod_name, sym, key, default)
  CLASS_ATTRIBUTES << [mod_name, sym, key, default]
end

._register_patch(name, *tested_segments) ⇒ Object

Record that a patch was developed/tested against the given Rails version segments. Delegates to RactorRailsShim::VersionPolicy.register.



262
263
264
# File 'lib/ractor_rails_shim/patches/core.rb', line 262

def _register_patch(name, *tested_segments)
  RactorRailsShim::VersionPolicy.register(name, *tested_segments)
end

._seed_mattr_default(key, default) ⇒ Object

Store the definition-time default for a mattr accessor in the runtime registry. If the default is shareable, also rebuild the SHAREABLE_MATTR_DEFAULTS constant (frozen + made shareable) so worker Ractors can read it before prepare_for_ractors! runs. The constant reassignment goes through _reassign_shareable_const (centralized $VERBOSE suppression).

INVARIANT: this method MUST only be called pre-spawn (i.e. during boot in the main Ractor, before worker Ractors are forked). The rebuild- from-scratch pattern (dup, freeze, make_shareable, const_set) reassigns the SHAREABLE_MATTR_DEFAULTS constant; any holder of a stale reference (e.g. a worker that already read the constant) won't see the update. The codebase is careful to call this only at mattr-definition time (boot), but a future maintainer adding a post-spawn call would silently break workers that cached the old constant. Don't.



159
160
161
162
163
164
165
166
167
168
# File 'lib/ractor_rails_shim/patches/mattr_accessor.rb', line 159

def _seed_mattr_default(key, default)
  MATTR_DEFAULTS[key] = default
  if default && Ractor.shareable?(default)
    h = SHAREABLE_MATTR_DEFAULTS.dup
    h[key] = default
    h.freeze
    Ractor.make_shareable(h)
    _reassign_shareable_const(:SHAREABLE_MATTR_DEFAULTS, h)
  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).



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 514

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 StandardError => 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.



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 390

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 StandardError => 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 StandardError => e
          # frozen owner — leave as-is
        end
      end
    rescue StandardError => 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)
    _reassign_shareable_const(:AR_PRIMARY_KEYS_SHAREABLE, shareable)
  rescue StandardError => 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.



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 357

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 StandardError => 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_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.


469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 469

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 StandardError => e
    end
    Ractor.make_shareable(h)
  else
    begin
      Ractor.make_shareable(v)
    rescue StandardError => 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

._swallow(label) ⇒ Object

Swallow an exception raised by the block, optionally logging it when debug? is on. Used by freeze/shareability paths where individual failures are expected (some ivars hold intrinsically unshareable values like Procs) but a worker crash on the same value later has no visible cause. label identifies the call site (e.g. "freeze AR ivar Post@column_defaults"). Keep the label short — it's only for grepping. Delegates to RactorRailsShim::Funnel.swallow (extracted Issue #31, step 31.1d).



92
93
94
# File 'lib/ractor_rails_shim/patches/core.rb', line 92

def _swallow(label)
  Funnel.swallow(label) { yield }
end

._version_mismatch(message) ⇒ Object

Apply the configured policy to a mismatch message. Delegates to RactorRailsShim::VersionPolicy.mismatch.



248
249
250
# File 'lib/ractor_rails_shim/patches/core.rb', line 248

def _version_mismatch(message)
  RactorRailsShim::VersionPolicy.mismatch(message)
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).



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
766
767
768
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 740

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 StandardError => 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).



718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 718

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 StandardError
      # 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: [...] }. Delegates to RactorRailsShim::VersionPolicy.applicable.



256
257
258
# File 'lib/ractor_rails_shim/patches/core.rb', line 256

def applicable_patches
  RactorRailsShim::VersionPolicy.applicable
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).



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

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

.capture_app_constants!Object

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). Capture a frozen name -> object map for every constant the application's Zeitwerk loaders manage. Delegates to WorkerAppFactory.capture_constants! (extracted Issue #13, Step 13.4; WorkerApp moved to ractor_rails_shim/worker_app.rb). See WorkerAppFactory for the contract (Zeitwerk introspection, the non-Zeitwerk guard, and why the captured map is needed for worker constant rebinding).



288
289
290
# File 'lib/ractor_rails_shim/patches/core.rb', line 288

def capture_app_constants!
  WorkerAppFactory.capture_constants!
end

.debug=(value) ⇒ Object



80
81
82
# File 'lib/ractor_rails_shim/patches/core.rb', line 80

def debug=(value)
  Funnel.debug = value
end

.debug?Boolean

When true, swallowed exceptions in freeze/shareability paths are reported to $stderr so a worker Ractor that later crashes on an unshareable value has a traceable cause. Default false (silent, the historical behaviour). Enable for diagnosis:

RactorRailsShim.debug = true

Delegates to RactorRailsShim::Funnel (extracted Issue #31, step 31.1d).

Returns:

  • (Boolean)


76
77
78
# File 'lib/ractor_rails_shim/patches/core.rb', line 76

def debug?
  Funnel.debug?
end

.fix_url_helpers_singleton_routes!Object

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!).



127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/ractor_rails_shim/patches/route_helpers.rb', line 127

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 StandardError
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.



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
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 222

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

  key = :active_record_connection_handler
  # Store the handler in Ractor-local storage (Ractor.current), NOT in
  # the IES-backed RactorRailsShim.storage. IES is per-THREAD (it is a
  # Thread.attr_accessor), so a value set on the init thread is invisible
  # to the other worker threads in the same worker Ractor ->
  # ConnectionNotEstablished ("No connection handler for Ractor X").
  # Ractor.current storage is per-Ractor and shared by every thread of the
  # worker, so connection_handler resolves the same handler for all threads.
  existing = Ractor.current[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 StandardError => e
        # Best-effort: if one connection fails, continue with others.
      end
    end

    Ractor.current[key] = handler
  end
end

.installObject

Install all the patches. Safe to call multiple times (idempotent). Delegates to Installer.install (extracted Issue #13, Step 13.6). See Installer for the orchestration contract (version check, run-mode resolve, branch by mode, early-boot install_* calls).



168
169
170
# File 'lib/ractor_rails_shim/patches/core.rb', line 168

def install
  Installer.install
end

.install_class_attributeObject



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 33

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
# 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 default
        # is stored too so the fallback builder can use it when the live
        # value can't be shared.
        RactorRailsShim._register_for_fallback(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. Also rebuilds
        # the shareable subset constant if the default is shareable.
        RactorRailsShim._seed_mattr_default(key, sym_default)

        # 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 = RactorRailsShim.storage[#{key_str}]
            return v if RactorRailsShim.storage.key?(#{key_str})

            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)
            RactorRailsShim.storage[#{key_str}] = val
            class_variable_set(#{cv_str}, val) if Ractor.main?
            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 = RactorRailsShim.storage[#{key_str}]
              return v if RactorRailsShim.storage.key?(#{key_str})
              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)
              RactorRailsShim.storage[#{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.

Two flags are intentional and guard different things:

@rails_load_hook_installed — the TracePoint one-shot is armed (so we
don't stack multiple TracePoints on repeated `install` calls).
@rails_module_patched — the patch itself has been applied (checked
again inside the TracePoint block and in `patch_rails_module!`
because the immediate-path caller also goes through that method).


46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 46

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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# 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 StandardError
  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 StandardError
  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)

      # Bound the stock `self.included` reinclude (actionpack
      # action_dispatch/routing/route_set.rb). That hook re-dups the module
      # and re-includes it while `!base._routes.equal?(@_proxy._routes)`.
      # Under the frozen, Ractor-shareable app graph a worker Ractor's
      # controller can report `_routes` as nil, so the equality never holds
      # and the reinclude loops forever (SystemStackError on the very first
      # request, e.g. GET /up -> Rails::HealthController). The intent is to
      # re-align `_routes` per base exactly once; bound it to one reinclude
      # per base so the loop can never happen while preserving that intent.
      seen = ::Set.new
      mod.instance_variable_set(:@_rrs_reinclude_seen, seen)
      mod.singleton_class.class_eval do
        alias_method :_rrs_orig_self_included, :included
        def included(base)
          seen = instance_variable_get(:@_rrs_reinclude_seen)
          return if seen.include?(base.object_id)
          seen << base.object_id
          _rrs_orig_self_included(base)
        end
      end
      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_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 StandardError => 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 StandardError => 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
35
36
37
38
39
40
41
# 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.
    #
    # Two flags guard different things (mirrors `rails_module.rb`):
    #   @zeitwerk_patched          — `install_zeitwerk_registry` ran
    #                                 (so we don't arm a second TracePoint).
    #   @zeitwerk_registry_patched — the actual patch was applied (checked
    #                                 again inside the TracePoint block and
    #                                 in `patch_zeitwerk_registry!`).
    @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)


172
173
174
# File 'lib/ractor_rails_shim/patches/core.rb', line 172

def installed?
  Installer.installed?
end

.make_app_shareable!(app) ⇒ Object

Public API: make Rails.application shareable across Ractors. Delegates to RactorRailsShim::AppShareabilizer.make_shareable!(app) (extracted Step 22.6, Issue #22). See AppShareabilizer for the full pipeline contract (precompute → freeze ivars → warm routes → neutralize logger → replace procs → replace locks → make_shareable → build fallback).

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).



52
53
54
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 52

def make_app_shareable!(app)
  AppShareabilizer.make_shareable!(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). Delegates to ConstantShareabilizer.make_shareable! (extracted Issue #13, Step 13.1).



197
198
199
# File 'lib/ractor_rails_shim/patches/core.rb', line 197

def make_constant_shareable!(const_path)
  ConstantShareabilizer.make_shareable!(const_path)
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.



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
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 55

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.
      RactorRailsShim.storage[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::Registry.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::Registry.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
      # Static missing-slot default: frozen shared Hash for __callbacks
      # (Rails indexes the result, so nil would NoMethodError), nil for
      # everything else. Decided once at method-definition time.
      missing_default = (name == :__callbacks) ? "RactorRailsShim::EMPTY_CALLBACKS_HASH" : "nil"
      # ONE heredoc for both modes — the selected strategy
      # (RactorRailsShim.storage_strategy, set once at install from
      # RunMode.thread?) decides the lookup/store backend. Collapses the
      # former two-mode `if thread_mode?` branch (Issue #15).
      target.module_eval RactorRailsShim._class_attr_methods(namespaced_name, key_str, missing_default),
                          __FILE__, __LINE__ + 1

      # When owner is a module's singleton class, the original also
      # defines a public reader `def #{name}` on owner directly. Override
      # it with the strategy-routed version.
      if owner.singleton_class? && owner.attached_object.is_a?(Module)
        owner.module_eval RactorRailsShim._class_attr_methods(name, key_str, missing_default),
                            __FILE__, __LINE__ + 1
      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 = RactorRailsShim.storage[#{key_str}]
      return v if RactorRailsShim.storage.key?(#{key_str})
      sym = :"active_execution_wrapper_\#{object_id}"
      RactorRailsShim.storage[#{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).



61
62
63
64
65
66
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 61

def patch_rails_module!(mod)
  return if @rails_module_patched
  @rails_module_patched = true
  RactorRailsShim::ConstantShareabilizer.apply!
  _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



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
# File 'lib/ractor_rails_shim/patches/zeitwerk_registry.rb', line 43

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 = RactorRailsShim.storage[#{key_str}]
        return v if RactorRailsShim.storage.key?(#{key_str})
        if Ractor.main?
          existing = instance_variable_get(#{ivar_str}) if instance_variable_defined?(#{ivar_str})
          if existing
            RactorRailsShim.storage[#{key_str}] = existing
            return existing
          end
        end
        v = #{builder}
        RactorRailsShim.storage[#{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

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!.

Delegates to Lifecycle.prepare_for_ractors! (extracted POODR §1 SRP).



225
226
227
# File 'lib/ractor_rails_shim/patches/core.rb', line 225

def prepare_for_ractors!
  Lifecycle.prepare_for_ractors!
end

.shareable_constantsObject

Public reader for the SHAREABLE_CONSTANTS registry. Users register their own constants via RactorRailsShim.shareable_constants << "MyGem::LIST". Delegates to ConstantShareabilizer.shareable_constants (which reads RactorRailsShim::SHAREABLE_CONSTANTS).



188
189
190
# File 'lib/ractor_rails_shim/patches/core.rb', line 188

def shareable_constants
  ConstantShareabilizer.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).



1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1702

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

.storageObject

The active storage implementation. Patch sites route through this so the shim doesn't open the ActiveSupport namespace to alias the fallback.



95
96
97
# File 'lib/ractor_rails_shim/foundation/storage.rb', line 95

def storage
  Storage.storage
end

.thread_mode=(value) ⇒ Object



157
158
159
160
161
162
# File 'lib/ractor_rails_shim/patches/core.rb', line 157

def thread_mode=(value)
  RactorRailsShim::RunMode.thread = value
  # `storage_strategy` derives lazily from `RunMode.thread?` (see
  # `StorageStrategy`), so no explicit sync here — resetting `RunMode`
  # is enough to reset the strategy.
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.

Configuration is owned by RactorRailsShim::RunMode: set explicitly via RactorRailsShim.thread_mode = true (or RunMode.thread = true), or let install resolve it from ENV["SERVER"] (puma|falcon|thin|webrick|thread*). The decision is a configuration responsibility extracted from install per POODR; install calls RunMode.resolve! (a no-op when already configured explicitly) and then reads RunMode.thread?. These facade methods delegate for backward compatibility with existing call sites (patches/class_attribute.rb, patches/active_support.rb, specs).

Returns:

  • (Boolean)


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

def thread_mode?
  RactorRailsShim::RunMode.thread?
end

.version_policyObject

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

RactorRailsShim.version_policy = :strict

Delegates to RactorRailsShim::VersionPolicy.policy.



61
62
63
# File 'lib/ractor_rails_shim/patches/core.rb', line 61

def version_policy
  RactorRailsShim::VersionPolicy.policy
end

.version_policy=(value) ⇒ Object



65
66
67
# File 'lib/ractor_rails_shim/patches/core.rb', line 65

def version_policy=(value)
  RactorRailsShim::VersionPolicy.policy = value
end

.worker_app!(frozen_app) ⇒ Object

Build the shareable Rack app handed to kino. Delegates to WorkerAppFactory.build (extracted Issue #13, Step 13.4). See WorkerAppFactory for the shareability contract (returns a frozen, Ractor.shareable? WorkerApp instance).



296
297
298
# File 'lib/ractor_rails_shim/patches/core.rb', line 296

def worker_app!(frozen_app)
  WorkerAppFactory.build(frozen_app)
end

.worker_ar_init(app) ⇒ Object

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



1636
1637
1638
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1636

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