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,1085 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches for ActiveSupport: Inflector::Inflections, ErrorReporter,
4
+ # I18n::Config, CurrentAttributes (ExecutionContext), LogSubscriber.
5
+
6
+ module RactorRailsShim
7
+ # ActiveSupport + concurrent-ruby constants that need to be made shareable.
8
+ SHAREABLE_CONSTANTS.concat([
9
+ "ActiveSupport::EnvironmentInquirer::DEFAULT_ENVIRONMENTS",
10
+ "ActiveSupport::EnvironmentInquirer::LOCAL_ENVIRONMENTS",
11
+ "ActiveSupport::ErrorReporter::SEVERITIES",
12
+ "ActiveSupport::CurrentAttributes::INVALID_ATTRIBUTE_NAMES",
13
+ "ActiveSupport::Delegation::RUBY_RESERVED_KEYWORDS",
14
+ "ActiveSupport::ExecutionWrapper::Null",
15
+ "Concurrent::NULL",
16
+ "I18n::RESERVED_KEYS",
17
+ # ActiveSupport::JSON::Encoding constants. The module is shareable, but its
18
+ # constants hold values (Regexps built via Regexp.union, and a Hash of
19
+ # frozen binary strings) that are NOT Ractor-shareable in Ruby 4.0, so a
20
+ # worker Ractor cannot read them (HTML_ENTITIES_REGEX etc.). Deep-freeze
21
+ # each into a shareable twin and const_set it back on the module.
22
+ "ActiveSupport::JSON::Encoding::ESCAPED_CHARS",
23
+ "ActiveSupport::JSON::Encoding::HTML_ENTITIES_REGEX",
24
+ "ActiveSupport::JSON::Encoding::FULL_ESCAPE_REGEX",
25
+ "ActiveSupport::JSON::Encoding::JS_SEPARATORS_REGEX",
26
+ ])
27
+
28
+ class << self
29
+ # Patch ActiveSupport::Inflector::Inflections to not read @__en_instance__
30
+ # / @__instance__ class ivars from a worker Ractor. The inflections instance
31
+ # holds rules (Arrays/Hashes of Strings) populated at boot; for a frozen
32
+ # shared app it's read-only. Workers share the main-ractor's inflections
33
+ # instance via a shareable fallback (made shareable in place). `instance`
34
+ # / `instance_or_fallback` are called per-request during routing (camelize).
35
+ # Patch ActiveSupport::Callbacks#run_callbacks to tolerate a nil
36
+ # __callbacks (the case in worker Ractors whose class_attribute fallback
37
+ # couldn't be made shareable because callback chains hold frozen,
38
+ # self-capturing Procs). For a frozen, read-only shared app the boot-time
39
+ # callbacks (ExecutionContext push/pop, CurrentAttributes clear) already
40
+ # ran in the main Ractor at boot; worker Ractors don't need to re-run
41
+ # them per request (CurrentAttributes/ExecutionContext are thread-local,
42
+ # hence per-Ractor, and start empty in a fresh worker). When __callbacks
43
+ # is nil, run_callbacks just yields the block — matching the empty-chain
44
+ # fast path in the original. Moved here from execution_wrapper.rb (it
45
+ # patches ActiveSupport::Callbacks, not ExecutionWrapper).
46
+ def _install_callbacks_nil_safe_patch
47
+ return if @callbacks_nil_safe_patched
48
+ @callbacks_nil_safe_patched = true
49
+ _register_patch :callbacks_nil_safe, "8.1"
50
+ return unless defined?(::ActiveSupport::Callbacks)
51
+ ::ActiveSupport::Callbacks.module_eval <<-RUBY, __FILE__, __LINE__ + 1
52
+ def run_callbacks_with_nil_safe(kind, type = nil)
53
+ kind = kind.to_sym
54
+ if RactorRailsShim.thread_mode? && kind == :process_action &&
55
+ defined?(::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS)
56
+ # Thread (Puma/Falcon) mode: the eager-load class_attribute leak
57
+ # corrupts __callbacks, so ALWAYS replay the captured symbolic
58
+ # filters (ignoring __callbacks entirely). Serving happens in the
59
+ # main Ractor, where the worker-style empty-__callbacks path
60
+ # below never triggers.
61
+ table = ::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS
62
+ action = (self.action_name rescue nil)
63
+ action = action.to_sym if action
64
+ entries = []
65
+ k = self.class
66
+ while k && k <= ::ActionController::Base
67
+ rec = table[k.object_id]
68
+ entries = rec + entries if rec
69
+ k = k.superclass
70
+ end
71
+ unless entries.empty?
72
+ applies = lambda do |e|
73
+ next false unless (e[:kind] == :before || e[:kind] == :after)
74
+ in_only = e[:only].nil? || (action && e[:only].include?(action))
75
+ not_except = e[:except].nil? || !(action && e[:except].include?(action))
76
+ in_only && not_except
77
+ end
78
+ result = nil
79
+ halted = false
80
+ entries.each do |e|
81
+ next unless e[:kind] == :before && applies.call(e)
82
+ send(e[:filter]) if respond_to?(e[:filter], true)
83
+ if respond_to?(:performed?) ? performed? : response_body
84
+ halted = true
85
+ break
86
+ end
87
+ end
88
+ result = (yield if block_given?) unless halted
89
+ entries.each do |e|
90
+ next unless e[:kind] == :after && applies.call(e)
91
+ send(e[:filter]) if respond_to?(e[:filter], true)
92
+ end
93
+ return result
94
+ end
95
+ end
96
+ callbacks = __callbacks[kind] if __callbacks
97
+ if callbacks.nil? || callbacks.empty?
98
+ # In a worker Ractor, class_attribute-backed `__callbacks`
99
+ # falls back to the empty default (see class_attribute.rb /
100
+ # make_shareable.rb), so controller `before_action` /
101
+ # `after_action` filters are normally SKIPED. For
102
+ # `:process_action` we replay the captured SYMBOL filters
103
+ # (see make_shareable!#_capture_controller_callbacks!)
104
+ # so actions that depend on a before_action (e.g. `set_post`
105
+ # loading `@post`) render correctly. Proc/lambda filters
106
+ # are not captured (self-capturing, unshareable) and are
107
+ # skipped — a known limitation.
108
+ if (RactorRailsShim.thread_mode? || !Ractor.main?) && kind.to_sym == :process_action &&
109
+ defined?(::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS)
110
+ table = ::RactorRailsShim::SHAREABLE_DECLARED_CALLBACKS
111
+ action = (self.action_name rescue nil)
112
+ action = action.to_sym if action
113
+ # Reconstruct this controller's callback list by walking its
114
+ # ancestors (superclass-first). Each class recorded ONLY the
115
+ # filters IT declared (via the set_callback interceptor), which
116
+ # bypasses the eager-load class_attribute leak that otherwise
117
+ # pollutes __callbacks.
118
+ entries = []
119
+ k = self.class
120
+ while k && k <= ::ActionController::Base
121
+ rec = table[k.object_id]
122
+ entries = rec + entries if rec
123
+ k = k.superclass
124
+ end
125
+ unless entries.empty?
126
+ # A captured symbolic filter applies to this action unless
127
+ # constrained by `only:`/`except:`. `kind` (before/after)
128
+ # decides whether it wraps the action pre- or post-yield.
129
+ applies = lambda do |e|
130
+ next false unless (e[:kind] == :before || e[:kind] == :after)
131
+ in_only = e[:only].nil? || (action && e[:only].include?(action))
132
+ not_except = e[:except].nil? || !(action && e[:except].include?(action))
133
+ in_only && not_except
134
+ end
135
+ result = nil
136
+ halted = false
137
+ entries.each do |e|
138
+ next unless e[:kind] == :before && applies.call(e)
139
+ send(e[:filter]) if respond_to?(e[:filter], true)
140
+ # A before-filter that renders/redirects performs — halt the
141
+ # chain (as the real ActiveSupport::Callbacks machinery
142
+ # does) so the action is NOT run. Otherwise an action that
143
+ # depends on a performed before_action (e.g. Devise's
144
+ # verify_signed_out_user redirect) would render twice.
145
+ if respond_to?(:performed?) ? performed? : response_body
146
+ halted = true
147
+ break
148
+ end
149
+ end
150
+ result = (yield if block_given?) unless halted
151
+ entries.each do |e|
152
+ next unless e[:kind] == :after && applies.call(e)
153
+ send(e[:filter]) if respond_to?(e[:filter], true)
154
+ end
155
+ return result
156
+ end
157
+ end
158
+ yield if block_given?
159
+ else
160
+ run_callbacks_without_nil_safe(kind, type) { yield if block_given? }
161
+ end
162
+ end
163
+ alias_method :run_callbacks_without_nil_safe, :run_callbacks
164
+ alias_method :run_callbacks, :run_callbacks_with_nil_safe
165
+ RUBY
166
+ end
167
+
168
+ # Patch ActiveSupport::Notifications.notifier to not read the @notifier
169
+ # class ivar from a worker Ractor. The original is `attr_accessor
170
+ # :notifier` with `@notifier = Fanout.new` set at module load — a raw
171
+ # class ivar holding a Fanout (which has a Mutex + subscriber Procs,
172
+ # both unshareable). Workers get their own per-Ractor Fanout (no
173
+ # subscribers — instrumentation is a no-op in workers, which is correct
174
+ # for a read-only shared app where log subscribers already ran in main).
175
+ # `notifier` is read by `instrumenter` (per-request via Rails::Rack::Logger).
176
+ # Moved here from execution_wrapper.rb (it patches ActiveSupport::
177
+ # Notifications, not ExecutionWrapper).
178
+ def _install_notifications_notifier_patch
179
+ return if @notifications_notifier_patched
180
+ @notifications_notifier_patched = true
181
+ _register_patch :notifications_notifier, "8.1"
182
+ return unless defined?(::ActiveSupport::Notifications)
183
+ notif = ::ActiveSupport::Notifications
184
+ nkey = :ractor_rails_shim_notifications_notifier
185
+ nkey_str = nkey.inspect
186
+ notif.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
187
+ def notifier
188
+ v = ActiveSupport::IsolatedExecutionState[#{nkey_str}]
189
+ return v unless v.nil?
190
+ if Ractor.main? && instance_variable_defined?(:@notifier)
191
+ @notifier
192
+ else
193
+ built = ActiveSupport::Notifications::Fanout.new
194
+ ActiveSupport::IsolatedExecutionState[#{nkey_str}] = built
195
+ built
196
+ end
197
+ end
198
+ RUBY
199
+ end
200
+
201
+ def _install_inflector_patch
202
+ return if @inflector_patched
203
+ @inflector_patched = true
204
+ _register_patch :inflector, "8.1"
205
+ return unless defined?(::ActiveSupport::Inflector::Inflections)
206
+ inf = ::ActiveSupport::Inflector::Inflections
207
+ en_key = :ractor_rails_shim_inflections_en
208
+ inst_key = :ractor_rails_shim_inflections_instance
209
+ en_key_str = en_key.inspect
210
+ inst_key_str = inst_key.inspect
211
+ inf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
212
+ def instance(locale = :en)
213
+ if locale == :en
214
+ v = ActiveSupport::IsolatedExecutionState[#{en_key_str}]
215
+ return v unless v.nil?
216
+ if Ractor.main?
217
+ existing = instance_variable_get(:@__en_instance__) if instance_variable_defined?(:@__en_instance__)
218
+ ActiveSupport::IsolatedExecutionState[#{en_key_str}] = existing
219
+ return existing || new.tap { |i| instance_variable_set(:@__en_instance__, i) }
220
+ end
221
+ fb = RactorRailsShim::SHAREABLE_FALLBACK[#{en_key_str}]
222
+ return fb if fb
223
+ built = new
224
+ ActiveSupport::IsolatedExecutionState[#{en_key_str}] = built
225
+ built
226
+ else
227
+ h = ActiveSupport::IsolatedExecutionState[#{inst_key_str}] ||= (Ractor.main? ? (instance_variable_defined?(:@__instance__) ? instance_variable_get(:@__instance__) : Concurrent::Map.new) : Concurrent::Map.new)
228
+ h[locale] ||= new
229
+ end
230
+ end
231
+
232
+ def instance_or_fallback(locale)
233
+ return instance(locale) if locale == :en
234
+ h = ActiveSupport::IsolatedExecutionState[#{inst_key_str}]
235
+ if h && h.key?(locale)
236
+ return h[locale]
237
+ end
238
+ if Ractor.main? && instance_variable_defined?(:@__instance__)
239
+ iv = instance_variable_get(:@__instance__)
240
+ return iv[locale] if iv && iv.key?(locale)
241
+ end
242
+ instance(locale)
243
+ end
244
+ RUBY
245
+ # Register so _build_shareable_fallback! captures the :en inflections
246
+ # instance (made shareable) for workers.
247
+ CLASS_ATTRIBUTES << ["ActiveSupport::Inflector::Inflections", :__en_instance__, en_key, nil]
248
+ # Materialize the :en instance into IES in main so the fallback builder
249
+ # can read + share it.
250
+ inf.instance(:en) if Ractor.main?
251
+ end
252
+
253
+ # Patch Module#module_parent_name so a worker Ractor does not write the
254
+ # `@parent_name` class ivar on a shared (non-frozen) module. The default
255
+ # memoizes `@parent_name ||= ...` on first use; when that first use happens
256
+ # in a worker it writes a class ivar on a shared module, which raises
257
+ # Ractor::IsolationError ("can not set instance variables of
258
+ # classes/modules by non-main Ractors"). Route the per-worker cache through
259
+ # IsolatedExecutionState (keyed by module object_id); main keeps the
260
+ # original class-ivar behavior.
261
+ def _install_module_introspection_patch
262
+ return if @module_introspection_patched
263
+ @module_introspection_patched = true
264
+ _register_patch :module_introspection, "8.1"
265
+ return unless defined?(::Module)
266
+ ::Module.module_eval do
267
+ def module_parent_name
268
+ if defined?(@parent_name)
269
+ @parent_name
270
+ else
271
+ name = self.name
272
+ return if name.nil?
273
+
274
+ parent_name = name =~ /::[^:]+\z/ ? -$` : nil
275
+ if Ractor.main?
276
+ @parent_name = parent_name unless frozen?
277
+ else
278
+ store = (ActiveSupport::IsolatedExecutionState[:rrs_module_parent_names] ||= {})
279
+ store[object_id] ||= parent_name
280
+ end
281
+ parent_name
282
+ end
283
+ end
284
+ end
285
+ end
286
+
287
+ # Patch ActiveSupport module's @error_reporter class ivar (defined via
288
+ # `singleton_class.attr_accessor :error_reporter` in active_support.rb:109)
289
+ # to not read from a worker Ractor. ExecutionWrapper.error_reporter delegates
290
+ # to ActiveSupport.error_reporter, which reads the @error_reporter ivar on
291
+ # the ActiveSupport module. Workers get a fresh ErrorReporter (no subscribers
292
+ # — correct for a read-only shared app where error reporting already ran
293
+ # in main via the Rails.error mechanism). Called per-request via
294
+ # ActionDispatch::Executor middleware.
295
+ def _install_active_support_error_reporter_patch
296
+ return if @error_reporter_patched
297
+ @error_reporter_patched = true
298
+ _register_patch :error_reporter, "8.1"
299
+ return unless defined?(::ActiveSupport)
300
+ er_key = :ractor_rails_shim_active_support_error_reporter
301
+ er_key_str = er_key.inspect
302
+ ::ActiveSupport.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
303
+ def error_reporter
304
+ v = ActiveSupport::IsolatedExecutionState[#{er_key_str}]
305
+ return v unless v.nil?
306
+ if Ractor.main? && instance_variable_defined?(:@error_reporter)
307
+ @error_reporter
308
+ else
309
+ built = ActiveSupport::ErrorReporter.new
310
+ ActiveSupport::IsolatedExecutionState[#{er_key_str}] = built
311
+ built
312
+ end
313
+ end
314
+ def error_reporter=(val)
315
+ ActiveSupport::IsolatedExecutionState[#{er_key_str}] = val
316
+ @error_reporter = val if Ractor.main?
317
+ val
318
+ end
319
+ RUBY
320
+ end
321
+
322
+ # Patch I18n::Config's class-variable-backed accessors (default_locale,
323
+ # locale, backend, etc.) to not read @@cvars from a worker Ractor. I18n
324
+ # defines these manually (`@@default_locale ||= :en`), not via
325
+ # cattr_accessor, so the shim's mattr rewrite doesn't catch them. The
326
+ # values are frozen Symbols / shareable config objects; route the
327
+ # frequently-read ones (default_locale, locale) through IES with the same
328
+ # default. Read per-request during view lookup (LookupContext details).
329
+ def _install_i18n_patch
330
+ return if @i18n_patched
331
+ @i18n_patched = true
332
+ _register_patch :i18n, "8.1"
333
+ return unless defined?(::I18n::Config)
334
+ cfg = ::I18n::Config
335
+ dl_key = :ractor_rails_shim_i18n_default_locale
336
+ l_key = :ractor_rails_shim_i18n_locale
337
+ av_key = :ractor_rails_shim_i18n_available_locales
338
+ avs_key = :ractor_rails_shim_i18n_available_locales_set
339
+ dl_key_str = dl_key.inspect
340
+ l_key_str = l_key.inspect
341
+ av_key_str = av_key.inspect
342
+ avs_key_str = avs_key.inspect
343
+ cfg.module_eval <<-RUBY, __FILE__, __LINE__ + 1
344
+ def default_locale
345
+ v = ActiveSupport::IsolatedExecutionState[#{dl_key_str}]
346
+ return v unless v.nil?
347
+ if Ractor.main? && defined?(@@default_locale)
348
+ cv = @@default_locale
349
+ ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = cv
350
+ return cv
351
+ end
352
+ ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = :en
353
+ :en
354
+ end
355
+ def default_locale=(locale)
356
+ v = locale && locale.to_sym
357
+ ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = v
358
+ @@default_locale = v if Ractor.main?
359
+ v
360
+ end
361
+ def locale
362
+ v = ActiveSupport::IsolatedExecutionState[#{l_key_str}]
363
+ return v unless v.nil?
364
+ default_locale
365
+ end
366
+ def locale=(locale)
367
+ v = locale && locale.to_sym
368
+ ActiveSupport::IsolatedExecutionState[#{l_key_str}] = v
369
+ v
370
+ end
371
+ # available_locales is read per-request during view template lookup
372
+ # (ActionView::Resolver::PathParser#build_path_regex). The original
373
+ # reads the @@available_locales class variable, which a worker Ractor
374
+ # cannot access (Ractor::IsolationError). Route it through IES. In
375
+ # main we mirror the class var; in a worker we default to [:en] WITHOUT
376
+ # delegating to backend.available_locales (that path reads the @@backend
377
+ # / @@load_path class vars, which are also unreadable from a worker).
378
+ # The template-regex path only needs the list of locale symbols, and
379
+ # [:en] is the documented I18n default — correct for apps that don't
380
+ # set config.i18n.available_locales explicitly.
381
+ def available_locales
382
+ v = ActiveSupport::IsolatedExecutionState[#{av_key_str}]
383
+ return v unless v.nil?
384
+ if Ractor.main?
385
+ if defined?(@@available_locales) && (cv = @@available_locales)
386
+ ActiveSupport::IsolatedExecutionState[#{av_key_str}] = cv
387
+ return cv
388
+ end
389
+ al = backend.available_locales
390
+ al = al.freeze if al.respond_to?(:freeze) && !al.frozen?
391
+ ActiveSupport::IsolatedExecutionState[#{av_key_str}] = al
392
+ return al
393
+ end
394
+ al = [:en].freeze
395
+ ActiveSupport::IsolatedExecutionState[#{av_key_str}] = al
396
+ al
397
+ end
398
+ def available_locales=(locales)
399
+ v = Array(locales).map { |l| l.to_sym }
400
+ v = nil if v.empty?
401
+ ActiveSupport::IsolatedExecutionState[#{av_key_str}] = v
402
+ @@available_locales = v if Ractor.main?
403
+ v
404
+ end
405
+ def available_locales_set
406
+ v = ActiveSupport::IsolatedExecutionState[#{avs_key_str}]
407
+ return v unless v.nil?
408
+ if Ractor.main? && defined?(@@available_locales_set) && (cv = @@available_locales_set)
409
+ ActiveSupport::IsolatedExecutionState[#{avs_key_str}] = cv
410
+ return cv
411
+ end
412
+ s = available_locales.inject(Set.new) { |set, locale| set << locale.to_s << locale.to_sym }
413
+ ActiveSupport::IsolatedExecutionState[#{avs_key_str}] = s
414
+ s
415
+ end
416
+ def available_locales_initialized?
417
+ !!(ActiveSupport::IsolatedExecutionState[#{av_key_str}])
418
+ end
419
+ # enforce_available_locales is read during every I18n.translate (the
420
+ # Label/tag translation path in views). The original reads the
421
+ # @@enforce_available_locales class variable, which a worker Ractor
422
+ # cannot access (Ractor::IsolationError). Route it through IES; in main
423
+ # we mirror the class var, in a worker we default to `true` (the
424
+ # documented I18n default).
425
+ def enforce_available_locales
426
+ v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce]
427
+ return v unless v.nil?
428
+ if Ractor.main? && defined?(@@enforce_available_locales)
429
+ cv = @@enforce_available_locales
430
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = cv
431
+ return cv
432
+ end
433
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = true
434
+ true
435
+ end
436
+ def enforce_available_locales=(val)
437
+ v = !!val
438
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = v
439
+ @@enforce_available_locales = v if Ractor.main?
440
+ v
441
+ end
442
+ # backend reads the @@backend class variable (which a worker Ractor
443
+ # cannot access). The backend holds the loaded translations. Because the
444
+ # translation data can contain Procs (e.g. `number.nth.ordinals` in
445
+ # ActiveSupport's en locale), the whole backend cannot be deep-frozen
446
+ # and shared. Instead, each worker builds its OWN backend instance (of
447
+ # the same class as the main backend, so fallbacks etc. are preserved)
448
+ # and lazy-loads translations from the shareable +load_path+ (see the
449
+ # patched `load_path`/`load_path=`). The worker-local backend is mutable
450
+ # (its @interpolations Proc is created in the worker, so it's fine).
451
+ def backend
452
+ if Ractor.main?
453
+ @@backend ||= ::I18n::Backend::Simple.new
454
+ else
455
+ key = :ractor_rails_shim_i18n_backend
456
+ b = ActiveSupport::IsolatedExecutionState[key]
457
+ return b if b
458
+ cls = (RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS) && RactorRailsShim::I18N_BACKEND_CLASS) || ::I18n::Backend::Simple
459
+ b = cls.new
460
+ ActiveSupport::IsolatedExecutionState[key] = b
461
+ b
462
+ end
463
+ end
464
+ def backend=(value)
465
+ @@backend = value
466
+ end
467
+ # load_path reads the @@load_path class variable (unreadable from a
468
+ # worker). Capture the (shareable) list of translation file paths in
469
+ # main; workers reload translations from disk via these paths.
470
+ def load_path
471
+ v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path]
472
+ return v unless v.nil?
473
+ if Ractor.main?
474
+ lp = (defined?(@@load_path) && @@load_path) || []
475
+ lp = lp.dup.freeze if lp.respond_to?(:freeze) && !lp.frozen?
476
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path] = lp
477
+ lp
478
+ else
479
+ RactorRailsShim.const_defined?(:I18N_LOAD_PATH) ? RactorRailsShim::I18N_LOAD_PATH : []
480
+ end
481
+ end
482
+ def load_path=(lp)
483
+ lp = Array(lp)
484
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path] = lp
485
+ @@load_path = lp if Ractor.main?
486
+ lp
487
+ end
488
+ # default_separator / exception_handler / missing_interpolation_argument_handler
489
+ # / interpolation_patterns each read a @@ class variable unreadable from a
490
+ # worker. Route them through IES; main mirrors the class var, workers use
491
+ # the documented default (each default is worker-local and shareable-safe:
492
+ # a String, a fresh ExceptionHandler, a fresh lambda, or the frozen
493
+ # DEFAULT_INTERPOLATION_PATTERNS constant).
494
+ def default_separator
495
+ v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep]
496
+ return v unless v.nil?
497
+ if Ractor.main?
498
+ cv = defined?(@@default_separator) ? @@default_separator : "."
499
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep] = cv
500
+ cv
501
+ else
502
+ "."
503
+ end
504
+ end
505
+ def default_separator=(separator)
506
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep] = separator
507
+ @@default_separator = separator if Ractor.main?
508
+ separator
509
+ end
510
+ def exception_handler
511
+ v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc]
512
+ return v unless v.nil?
513
+ if Ractor.main?
514
+ cv = defined?(@@exception_handler) ? @@exception_handler : ::I18n::ExceptionHandler.new
515
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc] = cv
516
+ cv
517
+ else
518
+ ::I18n::ExceptionHandler.new
519
+ end
520
+ end
521
+ def exception_handler=(handler)
522
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc] = handler
523
+ @@exception_handler = handler if Ractor.main?
524
+ handler
525
+ end
526
+ def missing_interpolation_argument_handler
527
+ v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss]
528
+ return v unless v.nil?
529
+ if Ractor.main?
530
+ cv = defined?(@@missing_interpolation_argument_handler) ? @@missing_interpolation_argument_handler : lambda do |missing_key, provided_hash, string|
531
+ raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string)
532
+ end
533
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss] = cv
534
+ cv
535
+ else
536
+ lambda do |missing_key, provided_hash, string|
537
+ raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string)
538
+ end
539
+ end
540
+ end
541
+ def missing_interpolation_argument_handler=(handler)
542
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss] = handler
543
+ @@missing_interpolation_argument_handler = handler if Ractor.main?
544
+ handler
545
+ end
546
+ def interpolation_patterns
547
+ v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip]
548
+ return v unless v.nil?
549
+ if Ractor.main?
550
+ cv = defined?(@@interpolation_patterns) ? @@interpolation_patterns : ::I18n::DEFAULT_INTERPOLATION_PATTERNS.dup
551
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip] = cv
552
+ cv
553
+ else
554
+ ::I18n::DEFAULT_INTERPOLATION_PATTERNS
555
+ end
556
+ end
557
+ def interpolation_patterns=(patterns)
558
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip] = patterns
559
+ @@interpolation_patterns = patterns if Ractor.main?
560
+ patterns
561
+ end
562
+ RUBY
563
+
564
+ # Capture the I18n backend class and shareable load paths in MAIN after
565
+ # the app has initialized. Workers build their own backend instance of
566
+ # the captured class and reload translations from the captured load paths
567
+ # (see `I18n::Config#backend` / `#load_path`). Eager-load the backend
568
+ # class so the constant is globally defined for worker Ractors.
569
+ if Ractor.main? && defined?(::I18n)
570
+ begin
571
+ # Eager-load I18n classes/constants in main so they are globally
572
+ # defined for worker Ractors (which cannot autoload).
573
+ ::I18n::Backend::Simple rescue nil
574
+ ::I18n::ExceptionHandler rescue nil
575
+ ::I18n::MissingInterpolationArgument rescue nil
576
+ ::I18n::DEFAULT_INTERPOLATION_PATTERNS rescue nil
577
+ backend = ::I18n.backend
578
+ backend.translate(:en, "") rescue nil
579
+ backend.available_locales rescue nil
580
+ const_set(:I18N_BACKEND_CLASS, backend.class) unless RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS)
581
+ raw_lp = (backend.respond_to?(:instance_variable_get) && backend.instance_variable_get(:@load_path)) ||
582
+ (::I18n.respond_to?(:load_path) && ::I18n.load_path) || []
583
+ shareable_lp = Ractor.make_shareable(Array(raw_lp).dup) rescue Array(raw_lp).map(&:to_s).freeze
584
+ const_set(:I18N_LOAD_PATH, shareable_lp) unless RactorRailsShim.const_defined?(:I18N_LOAD_PATH)
585
+ rescue
586
+ nil
587
+ end
588
+ end
589
+
590
+ # Patch I18n.fallbacks (a singleton method on the I18n module) to not
591
+ # read the @@fallbacks class variable from a worker Ractor. It already
592
+ # uses Fiber/Thread-local storage with @@fallbacks as the fallback;
593
+ # route the @@fallbacks read through IES so workers build their own
594
+ # I18n::Locale::Fallbacks. Called per-request via LookupContext details.
595
+ if defined?(::I18n)
596
+ i18n = ::I18n
597
+ fb_key = :ractor_rails_shim_i18n_fallbacks
598
+ fb_key_str = fb_key.inspect
599
+ i18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
600
+ def fallbacks
601
+ v = ActiveSupport::IsolatedExecutionState[#{fb_key_str}]
602
+ return v unless v.nil?
603
+ if Ractor.main? && defined?(@@fallbacks)
604
+ cv = @@fallbacks
605
+ if cv
606
+ ActiveSupport::IsolatedExecutionState[#{fb_key_str}] = cv
607
+ return cv
608
+ end
609
+ end
610
+ built = I18n::Locale::Fallbacks.new
611
+ ActiveSupport::IsolatedExecutionState[#{fb_key_str}] = built
612
+ built
613
+ end
614
+ RUBY
615
+
616
+ # I18n::Locale::Tag.implementation — manual @@implementation ||= Simple.
617
+ # The value is a module (shareable). Route through IES.
618
+ if defined?(::I18n::Locale::Tag)
619
+ tag = ::I18n::Locale::Tag
620
+ tag_key = :ractor_rails_shim_i18n_tag_implementation
621
+ tag_key_str = tag_key.inspect
622
+ tag.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
623
+ def implementation
624
+ v = ActiveSupport::IsolatedExecutionState[#{tag_key_str}]
625
+ return v unless v.nil?
626
+ if Ractor.main? && defined?(@@implementation)
627
+ cv = @@implementation
628
+ ActiveSupport::IsolatedExecutionState[#{tag_key_str}] = cv
629
+ return cv
630
+ end
631
+ ActiveSupport::IsolatedExecutionState[#{tag_key_str}] = I18n::Locale::Tag::Simple
632
+ I18n::Locale::Tag::Simple
633
+ end
634
+ RUBY
635
+ end
636
+
637
+ # I18n::Base#normalize_key reads the @@normalized_key_cache class
638
+ # variable (a double-nested Hash with default procs — unshareable, and
639
+ # unreadable from a worker). It's a pure performance cache, so route it
640
+ # through IsolatedExecutionState: each Ractor builds its own nested
641
+ # cache via I18n.new_double_nested_cache and reads/writes it locally.
642
+ if defined?(::I18n::Base)
643
+ nk_key = :ractor_rails_shim_i18n_normalized_key_cache
644
+ nk_key_str = nk_key.inspect
645
+ ::I18n::Base.module_eval <<-RUBY, __FILE__, __LINE__ + 1
646
+ def normalize_key(key, separator)
647
+ cache = ActiveSupport::IsolatedExecutionState[#{nk_key_str}]
648
+ cache ||= (ActiveSupport::IsolatedExecutionState[#{nk_key_str}] = ::I18n.new_double_nested_cache)
649
+ cache[separator][key] ||=
650
+ case key
651
+ when Array
652
+ key.flat_map { |k| normalize_key(k, separator) }
653
+ else
654
+ keys = key.to_s.split(separator)
655
+ keys.delete('')
656
+ keys.map! do |k|
657
+ case k
658
+ when /\A[-+]?([1-9]\d*|0)\z/ # integer
659
+ k.to_i
660
+ when 'true'
661
+ true
662
+ when 'false'
663
+ false
664
+ else
665
+ k.to_sym
666
+ end
667
+ end
668
+ keys
669
+ end
670
+ end
671
+ RUBY
672
+ end
673
+
674
+ # I18n.reserved_keys_pattern memoizes its compiled regex in a lazy class
675
+ # ivar (@reserved_keys_pattern) which a worker Ractor cannot write.
676
+ # Route the cache through IsolatedExecutionState.
677
+ if defined?(::I18n)
678
+ rkp_key = :ractor_rails_shim_i18n_reserved_keys_pattern
679
+ rkp_key_str = rkp_key.inspect
680
+ ::I18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
681
+ def reserved_keys_pattern
682
+ v = ActiveSupport::IsolatedExecutionState[#{rkp_key_str}]
683
+ return v if v
684
+ pat = /(?<!%)%\\{(#{::I18n::RESERVED_KEYS.join("|")})\\}/
685
+ ActiveSupport::IsolatedExecutionState[#{rkp_key_str}] = pat
686
+ pat
687
+ end
688
+ RUBY
689
+ end
690
+ end
691
+ end
692
+
693
+ # Patch I18n::Backend::Simple::Implementation#translations and
694
+ # #store_translations. The original uses a MUTEX-backed Concurrent::Hash
695
+ # default block (`MUTEX` is a non-shareable constant on the module), so a
696
+ # worker Ractor that builds its own (per the patched I18n::Config#backend)
697
+ # backend and lazy-loads translations hits "can not access non-shareable
698
+ # objects in constant ...MUTEX". Worker-local backends are single-threaded
699
+ # (a Ractor serializes its requests), so drop the mutex and use a plain
700
+ # Hash. Applied at prepare_for_ractors! time (after the i18n backend class
701
+ # is loaded).
702
+ def _install_i18n_backend_patch
703
+ return if @i18n_backend_patched
704
+ @i18n_backend_patched = true
705
+ _register_patch :i18n_backend, "8.1"
706
+ return unless defined?(::I18n::Backend::Simple::Implementation)
707
+ impl = ::I18n::Backend::Simple::Implementation
708
+ impl.module_eval <<-RUBY, __FILE__, __LINE__ + 1
709
+ def translations(do_init: false)
710
+ init_translations if do_init && !initialized?
711
+ @translations ||= {}
712
+ end
713
+ def store_translations(locale, data, options = {})
714
+ if ::I18n.enforce_available_locales &&
715
+ ::I18n.available_locales_initialized? &&
716
+ !::I18n.locale_available?(locale)
717
+ return data
718
+ end
719
+ locale = locale.to_sym
720
+ translations[locale] ||= {}
721
+ data = ::I18n::Utils.deep_symbolize_keys(data) unless options.fetch(:skip_symbolize_keys, false)
722
+ ::I18n::Utils.deep_merge!(translations[locale], data)
723
+ end
724
+ RUBY
725
+ end
726
+
727
+ # Patch I18n.interpolate_hash. It reads INTERPOLATION_PATTERNS_CACHE — a
728
+ # constant Hash with a default proc (unshareable) — to fetch the compiled
729
+ # interpolation Regexp. A worker Ractor cannot read that constant, raising
730
+ # "can not access non-shareable objects in constant
731
+ # I18n::INTERPOLATION_PATTERNS_CACHE by non-main ractor". Route the cache
732
+ # through IsolatedExecutionState so each Ractor compiles its own Regexp once.
733
+ def _install_i18n_interpolation_patch
734
+ return if @i18n_interpolation_patched
735
+ @i18n_interpolation_patched = true
736
+ _register_patch :i18n_interpolation, "8.1"
737
+ return unless defined?(::I18n)
738
+ ::I18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
739
+ def interpolate_hash(string, values)
740
+ patterns = config.interpolation_patterns
741
+ cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_interp_cache] ||= {})
742
+ pattern = cache[patterns] ||= ::Regexp.union(patterns)
743
+ interpolated = false
744
+
745
+ interpolated_string = string.gsub(pattern) do |match|
746
+ interpolated = true
747
+
748
+ if match == '%%'
749
+ '%'
750
+ else
751
+ key = ($1 || $2 || match.tr("%{}", "")).to_sym
752
+ value = if values.key?(key)
753
+ values[key]
754
+ else
755
+ config.missing_interpolation_argument_handler.call(key, values, string)
756
+ end
757
+ value = value.call(values) if value.respond_to?(:call)
758
+ $3 ? sprintf("%#{$3}", value) : value
759
+ end
760
+ end
761
+
762
+ interpolated ? interpolated_string : string
763
+ end
764
+ RUBY
765
+ end
766
+
767
+ # class ivar and nestable are read/written per-request. Route through
768
+ # IES; workers get empty arrays (correct for a read-only shared app
769
+ # where ExecutionContext is per-Ractor and starts empty in a fresh
770
+ # worker).
771
+ def _install_execution_context_patch
772
+ return if @exec_context_patched
773
+ @exec_context_patched = true
774
+ _register_patch :execution_context, "8.1"
775
+ return unless defined?(::ActiveSupport::ExecutionContext)
776
+ ec = ::ActiveSupport::ExecutionContext
777
+ acb_key = :ractor_rails_shim_exec_context_after_change_callbacks
778
+ nest_key = :ractor_rails_shim_exec_context_nestable
779
+ acb_key_str = acb_key.inspect
780
+ nest_key_str = nest_key.inspect
781
+ ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
782
+ def after_change_callbacks
783
+ v = ActiveSupport::IsolatedExecutionState[#{acb_key_str}]
784
+ return v unless v.nil?
785
+ if Ractor.main? && instance_variable_defined?(:@after_change_callbacks)
786
+ v = @after_change_callbacks
787
+ ActiveSupport::IsolatedExecutionState[#{acb_key_str}] = v
788
+ v
789
+ else
790
+ arr = []
791
+ ActiveSupport::IsolatedExecutionState[#{acb_key_str}] = arr
792
+ arr
793
+ end
794
+ end
795
+ def after_change(&block)
796
+ after_change_callbacks << block
797
+ end
798
+ def nestable
799
+ v = ActiveSupport::IsolatedExecutionState[#{nest_key_str}]
800
+ return v unless v.nil?
801
+ if Ractor.main? && instance_variable_defined?(:@nestable)
802
+ v = @nestable
803
+ ActiveSupport::IsolatedExecutionState[#{nest_key_str}] = v
804
+ v
805
+ else
806
+ false
807
+ end
808
+ end
809
+ def nestable=(val)
810
+ ActiveSupport::IsolatedExecutionState[#{nest_key_str}] = val
811
+ @nestable = val if Ractor.main?
812
+ val
813
+ end
814
+ RUBY
815
+ # Rewrite the methods that read @after_change_callbacks directly.
816
+ ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
817
+ def set(**options)
818
+ options.symbolize_keys!
819
+ keys = options.keys
820
+ store = record.store
821
+ previous_context = if block_given?
822
+ keys.zip(store.values_at(*keys)).to_h
823
+ end
824
+ store.merge!(options)
825
+ after_change_callbacks.each(&:call)
826
+ if block_given?
827
+ begin
828
+ yield
829
+ ensure
830
+ store.merge!(previous_context)
831
+ after_change_callbacks.each(&:call)
832
+ end
833
+ end
834
+ end
835
+ def []=(key, value)
836
+ record.store[key.to_sym] = value
837
+ after_change_callbacks.each(&:call)
838
+ end
839
+ RUBY
840
+ end
841
+
842
+ # Patch ActiveSupport::LogSubscriber.logger — a raw class ivar with lazy
843
+ # init (@logger ||= Rails.logger) that's WRITTEN at request teardown via
844
+ # flush_all!. Workers can't write class ivars → IsolationError. Route
845
+ # through IES; workers get Rails.logger (which the shim already routes
846
+ # through IES) so it resolves to the worker's own per-Ractor logger.
847
+ def _install_log_subscriber_patch
848
+ return if @log_subscriber_patched
849
+ @log_subscriber_patched = true
850
+ _register_patch :log_subscriber, "8.1"
851
+ return unless defined?(::ActiveSupport::LogSubscriber)
852
+ ls = ::ActiveSupport::LogSubscriber
853
+ key = :ractor_rails_shim_log_subscriber_logger
854
+ key_str = key.inspect
855
+ ls.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
856
+ def logger
857
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
858
+ return v unless v.nil?
859
+ if Ractor.main? && instance_variable_defined?(:@logger)
860
+ @logger
861
+ elsif defined?(::Rails) && ::Rails.respond_to?(:logger)
862
+ ::Rails.logger
863
+ end
864
+ end
865
+
866
+ def logger=(val)
867
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = val
868
+ end
869
+ RUBY
870
+ end
871
+
872
+ # Patch ActiveSupport::Reloader#check! / #reloaded!. These are CLASS
873
+ # methods that memoize `@should_reload` in a class ivar. ActionDispatch::
874
+ # Executor#call runs `Reloader.run!` -> `check!` on EVERY request, so a
875
+ # worker Ractor writing that class ivar raises Ractor::IsolationError
876
+ # ("can not set instance variables of classes/modules by non-main
877
+ # Ractors"). Route the flag through IsolatedExecutionState so each Ractor
878
+ # has its own. With reloading disabled (config.enable_reloading = false,
879
+ # the right setting for a frozen, shared kino :ractor graph) check.call is
880
+ # `lambda { false }`, so workers compute false (no reload) — but the write
881
+ # must still be Ractor-safe.
882
+ def _install_reloader_patch
883
+ return if @reloader_patched
884
+ @reloader_patched = true
885
+ _register_patch :reloader, "8.1"
886
+ return unless defined?(::ActiveSupport::Reloader)
887
+ rl = ::ActiveSupport::Reloader
888
+ key = :ractor_rails_shim_reloader_should_reload
889
+ key_str = key.inspect
890
+ rl.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
891
+ def check!
892
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
893
+ return v unless v.nil?
894
+ result = check.call
895
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = result
896
+ result
897
+ end
898
+
899
+ def reloaded!
900
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = false
901
+ end
902
+ RUBY
903
+ end
904
+
905
+ # Patch ActiveSupport::Cache::Strategy::LocalCache#local_cache_key. The
906
+ # original memoizes the key in a `@local_cache_key` ivar on the store:
907
+ # `@local_cache_key ||= "...".to_sym`
908
+ # When the store is part of the frozen, shared Rails.application graph
909
+ # (deep-frozen by make_app_shareable! for kino :ractor mode), a worker
910
+ # Ractor writing that ivar raises FrozenError. The key is a pure function
911
+ # of the store's class + object_id (both stable for the shared object),
912
+ # so compute it deterministically each call — no ivar write. The key still
913
+ # addresses LocalCacheRegistry, which is already Ractor-safe (it uses
914
+ # IsolatedExecutionState), so each Ractor keeps its own local cache.
915
+ def _install_local_cache_patch
916
+ return if @local_cache_patched
917
+ @local_cache_patched = true
918
+ _register_patch :local_cache, "8.1"
919
+ return unless defined?(::ActiveSupport::Cache::Strategy::LocalCache)
920
+ lc = ::ActiveSupport::Cache::Strategy::LocalCache
921
+ lc.module_eval <<-RUBY, __FILE__, __LINE__ + 1
922
+ def local_cache_key
923
+ str = "\#{self.class.name.underscore}_local_cache_\#{object_id}".gsub(/[\\/-]/, "_")
924
+ str.to_sym
925
+ end
926
+ RUBY
927
+ end
928
+
929
+ # Patch ActiveSupport::CachingKeyGenerator#generate_key. Its `@cache_keys`
930
+ # ivar is a Concurrent::Map; make_app_shareable! rewrites Concurrent::Map
931
+ # ivars into FROZEN Hashes (see make_shareable.rb), so a worker Ractor's
932
+ # `@cache_keys[args.join("|")] ||= ...` write raises FrozenError. The cache
933
+ # is pure memoization keyed by (generator, args), so route it through
934
+ # IsolatedExecutionState (one mutable cache per Ractor). The inner
935
+ # @key_generator.generate_key now works from workers thanks to the
936
+ # OpenSSL::Digest lambda patch.
937
+ def _install_caching_key_generator_patch
938
+ return if @caching_key_generator_patched
939
+ @caching_key_generator_patched = true
940
+ _register_patch :caching_key_generator, "8.1"
941
+ return unless defined?(::ActiveSupport::CachingKeyGenerator)
942
+ ::ActiveSupport::CachingKeyGenerator.class_eval <<-RUBY, __FILE__, __LINE__ + 1
943
+ def generate_key(*args)
944
+ store = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_caching_key_generator] ||= {})
945
+ key = "\#{object_id}|\#{args.join("|")}"
946
+ store.fetch(key) { store[key] = @key_generator.generate_key(*args) }
947
+ end
948
+ RUBY
949
+ end
950
+
951
+ # Patch ActiveSupport::Messages::SerializerWithFallback. Its SERIALIZERS
952
+ # constant is a Hash of serializer modules — but the Hash itself is not
953
+ # Ractor-shareable, so a worker Ractor reading it raises "can not access
954
+ # non-shareable objects in constant ...SERIALIZERS". The individual
955
+ # serializer modules ARE shareable, so route the lookup through
956
+ # IsolatedExecutionState (a per-Ractor cache of the same module
957
+ # references, which workers can read). `.load` resolves the fallback
958
+ # serializer the same way.
959
+ def _install_messages_serializer_patch
960
+ return if @messages_serializer_patched
961
+ @messages_serializer_patched = true
962
+ _register_patch :messages_serializer, "8.1"
963
+ return unless defined?(::ActiveSupport::Messages::SerializerWithFallback)
964
+ swf = ::ActiveSupport::Messages::SerializerWithFallback
965
+ swf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
966
+ def serializer_for(format)
967
+ if Ractor.main?
968
+ SERIALIZERS.fetch(format)
969
+ else
970
+ (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_serializers] ||= {
971
+ marshal: ::ActiveSupport::Messages::SerializerWithFallback::MarshalWithFallback,
972
+ json: ::ActiveSupport::Messages::SerializerWithFallback::JsonWithFallback,
973
+ json_allow_marshal: ::ActiveSupport::Messages::SerializerWithFallback::JsonWithFallbackAllowMarshal,
974
+ message_pack: ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallback,
975
+ message_pack_allow_marshal: ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallbackAllowMarshal,
976
+ })[format]
977
+ end
978
+ end
979
+
980
+ def [](format)
981
+ if format.to_s.include?("message_pack") && !defined?(::ActiveSupport::MessagePack)
982
+ require "active_support/message_pack"
983
+ end
984
+ serializer_for(format)
985
+ end
986
+ RUBY
987
+ swf.module_eval <<-RUBY, __FILE__, __LINE__ + 1
988
+ def load(dumped)
989
+ format = detect_format(dumped)
990
+ if format == self.format
991
+ _load(dumped)
992
+ elsif format && fallback?(format)
993
+ payload = { serializer: self.format, fallback: format, serialized: dumped }
994
+ ActiveSupport::Notifications.instrument("message_serializer_fallback.active_support", payload) do
995
+ payload[:deserialized] = serializer_for(format)._load(dumped)
996
+ end
997
+ else
998
+ raise "Unsupported serialization format"
999
+ end
1000
+ end
1001
+ RUBY
1002
+
1003
+ # MessagePackWithFallback#available? lazily memoizes `@available` directly
1004
+ # on the module. When a worker Ractor first deserializes a cookie,
1005
+ # SerializerWithFallback#load -> detect_format -> MessagePackWithFallback
1006
+ # .dumped? -> available? tries to SET that ivar, raising
1007
+ # Ractor::IsolationError: can not set instance variables of
1008
+ # classes/modules by non-main Ractors
1009
+ # Replace the ivar memoization with a pure constant check. The module is
1010
+ # shareable and ActiveSupport::MessagePack resolves to a shareable class,
1011
+ # so this is safe from any Ractor.
1012
+ ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallback.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1013
+ def available?
1014
+ defined?(::ActiveSupport::MessagePack)
1015
+ end
1016
+ RUBY
1017
+ end
1018
+
1019
+ # Patch ActiveSupport::JSON::Encoding. The module memoizes two encoders in
1020
+ # class ivars (@encoder_without_options / @encoder_without_escape) inside
1021
+ # `json_encoder=`, and exposes `json_encoder` as a `attr_reader` (so it too
1022
+ # reads the @json_encoder class ivar). A worker Ractor cannot read any of
1023
+ # these module ivars, raising Ractor::IsolationError ("can not get
1024
+ # unshareable values from instance variables of classes/modules from
1025
+ # non-main Ractors"). Capture the encoder CLASS in main (on assignment) into
1026
+ # a shareable constant, then build a per-Ractor encoder instance via
1027
+ # IsolatedExecutionState instead of reading the module ivars.
1028
+ def _install_json_encoding_patch
1029
+ return if @json_encoding_patched
1030
+ @json_encoding_patched = true
1031
+ _register_patch :json_encoding, "8.1"
1032
+ return unless defined?(::ActiveSupport::JSON::Encoding)
1033
+ enc = ::ActiveSupport::JSON::Encoding
1034
+ ec_key = :ractor_rails_shim_json_encoder
1035
+ ec_key_str = ec_key.inspect
1036
+ ecn_key = :ractor_rails_shim_json_encoder_no_escape
1037
+ ecn_key_str = ecn_key.inspect
1038
+ enc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1039
+ def json_encoder=(encoder)
1040
+ RactorRailsShim.const_set(:JSON_ENCODER_CLASS, encoder) if Ractor.main? && defined?(RactorRailsShim)
1041
+ @json_encoder = encoder if Ractor.main?
1042
+ encoder
1043
+ end
1044
+ def json_encoder
1045
+ RactorRailsShim::JSON_ENCODER_CLASS
1046
+ end
1047
+ def encode_without_options(value)
1048
+ encoder = ActiveSupport::IsolatedExecutionState[#{ec_key_str}]
1049
+ encoder ||= (ActiveSupport::IsolatedExecutionState[#{ec_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new)
1050
+ encoder.encode(value)
1051
+ end
1052
+ def encode_without_escape(value)
1053
+ encoder = ActiveSupport::IsolatedExecutionState[#{ecn_key_str}]
1054
+ encoder ||= (ActiveSupport::IsolatedExecutionState[#{ecn_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new(escape: false))
1055
+ encoder.encode(value)
1056
+ end
1057
+ RUBY
1058
+ # The JSON encoding constants (HTML_ENTITIES_REGEX etc.) live on
1059
+ # ActiveSupport::JSON::Encoding but are NOT Ractor-shareable in Ruby 4.0
1060
+ # (Regexp.union / frozen-string Hash return false for Ractor.shareable?).
1061
+ # Deep-freeze + replace them here (in main, during prepare_for_ractors!)
1062
+ # so worker Ractors can read them when the encoder escapes HTML. Belt and
1063
+ # suspenders alongside the SHAREABLE_CONSTANTS registration.
1064
+ if Ractor.main?
1065
+ %w[ESCAPED_CHARS HTML_ENTITIES_REGEX FULL_ESCAPE_REGEX JS_SEPARATORS_REGEX].each do |name|
1066
+ next unless enc.const_defined?(name, false)
1067
+ v = enc.const_get(name, false)
1068
+ unless Ractor.shareable?(v)
1069
+ begin
1070
+ enc.const_set(name, Ractor.make_shareable(v))
1071
+ rescue
1072
+ nil
1073
+ end
1074
+ end
1075
+ end
1076
+ end
1077
+ # Make sure the constant exists on RactorRailsShim so worker references
1078
+ # resolve. It is set on the first `json_encoder=` call during init; seed a
1079
+ # default here so even a direct call before init is safe.
1080
+ unless RactorRailsShim.const_defined?(:JSON_ENCODER_CLASS)
1081
+ RactorRailsShim.const_set(:JSON_ENCODER_CLASS, ::ActiveSupport::JSON::Encoding::JSONGemEncoder)
1082
+ end
1083
+ end
1084
+ end
1085
+ end