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,582 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches for ActionView: LookupContext (default_formats defined via
4
+ # define_method(&block)), Template::Handlers, and PathRegistry.
5
+
6
+ module RactorRailsShim
7
+ # Shareable, mutable module that holds compiled template methods
8
+ # (e.g. `_app_views_...`). ActionView attaches compiled template methods to
9
+ # `compiled_method_container`; the default returns a per-class container,
10
+ # which isolates the shared application layout's compiled method to whichever
11
+ # controller first rendered it (so other controllers raise NoMethodError on
12
+ # the layout). Routing every view_context_class to this ONE shared module
13
+ # makes compiled methods available to all controllers/workers. It is a plain
14
+ # Module (shareable without freezing, so workers can still define methods on
15
+ # it).
16
+ SHAREABLE_COMPILED_MODULE = Module.new unless defined?(::RactorRailsShim::SHAREABLE_COMPILED_MODULE)
17
+
18
+ # ActionView constants that need to be made shareable.
19
+ SHAREABLE_CONSTANTS.concat([
20
+ "ActionView::LookupContext::Accessors::DEFAULT_PROCS",
21
+ "ActionView::Template::NONE",
22
+ "ActionView::Template::Handlers::ERB::ENCODING_TAG",
23
+ "ActionView::AbstractRenderer::RenderedTemplate::EMPTY_SPACER",
24
+ "ActionView::Helpers::TagHelper::PRE_CONTENT_STRINGS",
25
+ "ActionView::Helpers::AssetUrlHelper::ASSET_EXTENSIONS",
26
+ "ActionView::Helpers::UrlHelper::BUTTON_TAG_METHOD_VERBS",
27
+ "ActionView::Helpers::UrlHelper::STRINGIFIED_COMMON_METHODS",
28
+ ])
29
+
30
+ class << self
31
+ # Patch `ActionView::Base.with_empty_template_cache` (action_view/base.rb:204)
32
+ # to a block-free `def`. The original defines `compiled_method_container`
33
+ # (instance + singleton) via `define_method(&block)` — an un-shareable Proc
34
+ # compiled in the main Ractor that raises "defined with an un-shareable Proc
35
+ # in a different Ractor" when a worker calls it. We also route compiled
36
+ # template methods through ONE shared `SHAREABLE_COMPILED_MODULE` so the
37
+ # `application` layout (and Devise shared partials) compile once and are
38
+ # visible to every controller / worker Ractor.
39
+ #
40
+ # Installed EARLY via ActiveSupport.on_load(:action_view) (see core.rb
41
+ # `install`) so it is in place before production eager load calls
42
+ # `DetailsKey.view_context_class` -> `with_empty_template_cache`. Idempotent.
43
+ def _install_with_empty_template_cache_patch
44
+ return if @with_empty_template_cache_patched
45
+ return unless defined?(::ActionView::Base) && Ractor.main?
46
+ @with_empty_template_cache_patched = true
47
+ _register_patch :with_empty_template_cache, "8.1"
48
+ ::ActionView::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
49
+ def with_empty_template_cache
50
+ subclass = Class.new(self) do
51
+ include RactorRailsShim::SHAREABLE_COMPILED_MODULE
52
+ def compiled_method_container
53
+ RactorRailsShim::SHAREABLE_COMPILED_MODULE
54
+ end
55
+ def self.compiled_method_container
56
+ RactorRailsShim::SHAREABLE_COMPILED_MODULE
57
+ end
58
+ def inspect
59
+ "#<ActionView::Base:\#{'%#016x' % (object_id << 1)}>"
60
+ end
61
+ end
62
+ subclass
63
+ end
64
+ RUBY
65
+ end
66
+
67
+ def _install_lookup_context_patch
68
+ return if @lookup_context_patched
69
+ @lookup_context_patched = true
70
+ _register_patch :lookup_context, "8.1"
71
+ return unless defined?(::ActionView::LookupContext)
72
+ # Force autoload of the nested ActionView::Template constants that the
73
+ # patched details_cache_key references (Template::Types,
74
+ # TemplateDetails::Requested). Constants are global, so defining them
75
+ # here (main ractor) makes them visible to worker ractors, which cannot
76
+ # autoload. Without this, the first template render in a worker dies on
77
+ # `NameError: uninitialized constant ActionView::Template::TemplateDetails`.
78
+ if Ractor.main? && defined?(::ActionView::Template)
79
+ ::ActionView::Template::Types rescue nil
80
+ ::ActionView::TemplateDetails rescue nil
81
+ end
82
+ lc = ::ActionView::LookupContext
83
+ key = :ractor_rails_shim_lookup_context_registered_details
84
+ key_str = key.inspect
85
+ lc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
86
+ def registered_details
87
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
88
+ return v unless v.nil?
89
+ if Ractor.main? && instance_variable_defined?(:@registered_details)
90
+ @registered_details
91
+ else
92
+ RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] || []
93
+ end
94
+ end
95
+ def registered_details=(val)
96
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = val
97
+ @registered_details = val if Ractor.main?
98
+ val
99
+ end
100
+ RUBY
101
+ CLASS_ATTRIBUTES << ["ActionView::LookupContext", :registered_details, key, []]
102
+
103
+ # Redefine default_#{name} methods. register_detail (line 25 of
104
+ # lookup_context.rb) defines these via Accessors.define_method(:"default_#{name}", &block)
105
+ # — a Proc from the main ractor. Calling them from a worker raises
106
+ # "defined with an un-shareable Proc in a different Ractor".
107
+ # Trigger: when Accept: */* is sent, request.formats returns [Mime::ALL],
108
+ # and LookupContext#formats= (the override at line 263) does
109
+ # `values.concat(default_formats) if values.delete "*/*"` — Mime::ALL
110
+ # compares == to "*/*", so delete removes it and default_formats is called.
111
+ # Fix: call each block once in main, make the result shareable, and
112
+ # redefine the method via string eval (no captured binding).
113
+ accessors = ::ActionView::LookupContext::Accessors
114
+ ::ActionView::LookupContext.registered_details.each do |name|
115
+ block = accessors::DEFAULT_PROCS[name]
116
+ next unless block
117
+ begin
118
+ value = block.call
119
+ rescue
120
+ next
121
+ end
122
+ begin
123
+ value = Ractor.make_shareable(value)
124
+ rescue
125
+ value = value.dup.freeze rescue value
126
+ end
127
+ const_name = "SHIM_DEFAULT_#{name.upcase}_VALUE"
128
+ verbose, $VERBOSE = $VERBOSE, nil
129
+ accessors.const_set(const_name, value)
130
+ $VERBOSE = verbose
131
+ accessors.module_eval <<-RUBY, __FILE__, __LINE__ + 1
132
+ def default_#{name}
133
+ ::ActionView::LookupContext::Accessors::#{const_name}
134
+ end
135
+ RUBY
136
+ end
137
+
138
+ # Patch ActionView::Rendering::ClassMethods#view_context_class to read
139
+ # from a shareable registry (populated in main) instead of building via
140
+ # Class.new{...} blocks (un-shareable Proc from a worker). The built
141
+ # class is made shareable; workers read it via the registry.
142
+ if defined?(::ActionView::Rendering::ClassMethods)
143
+ rcm = ::ActionView::Rendering::ClassMethods
144
+
145
+ # `ActionView::Base.with_empty_template_cache` is patched separately and
146
+ # EARLY (see _install_with_empty_template_cache_patch, installed via
147
+ # ActiveSupport.on_load(:action_view) before eager load) because the
148
+ # framework's original uses `define_method(:compiled_method_container)
149
+ # { subclass }` — a block/Proc captured in the main Ractor that raises
150
+ # "defined with an un-shareable Proc in a different Ractor" when a
151
+ # worker calls it. In production `DetailsKey.view_context_class` calls
152
+ # with_empty_template_cache during eager load, so the patch MUST be in
153
+ # place before then.
154
+ _install_with_empty_template_cache_patch if defined?(::ActionView::Base)
155
+
156
+ # Build the per-controller view_context_class registry in main. We call
157
+ # the ORIGINAL build_view_context_class directly (bypassing Rails'
158
+ # inherit_view_context_class? short-circuit, which otherwise makes
159
+ # subclasses reuse ActionController::Base's class built with a nil
160
+ # `_routes` and thus NO route url_helpers). `routes` is forced to the
161
+ # shareable Rails.application.routes when the controller's own `_routes`
162
+ # is nil, so named helpers (new_post_path, etc.) are always present.
163
+ if Ractor.main?
164
+ registry = {}
165
+ ::AbstractController::Base.descendants.each do |ctrl|
166
+ begin
167
+ routes = ctrl.respond_to?(:_routes) ? ctrl._routes : nil
168
+ routes = ::Rails.application.routes if routes.nil? && ::Rails.respond_to?(:application) && ::Rails.application
169
+ vcc = rcm.instance_method(:build_view_context_class).bind(ctrl).call(
170
+ ::ActionView::LookupContext::DetailsKey.view_context_class,
171
+ ctrl.respond_to?(:supports_path?) ? ctrl.supports_path? : true,
172
+ routes,
173
+ ctrl.respond_to?(:_helpers) ? ctrl._helpers : nil
174
+ )
175
+ registry[ctrl] = vcc if vcc
176
+ rescue => e
177
+ # skip controllers that can't build (e.g. abstract)
178
+ end
179
+ end
180
+ registry.delete_if { |_, v| v.nil? }
181
+ registry.freeze
182
+ begin
183
+ Ractor.make_shareable(registry)
184
+ self._view_context_registry = registry
185
+ rescue => e
186
+ # If the registry can't be made shareable, leave it — workers fall
187
+ # back to the empty-cache base.
188
+ end
189
+ end
190
+
191
+ rcm.module_eval <<-RUBY, __FILE__, __LINE__ + 1
192
+ def view_context_class
193
+ return @view_context_class if Ractor.main? && instance_variable_defined?(:@view_context_class) && @view_context_class
194
+
195
+ if Ractor.main?
196
+ @view_context_class ||= build_view_context_class(
197
+ ActionView::LookupContext::DetailsKey.view_context_class,
198
+ supports_path?,
199
+ _routes,
200
+ _helpers
201
+ )
202
+ return @view_context_class
203
+ end
204
+
205
+ # Worker Ractor. The frozen shared controller class carries a
206
+ # memoized @view_context_class built in main (in the registry) with
207
+ # its proper route url_helpers + controller helpers. Look it up by
208
+ # controller class; fall back to the shareable fallback class (built
209
+ # in main with Rails.application.routes) for any controller not
210
+ # present in the registry at prepare time.
211
+ vcc = RactorRailsShim._view_context_registry[self]
212
+ return vcc if vcc
213
+ RactorRailsShim._view_context_fallback
214
+ end
215
+
216
+ # `inherit_view_context_class?` (action_view/rendering.rb:52) compares
217
+ # `superclass._helpers` — and `_helpers` is defined via a block
218
+ # (redefine_singleton_method) that cannot run in a worker Ractor
219
+ # ("defined with an un-shareable Proc in a different Ractor"). Return
220
+ # false so each controller builds its own view_context_class (the
221
+ # normal Rails behaviour whenever _routes/_helpers differ), avoiding
222
+ # the `_helpers` comparison entirely. Behaviour is identical in the
223
+ # main Ractor (the inherited class would be equivalent).
224
+ def inherit_view_context_class?
225
+ false
226
+ end
227
+ RUBY
228
+ end
229
+ vcc_key = :ractor_rails_shim_lookup_context_view_context_class
230
+ vcc_key_str = vcc_key.inspect
231
+ CLASS_ATTRIBUTES << ["ActionView::LookupContext::DetailsKey", :view_context_class, vcc_key, nil]
232
+ # Build it now in main and stash in IES so the fallback builder picks it up.
233
+ if Ractor.main? && defined?(::ActionView::Base)
234
+ ActiveSupport::IsolatedExecutionState[vcc_key] = ::ActionView::LookupContext::DetailsKey.view_context_class
235
+ # Shareable fallback view_context_class for any controller not present in
236
+ # the registry at prepare time. Subclasses ActionView::Base, so it
237
+ # inherits the per-class compiled_method_container (self.class) and the
238
+ # route url_helpers. build_view_context_class is a ClassMethods method on
239
+ # controllers, so invoke it via the unbound method bound to
240
+ # ActionController::Base.
241
+ fallback = rcm.instance_method(:build_view_context_class).bind(::ActionController::Base).call(
242
+ ::ActionView::LookupContext::DetailsKey.view_context_class,
243
+ true,
244
+ (defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application) ? ::Rails.application.routes : nil,
245
+ nil
246
+ )
247
+ Ractor.make_shareable(fallback) rescue nil
248
+ self._view_context_fallback = fallback
249
+ end
250
+
251
+ dk = ::ActionView::LookupContext::DetailsKey
252
+ dk_key = :ractor_rails_shim_lookup_context_details_keys
253
+ dc_key = :ractor_rails_shim_lookup_context_digest_cache
254
+ dk_key_str = dk_key.inspect
255
+ dc_key_str = dc_key.inspect
256
+ vcc_key_str2 = vcc_key.inspect
257
+ dk.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
258
+ def view_context_class
259
+ v = ActiveSupport::IsolatedExecutionState[#{vcc_key_str2}]
260
+ return v unless v.nil?
261
+ if Ractor.main? && instance_variable_defined?(:@view_context_class)
262
+ v = @view_context_class
263
+ ActiveSupport::IsolatedExecutionState[#{vcc_key_str2}] = v
264
+ v
265
+ else
266
+ RactorRailsShim::SHAREABLE_FALLBACK[#{vcc_key_str2}]
267
+ end
268
+ end
269
+ def details_keys
270
+ v = ActiveSupport::IsolatedExecutionState[#{dk_key_str}]
271
+ return v if v
272
+ if Ractor.main? && instance_variable_defined?(:@details_keys)
273
+ v = @details_keys
274
+ else
275
+ v = Concurrent::Map.new
276
+ end
277
+ ActiveSupport::IsolatedExecutionState[#{dk_key_str}] = v
278
+ v
279
+ end
280
+ def digest_cache(details)
281
+ dc = (ActiveSupport::IsolatedExecutionState[#{dc_key_str}] ||= Concurrent::Map.new)
282
+ dc[details_cache_key(details)] ||= Concurrent::Map.new
283
+ end
284
+ def details_cache_key(details)
285
+ details_keys.fetch(details) do
286
+ if formats = details[:formats]
287
+ unless ::ActionView::Template::Types.valid_symbols?(formats)
288
+ details = details.dup
289
+ details[:formats] &= ::ActionView::Template::Types.symbols
290
+ end
291
+ end
292
+ details_keys[details] ||= ::ActionView::TemplateDetails::Requested.new(**details)
293
+ end
294
+ end
295
+ RUBY
296
+ end
297
+
298
+ def _install_template_handlers_patch
299
+ return if @template_handlers_patched
300
+ @template_handlers_patched = true
301
+ _register_patch :template_handlers, "8.1"
302
+ return unless defined?(::ActionView::Template::Handlers)
303
+ # Eager-load the handler classes in main so workers don't need to
304
+ # autoload them (workers can't autoload).
305
+ if Ractor.main?
306
+ ::ActionView::Template::Handlers::Raw rescue nil
307
+ ::ActionView::Template::Handlers::ERB rescue nil
308
+ ::ActionView::Template::Handlers::Html rescue nil
309
+ ::ActionView::Template::Handlers::Builder rescue nil
310
+ end
311
+ h = ::ActionView::Template::Handlers
312
+ th_key = :ractor_rails_shim_av_template_handlers
313
+ dth_key = :ractor_rails_shim_av_default_template_handlers
314
+ th_key_str = th_key.inspect
315
+ dth_key_str = dth_key.inspect
316
+ # The handler registry lives in class variables (@@template_handlers,
317
+ # @@default_template_handlers, @@template_extensions) whose values are
318
+ # mutable Hashes holding handler instances — and an unshareable `:ruby`
319
+ # lambda. A worker Ractor cannot read these class vars. Route the
320
+ # registry through IsolatedExecutionState: each Ractor builds its own
321
+ # handler map (the defaults are deterministic), and in main we seed from
322
+ # the live class var (capturing any custom handlers gems registered at
323
+ # boot). The `:ruby` lambda makes the map unshareable, so the old
324
+ # SHAREABLE_FALLBACK approach (which skips unshareable values) left
325
+ # workers with an empty map. These are instance methods (Handlers is
326
+ # extended into ActionView::Template, and the render path calls them on
327
+ # the Template instance), so they must be defined on the module itself,
328
+ # not just the singleton class.
329
+ h.module_eval <<-RUBY, __FILE__, __LINE__ + 1
330
+ def self._ractor_rails_shim_handlers
331
+ map = ActiveSupport::IsolatedExecutionState[#{th_key_str}]
332
+ return map unless map.nil?
333
+ if Ractor.main?
334
+ cv = class_variable_get(:@@template_handlers) rescue nil
335
+ cv = nil if cv && cv.empty?
336
+ if cv
337
+ ActiveSupport::IsolatedExecutionState[#{th_key_str}] = cv
338
+ return cv
339
+ end
340
+ end
341
+ built = {
342
+ raw: ::ActionView::Template::Handlers::Raw.new,
343
+ erb: ::ActionView::Template::Handlers::ERB.new,
344
+ html: ::ActionView::Template::Handlers::Html.new,
345
+ builder: ::ActionView::Template::Handlers::Builder.new,
346
+ ruby: ->(_, source) { source },
347
+ }
348
+ ActiveSupport::IsolatedExecutionState[#{th_key_str}] = built
349
+ built
350
+ end
351
+
352
+ def self._ractor_rails_shim_persist(map)
353
+ ActiveSupport::IsolatedExecutionState[#{th_key_str}] = map
354
+ class_variable_set(:@@template_handlers, map) if Ractor.main?
355
+ end
356
+
357
+ def self.extensions
358
+ self._ractor_rails_shim_handlers.keys
359
+ end
360
+
361
+ def registered_template_handler(extension)
362
+ extension && ::ActionView::Template::Handlers._ractor_rails_shim_handlers[extension.to_sym]
363
+ end
364
+
365
+ def handler_for_extension(extension)
366
+ registered_template_handler(extension) || ::ActionView::Template::Handlers::ERB.new
367
+ end
368
+
369
+ def template_handler_extensions
370
+ ::ActionView::Template::Handlers._ractor_rails_shim_handlers.keys.map(&:to_s).sort
371
+ end
372
+
373
+ def register_template_handler(*extensions, handler)
374
+ map = ::ActionView::Template::Handlers._ractor_rails_shim_handlers.dup
375
+ extensions.each { |ext| map[ext.to_sym] = handler }
376
+ ::ActionView::Template::Handlers._ractor_rails_shim_persist(map)
377
+ end
378
+
379
+ def unregister_template_handler(*extensions)
380
+ map = ::ActionView::Template::Handlers._ractor_rails_shim_handlers.dup
381
+ extensions.each { |ext| map.delete(ext.to_sym) }
382
+ ::ActionView::Template::Handlers._ractor_rails_shim_persist(map)
383
+ end
384
+
385
+ def register_default_template_handler(extension, klass)
386
+ register_template_handler(extension, klass)
387
+ end
388
+ RUBY
389
+ end
390
+
391
+ # Patch ActionView::PathRegistry to not read its raw class ivars
392
+ # (@view_paths_by_class, @file_system_resolvers) from a worker Ractor.
393
+ # These are populated at boot (view paths registered by the app). For a
394
+ # frozen shared app they're read-only; workers read them via the shareable
395
+ # fallback (built from main's values, made shareable). `get_view_paths` is
396
+ # called per-request during view lookup; `all_file_system_resolvers` is
397
+ # called by the exception backtrace builder.
398
+ def _install_path_registry_patch
399
+ return if @path_registry_patched
400
+ @path_registry_patched = true
401
+ _register_patch :path_registry, "8.1"
402
+ return unless defined?(::ActionView::PathRegistry)
403
+ pr = ::ActionView::PathRegistry
404
+ vpc_key = :ractor_rails_shim_path_registry_view_paths_by_class
405
+ fsr_key = :ractor_rails_shim_path_registry_file_system_resolvers
406
+ vpc_key_str = vpc_key.inspect
407
+ fsr_key_str = fsr_key.inspect
408
+ pr.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
409
+ def get_view_paths(klass)
410
+ h = ActiveSupport::IsolatedExecutionState[#{vpc_key_str}]
411
+ h = (Ractor.main? ? (instance_variable_defined?(:@view_paths_by_class) ? instance_variable_get(:@view_paths_by_class) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{vpc_key_str}]) if h.nil?
412
+ h[klass] || get_view_paths(klass.superclass)
413
+ end
414
+
415
+ def set_view_paths(klass, paths)
416
+ h = ActiveSupport::IsolatedExecutionState[#{vpc_key_str}] ||= (Ractor.main? ? (instance_variable_defined?(:@view_paths_by_class) ? instance_variable_get(:@view_paths_by_class) : {}) : {})
417
+ h[klass] = paths
418
+ instance_variable_set(:@view_paths_by_class, h) if Ractor.main?
419
+ end
420
+
421
+ def all_file_system_resolvers
422
+ h = ActiveSupport::IsolatedExecutionState[#{fsr_key_str}]
423
+ h = (Ractor.main? ? (instance_variable_defined?(:@file_system_resolvers) ? instance_variable_get(:@file_system_resolvers) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{fsr_key_str}]) if h.nil?
424
+ h.values
425
+ end
426
+ RUBY
427
+ # Register so the fallback builder captures + shares these.
428
+ CLASS_ATTRIBUTES << ["ActionView::PathRegistry", :view_paths_by_class, vpc_key, {}]
429
+ CLASS_ATTRIBUTES << ["ActionView::PathRegistry", :file_system_resolvers, fsr_key, {}]
430
+ end
431
+
432
+ # Patch ActionView::FileSystemResolver#_find_all. The original reads the
433
+ # resolver's `@unbound_templates` cache, which `make_app_shareable!`
434
+ # rewrites from a Concurrent::Map into a frozen Hash (Concurrent::Map
435
+ # refuses #freeze). The original then calls `cache.compute_if_absent`
436
+ # (a Concurrent::Map API) on it, which a frozen Hash lacks -> NoMethodError
437
+ # in a worker Ractor. Route the per-virtual-path cache through
438
+ # IsolatedExecutionState instead: each Ractor builds its own mutable Hash
439
+ # (deterministic from disk via `unbound_templates_from_path`), so the
440
+ # frozen shareable app graph is never mutated.
441
+ def _install_action_view_resolver_patch
442
+ return if @action_view_resolver_patched
443
+ @action_view_resolver_patched = true
444
+ _register_patch :action_view_resolver, "8.1"
445
+ return unless defined?(::ActionView::FileSystemResolver)
446
+ # Eager-load nested constants referenced below (workers can't autoload).
447
+ if Ractor.main?
448
+ ::ActionView::TemplateDetails rescue nil
449
+ ::ActionView::TemplatePath rescue nil
450
+ end
451
+ ::ActionView::FileSystemResolver.module_eval <<-RUBY, __FILE__, __LINE__ + 1
452
+ def _find_all(name, prefix, partial, details, key, locals)
453
+ requested_details = key || ::ActionView::TemplateDetails::Requested.new(**details)
454
+ virtual = ::ActionView::TemplatePath.virtual(name, prefix, partial)
455
+ # Key the cache by resolver path AND virtual path: each resolver
456
+ # (app/views, each gem) has its own @path and its own templates.
457
+ # Keying only by virtual path would let the first resolver poison
458
+ # the cache for all others (e.g. app/views caches [] for
459
+ # devise/sessions/new, hiding the template that lives in the
460
+ # devise gem resolver).
461
+ cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_resolver_cache] ||= {})
462
+ cache_key = [@path, virtual]
463
+ unbound_templates =
464
+ if cache.key?(cache_key)
465
+ cache[cache_key]
466
+ else
467
+ path = ::ActionView::TemplatePath.build(name, prefix, partial)
468
+ tmpls = unbound_templates_from_path(path)
469
+ cache[cache_key] = tmpls
470
+ tmpls
471
+ end
472
+ filter_and_sort_by_details(unbound_templates, requested_details).map do |unbound_template|
473
+ unbound_template.bind_locals(locals)
474
+ end
475
+ end
476
+ RUBY
477
+
478
+ # Patch ActionView::Resolver::PathParser#parse. The resolver's
479
+ # @path_parser instance is part of the shareable app graph frozen by
480
+ # make_app_shareable!, and the original method memoizes its compiled
481
+ # regex in `@regex ||= build_path_regex` — assigning @regex on a frozen
482
+ # object raises FrozenError in a worker Ractor. Route the memoization
483
+ # through IsolatedExecutionState keyed by the parser's object_id, so each
484
+ # Ractor compiles its own regex once without mutating the frozen object.
485
+ if defined?(::ActionView::Resolver::PathParser)
486
+ pp = ::ActionView::Resolver::PathParser
487
+ pp_key = :ractor_rails_shim_path_parser_regex
488
+ pp_key_str = pp_key.inspect
489
+ pp.module_eval <<-RUBY, __FILE__, __LINE__ + 1
490
+ def parse(path)
491
+ regex = ActiveSupport::IsolatedExecutionState[:"#{pp_key_str}_\#{object_id}"] ||= build_path_regex
492
+ match = regex.match(path)
493
+ path = ::ActionView::TemplatePath.build(match[:action], match[:prefix] || "", !!match[:partial])
494
+ details = ::ActionView::TemplateDetails.new(
495
+ match[:locale]&.to_sym,
496
+ match[:handler]&.to_sym,
497
+ match[:format]&.to_sym,
498
+ match[:variant]&.to_sym
499
+ )
500
+ ::ActionView::Resolver::PathParser::ParsedPath.new(path, details)
501
+ end
502
+ RUBY
503
+ end
504
+ end
505
+
506
+ # Patch ActionView::AbstractRenderer::ObjectRendering#partial_path. The
507
+ # original reads `PREFIXED_PARTIAL_NAMES` — a `Concurrent::Map` constant
508
+ # (nested Concurrent::Maps) — and writes a nested entry via
509
+ # `PREFIXED_PARTIAL_NAMES[@context_prefix][path] ||= ...`. Concurrent::Map
510
+ # is intrinsically unshareable (it refuses #freeze), so a worker Ractor
511
+ # cannot read the constant NOR write to it. Redefine the method to use a
512
+ # per-Ractor Hash via IsolatedExecutionState (each Ractor builds its own
513
+ # cache from `merge_prefix_into_object_path`, which is deterministic).
514
+ def _install_action_view_partial_path_patch
515
+ return if @action_view_partial_path_patched
516
+ @action_view_partial_path_patched = true
517
+ _register_patch :action_view_partial_path, "8.1"
518
+ return unless defined?(::ActionView::AbstractRenderer)
519
+ ::ActionView::AbstractRenderer::ObjectRendering.module_eval <<-RUBY, __FILE__, __LINE__ + 1
520
+ def partial_path(object, view)
521
+ object = object.to_model if object.respond_to?(:to_model)
522
+ path = if object.respond_to?(:to_partial_path)
523
+ object.to_partial_path
524
+ else
525
+ raise ArgumentError.new("\#{object.inspect}' is not an ActiveModel-compatible object. It must implement #to_partial_path.")
526
+ end
527
+ if view.prefix_partial_path_with_controller_namespace
528
+ cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_prefixed_partial_names] ||= {})
529
+ cache[@context_prefix] ||= {}
530
+ cache[@context_prefix][path] ||= merge_prefix_into_object_path(@context_prefix, path.dup)
531
+ else
532
+ path
533
+ end
534
+ end
535
+ RUBY
536
+ end
537
+
538
+ # Patch ActionView::Helpers::Tags::TextField.field_type (and the subclasses
539
+ # EmailField/PasswordField/... that inherit it). The original memoizes its
540
+ # computed String in a lazy class ivar (`@field_type ||= name...`). The
541
+ # class ivar is per-subclass and unshareable-writable from a worker Ractor.
542
+ # Route the cache through IsolatedExecutionState keyed by the class name so
543
+ # each Ractor builds its own copy; the computation is deterministic.
544
+ def _install_action_view_field_type_patch
545
+ return if @action_view_field_type_patched
546
+ @action_view_field_type_patched = true
547
+ _register_patch :action_view_field_type, "8.1"
548
+ return unless defined?(::ActionView::Helpers::Tags::TextField)
549
+ tf = ::ActionView::Helpers::Tags::TextField
550
+ tf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
551
+ def field_type
552
+ key = :"ractor_rails_shim_field_type_\#{name}"
553
+ v = ActiveSupport::IsolatedExecutionState[key]
554
+ return v if v
555
+ ft = name.split("::").last.sub("Field", "").downcase
556
+ ActiveSupport::IsolatedExecutionState[key] = ft
557
+ ft
558
+ end
559
+ RUBY
560
+ end
561
+
562
+ # Patch ActionView::Helpers::OutputSafetyHelper#safe_join. Its default
563
+ # separator parameter is `sep = $,` — a reference to the `$` global, which a
564
+ # worker Ractor cannot read (Ractor::IsolationError: can not access global
565
+ # variable $,). The `$` global is nil in every normal Rails process, so
566
+ # defaulting to nil reproduces the identical behaviour without touching the
567
+ # global.
568
+ def _install_action_view_safe_join_patch
569
+ return if @action_view_safe_join_patched
570
+ @action_view_safe_join_patched = true
571
+ _register_patch :action_view_safe_join, "8.1"
572
+ return unless defined?(::ActionView::Helpers::OutputSafetyHelper)
573
+ ::ActionView::Helpers::OutputSafetyHelper.module_eval <<-RUBY, __FILE__, __LINE__ + 1
574
+ def safe_join(array, sep = nil)
575
+ sep = ERB::Util.unwrapped_html_escape(sep)
576
+ array.flatten.map! { |i| ERB::Util.unwrapped_html_escape(i) }.join(sep).html_safe
577
+ end
578
+ RUBY
579
+ end
580
+
581
+ end
582
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch two ActiveModel/ActiveRecord behaviors so worker Ractors (under a
4
+ # frozen `:ractor` shared graph) can build and persist records.
5
+ #
6
+ # 1. Attribute#dup_or_share (writes): see ActiveModelAttributePatch below.
7
+ # 2. AttributeRegistration class caches (reads): `@attribute_types`,
8
+ # `@default_attributes`, etc. hold ActiveModel::Type instances, which are
9
+ # NOT Ractor-shareable. Under `:ractor` the shim freezes the graph, so those
10
+ # class-ivar values become unshareable; a worker reading them raises
11
+ # `Ractor::IsolationError: can not get unshareable values from instance
12
+ # variables ... (@attribute_types from Post)`, and a worker writing them
13
+ # (the `||=` memoization) raises `can not set instance variables ...`.
14
+ #
15
+ # Fix: serve these caches from per-Ractor storage (ActiveSupport::
16
+ # IsolatedExecutionState), keyed by the class. Each worker Ractor computes
17
+ # and keeps its OWN copy — it never touches the shared class ivar, so there
18
+ # is no cross-boundary (unshareable) value and no class-ivar write. In the
19
+ # main Ractor this behaves identically to the original (compute once, cache).
20
+ # The values are deterministic from the schema, so per-Ractor caching is
21
+ # behavior-preserving.
22
+
23
+ module RactorRailsShim
24
+ module ActiveModelAttributePatch
25
+ def self.included(base)
26
+ base.prepend(InstanceMethods)
27
+ end
28
+
29
+ module InstanceMethods
30
+ def dup_or_share # :nodoc:
31
+ if frozen?
32
+ self.class.from_database(
33
+ name,
34
+ value_before_type_cast,
35
+ type,
36
+ defined?(@value) ? @value : nil
37
+ )
38
+ else
39
+ super
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ module ActiveModelAttributeRegistrationPatch
46
+ def self.prepended(base)
47
+ base.prepend(InstanceMethods)
48
+ end
49
+
50
+ module InstanceMethods
51
+ def _default_attributes # :nodoc:
52
+ key = :"rrs_default_attributes_#{object_id}"
53
+ ActiveSupport::IsolatedExecutionState[key] ||=
54
+ ::ActiveModel::AttributeSet.new({}).tap do |attribute_set|
55
+ apply_pending_attribute_modifications(attribute_set)
56
+ end
57
+ end
58
+
59
+ def attribute_types # :nodoc:
60
+ key = :"rrs_attribute_types_#{object_id}"
61
+ ActiveSupport::IsolatedExecutionState[key] ||= begin
62
+ types = _default_attributes.cast_types
63
+ types.default = ::ActiveModel::Type.default_value
64
+ types
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ module ActiveRecordAttributesPatch
71
+ def self.prepended(base)
72
+ base.prepend(InstanceMethods)
73
+ end
74
+
75
+ module InstanceMethods
76
+ # ActiveRecord overrides _default_attributes (attributes.rb:253) with a
77
+ # version that reads the @default_attributes class ivar and opens a
78
+ # connection to build the AttributeSet. Same per-Ractor fix as above:
79
+ # cache in IES so workers never read/write the shared class ivar.
80
+ def _default_attributes # :nodoc:
81
+ key = :"rrs_default_attributes_#{object_id}"
82
+ ActiveSupport::IsolatedExecutionState[key] ||= begin
83
+ attributes_hash = with_connection do |connection|
84
+ columns_hash.transform_values do |column|
85
+ ::ActiveModel::Attribute.from_database(
86
+ column.name, column.default, type_for_column(connection, column)
87
+ )
88
+ end
89
+ end
90
+ attribute_set = ::ActiveModel::AttributeSet.new(attributes_hash)
91
+ apply_pending_attribute_modifications(attribute_set)
92
+ attribute_set
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end