ractor-rails-shim 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +101 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +265 -0
  5. data/exe/ractor-rails-check +39 -0
  6. data/lib/ractor_rails_shim/check.rb +190 -0
  7. data/lib/ractor_rails_shim/fallback_ies.rb +33 -0
  8. data/lib/ractor_rails_shim/patches/action_controller.rb +301 -0
  9. data/lib/ractor_rails_shim/patches/action_dispatch.rb +851 -0
  10. data/lib/ractor_rails_shim/patches/action_view.rb +582 -0
  11. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +97 -0
  12. data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +58 -0
  13. data/lib/ractor_rails_shim/patches/active_support.rb +1085 -0
  14. data/lib/ractor_rails_shim/patches/activerecord.rb +1727 -0
  15. data/lib/ractor_rails_shim/patches/class_attribute.rb +194 -0
  16. data/lib/ractor_rails_shim/patches/core.rb +806 -0
  17. data/lib/ractor_rails_shim/patches/devise.rb +172 -0
  18. data/lib/ractor_rails_shim/patches/execution_wrapper.rb +76 -0
  19. data/lib/ractor_rails_shim/patches/kaminari.rb +82 -0
  20. data/lib/ractor_rails_shim/patches/make_shareable.rb +853 -0
  21. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +162 -0
  22. data/lib/ractor_rails_shim/patches/openssl.rb +58 -0
  23. data/lib/ractor_rails_shim/patches/orm_adapter.rb +32 -0
  24. data/lib/ractor_rails_shim/patches/polymorphic_routes.rb +115 -0
  25. data/lib/ractor_rails_shim/patches/propshaft.rb +110 -0
  26. data/lib/ractor_rails_shim/patches/rack.rb +160 -0
  27. data/lib/ractor_rails_shim/patches/rails_module.rb +192 -0
  28. data/lib/ractor_rails_shim/patches/route_helpers.rb +119 -0
  29. data/lib/ractor_rails_shim/patches/rubygems.rb +65 -0
  30. data/lib/ractor_rails_shim/patches/url_helpers.rb +78 -0
  31. data/lib/ractor_rails_shim/patches/warden.rb +195 -0
  32. data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +124 -0
  33. data/lib/ractor_rails_shim/patches.rb +67 -0
  34. data/lib/ractor_rails_shim/version.rb +5 -0
  35. data/lib/ractor_rails_shim/version_check.rb +88 -0
  36. data/lib/ractor_rails_shim.rb +33 -0
  37. metadata +104 -0
