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,853 @@
1
+ # frozen_string_literal: true
2
+
3
+ # make_app_shareable! infrastructure: callable/lock replacement classes,
4
+ # graph traversal (collect procs, replace locks/maps), shareable fallback
5
+ # builder, and the main make_app_shareable! entry point.
6
+
7
+ module RactorRailsShim
8
+ class << self
9
+ # Devise defines several mutable module-level constants (Array/Hashes
10
+ # populated at load time: mappings, strategies, url helpers, no_input
11
+ # strategies). Worker Ractors read them (e.g. Devise::NO_INPUT in
12
+ # mapping.rb), so they must be deep-frozen + made shareable before the
13
+ # app graph is frozen. Added here; make_constant_shareable resolves each
14
+ # lazily once Devise is loaded.
15
+ SHAREABLE_CONSTANTS.concat([
16
+ "Devise::ALL",
17
+ "Devise::CONTROLLERS",
18
+ "Devise::ROUTES",
19
+ "Devise::STRATEGIES",
20
+ "Devise::URL_HELPERS",
21
+ "Devise::NO_INPUT",
22
+ ])
23
+
24
+ # Class instance variables holding unshareable values that workers read
25
+ # during request dispatch. Made Ractor-shareable (deep-frozen) at boot.
26
+ SHAREABLE_CLASS_IVARS.concat([
27
+ ["ActiveSupport::Editor", :@editors],
28
+ ["Warden::Strategies", :@strategies],
29
+ ])
30
+
31
+ # Public API: make Rails.application shareable across Ractors. Replaces
32
+ # every self-capturing Proc in the app graph with a callable object (no
33
+ # captured binding), every Mutex/Monitor with a NoOpLock, and every
34
+ # Concurrent::Map with a frozen Hash, then calls Ractor.make_shareable.
35
+ # After this, `Ractor.new(app) { |a| a.call(env) }` works from worker
36
+ # Ractors. Must run in the main Ractor after prepare_for_ractors! and
37
+ # before spawning workers.
38
+ #
39
+ # WARNING: this MUTATES the app object graph in place (replaces ivars).
40
+ # The app becomes read-only (frozen). Do NOT call if you intend to keep
41
+ # mutating the app (e.g. development reloading). Production-only.
42
+ #
43
+ # Returns the shareable app. Raises on failure (e.g. if a Proc can't be
44
+ # replaced — add the missing constant to shareable_constants first).
45
+ def make_app_shareable!(app = Rails.application)
46
+ # Shareable constants + Rack::Request + Inflector + ParameterEncoding +
47
+ # PathRegistry + AbstractController + error_reporter + LookupContext +
48
+ # I18n + Template::Handlers + ExecutionContext + Request param parsers.
49
+ do_install_shareable_constants unless @shareable_constants_done
50
+ # Install (or re-run, idempotently) the full framework-patch set. Most
51
+ # are already applied by prepare_for_ractors!; this guarantees every
52
+ # patch is present after full boot even if prepare_for_ractors! ran
53
+ # before some classes were loaded.
54
+ _install_all_framework_patches
55
+ # Pre-compute lazy ivars BEFORE freezing (they mutate the app).
56
+ _precompute_lazy_ivars(app)
57
+ _precompute_propshaft!(app)
58
+ # Force ActiveRecord attribute-method generation in the MAIN Ractor for
59
+ # every loaded model. AR defines these lazily on first instantiation; if
60
+ # left undone, a worker Ractor's first `Post.new` / record load re-enters
61
+ # `define_attribute_methods`, which locks
62
+ # `GeneratedAttributeMethods::LOCK` — a `Monitor` created in the main
63
+ # Ractor and therefore non-shareable — raising Ractor::IsolationError.
64
+ # Generating here (where the Monitor is reachable) sets
65
+ # `@attribute_methods_generated = true` on the shared, frozen classes so
66
+ # workers skip the lock entirely.
67
+ _generate_ar_attribute_methods!
68
+ # Warm + freeze ActiveModel's per-class `attribute_method_patterns_cache`
69
+ # (and `attribute_method_matchers`) in MAIN for every loaded model. See
70
+ # `_warm_attribute_method_patterns!` for why: a worker Ractor reading these
71
+ # lazy class ivars (Array of [Regexp, Symbol], but mutable => unshareable)
72
+ # during `redirect_to @post` -> `respond_to?` raises Ractor::IsolationError.
73
+ _warm_attribute_method_patterns!
74
+ # Capture each controller's OWN declared `process_action` symbol filters
75
+ # (before_action / after_action) into a shareable table so worker
76
+ # Ractors can replay them. The shim routes class_attribute-backed
77
+ # `__callbacks` through IES and seeds workers with the empty default, so
78
+ # controller filters do NOT run in workers by default (see
79
+ # execution_wrapper.rb run_callbacks patch). For GET requests that depend
80
+ # on a before_action (e.g. `set_post` loading `@post`), that breaks
81
+ # rendering. We freeze the declared-filter table captured during eager
82
+ # load (in main, before freeze) and the patched run_callbacks replays
83
+ # them per controller.
84
+ _freeze_declared_callbacks!
85
+ # Warm + cache the routes' @ast / @simulator on the live graph. This MUST
86
+ # run AFTER the route precompute above (which reloads/resets the routes)
87
+ # and BEFORE _replace_unshareable_procs! / Ractor.make_shareable below:
88
+ # the proc-replacement pass rewrites the Route constraint Procs held in
89
+ # the simulator's @memos, and the freeze then shares the whole thing so
90
+ # worker Ractors read the cached, frozen simulator via the original
91
+ # Routes#simulator (no per-worker rebuild). See action_dispatch.rb.
92
+ _freeze_shareable_class_ivars!
93
+ _warm_journey_routes!
94
+ # Neutralize the app's logger IO so Ractor.make_shareable doesn't freeze
95
+ # $stdout/$stderr (freezing STDOUT breaks the process's own output).
96
+ # Workers build their own per-Ractor Rails.logger, so the app-instance
97
+ # logger is unused post-freeze; redirect its logdev to a fresh StringIO
98
+ # sink (which is safely freezable).
99
+ _neutralize_logger_io!(app)
100
+ _replace_unshareable_procs!(app)
101
+ _replace_locks_and_concurrent_maps!(app)
102
+ Ractor.make_shareable(app)
103
+ # Stash the now-shareable app in a constant so worker Ractors can read
104
+ # `Rails.application` (e.g. Propshaft::Helper reads
105
+ # `Rails.application.assets`, and various gems call Rails.application
106
+ # internally). The shared app is frozen (read-only), so returning it
107
+ # from worker Ractors is safe — they only read from it, never mutate.
108
+ if Ractor.main?
109
+ verbose, $VERBOSE = $VERBOSE, nil
110
+ begin
111
+ const_set(:SHAREABLE_APP, app) unless const_defined?(:SHAREABLE_APP)
112
+ ensure
113
+ $VERBOSE = verbose
114
+ end
115
+ end
116
+ # Build the framework-config fallback AFTER the app is frozen. The
117
+ # fallback makes class_attribute / mattr_accessor values shareable; some
118
+ # of those values reference the app graph (e.g. config objects that point
119
+ # back at Rails.application). Doing this after the app is already
120
+ # shareable means Ractor.make_shareable on the config values is a no-op
121
+ # for the app portion (already frozen) — avoiding a "can't modify frozen
122
+ # app" error when precompute wrote to it. (prepare_for_ractors!, which
123
+ # also builds the fallback, is a no-op now via @fallback_built.)
124
+ _build_shareable_fallback!
125
+ app
126
+ end
127
+
128
+ # Detach the logger IO from the app graph so Ractor.make_shareable(app)
129
+ # doesn't freeze the process's real $stdout/$stderr. The app-instance
130
+ # logger (app.config.logger) holds an IO in its logdev; freezing it would
131
+ # silence main-ractor logging and break minitest/server output.
132
+ #
133
+ # Strategy: replace app.config.logger (and any broadcast target reachable
134
+ # from the app) with a frozen, shareable no-op BroadcastLogger (no IO).
135
+ # Then re-point the MAIN ractor's Rails.logger (the per-Ractor module
136
+ # accessor, NOT in the app graph) at a fresh live BroadcastLogger writing
137
+ # to $stderr — which is NOT reachable from the frozen app, so it stays
138
+ # mutable. Workers already build their own per-Ractor Rails.logger in the
139
+ # patched reader, so they're unaffected.
140
+ def _neutralize_logger_io!(app)
141
+ # A frozen, shareable no-op BroadcastLogger (no broadcasts → no IO) to
142
+ # swap in for the app-instance logger graph.
143
+ noop_logger = ::ActiveSupport::BroadcastLogger.new
144
+ noop_logger.freeze
145
+ Ractor.make_shareable(noop_logger)
146
+
147
+ # Replace the app-instance logger + any IO reachable from the app graph.
148
+ seen = {}
149
+ stack = [app]
150
+ until stack.empty?
151
+ o = stack.pop
152
+ next if o.equal?(nil) || seen[o.object_id]
153
+ seen[o.object_id] = true
154
+ begin
155
+ o.instance_variables.each do |iv|
156
+ begin; v = o.instance_variable_get(iv); rescue; next; end
157
+ if iv == :@logger
158
+ # Replace the app-instance / config logger with the no-op (so the
159
+ # frozen app graph holds no live IO). Best-effort; rescue if the
160
+ # owner is frozen.
161
+ o.instance_variable_set(iv, noop_logger) rescue nil
162
+ elsif v.is_a?(::IO) && (v == $stdout || v == $stderr || v == STDOUT || v == STDERR)
163
+ # Any stray IO reference → a shareable no-op sink.
164
+ sink = NoOpLogDev.new
165
+ sink.freeze
166
+ Ractor.make_shareable(sink)
167
+ o.instance_variable_set(iv, sink) rescue nil
168
+ elsif v
169
+ stack << v
170
+ end
171
+ end
172
+ rescue => e
173
+ # BasicObject or frozen objects don't support instance_variables
174
+ end
175
+ if o.is_a?(Array); o.each { |e| stack << e if e }
176
+ elsif o.is_a?(Hash); o.each { |_, val| stack << val if val }
177
+ end
178
+ end
179
+
180
+ # Re-point the MAIN ractor's Rails.logger at a fresh live logger (not
181
+ # reachable from the frozen app) so main keeps logging after the app is
182
+ # made shareable. $stderr is each-Ractor-local and not in the app graph,
183
+ # so it stays mutable. Use the same shape Rails uses (BroadcastLogger
184
+ # broadcasting to a Logger writing $stderr).
185
+ if Ractor.main? && defined?(::Rails)
186
+ live = ::ActiveSupport::BroadcastLogger.new(::Logger.new($stderr))
187
+ ::Rails.logger = live
188
+ end
189
+ end
190
+
191
+ # --- shareable fallback for framework class config ---
192
+
193
+ # Build the shareable fallback for every class_attribute / mattr_accessor
194
+ # value the shim has rerouted. For each registered attribute we:
195
+ # 1. Read the main-ractor value (from its IES slot, which `redefine`
196
+ # seeded at class_attribute-definition time).
197
+ # 2. Make it shareable (deep-freeze + callable-replacement for any Procs
198
+ # it holds — same technique as make_app_shareable!, applied to the
199
+ # config sub-graph).
200
+ # 3. Store under the IES key in a frozen Hash on RactorRailsShim, which
201
+ # is readable from every Ractor (it's a constant).
202
+ # Workers' class_attribute readers fall back to this when their own IES
203
+ # slot is nil. Must run in the main Ractor. Idempotent.
204
+ def _build_shareable_fallback!
205
+ return if @fallback_built
206
+ @fallback_built = true
207
+
208
+ fallback = {}
209
+ CLASS_ATTRIBUTES.each do |(owner_name, attr_name, ies_key, default_val)|
210
+ # Skip the Rails logger — it's intrinsically unshareable (IO + Mutex +
211
+ # mutable formatter) and workers build their own per-Ractor logger
212
+ # via the patched reader. Trying to make it shareable would freeze the
213
+ # IO, breaking logging in main too.
214
+ next if owner_name == "Rails" && attr_name == :logger
215
+ val = ActiveSupport::IsolatedExecutionState[ies_key]
216
+ # For class_attribute values whose IES slot was never written but
217
+ # whose definition-time DEFAULT was mutated in place during boot
218
+ # (e.g. AbstractController::Base's `config`, whose default
219
+ # ActiveSupport::OrderedOptions is filled with the real nested config
220
+ # by railties), the live value lives in the main-Ractor
221
+ # CLASS_ATTR_VALUES store, NOT in IES. Read it there so workers get
222
+ # the real value instead of the empty definition-time default.
223
+ if val.nil? && Ractor.main?
224
+ val = RactorRailsShim::CLASS_ATTR_VALUES[ies_key]
225
+ end
226
+ # For mattr_accessor: the value may have been written to @@sym after
227
+ # define-time (e.g. by an initializer). Read it from there if the IES
228
+ # slot is nil (the seed only set the default; the live value may differ).
229
+ if val.nil? && owner_name && attr_name.is_a?(Symbol)
230
+ begin
231
+ owner_mod = owner_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil
232
+ if owner_mod && owner_mod.class_variable_defined?("@@#{attr_name}")
233
+ val = owner_mod.class_variable_get("@@#{attr_name}")
234
+ end
235
+ rescue => e
236
+ # ignore — best-effort read
237
+ end
238
+ end
239
+ # For raw class ivars (PathRegistry, etc.): read @<attr_name> in main.
240
+ if val.nil? && owner_name && attr_name.is_a?(Symbol)
241
+ begin
242
+ owner_mod = owner_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil
243
+ if owner_mod && owner_mod.instance_variable_defined?("@#{attr_name}")
244
+ val = owner_mod.instance_variable_get("@#{attr_name}")
245
+ end
246
+ rescue => e
247
+ # ignore — best-effort read
248
+ end
249
+ end
250
+ # For the Rails module accessors (owner_name == "Rails"): the value
251
+ # may live in the @ivar (set by Rails' own writer via super, or
252
+ # lazy-init'd by Rails' own reader) rather than in IES. Read it via
253
+ # the actual accessor in main, which materializes the lazy-init value.
254
+ if val.nil? && owner_name == "Rails" && defined?(::Rails)
255
+ begin
256
+ val = ::Rails.public_send(attr_name) if ::Rails.respond_to?(attr_name, false)
257
+ rescue => e
258
+ # ignore — best-effort read
259
+ end
260
+ end
261
+
262
+ shareable_val = nil
263
+ # Try the live value first.
264
+ if !val.nil?
265
+ shareable_val = _try_make_shareable(val, owner_name, attr_name)
266
+ end
267
+ # If the live value couldn't be shared (e.g. __callbacks holds
268
+ # self-capturing Procs), fall back to the definition-time default.
269
+ # For a frozen shared app this is correct: boot-time callbacks already
270
+ # ran in main; workers treat them as already-run (empty/no-op). The
271
+ # default is dup'd if it's a mutable container (Hash/Array) so each
272
+ # entry in the fallback is an independent shareable copy.
273
+ if shareable_val.nil? && !default_val.nil?
274
+ shareable_val = _try_make_shareable(_shareable_copy(default_val), owner_name, attr_name, default: true)
275
+ end
276
+
277
+ fallback[ies_key] = shareable_val if shareable_val
278
+ end
279
+ fallback.freeze
280
+ Ractor.make_shareable(fallback)
281
+
282
+ # Make the shareable mattr-defaults subset shareable too (workers read
283
+ # it via the constant). Frozen + reassigned via const_set.
284
+ SHAREABLE_MATTR_DEFAULTS.freeze
285
+ Ractor.make_shareable(SHAREABLE_MATTR_DEFAULTS)
286
+
287
+ # Reassign the constants with the built (shareable) tables. const_set
288
+ # warns "already initialized constant" — silence that one warning.
289
+ verbose, $VERBOSE = $VERBOSE, nil
290
+ begin
291
+ const_set(:SHAREABLE_FALLBACK, fallback)
292
+ const_set(:SHAREABLE_MATTR_DEFAULTS, SHAREABLE_MATTR_DEFAULTS)
293
+ ensure
294
+ $VERBOSE = verbose
295
+ end
296
+ fallback
297
+ end
298
+
299
+ # Best-effort attempt to make `val` shareable (callable-replacement for
300
+ # Procs + lock-replacement + make_shareable). Returns the shareable val,
301
+ # or nil if it can't be made shareable. On failure, emits a warning
302
+ # (unless `default:` — defaults are expected to sometimes be unshareable,
303
+ # so we skip the noise).
304
+ def _try_make_shareable(val, owner_name, attr_name, default: false)
305
+ # __callbacks and validators hold callback chains / validator instances
306
+ # with self-capturing Procs that can NEVER be made shareable. This is
307
+ # expected: workers correctly treat callbacks as already-run (the
308
+ # nil-safe run_callbacks patch yields the block directly). Skip the
309
+ # attempt entirely — don't waste cycles traversing the graph, and don't
310
+ # emit warnings for known-expected failures.
311
+ attr_sym = attr_name.to_s
312
+ return nil if attr_sym.end_with?("__callbacks") ||
313
+ attr_sym.end_with?("__validators") ||
314
+ attr_sym.end_with?("default_connection_handler")
315
+
316
+ begin
317
+ _replace_unshareable_procs!(val)
318
+ _replace_locks_and_concurrent_maps!(val)
319
+ Ractor.make_shareable(val)
320
+ val
321
+ rescue => e
322
+ unless default
323
+ warn "ractor-rails-shim: could not make attribute " \
324
+ "#{owner_name}##{attr_name} shareable (#{e.class}: #{e.message[0,80]}); workers will fall back to default or nil"
325
+ end
326
+ nil
327
+ end
328
+ end
329
+
330
+ # Return a fresh copy of a mutable default container (Hash/Array) so the
331
+ # fallback entry is independent. Frozen/shareable defaults pass through.
332
+ def _shareable_copy(val)
333
+ case val
334
+ when Hash then val.dup
335
+ when Array then val.dup
336
+ else val
337
+ end
338
+ end
339
+
340
+ # --- callable / lock replacement classes ---
341
+ # Defined via string eval on the singleton class so they're referenced
342
+ # the same way the original code did (specs access via
343
+ # RactorRailsShim.singleton_class.const_get).
344
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
345
+ class NoOpProc
346
+ def call(*_); nil; end
347
+ # A NoOpProc is a shareable stand-in for an arbitrary Proc in the app
348
+ # graph. Some Rails code passes such values through `&block`, which
349
+ # calls `#to_proc` and then requires the result to be a real Proc.
350
+ # Return a frozen no-op lambda so the implicit conversion succeeds and
351
+ # the (side-effect-free) call is a true no-op, matching `#call`.
352
+ def to_proc
353
+ @_to_proc ||= ->(*) { nil }.freeze
354
+ end
355
+ end
356
+ class Callable
357
+ def initialize(target, method_name)
358
+ @target = target
359
+ @method_name = method_name
360
+ end
361
+ def call(*args)
362
+ @target.__send__(@method_name, *args)
363
+ end
364
+ end
365
+ class CallableConst
366
+ def initialize(value); @value = value; end
367
+ def call(*_); @value; end
368
+ end
369
+ # Shareable snapshot of a Devise::Mapping. The real Mapping holds an
370
+ # unshareable lambda (failure_app) plus a default-proc Hash (controllers),
371
+ # so it can't be Ractor.make_shareable'd. Request-time code only reads a
372
+ # handful of attributes (name, to/class, router_name, controllers, ...),
373
+ # which are all shareable values. We copy those now (in main) into a
374
+ # frozen Plain Old Object that plays the role of the Mapping in workers.
375
+ class DeviseMappingSnapshot
376
+ def initialize(mapping)
377
+ @name = mapping.name
378
+ @klass = mapping.to
379
+ @router_name = mapping.instance_variable_get(:@router_name)
380
+ @singular = mapping.instance_variable_get(:@singular)
381
+ @scoped_path = mapping.instance_variable_get(:@scoped_path)
382
+ @path = mapping.instance_variable_get(:@path)
383
+ @path_prefix = mapping.instance_variable_get(:@path_prefix)
384
+ @format = mapping.instance_variable_get(:@format)
385
+ @sign_out_via = mapping.instance_variable_get(:@sign_out_via)
386
+ @modules = mapping.modules
387
+ @strategies = mapping.strategies
388
+ @routes = mapping.routes
389
+ @used_helpers = mapping.used_helpers
390
+ # controllers is a Hash with a default proc (unshareable) — copy the
391
+ # entries into a plain frozen Hash.
392
+ h = {}
393
+ mapping.controllers.each { |k, v| h[k] = v } rescue nil
394
+ @controllers = h.freeze
395
+ # failure_app is either Devise::FailureApp (a shareable class) or a
396
+ # lambda (when configured as a String) — keep only the shareable class.
397
+ fa = mapping.instance_variable_get(:@failure_app)
398
+ fa = ::Devise::FailureApp unless fa.is_a?(Class)
399
+ @failure_app = fa
400
+ freeze
401
+ end
402
+
403
+ def name; @name; end
404
+ def to; @klass; end
405
+ def router_name; @router_name; end
406
+ def singular; @singular; end
407
+ def scoped_path; @scoped_path; end
408
+ def path; @path; end
409
+ def path_prefix; @path_prefix; end
410
+ def format; @format; end
411
+ def sign_out_via; @sign_out_via; end
412
+ def modules; @modules; end
413
+ def strategies; @strategies; end
414
+ def routes; @routes; end
415
+ def used_helpers; @used_helpers; end
416
+ def controllers; @controllers; end
417
+ def failure_app; @failure_app; end
418
+ def authenticatable?; @modules.any? { |m| m.to_s =~ /authenticatable/ }; end
419
+ def no_input_strategies; @strategies & Devise::NO_INPUT; end
420
+ def fullpath; "/#{@path_prefix}/#{@path}".squeeze("/"); end
421
+ # Devise::Mapping defines one `x?` predicate per Devise module
422
+ # (confirmable?, rememberable?, registerable?, ...) via `add_module`.
423
+ # Rather than enumerate them, fall back for any `x?` predicate to
424
+ # checking @modules — matching the generated behaviour.
425
+ def respond_to_missing?(method, _)
426
+ method.to_s.end_with?("?") || super
427
+ end
428
+
429
+ def method_missing(method, *args)
430
+ s = method.to_s
431
+ if s.end_with?("?") && args.empty?
432
+ @modules.include?(s.chomp("?").to_sym)
433
+ else
434
+ super
435
+ end
436
+ end
437
+ end
438
+
439
+ def _devise_mapping_snapshot(mapping)
440
+ DeviseMappingSnapshot.new(mapping)
441
+ rescue
442
+ nil
443
+ end
444
+ # StrategyServe / StrategyCall moved to action_dispatch.rb (ActionDispatch
445
+ # routing mapper strategy procs).
446
+ class NoOpLock
447
+ def synchronize; yield; end
448
+ def mon_synchronize; yield; end
449
+ def lock; self; end
450
+ def unlock; self; end
451
+ def locked?; false; end
452
+ def mon_enter; end
453
+ def mon_exit; end
454
+ def mon_locked?; false; end
455
+ def try_lock; true; end
456
+ def new_cond; Struct.new(:wait, :signal, :broadcast).new(-> {}, -> {}, -> {}); end
457
+ end
458
+ # NOTE: StrategyServe / StrategyCall (ActionDispatch::Routing::Mapper
459
+ # strategy procs) and RequestCallable (CookieStore) are now defined in
460
+ # action_dispatch.rb. DeviseMappingCallable + _devise_mapping_replacement
461
+ # are in warden.rb; FILES_LOC + _find_files_server are in rack.rb. The
462
+ # source-location constants (SSL_LOC / COOKIE_LOC / MAPPER_LOC /
463
+ # DEVISE_SCOPE_LOC / FILES_LOC) live alongside their callables.
464
+ # No-op log device sink: a frozen, shareable stand-in for an IO, swapped
465
+ # in for $stdout/$stderr in the app's logger before make_shareable so
466
+ # the real IOs aren't frozen. Responds to the write methods a
467
+ # Logger::LogDevice might call.
468
+ class NoOpLogDev
469
+ def write(*_); self; end
470
+ def <<(*_); self; end
471
+ def puts(*_); self; end
472
+ def print(*_); self; end
473
+ def flush; self; end
474
+ def close; self; end
475
+ def sync=(*_); self; end
476
+ def binmode; self; end
477
+ def tty?; false; end
478
+ def closed?; false; end
479
+ end
480
+ RUBY
481
+
482
+ # --- graph traversal helpers ---
483
+
484
+ def _precompute_lazy_ivars(app)
485
+ app.env_config
486
+ app.app_env_config rescue nil
487
+ app.routes.url_helpers rescue nil
488
+ app.routes.named_routes rescue nil
489
+ app.routes.helpers rescue nil
490
+ end
491
+
492
+ # Force AR attribute-method generation for every loaded model in the MAIN
493
+ # Ractor. See the call site in make_app_shareable! for why; without this a
494
+ # worker Ractor dies with Ractor::IsolationError on the first model
495
+ # instantiation (GeneratedAttributeMethods::LOCK is a non-shareable Monitor).
496
+ def _generate_ar_attribute_methods!
497
+ return unless defined?(::ActiveRecord::Base)
498
+ ::ActiveRecord::Base.descendants.each do |klass|
499
+ next unless klass.respond_to?(:define_attribute_methods)
500
+ klass.define_attribute_methods
501
+ rescue StandardError
502
+ nil
503
+ end
504
+ end
505
+
506
+ # Build + freeze ActiveModel's per-class `attribute_method_patterns_cache`
507
+ # (and `attribute_method_matchers`) in the MAIN Ractor for every loaded
508
+ # model. These are lazy class ivars populated on the first `respond_to?`
509
+ # call; they hold an Array of `[Regexp, Symbol]` pairs (shareable elements)
510
+ # but the Array itself is mutable and therefore NOT Ractor-shareable. A
511
+ # worker Ractor reading the ivar raises
512
+ # `Ractor::IsolationError: can not get unshareable values from instance
513
+ # variables of classes/modules`. `redirect_to @post` calls
514
+ # `Post#respond_to?(:to_model)` in the worker, which reads this cache, so
515
+ # the write-path 302 redirect dies. Building it in MAIN (where it is
516
+ # reachable) and freezing the Array makes it shareable; the cache is never
517
+ # mutated after build (`attribute_method_patterns_matching` only does
518
+ # `.select` on it), so freezing is safe.
519
+ def _warm_attribute_method_patterns!
520
+ return unless defined?(::ActiveRecord::Base)
521
+ ::ActiveRecord::Base.descendants.each do |klass|
522
+ next unless klass.respond_to?(:attribute_method_patterns_cache, true)
523
+ begin
524
+ cache = klass.send(:attribute_method_patterns_cache)
525
+ cache.freeze if cache
526
+ if klass.respond_to?(:attribute_method_matchers, true)
527
+ matchers = klass.send(:attribute_method_matchers)
528
+ matchers.freeze if matchers
529
+ end
530
+ rescue StandardError
531
+ nil
532
+ end
533
+ end
534
+ end
535
+
536
+ # Replace every Proc in the app graph with a callable/no-op object.
537
+ # Multiple passes because the same Proc object can live in many
538
+ # containers (e.g. deprecation behaviors shared across deprecators).
539
+ # Doesn't dedup procs — must replace every occurrence.
540
+ def _replace_unshareable_procs!(app)
541
+ mw = (app.instance_variable_get(:@app) rescue nil)
542
+ 3.times do
543
+ procs = _collect_procs(app)
544
+ break if procs.empty?
545
+ procs.each { |proc_obj, _path, parent, ivar| _replace_one_proc(proc_obj, parent, ivar, mw) }
546
+ end
547
+ end
548
+
549
+ # BasicObject (and its subclasses) don't define respond_to?, so calling
550
+ # o.respond_to? on one raises NoMethodError. Use this to safely test
551
+ # whether an object can be introspected (is_a?, instance_variables, ...).
552
+ def _introspectable?(o)
553
+ o.respond_to?(:is_a?)
554
+ rescue NoMethodError
555
+ false
556
+ end
557
+
558
+ def _collect_procs(app)
559
+ seen = {}
560
+ procs = []
561
+ stack = [[app, "app", nil, nil]]
562
+ until stack.empty?
563
+ o, _path, parent, ivar = stack.pop
564
+ next if o.equal?(nil)
565
+ # Skip BasicObject subclasses that don't respond to is_a?/object_id
566
+ # (e.g. ActiveSupport::Callbacks::CallTemplate internals). Must guard
567
+ # BEFORE calling is_a? — BasicObject doesn't define it.
568
+ next unless _introspectable?(o)
569
+ if o.is_a?(Proc)
570
+ procs << [o, _path, parent, ivar]
571
+ next
572
+ end
573
+ next if seen[o.object_id]
574
+ seen[o.object_id] = true
575
+ next if o.is_a?(Mutex) || o.is_a?(Monitor)
576
+ begin
577
+ o.instance_variables.each do |iv|
578
+ begin; v = o.instance_variable_get(iv); rescue; next; end
579
+ stack << [v, "#{_path}.#{iv}", o, iv] if v
580
+ end
581
+ rescue => e
582
+ # Some objects (BasicObject) don't support instance_variables
583
+ end
584
+ if o.is_a?(Array)
585
+ o.each_with_index { |e, i| stack << [e, "#{_path}[#{i}]", o, nil] if e }
586
+ elsif o.is_a?(Hash)
587
+ o.each do |k, val|
588
+ stack << [k, "#{_path}.key", o, nil] if k
589
+ stack << [val, "#{_path}[#{k.inspect}]", o, nil] if val
590
+ end
591
+ dp = o.default_proc
592
+ procs << [dp, "#{_path}.default_proc", o, :__default_proc__] if dp
593
+ end
594
+ end
595
+ procs
596
+ end
597
+
598
+ def _replace_one_proc(proc_obj, parent, ivar, mw)
599
+ src = proc_obj.source_location&.first || ""
600
+ replacement =
601
+ if src.end_with?(SSL_LOC) && ivar == :@exclude
602
+ redirect = parent.instance_variable_get(:@redirect)
603
+ CallableConst.new(!redirect)
604
+ elsif src.end_with?(FILES_LOC) && ivar == :@app
605
+ # The lambda is `Rack::Files#initialize`'s `lambda { |env| get env }`,
606
+ # stored as `Rack::Head#@app`. Its `self` (binding receiver) is the
607
+ # `Rack::Files` instance that defines `get` — NOT the `Rack::Head`
608
+ # that holds it. Use the binding receiver as the callable target so
609
+ # the worker calls `Rack::Files#get(env)` (the original behavior).
610
+ # Fall back to the middleware-chain search if the receiver can't be
611
+ # resolved (e.g. frozen/unavailable binding).
612
+ receiver = proc_obj.binding.receiver rescue nil
613
+ files_server = receiver if receiver && receiver.respond_to?(:get)
614
+ files_server ||= _find_files_server(mw)
615
+ files_server ||= parent
616
+ Callable.new(files_server, :get)
617
+ elsif src.end_with?(COOKIE_LOC)
618
+ RequestCallable.new(:cookies_same_site_protection)
619
+ elsif src.end_with?(DEVISE_SCOPE_LOC)
620
+ _devise_mapping_replacement(proc_obj, parent)
621
+ elsif src.end_with?(MAPPER_LOC) && ivar == :@strategy
622
+ line = proc_obj.source_location[1]
623
+ line == 32 ? StrategyServe.new : StrategyCall.new
624
+ else
625
+ NoOpProc.new
626
+ end
627
+
628
+ if ivar == :__default_proc__
629
+ # The parent Hash may already be frozen (e.g. by an earlier
630
+ # shareability pass on AR internals). A frozen Hash can't have its
631
+ # default cleared, but a frozen Hash with a default_proc is still
632
+ # unshareable — Ractor.make_shareable(parent) later will replace it
633
+ # wholesale if needed. Just skip here when frozen.
634
+ begin
635
+ parent.default = nil
636
+ rescue FrozenError, RuntimeError
637
+ # frozen Hash — leave the default_proc; make_shareable handles it.
638
+ end
639
+ elsif ivar
640
+ parent.instance_variable_set(ivar, replacement) rescue nil
641
+ elsif parent.is_a?(Array)
642
+ idx = parent.index(proc_obj)
643
+ if idx then parent[idx] = replacement
644
+ else parent.each_with_index { |e, i| parent[i] = replacement if e.equal?(proc_obj) }
645
+ end
646
+ elsif parent.is_a?(Hash)
647
+ key = parent.key(proc_obj)
648
+ parent[key] = replacement if key rescue nil
649
+ end
650
+ end
651
+
652
+ # NOTE: `_devise_mapping_replacement` (Devise scope constraint →
653
+ # DeviseMappingCallable) now lives in warden.rb; `_find_files_server`
654
+ # (Rack::Files target for the asset stack) now lives in rack.rb.
655
+
656
+ def _replace_locks_and_concurrent_maps!(app)
657
+ seen = {}
658
+ stack = [[app, "app", nil, nil]]
659
+ until stack.empty?
660
+ o, _p, _parent, _ivar = stack.pop
661
+ next if o.equal?(nil)
662
+ next unless _introspectable?(o)
663
+ next if seen[o.object_id]
664
+ seen[o.object_id] = true
665
+ next if o.is_a?(Mutex) || o.is_a?(Monitor)
666
+ begin
667
+ o.instance_variables.each do |iv|
668
+ begin; v = o.instance_variable_get(iv); rescue; next; end
669
+ if v.is_a?(Mutex) || v.is_a?(Monitor)
670
+ o.instance_variable_set(iv, NoOpLock.new) rescue nil
671
+ elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map)
672
+ hash_copy = {}
673
+ v.each_pair { |k, val| hash_copy[k] = val }
674
+ o.instance_variable_set(iv, hash_copy) rescue nil
675
+ elsif v
676
+ stack << [v, "#{_p}.#{iv}", o, iv]
677
+ end
678
+ end
679
+ rescue => e
680
+ # BasicObject or frozen objects don't support instance_variables
681
+ end
682
+ if o.is_a?(Array); o.each_with_index { |e, i| stack << [e, "#{_p}[#{i}]", o, nil] if e }
683
+ elsif o.is_a?(Hash); o.each { |k, val| stack << [k, "#{_p}.key", o, nil] if k; stack << [val, "#{_p}[#{k.inspect}]", o, nil] if val }
684
+ end
685
+ end
686
+ end
687
+
688
+ # Capture each controller's OWN declared `process_action` symbol filters
689
+ # (before_action / after_action) so worker Ractors can replay them.
690
+ #
691
+ # WHY NOT READ __callbacks: Rails 8.1.3 under Ruby 4.0.5 (with Devise
692
+ # 5.0.4) has an eager-load class_attribute callback-chain leak — a parent
693
+ # controller's `__callbacks` accumulates every subclass's filters (and
694
+ # vice-versa), so `ApplicationController.__callbacks[:process_action]`
695
+ # ends up carrying Devise's `require_no_authentication` AND
696
+ # `PostsController`'s `set_post`. Reading `__callbacks` therefore yields a
697
+ # corrupted, unshareable chain. The app is genuinely broken in eager-load
698
+ # (production) mode even without the shim.
699
+ #
700
+ # Instead we intercept `ActiveSupport::Callbacks.set_callback` during
701
+ # eager load (see _install_callback_declaration_capture!) and record, per
702
+ # declaring controller class, the symbolic filters IT declares (kind,
703
+ # filter, only/except). This captures the truth regardless of the leak.
704
+ # The patched run_callbacks replays these per controller, walking
705
+ # ancestors for inheritance.
706
+ #
707
+ # We only capture SYMBOL filters (the common `before_action :set_post`
708
+ # form). Proc/lambda filters are skipped — they are self-capturing and
709
+ # cannot be replayed safely in a worker (known limitation; symbolic
710
+ # filters cover the typical case, including Devise's `:authenticate_user!`
711
+ # / `:require_no_authentication`). Stored in
712
+ # RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS keyed by the controller
713
+ # class object_id (stable across Ractors since classes are shared).
714
+ # Make Ractor-shareable the class instance variables listed in
715
+ # SHAREABLE_CLASS_IVARS (e.g. ActiveSupport::Editor.@editors,
716
+ # Warden::Strategies.@strategies). Worker Ractors read these during request
717
+ # dispatch; an unshareable value raises Ractor::IsolationError. We deep-freeze
718
+ # the value and write it back so workers read the shareable copy. Also
719
+ # pre-touch any memoizing accessor so workers don't try to write the ivar
720
+ # lazily (which would raise FrozenError on the frozen class).
721
+ def _freeze_shareable_class_ivars!
722
+ SHAREABLE_CLASS_IVARS.each do |(class_name, ivar)|
723
+ mod = class_name.split("::").inject(Object) { |ns, n| ns.const_get(n) } rescue nil
724
+ next unless mod && mod.instance_variable_defined?(ivar)
725
+ val = mod.instance_variable_get(ivar)
726
+ next if val.nil?
727
+ begin
728
+ Ractor.make_shareable(val)
729
+ mod.instance_variable_set(ivar, val) rescue nil
730
+ rescue
731
+ nil
732
+ end
733
+ end
734
+ # Pre-touch memoizing accessors so workers short-circuit instead of
735
+ # writing the (now frozen) ivar on first read.
736
+ begin
737
+ ::ActiveSupport::Editor.current if defined?(::ActiveSupport::Editor)
738
+ rescue
739
+ nil
740
+ end
741
+ begin
742
+ ::Warden::Strategies._strategies if defined?(::Warden::Strategies)
743
+ rescue
744
+ nil
745
+ end
746
+ end
747
+
748
+ def _freeze_declared_callbacks!
749
+ table = (@declared_callbacks || {})
750
+ # Deep-freeze (make shareable) so worker Ractors can read the constant.
751
+ # Entries are Hashes of Symbols/booleans/nil/Arrays — all natively
752
+ # shareable. A non-frozen constant raises Ractor::IsolationError when a
753
+ # worker reads it.
754
+ begin
755
+ Ractor.make_shareable(table)
756
+ RactorRailsShim.const_set(:SHAREABLE_DECLARED_CALLBACKS, table)
757
+ rescue
758
+ nil
759
+ end
760
+ end
761
+
762
+ # Record a single declared symbolic filter. Called from the
763
+ # set_callback interceptor during eager load (main Ractor only).
764
+ def _record_declared_callback(klass_id, kind, filter, only, except)
765
+ @declared_callbacks ||= {}
766
+ (@declared_callbacks[klass_id] ||= []) << {
767
+ kind: kind,
768
+ filter: filter,
769
+ only: (only.freeze if only),
770
+ except: (except.freeze if except)
771
+ }
772
+ end
773
+
774
+ # Install an interceptor on ActiveSupport::Callbacks.set_callback that
775
+ # records, per declaring class, every symbolic `:process_action` filter
776
+ # it declares. This must run BEFORE eager load (so declarations are
777
+ # captured as they happen) — install wires it via
778
+ # ActiveSupport.on_load(:active_support).
779
+ def _install_callback_declaration_capture!
780
+ return if @callback_capture_installed
781
+ @callback_capture_installed = true
782
+ # ActiveSupport::Callbacks may not be loaded yet at on_load(:active_support)
783
+ # time (it's required lazily). Require it so the ClassMethods module with
784
+ # set_callback exists before we alias it.
785
+ require "active_support/callbacks" rescue nil
786
+ mod = (defined?(::ActiveSupport::Callbacks) &&
787
+ ::ActiveSupport::Callbacks.const_defined?(:ClassMethods)) ?
788
+ ::ActiveSupport::Callbacks::ClassMethods : nil
789
+ return unless mod && mod.method_defined?(:set_callback)
790
+ mod.alias_method(:_rrs_orig_set_callback, :set_callback)
791
+ mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
792
+ def set_callback(name, *filters, &block)
793
+ if name == :process_action && filters.length >= 2 && filters[0].is_a?(Symbol)
794
+ kind = filters[0]
795
+ filter = filters[1]
796
+ if filter.is_a?(Symbol) &&
797
+ self.is_a?(::Class) &&
798
+ self.ancestors.include?(::AbstractController::Base)
799
+ # Rails converts `only:`/`except:` into an ActionFilter object
800
+ # stored in the callback's `:if`/`:unless` options (NOT a bare
801
+ # `:only` key). Read the constraint back from the ActionFilter's
802
+ # @conditional_key (:only/:except) and @actions (a Set of action
803
+ # name Strings).
804
+ opts = filters.find { |f| f.is_a?(Hash) }
805
+ only = nil
806
+ except = nil
807
+ if opts
808
+ [opts[:if], opts[:unless]].each do |arr|
809
+ next unless arr.is_a?(Array)
810
+ arr.each do |af|
811
+ ck = af.instance_variable_get(:@conditional_key) rescue nil
812
+ acts = af.instance_variable_get(:@actions) rescue nil
813
+ next unless ck && acts
814
+ acts = acts.to_a.map(&:to_sym) if acts.respond_to?(:to_a)
815
+ only = acts if ck == :only
816
+ except = acts if ck == :except
817
+ end
818
+ end
819
+ end
820
+ ::RactorRailsShim._record_declared_callback(
821
+ self.object_id, kind, filter, only, except)
822
+ end
823
+ end
824
+ _rrs_orig_set_callback(name, *filters, &block)
825
+ end
826
+ RUBY
827
+ @callback_capture_installed = true
828
+ end
829
+
830
+ def _collect_controller_classes(app)
831
+ classes = []
832
+ begin
833
+ router = (app.respond_to?(:routes) ? app.routes : nil) || (defined?(::Rails) && ::Rails.application && ::Rails.application.routes)
834
+ router.routes.each do |r|
835
+ c = r.defaults[:controller] rescue nil
836
+ next unless c && c.respond_to?(:camelize)
837
+ klass = "#{c.camelize}Controller".safe_constantize rescue nil
838
+ classes << klass if klass
839
+ end
840
+ rescue
841
+ nil
842
+ end
843
+ begin
844
+ if defined?(::ApplicationController) && ::ApplicationController.respond_to?(:descendants)
845
+ classes.concat(::ApplicationController.descendants)
846
+ end
847
+ rescue
848
+ nil
849
+ end
850
+ classes.compact.uniq
851
+ end
852
+ end
853
+ end