@@ -0,0 +1,806 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/lazy_load_hooks"
4
+
5
+ # Core: module-level constants, the install entry point, prepare_for_ractors!,
6
+ # version detection helpers, and the patch registry. All other patch files
7
+ # reopen `class << self` to add their `_install_*` methods.
8
+
9
+ module RactorRailsShim
10
+ # The keys under which each global is stored in IsolatedExecutionState.
11
+ # Namespaced to avoid collisions with Rails' own uses of IES.
12
+ KEYS = {
13
+ application: :ractor_rails_shim_application,
14
+ app_class: :ractor_rails_shim_app_class,
15
+ cache: :ractor_rails_shim_cache,
16
+ logger: :ractor_rails_shim_logger,
17
+ env: :ractor_rails_shim_env,
18
+ backtrace_cleaner: :ractor_rails_shim_backtrace_cleaner
19
+ }.freeze
20
+
21
+ # Registry of every class_attribute definition the shim's `redefine` patch
22
+ # has seen. Each entry is [owner_name, namespaced_name (Symbol), key (Symbol)]
23
+ # so that at `prepare_for_ractors!` time we can capture the main-ractor value
24
+ # of each attribute, make it shareable, and expose it as a read-only fallback
25
+ # for worker Ractors (whose own IES slot is empty). Without this, framework
26
+ # class config (ActionController::Base.config, etc.) is per-Ractor-nil in
27
+ # workers and request dispatch dies (e.g. default_static_extension -> config
28
+ # is nil). The fallback is ONLY read by workers; the main ractor keeps its
29
+ # own live (possibly mutable) value in its IES slot, untouched.
30
+ #
31
+ # The fallback table itself is built once, at prepare_for_ractors! time, and
32
+ # made shareable; workers read it via a constant (RactorRailsShim::SHAREABLE_FALLBACK).
33
+ CLASS_ATTRIBUTES = []
34
+ # Shareable registry: controller class → abstract? boolean. Populated at
35
+ # prepare_for_ractors! time by scanning all AbstractController::Base
36
+ # descendants for their @abstract ivar. Workers read this via the
37
+ # patched AbstractController::Base.abstract? (per-class values can't live in
38
+ # per-Ractor IES). Made shareable (frozen) at prepare time.
39
+ ABSTRACT_REGISTRY = Ractor.make_shareable({})
40
+ # Runtime registry: mattr_accessor IES key → default value. Written at
41
+ # mattr-definition time (boot, in main). The mattr reader (string-eval'd)
42
+ # looks the default up here by key — we CANNOT inline arbitrary default
43
+ # values into the eval'd method body (a Logger's `.inspect` is
44
+ # `#<Logger:...>`, invalid Ruby). Read by workers when both their IES slot
45
+ # and SHAREABLE_FALLBACK are empty. NOT made shareable (some defaults like
46
+ # Logger are intrinsically unshareable); workers only reach here if the
47
+ # value is a simple shareable literal (Symbol/String/Integer), in which
48
+ # case reading the constant is fine because... actually it IS a constant
49
+ # holding an unshareable Hash → worker read would raise. So this registry is
50
+ # ONLY safe to read from workers for shareable defaults. We guard the reader
51
+ # to only consult it for defaults that are Ractor.shareable?.
52
+ MATTR_DEFAULTS = {}
53
+ # class_attribute default values, keyed by IES key. Written at
54
+ # class_attribute-definition time (boot, in main). The class_attribute reader
55
+ # falls back to this in the MAIN ractor when the IES slot is empty (which it
56
+ # is on non-boot threads — IES is thread-local, and Puma's request threads
57
+ # have empty slots). NOT made shareable (values may be mutable Hashes/Arrays);
58
+ # only safe to read from the main ractor. Workers use SHAREABLE_FALLBACK
59
+ # (built at prepare_for_ractors! time) instead.
60
+ CLASS_ATTR_VALUES = {}
61
+ # Shareable subset of MATTR_DEFAULTS: only defaults that are
62
+ # Ractor.shareable? (so workers can read the constant safely). Written at
63
+ # mattr-definition time (boot, in main, before workers spawn); frozen +
64
+ # made shareable at prepare_for_ractors! time.
65
+ SHAREABLE_MATTR_DEFAULTS = {}
66
+ # Registry of constant path strings ("A::B::C") whose values are mutable
67
+ # Arrays/Hashes/Sets that need to be made shareable (deep-frozen) at boot.
68
+ # Each per-concern file concats its own constants into this array. Users
69
+ # can add their own via RactorRailsShim.shareable_constants << "MyGem::LIST".
70
+ SHAREABLE_CONSTANTS = []
71
+ # Registry of [ClassName, :ivar] pairs: class-level instance variables whose
72
+ # values are mutable (Hashes/Arrays/objects) and must be made Ractor-shareable
73
+ # (deep-frozen) at boot so worker Ractors can read them. Unlike
74
+ # SHAREABLE_CONSTANTS (top-level constants), these are class instance
75
+ # variables (e.g. ActiveSupport::Editor.@editors, Warden::Strategies.@strategies)
76
+ # that hold unshareable values and are read during request dispatch.
77
+ SHAREABLE_CLASS_IVARS = []
78
+ # Shareable registry: controller class → its built view_context_class.
79
+ # Populated at prepare_for_ractors! time by calling view_context_class on
80
+ # each loaded controller in main (build_view_context_class uses
81
+ # Class.new{...} blocks → un-shareable Proc from a worker). Made shareable.
82
+ VIEW_CONTEXT_REGISTRY = Ractor.make_shareable({})
83
+ # Frozen, shareable fallback table for class_attribute / mattr_accessor
84
+ # values. Built once at prepare_for_ractors! time from the main ractor's
85
+ # live values (class_attribute IES slot / mattr @@sym), each made shareable
86
+ # via callable-replacement + make_shareable. Workers read this via the
87
+ # RactorRailsShim::SHAREABLE_FALLBACK constant when their own IES slot is
88
+ # empty. Values that can't be made shareable are skipped (workers see nil
89
+ # for those and must set their own).
90
+ SHAREABLE_FALLBACK = Ractor.make_shareable({})
91
+
92
+ # Versions each patch was tested against. Populated by the install_*
93
+ # methods as they register themselves. A patch applies to a runtime Rails
94
+ # version only if the runtime segment matches one of its tested entries.
95
+ # This is the "load different patches for different Rails versions"
96
+ # infrastructure: to add 7.x support, write version-specific variants and
97
+ # tag them here. Defined on the module (not the singleton class) so it's
98
+ # readable from outside as RactorRailsShim::PATCH_VERSIONS.
99
+ PATCH_VERSIONS = {}
100
+
101
+ # Raised under :strict version policy when the runtime Rails/Ruby version
102
+ # isn't in the tested set. Defined on the module so it's catchable as
103
+ # RactorRailsShim::UnsupportedVersionError.
104
+ class UnsupportedVersionError < StandardError; end
105
+
106
+ class << self
107
+ # Accessor for the abstract-controller registry (written by abstract! in
108
+ # main, read by abstract? in workers). Reassigned to a shareable frozen
109
+ # Hash at prepare_for_ractors! time.
110
+ attr_accessor :_abstract_registry
111
+ attr_accessor :_view_context_registry
112
+ attr_accessor :_view_context_fallback
113
+
114
+ # Policy for version mismatches. One of :warn (default), :strict, :off.
115
+ # Set before `install`:
116
+ # RactorRailsShim.version_policy = :strict
117
+ # Defaults to :warn when never explicitly set.
118
+ def version_policy
119
+ @version_policy || :warn
120
+ end
121
+ attr_writer :version_policy
122
+
123
+ SUPPORTED_RUBY = RactorRailsShim::Version::SUPPORTED_RUBY
124
+ SUPPORTED_RAILS = "8.1"
125
+
126
+ # Install all the patches. Safe to call multiple times (idempotent).
127
+ #
128
+ # May be called either before or after Rails is loaded:
129
+ # - If Rails is already defined (e.g. `Bundler.require` ran first), the
130
+ # Rails module accessors are patched immediately.
131
+ # - If Rails is not yet defined (the normal `config/boot.rb` case, where
132
+ # `install` is called before `require "rails"`), a one-shot load hook
133
+ # defers the Rails-module patch until `rails.rb` is loaded. The
134
+ # `mattr_accessor` macro patch (a `Module.prepend`) applies
135
+ # immediately regardless, because it patches the macro itself, not
136
+ # any Rails constant.
137
+ # True when the shim should install its THREAD-server (Puma/Falcon) mode
138
+ # instead of the default Ractor (kino) mode. In thread mode Ractor.main? is
139
+ # true, so Rails' own globals (class variables / class ivars) are
140
+ # thread-safe and used as-is; only the class_attribute callback-chain
141
+ # isolation fix and the nil-safe callback replay are installed. The other
142
+ # patches route framework globals through per-Ractor
143
+ # IsolatedExecutionState, which is empty on Puma's request threads and
144
+ # would break the app, so they are skipped.
145
+ #
146
+ # Set explicitly via RactorRailsShim.thread_mode = true, or implicitly from
147
+ # ENV["SERVER"] (puma|falcon|thin|webrick|thread*). Detected in install.
148
+ def thread_mode?
149
+ return @thread_mode if defined?(@thread_mode)
150
+ false
151
+ end
152
+
153
+ attr_writer :thread_mode
154
+
155
+ def install
156
+ _check_version_support
157
+ @thread_mode = !!(ENV["SERVER"] && ENV["SERVER"] =~ /puma|falcon|thin|webrick|thread/i) unless defined?(@thread_mode)
158
+
159
+ if thread_mode?
160
+ # Minimal install for thread (Puma/Falcon) servers: only the
161
+ # class_attribute isolation fix + nil-safe callback replay. The other
162
+ # patches route framework globals through per-Ractor IES, which is
163
+ # empty on Puma's request threads and would break the app, so they are
164
+ # skipped; the original Rails globals are thread-safe and used as-is.
165
+ install_class_attribute
166
+ install_execution_wrapper
167
+ # Capture each controller's OWN declared before_action/after_action
168
+ # filters at declaration time (during eager load) by intercepting
169
+ # ActiveSupport::Callbacks.set_callback. This must be installed BEFORE
170
+ # eager load so declarations are captured as they happen — the
171
+ # class_attribute callback chain is corrupted by an eager-load leak
172
+ # under Ruby 4.0.5 + Rails 8.1.3 + Devise, so reading __callbacks later
173
+ # yields a wrong, unshareable chain. Install requires active_support/
174
+ # callbacks to be loaded, so require it first; install runs before the
175
+ # app's eager_load, so every controller declaration is captured.
176
+ require "active_support/callbacks" rescue nil
177
+ _install_callback_declaration_capture!
178
+ else
179
+ install_mattr_accessor
180
+ install_class_attribute
181
+ install_zeitwerk_registry
182
+ install_rubygems
183
+ install_rails_module
184
+ install_shareable_constants
185
+ install_execution_wrapper
186
+ require "active_support/callbacks" rescue nil
187
+ _install_callback_declaration_capture!
188
+ # Patch ActionView::Base.with_empty_template_cache EARLY (before eager
189
+ # load) so production's DetailsKey.view_context_class uses the block-free
190
+ # version. The framework's original defines compiled_method_container via
191
+ # define_method(&block) — an un-shareable Proc that breaks worker
192
+ # Ractors. on_load fires as soon as ActionView is required, well before
193
+ # the app's eager_load.
194
+ ActiveSupport.on_load(:action_view) do
195
+ RactorRailsShim._install_with_empty_template_cache_patch
196
+ end
197
+ end
198
+ @installed = true
199
+ true
200
+ end
201
+
202
+ def installed?
203
+ @installed ||= false
204
+ end
205
+
206
+ # --- Generic constant-sharing utilities (moved from rails_module.rb) -----
207
+ # These are framework-agnostic; SHAREABLE_CONSTANTS lives here too, so the
208
+ # whole constant-shareability machinery is owned by core.rb.
209
+
210
+ def shareable_constants
211
+ SHAREABLE_CONSTANTS
212
+ end
213
+
214
+ def install_shareable_constants
215
+ # Called at install time; if ActiveSupport isn't loaded yet, the
216
+ # constants don't exist. We re-run from patch_rails_module! (which
217
+ # fires once Rails — and thus ActiveSupport — is defined). Guarded
218
+ # by @shareable_constants_done so both paths are safe.
219
+ _register_patch :shareable_constants, "8.1"
220
+ return unless defined?(::ActiveSupport)
221
+
222
+ do_install_shareable_constants
223
+ end
224
+
225
+ # Run after Rails is fully booted (after Rails.application.initialize!)
226
+ # and BEFORE spawning worker Ractors. Re-attempts to make every
227
+ # registered constant shareable; constants that didn't exist at install
228
+ # time (e.g. Rails::Railtie, loaded after `module Rails` opens) get
229
+ # fixed here. Safe to call multiple times; already-shareable constants
230
+ # are no-ops.
231
+ #
232
+ # This MUST run in the main Ractor (const_set writes the constant table).
233
+ # Public wrapper is `prepare_for_ractors!` above.
234
+ def do_install_shareable_constants
235
+ shareable_constants.each { |path| make_constant_shareable(path) }
236
+ end
237
+
238
+ # Resolve a constant path string to a value, and if it exists and is
239
+ # not already shareable, replace it with its shareable (deep-frozen)
240
+ # version. Returns true if the constant was made shareable (or already
241
+ # was); false if it doesn't exist yet (caller may retry).
242
+ def make_constant_shareable(const_path)
243
+ owner, name = split_const_path(const_path)
244
+ return false unless owner && name
245
+ return true if owner.const_defined?(name, false) == false
246
+
247
+ val = owner.const_get(name, false)
248
+ return true if Ractor.shareable?(val)
249
+
250
+ shareable = _make_value_shareable(val)
251
+ return true unless shareable
252
+
253
+ # Deep-freeze and reassign. Ractor.make_shareable mutates `val` in
254
+ # place (freezing it and its reachable objects) and returns it.
255
+ # const_set warns "already initialized constant" because Rails'
256
+ # environment_inquirer.rb defined the constant first. The reassign is
257
+ # intentional (we're replacing the mutable value with its frozen
258
+ # shareable twin), so silence that one warning.
259
+ verbose, $VERBOSE = $VERBOSE, nil
260
+ begin
261
+ owner.const_set(name, shareable)
262
+ ensure
263
+ $VERBOSE = verbose
264
+ end
265
+ true
266
+ end
267
+
268
+ # Best-effort shareable replacement for a constant value. Monitor/Mutex
269
+ # become a NoOpLock (never contended post-boot). BasicObject instances
270
+ # (used as sentinel sentinels, e.g. PRIMARY_KEY_NOT_SET) can't be frozen
271
+ # (BasicObject has no #freeze method) — replace with a frozen Symbol.
272
+ # Everything else is deep-frozen via Ractor.make_shareable; if that fails
273
+ # (e.g. a Proc, or a Concurrent::Map / TypeMap holding Procs — both
274
+ # intrinsically unshareable and needing upstream Rails changes), returns
275
+ # nil and the constant is left as-is (the worker will raise a clear
276
+ # IsolationError on read).
277
+ def _make_value_shareable(val)
278
+ if (val.is_a?(::Monitor) rescue false) || (val.is_a?(::Mutex) rescue false)
279
+ Ractor.make_shareable(NoOpLock.new)
280
+ elsif !(val.respond_to?(:freeze) rescue false)
281
+ # BasicObject subclasses don't have #freeze/#respond_to? (Kernel not
282
+ # included). Replace with a frozen Symbol sentinel — it's compared
283
+ # with `equal?`, and a frozen Symbol is always shareable.
284
+ Ractor.make_shareable(:"__shim_unshareable_sentinel__")
285
+ else
286
+ begin
287
+ Ractor.make_shareable(val)
288
+ rescue => e
289
+ nil
290
+ end
291
+ end
292
+ end
293
+
294
+ # Split "A::B::C" into [A::B (module), :C]. Returns [nil, nil] if the
295
+ # parent isn't defined.
296
+ def split_const_path(path)
297
+ parts = path.split("::")
298
+ return [Object, parts.first.to_sym] if parts.size == 1
299
+ parent = parts[0...-1].inject(Object) { |ns, n| ns.const_get(n) } rescue nil
300
+ return [nil, nil] unless parent
301
+ [parent, parts.last.to_sym]
302
+ end
303
+
304
+ # Public API: run after Rails.application.initialize! and BEFORE spawning
305
+ # worker Ractors. Makes every registered constant shareable (deep-freeze).
306
+ # Constants that didn't exist at install time (e.g. Rails::Railtie, loaded
307
+ # after `module Rails` opens) get fixed here. Idempotent; safe to call
308
+ # multiple times. Must run in the main Ractor.
309
+ #
310
+ # NOTE: this does NOT build the framework-config shareable fallback. That
311
+ # step is folded into `make_app_shareable!` because some class_attribute /
312
+ # mattr_accessor values reference the app graph, and making them shareable
313
+ # must happen AFTER the app itself is already frozen (otherwise the app
314
+ # gets frozen prematurely and precompute/proc-replacement can't mutate
315
+ # it). If you call prepare_for_ractors! standalone (without
316
+ # make_app_shareable!), worker Ractors will see nil for framework config
317
+ # values that couldn't be shared without freezing the app — set them
318
+ # explicitly per worker, or use make_app_shareable!.
319
+ # Shared list of every framework patch install method. Both
320
+ # `prepare_for_ractors!` (pre-worker boot) and `make_app_shareable!`
321
+ # (post-boot, pre-freeze) call this, so the two no longer duplicate the
322
+ # literal `_install_*` list. Each `_install_*` is idempotent (guarded by
323
+ # its own @*_patched flag), so calling the full set at either point is a
324
+ # no-op for already-applied patches. Duplicates from the original
325
+ # prepare_for_ractors! list (_install_csrf_reset_patch,
326
+ # _install_messages_serializer_patch, _install_activerecord_delegation_patch)
327
+ # are collapsed here.
328
+ def _install_all_framework_patches
329
+ _install_rack_request_patch
330
+ _install_inflector_patch
331
+ _install_module_introspection_patch
332
+ _install_parameter_encoding_patch
333
+ _install_path_registry_patch
334
+ _install_action_view_resolver_patch
335
+ _install_action_view_partial_path_patch
336
+ _install_action_view_field_type_patch
337
+ _install_action_view_safe_join_patch
338
+ _install_abstract_controller_patch
339
+ _install_action_controller_controller_name_patch
340
+ _install_flash_helpers_patch
341
+ _install_csrf_reset_patch
342
+ _install_active_support_error_reporter_patch
343
+ _install_lookup_context_patch
344
+ _install_i18n_patch
345
+ _install_i18n_backend_patch
346
+ _install_i18n_interpolation_patch
347
+ _install_messages_serializer_patch
348
+ _install_template_handlers_patch
349
+ _install_execution_context_patch
350
+ _install_request_parameter_parsers_patch
351
+ _install_query_parser_patch
352
+ _install_rack_utils_patch
353
+ _install_log_subscriber_patch
354
+ _install_local_cache_patch
355
+ _install_reloader_patch
356
+ _install_exception_wrapper_patch
357
+ _install_action_dispatch_routing_patch
358
+ _install_action_dispatch_mounted_helpers_patch
359
+ _install_action_dispatch_http_url_patch
360
+ _install_journey_routes_patch
361
+ _install_warden_hooks_patch
362
+ _install_warden_strategies_patch
363
+ _install_devise_failure_app_patch
364
+ _install_activerecord_connection_handler_patch
365
+ _install_activerecord_configurations_patch
366
+ _install_activerecord_db_config_handlers_patch
367
+ _install_activerecord_query_transformers_patch
368
+ _install_activerecord_module_attrs_patch
369
+ _install_activerecord_deduplicable_patch
370
+ _install_activerecord_pool_config_patch
371
+ _install_activerecord_reaper_patch
372
+ _install_arel_visitor_dispatch_cache_patch
373
+ _install_arel_bind_block_patch
374
+ _install_activerecord_quoting_cache_patch
375
+ _install_activerecord_serialize_cast_value_patch
376
+ _install_activerecord_delegation_patch
377
+ _install_activerecord_primary_key_patch
378
+ _install_activerecord_query_constraints_patch
379
+ _install_activerecord_relation_delegate_cache_patch
380
+ _install_activerecord_model_classes_patch
381
+ _install_active_model_naming_patch
382
+ _install_active_record_core_patch
383
+ _install_active_record_inheritance_patch
384
+ _install_active_record_model_schema_patch
385
+ _install_activerecord_model_schema_patch
386
+ _install_openssl_digest_patch
387
+ _install_caching_key_generator_patch
388
+ _install_active_model_conversion_patch
389
+ _install_activerecord_find_by_cache_patch
390
+ _install_activerecord_migration_patch
391
+ _install_activerecord_transaction_callbacks_patch
392
+ _install_activerecord_query_logs_patch
393
+ _install_kaminari_config_patch
394
+ _install_propshaft_patch
395
+ _install_devise_url_helpers_patch
396
+ _install_devise_authenticatable_patch
397
+ _install_polymorphic_routes_patch
398
+ _install_orm_adapter_patch
399
+ _install_warden_serializer_patch
400
+ _install_json_encoding_patch
401
+ _install_active_model_attribute_patch
402
+ _install_hash_compute_if_absent_patch
403
+ end
404
+
405
+ def prepare_for_ractors!
406
+ do_install_shareable_constants
407
+ RactorRailsShim._freeze_shareable_class_ivars! if RactorRailsShim.respond_to?(:_freeze_shareable_class_ivars!)
408
+ snapshot_gem_paths!
409
+ snapshot_query_logs!
410
+ _install_all_framework_patches
411
+ install_url_helpers_patch
412
+ fix_url_helpers_singleton_routes
413
+ _warm_active_record_class_caches!
414
+ _freeze_active_record_class_ivars!
415
+ _freeze_global_class_ivars!
416
+ _freeze_global_constants!
417
+ _freeze_messages_constants!
418
+ end
419
+
420
+ # Verify the runtime matches the versions the shim was developed against.
421
+ # The shim's patches target specific Rails 8.1 class layouts and Ruby 4.0
422
+ # Ractor semantics. On other versions, the patches may silently miss or
423
+ # break things. Behavior on mismatch is governed by `version_policy`:
424
+ #
425
+ # :warn (default) print a warning to $stderr, proceed anyway
426
+ # :strict raise RactorRailsShim::UnsupportedVersionError
427
+ # :off silent (for advanced users / experimentation)
428
+ #
429
+ # Ruby mismatch always warns (Ractor semantics are not stable across
430
+ # majors); Rails mismatch uses the policy. This is real version detection
431
+ # (Gem::Version-based), not a string-prefix compare, so pre-release and
432
+ # patch versions sort correctly.
433
+ def _check_version_support
434
+ @version_policy ||= :warn
435
+ unless RactorRailsShim::Version.supported_ruby?
436
+ msg = "ractor-rails-shim: Ruby #{RUBY_VERSION} — shim developed " \
437
+ "against Ruby #{SUPPORTED_RUBY}. Ractor semantics may differ; " \
438
+ "the shim may break. Proceeding anyway."
439
+ _version_mismatch(msg)
440
+ end
441
+ if RactorRailsShim::Version.rails &&
442
+ !RactorRailsShim::Version.supported_rails?
443
+ rv = ::Rails::VERSION::STRING
444
+ msg = "ractor-rails-shim: Rails #{rv} — shim developed against " \
445
+ "Rails #{RactorRailsShim::Version::TESTED_RAILS.join(", ")}. " \
446
+ "Class layouts (class_attribute, callbacks, PathRegistry, etc.) " \
447
+ "may differ; patches may miss blockers. Proceeding anyway. " \
448
+ "Set RactorRailsShim.version_policy = :strict to make this " \
449
+ "fatal; :off to silence."
450
+ _version_mismatch(msg)
451
+ end
452
+ end
453
+
454
+ # Apply the configured policy to a mismatch message.
455
+ def _version_mismatch(message)
456
+ case version_policy
457
+ when :strict
458
+ raise UnsupportedVersionError, message
459
+ when :off
460
+ # silent
461
+ else
462
+ warn message
463
+ end
464
+ end
465
+
466
+ # Report which registered patches apply to the runtime Rails version
467
+ # (and which were skipped because they're untested on it). Useful for
468
+ # diagnostics and CI. Returns a Hash: { applied: [...], skipped: [...] }.
469
+ def applicable_patches
470
+ seg = RactorRailsShim::Version.rails_segment
471
+ applied = []
472
+ skipped = []
473
+ PATCH_VERSIONS.each do |name, tested|
474
+ if seg.nil? || tested.include?(seg)
475
+ applied << name
476
+ else
477
+ skipped << { name: name, tested: tested, runtime: seg }
478
+ end
479
+ end
480
+ { applied: applied, skipped: skipped }
481
+ end
482
+
483
+ # Record that a patch was developed/tested against the given Rails version
484
+ # segments. Called by each install_* method. This populates PATCH_VERSIONS
485
+ # so `applicable_patches` can report what applied to the runtime. To add
486
+ # support for a new version, add the segment here (after writing/testing
487
+ # the variant) — no other wiring needed.
488
+ def _register_patch(name, *tested_segments)
489
+ existing = PATCH_VERSIONS[name] || []
490
+ PATCH_VERSIONS[name] = (existing + tested_segments).uniq
491
+ end
492
+
493
+ # Capture a frozen name -> object map for every constant the application's
494
+ # Zeitwerk loaders manage. Runs in the MAIN Ractor, after eager load, where
495
+ # all app constants are defined. The map travels to worker Ractors, which
496
+ # use it to (re)bind the constant *names* into their own namespaces.
497
+ #
498
+ # Why this is needed: a Ractor boundary does NOT share top-level constant
499
+ # *names* — only the class/module *objects* reachable from the frozen shared
500
+ # app graph cross the boundary. A worker Ractor therefore sees
501
+ # `RactorRailsShim`, `ActiveRecord`, `ApplicationRecord`, the controllers,
502
+ # etc. (objects reachable from the app), but NOT the application's own
503
+ # model constants (e.g. `Post`): the object is in the graph, but its name
504
+ # is not bound in the worker, so `PostsController#index`'s `Post` reference
505
+ # raises NameError. Rebinding the captured names fixes it without
506
+ # re-running autoloading (which is itself impossible in a worker, since
507
+ # `Zeitwerk::Loader.new` raises IsolationError off the main Ractor).
508
+ def capture_app_constants
509
+ map = {}
510
+ return map unless defined?(::Rails) && Rails.respond_to?(:autoloaders)
511
+ [Rails.autoloaders.main, Rails.autoloaders.once].each do |loader|
512
+ next unless loader.respond_to?(:all_expected_cpaths)
513
+ begin
514
+ loader.all_expected_cpaths.values.each do |cpath|
515
+ obj = Object.const_get(cpath) rescue next
516
+ begin
517
+ Ractor.make_shareable(obj) unless Ractor.shareable?(obj)
518
+ rescue
519
+ next
520
+ end
521
+ map[cpath] = obj if Ractor.shareable?(obj)
522
+ end
523
+ rescue => e
524
+ warn "[ractor_rails_shim] capture_app_constants: #{e.class}: #{e.message}"
525
+ end
526
+ end
527
+ map.freeze
528
+ end
529
+
530
+ # Build the shareable Rack app handed to kino. Captures the application's
531
+ # constants in the main Ractor and wraps the frozen, shareable app in a
532
+ # WorkerApp that rebinds those constants (and initializes the worker's
533
+ # ActiveRecord connection) on the first request served by each worker
534
+ # Ractor. Returns a shareable WorkerApp instance.
535
+ def worker_app(frozen_app)
536
+ bindings = capture_app_constants
537
+ WorkerApp.new(frozen_app, bindings)
538
+ end
539
+
540
+ # See patches/active_model_attribute.rb. When the frozen `:ractor` graph is
541
+ # built, each model class's `_default_attributes` template (and the
542
+ # FromDatabase instances within it) is deep-frozen. `Attribute#dup_or_share`
543
+ # returns `self` for immutable column types, so a worker's NEW record would
544
+ # share a frozen Attribute and raise FrozenError on first read/write. This
545
+ # patch makes a frozen receiver yield a fresh, mutable Attribute so writes
546
+ # (POST/create) work in workers. No-op in normal (unfrozen) Rails.
547
+ def _install_active_model_attribute_patch
548
+ return @am_attribute_patched if defined?(@am_attribute_patched) && @am_attribute_patched
549
+ @am_attribute_patched = true
550
+ return unless defined?(::ActiveModel::Attribute)
551
+ ::ActiveModel::Attribute.include(::RactorRailsShim::ActiveModelAttributePatch)
552
+ if defined?(::ActiveModel::AttributeRegistration) &&
553
+ ::ActiveModel::AttributeRegistration.const_defined?(:ClassMethods)
554
+ ::ActiveModel::AttributeRegistration::ClassMethods.prepend(
555
+ ::RactorRailsShim::ActiveModelAttributeRegistrationPatch
556
+ )
557
+ end
558
+ if defined?(::ActiveRecord::Attributes) &&
559
+ ::ActiveRecord::Attributes.const_defined?(:ClassMethods)
560
+ ::ActiveRecord::Attributes::ClassMethods.prepend(
561
+ ::RactorRailsShim::ActiveRecordAttributesPatch
562
+ )
563
+ end
564
+ if defined?(::ActiveRecord::ModelSchema) &&
565
+ ::ActiveRecord::ModelSchema.const_defined?(:ClassMethods)
566
+ ::ActiveRecord::ModelSchema::ClassMethods.prepend(
567
+ ::RactorRailsShim::ActiveRecordModelSchemaPatch
568
+ )
569
+ end
570
+ end
571
+
572
+ # The shim's make_app_shareable! replaces Concurrent::Map instance variables
573
+ # (which are not Ractor-shareable) with plain Hashes so workers can read
574
+ # them. But Rails code calls Concurrent::Map#compute_if_absent on these
575
+ # caches (e.g. ActiveModel::AttributeMethods' attribute_method_patterns_
576
+ # cache). Plain Hash lacks that method, so we add a compatible definition.
577
+ # For a MUTABLE cache the shim freezes the replaced Hash (so it is shareable
578
+ # across workers); mutating it would raise FrozenError. So when the receiver
579
+ # is frozen we route the store to per-Ractor IES keyed by the Hash identity
580
+ # and the key — giving each worker its own cache entry without mutating the
581
+ # shared object. Semantics otherwise match Concurrent::Map.
582
+ def _install_hash_compute_if_absent_patch
583
+ return @hash_compute_if_absent_patched if defined?(@hash_compute_if_absent_patched) && @hash_compute_if_absent_patched
584
+ @hash_compute_if_absent_patched = true
585
+ return if ::Hash.method_defined?(:compute_if_absent)
586
+ ::Hash.prepend(Module.new do
587
+ def compute_if_absent(key)
588
+ if frozen?
589
+ ies_key = :"rrs_cia_#{object_id}_#{key}"
590
+ ActiveSupport::IsolatedExecutionState[ies_key] ||= yield(key)
591
+ elsif key?(key)
592
+ self[key]
593
+ else
594
+ self[key] = yield(key)
595
+ end
596
+ end
597
+ end)
598
+ end
599
+
600
+ # Freeze (make Ractor-shareable) every instance variable on every ActiveRecord
601
+ # model class in the MAIN Ractor, before the graph is frozen. Many AR model
602
+ # classes cache unshareable values in class-level ivars (@pending_attribute_
603
+ # modifications, @column_defaults, @symbol_column_to_string_name_hash,
604
+ # @yaml_encoder, @dangerous_attribute_methods, ...). A worker Ractor cannot
605
+ # read an unshareable class-ivar value (Ractor::IsolationError) nor set one.
606
+ # Freezing them in main (where setting is allowed) yields shareable values
607
+ # that workers read without writing. AR Type objects freeze cleanly, so this
608
+ # is behavior-preserving; per-request code never mutates model class ivars.
609
+ def _freeze_active_record_class_ivars!
610
+ return unless defined?(::ActiveRecord::Base)
611
+ models = [::ActiveRecord::Base] + (::ActiveRecord::Base.descendants rescue [])
612
+ models.each do |klass|
613
+ # NOTE: do NOT skip abstract classes (e.g. a primary_abstract_class
614
+ # ApplicationRecord). Workers recurse into them via
615
+ # apply_pending_attribute_modifications, so their class ivars must also
616
+ # be shareable.
617
+ klass.instance_variables.each do |ivar|
618
+ val = klass.instance_variable_get(ivar)
619
+ next if val.nil? || Ractor.shareable?(val)
620
+ begin
621
+ Ractor.make_shareable(val)
622
+ rescue
623
+ nil
624
+ end
625
+ end
626
+ end
627
+ end
628
+
629
+ # Freeze (make Ractor-shareable) unshareable class-level ivars on GLOBAL
630
+ # classes (Time/Date timezone caches, I18n locale caches, ...) in the MAIN
631
+ # Ractor, before the graph is frozen. Unlike model classes, these are shared
632
+ # singletons whose class ivars (e.g. Time's @zone_default / @zone_cache) hold
633
+ # unshareable values that a worker Ractor would otherwise fail to read
634
+ # (Ractor::IsolationError). Freezing them in main yields shareable values.
635
+ def _freeze_global_class_ivars!
636
+ classes = %w[Time Date DateTime I18n].filter_map { |n| Object.const_get(n) rescue nil }
637
+ classes.each do |klass|
638
+ klass.instance_variables.each do |ivar|
639
+ val = klass.instance_variable_get(ivar)
640
+ next if val.nil? || Ractor.shareable?(val)
641
+ begin
642
+ Ractor.make_shareable(val)
643
+ rescue
644
+ nil
645
+ end
646
+ end
647
+ end
648
+ end
649
+
650
+ # Replace GLOBAL constants that hold non-shareable values (e.g.
651
+ # Time/Date/DateTime::DATE_FORMATS contain Proc values) with frozen,
652
+ # shareable equivalents so worker Ractors can read them. Proc-valued format
653
+ # entries are dropped (to_fs falls back to to_s for those formats). This is
654
+ # done in the MAIN Ractor, where const_set is allowed.
655
+ def _freeze_global_constants!
656
+ constants = %w[Time Date DateTime].filter_map do |n|
657
+ mod = Object.const_get(n) rescue nil
658
+ mod.is_a?(Module) ? [mod, :DATE_FORMATS] : nil
659
+ end
660
+ constants.each do |mod, name|
661
+ next unless mod.const_defined?(name, false)
662
+ val = mod.const_get(name, false)
663
+ next if Ractor.shareable?(val)
664
+ shareable = if val.is_a?(Hash)
665
+ h = {}
666
+ val.each { |k, v| h[k] = v if Ractor.shareable?(v) }
667
+ h.freeze
668
+ elsif val.is_a?(Array)
669
+ val.select { |v| Ractor.shareable?(v) }.freeze
670
+ else
671
+ val
672
+ end
673
+ begin
674
+ mod.const_set(name, shareable)
675
+ rescue
676
+ nil
677
+ end
678
+ end
679
+ end
680
+
681
+ # ActiveSupport::Messages::Metadata holds non-shareable Array constants
682
+ # (ENVELOPE_SERIALIZERS / TIMESTAMP_SERIALIZERS) of serializer Modules, used
683
+ # by MessageEncryptor during flash/session cookie serialization. A worker
684
+ # Ractor reading these constants (e.g. on `redirect_to`, which encrypts a
685
+ # flash message) raises Ractor::IsolationError. The Arrays are shareable once
686
+ # frozen (their elements are Modules), so deep-freeze and const_set the
687
+ # shareable copy back so workers read a shareable constant.
688
+ def _freeze_messages_constants!
689
+ # Load ActiveSupport::MessagePack in the MAIN Ractor FIRST. metadata.rb
690
+ # registers an `ActiveSupport.on_load(:message_pack)` callback that mutates
691
+ # ENVELOPE_SERIALIZERS / TIMESTAMP_SERIALIZERS via `<<`. If that callback
692
+ # first fires inside a worker Ractor (which happens the first time a
693
+ # cookie's `detect_format` probes MessagePackWithFallback.dumped?, because
694
+ # it lazily requires "active_support/message_pack"), it runs against the
695
+ # already-frozen arrays below and raises
696
+ # FrozenError: can't modify frozen Array
697
+ # Loading here makes the callback fire once, in main, against the
698
+ # non-frozen arrays; load hooks never fire again in workers.
699
+ # Gem::LoadError is a ScriptError (not StandardError), so a bare
700
+ # `rescue nil` on the require would NOT catch a missing msgpack gem.
701
+ begin
702
+ require "active_support/message_pack"
703
+ rescue LoadError
704
+ return
705
+ end
706
+
707
+ mod = (Object.const_get(:ActiveSupport) rescue nil)&.const_get(:Messages, false) rescue nil
708
+ mod = mod&.const_get(:Metadata, false) rescue nil
709
+ return unless mod.is_a?(Module)
710
+ %i[ENVELOPE_SERIALIZERS TIMESTAMP_SERIALIZERS].each do |name|
711
+ next unless mod.const_defined?(name, false)
712
+ val = mod.const_get(name, false)
713
+ next if Ractor.shareable?(val)
714
+ shareable = Ractor.make_shareable(val) rescue val
715
+ begin
716
+ mod.const_set(name, shareable)
717
+ rescue
718
+ nil
719
+ end
720
+ end
721
+ end
722
+
723
+ # Warm ActiveRecord model classes' lazily-computed, shareable class-ivar
724
+ # memoizations in the MAIN Ractor, BEFORE the graph is frozen. Methods like
725
+ # the timestamp_attribute_* helpers cache frozen Arrays of strings (shareable
726
+ # once warmed), so pre-populating them here lets workers read via `||=`
727
+ # without ever setting the class ivar. (Class ivars holding unshareable
728
+ # values are handled by _freeze_active_record_class_ivars!.)
729
+ def _warm_active_record_class_caches!
730
+ return unless defined?(::ActiveRecord::Base)
731
+ models = [::ActiveRecord::Base] + (::ActiveRecord::Base.descendants rescue [])
732
+ warmers = %i[
733
+ timestamp_attributes_for_create_in_model
734
+ timestamp_attributes_for_update_in_model
735
+ all_timestamp_attributes_in_model
736
+ sequence_name
737
+ columns
738
+ column_names
739
+ attribute_names
740
+ column_defaults
741
+ symbol_column_to_string_name_hash
742
+ returning_columns_for_insert
743
+ yaml_encoder
744
+ attribute_types
745
+ ]
746
+ models.each do |klass|
747
+ next if klass.abstract_class?
748
+ warmers.each do |m|
749
+ next unless klass.respond_to?(m, true)
750
+ begin
751
+ klass.send(m)
752
+ rescue
753
+ nil
754
+ end
755
+ end
756
+ end
757
+ end
758
+ end
759
+ # A shareable Rack wrapper that performs per-worker initialization lazily,
760
+ # inside the worker Ractor's request path (kino's :ractor mode has no
761
+ # per-worker init hook). On the first request served by a worker it:
762
+ #
763
+ # 1. rebinds the captured application constants into that worker's
764
+ # namespace (so bare `Post` etc. resolve), then
765
+ # 2. ensures the worker's ActiveRecord connection handler is initialized.
766
+ #
767
+ # The wrapper holds only shareable state (@app, @bindings), so the instance
768
+ # is Ractor.make_shareable. `Ractor.current` provides per-worker storage for
769
+ # the one-time guard, avoiding any top-level constant reference.
770
+ class WorkerApp
771
+ def initialize(app, bindings)
772
+ @app = app
773
+ @bindings = bindings
774
+ end
775
+
776
+ def call(env)
777
+ setup_once!
778
+ @app.call(env)
779
+ end
780
+
781
+ private
782
+
783
+ def setup_once!
784
+ return if Ractor.current[:rrs_worker_ready]
785
+ rebind_constants
786
+ RactorRailsShim.init_worker_ar_connections! if defined?(RactorRailsShim)
787
+ Ractor.current[:rrs_worker_ready] = true
788
+ end
789
+
790
+ def rebind_constants
791
+ @bindings.each do |cpath, obj|
792
+ parent = Object
793
+ parts = cpath.split("::")
794
+ parts[0...-1].each do |p|
795
+ parent = if parent.const_defined?(p, false)
796
+ parent.const_get(p, false)
797
+ else
798
+ parent.const_set(p, Module.new)
799
+ end
800
+ end
801
+ leaf = parts.last
802
+ parent.const_set(leaf, obj) unless parent.const_defined?(leaf, false)
803
+ end
804
+ end
805
+ end
806
+ end