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,851 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches for ActionDispatch: ExceptionWrapper (reads @@rescue_responses
4
+ # directly), and Request.parameter_parsers.
5
+
6
+ module RactorRailsShim
7
+ # ActionDispatch + Mime constants that need to be made shareable.
8
+ SHAREABLE_CONSTANTS.concat([
9
+ "ActionDispatch::FileHandler::PRECOMPRESSED",
10
+ "ActionDispatch::SSL::PERMANENT_REDIRECT_REQUEST_METHODS",
11
+ "ActionDispatch::HostAuthorization::VALID_IP_HOSTNAME",
12
+ "ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT",
13
+ "ActionDispatch::Request::HTTP_METHODS",
14
+ "ActionDispatch::Request::HTTP_METHOD_LOOKUP",
15
+ "ActionDispatch::Request::LOCALHOST",
16
+ "ActionDispatch::DebugView::RESCUES_TEMPLATE_PATHS",
17
+ "Mime::SET",
18
+ "Mime::EXTENSION_LOOKUP",
19
+ "Mime::LOOKUP",
20
+ "Mime::Type::TRAILING_STAR_REGEXP",
21
+ "Mime::Type::PARAMETER_SEPARATOR_REGEXP",
22
+ "Mime::Type::ACCEPT_HEADER_REGEXP",
23
+ "Mime::ALL",
24
+ "ActionDispatch::Response::NullContentTypeHeader",
25
+ "ActionDispatch::Response::NO_CONTENT_CODES",
26
+ "ActionDispatch::Response::RackBody::BODY_METHODS",
27
+ "ActionDispatch::Response::Buffer::BODY_METHODS",
28
+ "ActionView::Helpers::ControllerHelper::CONTROLLER_DELEGATES",
29
+ ])
30
+
31
+ # Source-location constants used by make_app_shareable!'s proc-replacement
32
+ # graph traversal (moved here from make_shareable.rb so each concern's pieces
33
+ # live together).
34
+ SSL_LOC = "/active_dispatch/middleware/ssl.rb".freeze
35
+ COOKIE_LOC = "/session/cookie_store.rb".freeze
36
+ MAPPER_LOC = "/action_dispatch/routing/mapper.rb".freeze
37
+
38
+ class << self
39
+ # Shareable callable replacements for ActionDispatch/ActionController
40
+ # self-capturing Procs (moved here from make_shareable.rb). Defined via
41
+ # string eval on the singleton class so they're referenced the same way the
42
+ # original code did (the engine resolves them as RactorRailsShim singleton
43
+ # class constants; specs access via RactorRailsShim.singleton_class.const_get).
44
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
45
+ class RequestCallable
46
+ def initialize(method_name); @method_name = method_name; end
47
+ def call(request, response = nil); request.__send__(@method_name); end
48
+ end
49
+ class StrategyServe
50
+ def call(app, req); app.serve(req); end
51
+ end
52
+ class StrategyCall
53
+ def call(app, req); app.call(req.env); end
54
+ end
55
+ RUBY
56
+
57
+ # Patch ActionDispatch::ExceptionWrapper instance methods that read
58
+ # @@rescue_responses / @@rescue_templates class variables directly
59
+ # (bypassing the mattr_accessor reader the shim already reroutes through
60
+ # IES). Workers can't read class vars; route through the class method.
61
+ def _install_exception_wrapper_patch
62
+ return if @exception_wrapper_patched
63
+ @exception_wrapper_patched = true
64
+ _register_patch :exception_wrapper, "8.1"
65
+ return unless defined?(::ActionDispatch::ExceptionWrapper)
66
+ ::ActionDispatch::ExceptionWrapper.module_eval <<-RUBY, __FILE__, __LINE__ + 1
67
+ def rescue_template
68
+ self.class.rescue_templates[exception_class_name]
69
+ end
70
+ def status_code
71
+ ActionDispatch::Response.rack_status_code(self.class.rescue_responses[exception_class_name])
72
+ end
73
+ def rescue_response?
74
+ self.class.rescue_responses.key?(exception.class.name)
75
+ end
76
+ RUBY
77
+ # Also patch the class method (status_code_for_exception) that reads
78
+ # @@rescue_responses directly — called by ActionController::Instrumentation
79
+ # at request time. Route through the mattr reader (which the shim already
80
+ # reroutes through IES).
81
+ ::ActionDispatch::ExceptionWrapper.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
82
+ def status_code_for_exception(class_name)
83
+ ActionDispatch::Response.rack_status_code(rescue_responses[class_name])
84
+ end
85
+ RUBY
86
+ end
87
+
88
+ # Patch ActionDispatch::Request.parameter_parsers (singleton attr_reader
89
+ # backed by @parameter_parsers) to not read the class ivar from a worker
90
+ # Ractor. The value is a Hash of MIME-type → parser (lambdas). Route
91
+ # through IES; workers read the shareable fallback (the boot-time parsers,
92
+ # made shareable). Read per-request during parameter parsing.
93
+ def _install_request_parameter_parsers_patch
94
+ return if @request_param_parsers_patched
95
+ @request_param_parsers_patched = true
96
+ _register_patch :request_parameter_parsers, "8.1"
97
+ return unless defined?(::ActionDispatch::Request)
98
+ req = ::ActionDispatch::Request
99
+ pp_key = :ractor_rails_shim_request_parameter_parsers
100
+ pp_key_str = pp_key.inspect
101
+ req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
102
+ def parameter_parsers
103
+ v = ActiveSupport::IsolatedExecutionState[#{pp_key_str}]
104
+ return v unless v.nil?
105
+ if Ractor.main? && instance_variable_defined?(:@parameter_parsers)
106
+ v = @parameter_parsers
107
+ ActiveSupport::IsolatedExecutionState[#{pp_key_str}] = v
108
+ v
109
+ else
110
+ RactorRailsShim::SHAREABLE_FALLBACK[#{pp_key_str}] || ActionDispatch::Request::DEFAULT_PARSERS
111
+ end
112
+ end
113
+ RUBY
114
+ CLASS_ATTRIBUTES << ["ActionDispatch::Request", :parameter_parsers, pp_key, nil]
115
+ end
116
+
117
+ # ActionDispatch::QueryParser.each_pair returns `enum_for(:each_pair, s,
118
+ # separator)` when called without a block. The Enumerator wraps a Proc that
119
+ # was compiled in the main Ractor, so when a worker Ractor iterates it
120
+ # (`pairs.each` inside ParamBuilder#from_pairs) Ruby raises "defined with an
121
+ # un-shareable Proc in a different Ractor". Redefine it to materialize into a
122
+ # plain (shareable) frozen Array using a block-free loop, so worker Ractors
123
+ # can parse form/query pairs without crossing Ractor boundaries.
124
+ def _install_query_parser_patch
125
+ return if @query_parser_patched
126
+ @query_parser_patched = true
127
+ return unless defined?(::ActionDispatch::QueryParser)
128
+ ::ActionDispatch::QueryParser.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1
129
+ def each_pair(s, separator = nil)
130
+ return _materialized_pairs(s, separator) unless block_given?
131
+ s ||= ""
132
+ splitter =
133
+ if separator
134
+ ::ActionDispatch::QueryParser::COMMON_SEP[separator] || /[#{separator}] */n
135
+ else
136
+ ::ActionDispatch::QueryParser::DEFAULT_SEP
137
+ end
138
+ s.split(splitter).each do |part|
139
+ next if part.empty?
140
+ k, v = part.split("=", 2)
141
+ k = URI.decode_www_form_component(k)
142
+ v &&= URI.decode_www_form_component(v)
143
+ yield k, v
144
+ end
145
+ nil
146
+ end
147
+
148
+ def _materialized_pairs(s, separator)
149
+ s ||= ""
150
+ splitter =
151
+ if separator
152
+ ::ActionDispatch::QueryParser::COMMON_SEP[separator] || /[#{separator}] */n
153
+ else
154
+ ::ActionDispatch::QueryParser::DEFAULT_SEP
155
+ end
156
+ parts = s.split(splitter)
157
+ result = []
158
+ i = 0
159
+ while i < parts.length
160
+ part = parts[i]
161
+ i += 1
162
+ if part.empty?
163
+ next
164
+ end
165
+ kv = part.split("=", 2)
166
+ k = URI.decode_www_form_component(kv[0])
167
+ v = kv[1] && URI.decode_www_form_component(kv[1])
168
+ result << [k, v]
169
+ end
170
+ result.freeze
171
+ end
172
+ RUBY
173
+ end
174
+
175
+ # Patch ActionDispatch::Routing::RouteSet::MountedHelpers#main_app (and
176
+ # its _main_app worker). main_app is `define_method`-ed at boot capturing
177
+ # the MAIN ractor's RouteSet + url_helpers in its block binding, so
178
+ # calling it from a worker Ractor raises "defined with an un-shareable
179
+ # Proc in a different Ractor". Devise's _devise_route_context calls
180
+ # `send(:main_app)` to get the route context for its url helpers. Redefine
181
+ # via string eval, building the RoutesProxy from the shareable RouteSet
182
+ # (RactorRailsShim::SHAREABLE_ROUTES) so workers get a valid context.
183
+ def _install_action_dispatch_mounted_helpers_patch
184
+ return if @mounted_helpers_patched
185
+ @mounted_helpers_patched = true
186
+ _register_patch :mounted_helpers, "8.1"
187
+ return unless defined?(::ActionDispatch::Routing::RouteSet::MountedHelpers)
188
+ mh = ::ActionDispatch::Routing::RouteSet::MountedHelpers
189
+ return unless mh.method_defined?(:main_app)
190
+ mh.class_eval <<-RUBY, __FILE__, __LINE__ + 1
191
+ def _main_app
192
+ ::ActionDispatch::Routing::RoutesProxy.new(
193
+ RactorRailsShim::SHAREABLE_ROUTES,
194
+ _routes_context,
195
+ RactorRailsShim::SHAREABLE_ROUTES.url_helpers,
196
+ nil
197
+ )
198
+ end
199
+ def main_app
200
+ @_main_app ||= _main_app
201
+ end
202
+ RUBY
203
+ end
204
+
205
+ # Patch ActionDispatch::Routing::RouteSet URL generation. The named route
206
+ # helpers (`post_path`, `session_path`, ...) are generated at boot in the
207
+ # main Ractor by `RouteSet#add`, which captures the `PATH` / `UNKNOWN`
208
+ # lambda constants (route_set.rb:349-350) into each helper's `url_strategy`
209
+ # ivar. Those lambdas were defined in the main Ractor, so calling them from
210
+ # a worker Ractor raises `RuntimeError: defined with an un-shareable Proc in
211
+ # a different Ractor`. Replace them with shareable Callable objects (Plain
212
+ # old objects with a `#call` method, made shareable via
213
+ # `Ractor.make_shareable`) that delegate to `ActionDispatch::Http::URL`
214
+ # (module methods, callable from any Ractor).
215
+ def _install_action_dispatch_routing_patch
216
+ return if @action_dispatch_routing_patched
217
+ @action_dispatch_routing_patched = true
218
+ _register_patch :action_dispatch_routing, "8.1"
219
+ return unless defined?(::ActionDispatch::Routing::RouteSet)
220
+ return unless defined?(::ActionDispatch::Http::URL)
221
+
222
+ # Shareable Callable replacements for the PATH / UNKNOWN lambda constants.
223
+ unless RactorRailsShim.const_defined?(:AVPathStrategy)
224
+ RactorRailsShim.const_set(:AVPathStrategy,
225
+ Ractor.make_shareable(Object.new.tap do |o|
226
+ def o.call(options)
227
+ ActionDispatch::Http::URL.path_for(options)
228
+ end
229
+ end))
230
+ end
231
+ unless RactorRailsShim.const_defined?(:AVUnknownStrategy)
232
+ RactorRailsShim.const_set(:AVUnknownStrategy,
233
+ Ractor.make_shareable(Object.new.tap do |o|
234
+ def o.call(options)
235
+ ActionDispatch::Http::URL.url_for(options)
236
+ end
237
+ end))
238
+ end
239
+
240
+ rs = ::ActionDispatch::Routing::RouteSet
241
+ # `RouteSet::PATH` / `RouteSet::UNKNOWN` (route_set.rb:349-350) are lambdas
242
+ # defined in the main Ractor. They are referenced as default parameter
243
+ # values (`def url_for(..., url_strategy = UNKNOWN, ...)`) and inside
244
+ # `path_for`/`define_url_helper`. Reading those constants from a worker
245
+ # Ractor raises `Ractor::IsolationError: can not access non-shareable
246
+ # objects in constant ...UNKNOWN`. Replace them with the shareable
247
+ # Callable objects (which perform the identical `ActionDispatch::Http::URL`
248
+ # lookups) so workers read a shareable constant instead of an unshareable
249
+ # lambda. Behaviour is unchanged in main (same `#call(options)` contract).
250
+ unless rs.const_defined?(:PATH) && Ractor.shareable?(rs.const_get(:PATH))
251
+ verbose = $VERBOSE
252
+ $VERBOSE = nil
253
+ rs.const_set(:PATH, RactorRailsShim::AVPathStrategy)
254
+ rs.const_set(:UNKNOWN, RactorRailsShim::AVUnknownStrategy)
255
+ $VERBOSE = verbose if defined?(verbose)
256
+ end
257
+ # `ActionDispatch::Journey::Router::Utils::ENCODER` (`UriEncoder.new`) and
258
+ # its sibling constants (`DEC2HEX`, `EMPTY`, `US_ASCII`, the unreserved/
259
+ # segment regexes, ...) are referenced by `escape_path`/`escape_segment`,
260
+ # which the journey URL formatter invokes while building a path in a worker
261
+ # Ractor. An unfrozen object/array/string held in a constant is unshareable,
262
+ # so workers reading it raise IsolationError. Freeze each constant in place
263
+ # (via `Ractor.make_shareable`) so the shareable-frozen values are readable
264
+ # from any Ractor.
265
+ if defined?(::ActionDispatch::Journey::Router::Utils)
266
+ utu = ::ActionDispatch::Journey::Router::Utils
267
+ utu.constants.each do |c|
268
+ begin
269
+ v = utu.const_get(c)
270
+ Ractor.make_shareable(v) if v && !Ractor.shareable?(v)
271
+ rescue
272
+ nil
273
+ end
274
+ end
275
+ end
276
+ # `RouteSet::RESERVED_OPTIONS` (route_set.rb:838) is a mutable Array used
277
+ # as a default parameter value in `url_for`/`path_for`. A non-frozen Array
278
+ # is unshareable, so workers reading the constant raise IsolationError.
279
+ # Freeze it in place so the constant becomes shareable.
280
+ begin
281
+ Ractor.make_shareable(rs.const_get(:RESERVED_OPTIONS))
282
+ rescue
283
+ nil
284
+ end
285
+ # Warm the lazy (memoized) caches on every Journey route and its
286
+ # Path::Pattern BEFORE `make_app_shareable!` deep-freezes them. Several of
287
+ # these caches are filled with `||=` (e.g. `Route#parts`,
288
+ # `Route#required_parts`, `Route#required_defaults`,
289
+ # `Path::Pattern#requirements_for_missing_keys_check`, `#to_regexp`,
290
+ # `#offsets`, `#required_names`, `#optional_names`). They are computed
291
+ # deterministically, but assigning the memoized ivar on a frozen object
292
+ # from a worker Ractor raises FrozenError. Computing them here (in main,
293
+ # while the objects are still mutable) populates the ivars so the frozen,
294
+ # shared copies already hold the values and workers only read them.
295
+ if Ractor.main? && defined?(::Rails) && ::Rails.application
296
+ begin
297
+ rset = ::Rails.application.routes
298
+ all = []
299
+ all.concat(rset.named_routes.send(:routes).values) rescue nil
300
+ all.concat(rset.set.routes) rescue nil
301
+ all.uniq.each do |route|
302
+ next unless route.respond_to?(:path)
303
+ route.parts rescue nil
304
+ route.required_parts rescue nil
305
+ route.required_defaults rescue nil
306
+ p = route.path
307
+ p.requirements_for_missing_keys_check rescue nil
308
+ p.to_regexp rescue nil
309
+ p.offsets rescue nil
310
+ p.required_names rescue nil
311
+ p.optional_names rescue nil
312
+ end
313
+ rescue
314
+ nil
315
+ end
316
+ end
317
+ # Capture the (shareable) RouteSet so workers can build URLs without
318
+ # calling `#_routes` — which is `define_method(:_routes) { @_routes ||
319
+ # routes }` (route_set.rb:612), a block capturing the main Ractor's
320
+ # `routes` reference. Calling that block from a worker raises
321
+ # "defined with an un-shareable Proc in a different Ractor". We stash the
322
+ # RouteSet as a shareable constant and point `_routes` at it.
323
+ if Ractor.main?
324
+ begin
325
+ routes = Rails.application.routes if defined?(::Rails) && ::Rails.application
326
+ unless routes.nil?
327
+ verbose = $VERBOSE
328
+ $VERBOSE = nil
329
+ RactorRailsShim.const_set(:SHAREABLE_ROUTES, routes) unless RactorRailsShim.const_defined?(:SHAREABLE_ROUTES)
330
+ end
331
+ rescue
332
+ nil
333
+ ensure
334
+ $VERBOSE = verbose if defined?(verbose)
335
+ end
336
+ end
337
+
338
+ # RouteSet#url_for receives url_strategy (the captured PATH/UNKNOWN
339
+ # lambda) and calls `url_strategy.call options` internally. Coerce a
340
+ # non-shareable strategy to the shareable Callable before delegating.
341
+ unless rs.method_defined?(:url_for_without_shim)
342
+ rs.alias_method(:url_for_without_shim, :url_for)
343
+ end
344
+ rs.module_eval <<-RUBY, __FILE__, __LINE__ + 1
345
+ def url_for(options, route_name = nil, url_strategy = UNKNOWN, method_name = nil, reserved = RESERVED_OPTIONS)
346
+ url_strategy = RactorRailsShim::AVUnknownStrategy unless Ractor.shareable?(url_strategy)
347
+ url_for_without_shim(options, route_name, url_strategy, method_name, reserved)
348
+ end
349
+ RUBY
350
+
351
+ # OptimizedUrlHelper#call invokes `url_strategy.call options` DIRECTLY
352
+ # (route_set.rb:228) without going through url_for, so the coercion above
353
+ # doesn't cover it. Redefine it to call the shareable strategy Callable
354
+ # (the one passed in by our redefined helper methods), replicating the
355
+ # original body exactly otherwise.
356
+ ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper::OptimizedUrlHelper.class_eval <<-RUBY, __FILE__, __LINE__ + 1
357
+ def call(t, method_name, args, inner_options, url_strategy)
358
+ if args.size == arg_size && !inner_options && optimize_routes_generation?(t)
359
+ options = t.url_options.merge @options
360
+ path = optimized_helper(args)
361
+ path << "/" if options[:trailing_slash] && !path.end_with?("/")
362
+ options[:path] = path
363
+ original_script_name = options.delete(:original_script_name)
364
+ script_name = t._routes.find_script_name(options)
365
+ if original_script_name
366
+ script_name = original_script_name + script_name
367
+ end
368
+ options[:script_name] = script_name
369
+ strat = Ractor.shareable?(url_strategy) ? url_strategy : RactorRailsShim::AVPathStrategy
370
+ strat.call(options)
371
+ else
372
+ super
373
+ end
374
+ end
375
+ RUBY
376
+
377
+ # The base (non-optimized) `UrlHelper#call` (route_set.rb:278) is hit
378
+ # whenever a helper is generated as a plain `UrlHelper` (e.g. our re-run
379
+ # loop) or when optimization is skipped. The original reads `t.url_options`
380
+ # and `t._routes`, both of which assume `t` is a controller/view context
381
+ # whose `_routes`/`url_options` are reachable from a worker. In practice
382
+ # `t` may be the `NamedRouteCollection` (helpers proxy) or any object that
383
+ # lacks these. Route both through the shareable RouteSet / url-options
384
+ # snapshot, falling back to `t`'s own accessors only when it actually
385
+ # provides them (real controller/view). This makes path generation
386
+ # (host-independent) work from any Ractor regardless of `t`.
387
+ ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper.class_eval <<-RUBY, __FILE__, __LINE__ + 1
388
+ def call(t, method_name, args, inner_options, url_strategy)
389
+ begin
390
+ controller_options = t.url_options
391
+ rescue
392
+ controller_options = RactorRailsShim::URL_OPTIONS_DEFAULTS || {}
393
+ end
394
+ options = controller_options.merge @options
395
+ hash = handle_positional_args(controller_options, inner_options || {}, args, options, @segment_keys)
396
+ begin
397
+ routes = t._routes
398
+ rescue
399
+ routes = RactorRailsShim::SHAREABLE_ROUTES
400
+ end
401
+ routes.url_for(hash, route_name, url_strategy, method_name)
402
+ end
403
+ RUBY
404
+
405
+ # Named route helpers (`post_path`, `post_url`, ...) are generated by
406
+ # `NamedRouteCollection#define_url_helper` (route_set.rb:333) via
407
+ # `mod.define_method(name) { |*args| ... helper.call(...) }` — a BLOCK that
408
+ # captures the `helper` object (an OptimizedUrlHelper holding the route)
409
+ # and the `url_strategy` lambda (PATH/UNKNOWN, both defined in main).
410
+ # Calling that block from a worker Ractor raises
411
+ # "defined with an un-shareable Proc in a different Ractor" before any
412
+ # code runs. Patch `define_url_helper` to (a) make the helper shareable
413
+ # via `Ractor.make_shareable` (deep-freeze; routes are read-only after
414
+ # boot) and stash it in a shareable Hash keyed by name, and (b) define the
415
+ # method with a STRING (no captured binding) that references the Hash and
416
+ # the shareable strategy Callable directly.
417
+ unless RactorRailsShim.const_defined?(:URL_HELPERS)
418
+ RactorRailsShim.const_set(:URL_HELPERS, {})
419
+ end
420
+ nrc = ::ActionDispatch::Routing::RouteSet::NamedRouteCollection
421
+ unless nrc.method_defined?(:define_url_helper_without_shim)
422
+ nrc.alias_method(:define_url_helper_without_shim, :define_url_helper)
423
+ end
424
+ nrc.define_method(:define_url_helper) do |mod, name, helper, url_strategy|
425
+ begin
426
+ # Detach the helper from the live route object before deep-freezing
427
+ # it for cross-Ractor sharing. The non-optimized UrlHelper#call only
428
+ # needs @options / @segment_keys / @route_name to build the options
429
+ # hash and then delegates to `t._routes.url_for(route_name, ...)`,
430
+ # which looks the route up in the (shareable) RouteSet by name. The
431
+ # @route reference would pull the whole route graph into the freeze,
432
+ # freezing objects that make_app_shareable! must still be able to
433
+ # mutate (e.g. Devise route constraints) -> FrozenError.
434
+ if helper.respond_to?(:instance_variable_get)
435
+ helper.instance_variable_set(:@route, nil) rescue nil
436
+ opts = helper.instance_variable_get(:@options)
437
+ helper.instance_variable_set(:@options, opts.dup.freeze) rescue nil
438
+ segs = helper.instance_variable_get(:@segment_keys)
439
+ helper.instance_variable_set(:@segment_keys, segs.dup.freeze) rescue nil
440
+ end
441
+ helper = Ractor.make_shareable(helper)
442
+ rescue
443
+ nil
444
+ end
445
+ RactorRailsShim::URL_HELPERS[name] = helper
446
+ strategy_const = url_strategy.equal?(::ActionDispatch::Routing::RouteSet::PATH) ?
447
+ "AVPathStrategy" : "AVUnknownStrategy"
448
+ mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
449
+ def #{name}(*args)
450
+ last = args.last
451
+ options = \\
452
+ case last
453
+ when ::Hash
454
+ args.pop
455
+ when ::ActionController::Parameters
456
+ args.pop.to_h
457
+ end
458
+ RactorRailsShim::URL_HELPERS[#{name.inspect}].call(
459
+ self, #{name.inspect}, args, options, RactorRailsShim::#{strategy_const})
460
+ end
461
+ RUBY
462
+ end
463
+
464
+ # Re-run the (now patched) helper generation for every route already
465
+ # drawn at boot, so the helpers the app actually uses are worker-safe.
466
+ # Routes are drawn during `Rails.application.initialize!`, which runs
467
+ # before `prepare_for_ractors!`, so the originals are still block-based.
468
+ if Ractor.main? && defined?(::Rails) && ::Rails.application
469
+ begin
470
+ named = ::Rails.application.routes.named_routes
471
+ path_mod = named.instance_variable_get(:@path_helpers_module)
472
+ url_mod = named.instance_variable_get(:@url_helpers_module)
473
+ named.send(:routes).each do |route_name, route|
474
+ # Build the helper directly via `UrlHelper.new` (NOT `UrlHelper.create`,
475
+ # which calls `optimize_helper?` -> `route.glob?` -> `route.path.ast.glob?`
476
+ # and `route.path.ast` is nil by the time routes are finalized post-boot).
477
+ helper = ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper.new(
478
+ route, route.defaults, route_name)
479
+ named.define_url_helper(path_mod, :"#{route_name}_path", helper, ::ActionDispatch::Routing::RouteSet::PATH) if path_mod
480
+ named.define_url_helper(url_mod, :"#{route_name}_url", helper, ::ActionDispatch::Routing::RouteSet::UNKNOWN) if url_mod
481
+ end
482
+ verbose = $VERBOSE
483
+ $VERBOSE = nil
484
+ RactorRailsShim.const_set(:URL_HELPERS, Ractor.make_shareable(RactorRailsShim::URL_HELPERS)) unless Ractor.shareable?(RactorRailsShim::URL_HELPERS)
485
+ rescue
486
+ nil
487
+ ensure
488
+ $VERBOSE = verbose if defined?(verbose)
489
+ end
490
+ end
491
+
492
+ # `ActionController::UrlFor#url_options` (action_controller/metal/url_for.rb:45)
493
+ # builds its option hash from `request.host` / `request.optional_port` /
494
+ # `request.protocol` / `request.path_parameters` and merges in
495
+ # `default_url_options`. The controller instance rendered in a worker Ractor
496
+ # has a `request` built from the shared Rack env, but the values it returns
497
+ # (and the `default_url_options` class value, an unshareable Hash stored as a
498
+ # class ivar on ActionController::Base) cannot be read/called from a worker
499
+ # without raising Ractor isolation errors. For path-only helpers (the common
500
+ # case in views) the host/port/protocol are irrelevant, and `default_url_options`
501
+ # is the same deterministic value everywhere, so capture it once in main as a
502
+ # shareable snapshot and have workers use it directly, skipping the
503
+ # request-derived portion.
504
+ unless RactorRailsShim.const_defined?(:URL_OPTIONS_DEFAULTS)
505
+ begin
506
+ if Ractor.main? && defined?(::ActionController::Base)
507
+ defaults = ::ActionController::Base.default_url_options
508
+ defaults = defaults.dup.freeze if defaults.respond_to?(:freeze)
509
+ RactorRailsShim.const_set(:URL_OPTIONS_DEFAULTS, Ractor.make_shareable(defaults))
510
+ end
511
+ rescue
512
+ nil
513
+ end
514
+ end
515
+ if defined?(::ActionController::UrlFor)
516
+ ::ActionController::UrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1
517
+ def url_options
518
+ return super if Ractor.main?
519
+ @_url_options ||= begin
520
+ opts = (RactorRailsShim::URL_OPTIONS_DEFAULTS || {}).dup
521
+ begin
522
+ req = request if respond_to?(:request)
523
+ if req
524
+ opts[:host] = req.host if opts[:host].nil? && req.respond_to?(:host)
525
+ opts[:protocol] = req.protocol if opts[:protocol].nil? && req.respond_to?(:protocol)
526
+ opts[:port] = req.port if opts[:port].nil? && req.respond_to?(:port)
527
+ opts[:_recall] = req.path_parameters if req.respond_to?(:path_parameters)
528
+ end
529
+ rescue
530
+ nil
531
+ end
532
+ opts.freeze
533
+ end
534
+ end
535
+ RUBY
536
+ end
537
+
538
+ # NOTE: the block-based `_routes` accessors that break workers are now
539
+ # fixed at their source by `_install_url_helpers_patch` (patches/
540
+ # url_helpers.rb), which intercepts `Module#redefine_singleton_method`
541
+ # / `Module#define_method` for `:_routes` and replaces the main-Ractor
542
+ # block with a string-eval'd method returning `Rails.application.routes`.
543
+ # No per-class enumeration needed.
544
+ end
545
+
546
+ # Make Journey route recognition work under kino :ractor.
547
+ #
548
+ # The shared app graph (frozen + made shareable by make_app_shareable!)
549
+ # already carries the routes' ast and the GTG simulator. The simulator,
550
+ # however, is normally UNshareable because TransitionTable seeds @memos
551
+ # with a default-Proc Hash (`Hash.new { |h,k| h[k] = [] }`), and because
552
+ # its @memos holds the per-route Route objects whose constraint Procs can't
553
+ # cross Ractor boundaries. The default Proc is the only thing *we* can fix;
554
+ # the Route constraint Procs are instead made shareable by make_app_shareable!
555
+ # when it freezes the whole graph (its proc-replacement pass rewrites them).
556
+ #
557
+ # So the plan:
558
+ # 1. Patch TransitionTable to use a plain Hash + `add_memo` using `||= []`
559
+ # (behavior-identical, but shareable once Route memos are frozen).
560
+ # 2. Warm + cache `@ast` / `@simulator` on the live Routes object AFTER
561
+ # make_app_shareable!'s route precompute (which reloads/resets the
562
+ # routes) and BEFORE Ractor.make_shareable freezes the graph. Once
563
+ # frozen into the shared graph, worker Ractors read the cached ivars
564
+ # via the ORIGINAL Routes#ast/#simulator (no per-worker rebuild — a
565
+ # rebuild would have to read the frozen Route memos AND reuse several
566
+ # non-shareable class constants, which is fragile).
567
+ #
568
+ # We deliberately do NOT override ast/simulator: the original methods read
569
+ # the cached ivars, which is exactly what workers need.
570
+ def _install_journey_routes_patch
571
+ return if @journey_routes_patched
572
+ @journey_routes_patched = true
573
+ _register_patch :journey_routes, "8.1"
574
+ return unless defined?(::ActionDispatch::Journey::Routes)
575
+
576
+ # Patch TransitionTable to drop its default-Proc @memos. Must run before
577
+ # the simulator is warmed (in _warm_journey_routes!, called from
578
+ # make_app_shareable! after the route precompute).
579
+ if defined?(::ActionDispatch::Journey::GTG::TransitionTable)
580
+ tt = ::ActionDispatch::Journey::GTG::TransitionTable
581
+ tt.class_eval <<-RUBY, __FILE__, __LINE__ + 1
582
+ def initialize
583
+ @stdparam_states = {}
584
+ @regexp_states = {}
585
+ @string_states = {}
586
+ @accepting = {}
587
+ @memos = {}
588
+ end
589
+ def add_memo(idx, memo)
590
+ (@memos[idx] ||= []) << memo
591
+ end
592
+ RUBY
593
+ end
594
+
595
+ # Make Routes#ast / #simulator tolerant of a FROZEN receiver. The shim
596
+ # warms + caches these ivars on the live (unfrozen) graph before freezing,
597
+ # but if the cache is missing on the frozen shared object (e.g. routes
598
+ # were re-drawn after warming, or warming was skipped), the original
599
+ # `@simulator ||= build` raise FrozenError in a worker Ractor. When frozen,
600
+ # build and RETURN the value without memoizing — the build is read-only
601
+ # over the frozen route nodes, so it's safe and stays worker-local.
602
+ routes = ::ActionDispatch::Journey::Routes
603
+ routes.class_eval <<-RUBY, __FILE__, __LINE__ + 1
604
+ def ast
605
+ return @ast if defined?(@ast) && @ast
606
+ built = ::ActionDispatch::Journey::Nodes::Or.new(anchored_routes.map(&:ast))
607
+ frozen? ? built : (@ast ||= built)
608
+ end
609
+
610
+ def simulator
611
+ return @simulator if defined?(@simulator) && @simulator
612
+ gtg = ::ActionDispatch::Journey::GTG::Builder.new(ast).transition_table
613
+ built = ::ActionDispatch::Journey::GTG::Simulator.new(gtg)
614
+ frozen? ? built : (@simulator ||= built)
615
+ end
616
+ RUBY
617
+
618
+ # Path::Pattern memoizes several ivars via `||=` (@re, @offsets,
619
+ # @required_names, @optional_names, @requirements_for_missing_keys_check).
620
+ # On a frozen shared graph the `||=` assignment raises FrozenError in a
621
+ # worker Ractor. Make them frozen-tolerant: return the cached value if
622
+ # present, otherwise build and RETURN it without memoizing (the build is
623
+ # read-only over the frozen ast/requirements). This keeps workers correct
624
+ # even if warming skipped a pattern.
625
+ if defined?(::ActionDispatch::Journey::Path::Pattern)
626
+ ::ActionDispatch::Journey::Path::Pattern.class_eval <<-RUBY, __FILE__, __LINE__ + 1
627
+ def required_names
628
+ return @required_names if defined?(@required_names) && @required_names
629
+ built = names - optional_names
630
+ frozen? ? built : (@required_names ||= built)
631
+ end
632
+
633
+ def optional_names
634
+ return @optional_names if defined?(@optional_names) && @optional_names
635
+ built = spec.find_all(&:group?).flat_map { |g| g.find_all(&:symbol?) }.map(&:name).uniq
636
+ frozen? ? built : (@optional_names ||= built)
637
+ end
638
+
639
+ def to_regexp
640
+ return @re if defined?(@re) && @re
641
+ built = regexp_visitor.new(@separators, @requirements).accept(spec)
642
+ frozen? ? built : (@re ||= built)
643
+ end
644
+
645
+ def requirements_for_missing_keys_check
646
+ return @requirements_for_missing_keys_check if defined?(@requirements_for_missing_keys_check) && @requirements_for_missing_keys_check
647
+ built = requirements.transform_values { |regex| /\A#\{regex\}\Z/ }
648
+ frozen? ? built : (@requirements_for_missing_keys_check ||= built)
649
+ end
650
+
651
+ def offsets
652
+ return @offsets if defined?(@offsets) && @offsets
653
+ built = begin
654
+ offs = [0]
655
+ spec.find_all(&:symbol?).each do |node|
656
+ node = node.to_sym
657
+ if @requirements.key?(node)
658
+ re = /#\{Regexp.union(@requirements[node])\}|/
659
+ offs.push((re.match("").length - 1) + offs.last)
660
+ else
661
+ offs << offs.last
662
+ end
663
+ end
664
+ offs
665
+ end
666
+ frozen? ? built : (@offsets ||= built)
667
+ end
668
+ RUBY
669
+ end
670
+ end
671
+
672
+ # Journey's routing visitors are stored as instance singletons in class
673
+ # constants (e.g. `ActionDispatch::Journey::Visitors::Each::INSTANCE`).
674
+ # Worker Ractors read these constants while recognizing routes
675
+ # (`Node#each` → `Each::INSTANCE.accept`, `Path::Pattern#match` →
676
+ # `offsets` → `node.each`), and a non-frozen instance is NOT a shareable
677
+ # object → `Ractor::IsolationError: can not access non-shareable objects in
678
+ # constant ...::Each::INSTANCE by non-main Ractor`. The visitor instances
679
+ # are stateless, so freezing them makes them shareable with no behavior
680
+ # change. The same applies to the `DISPATCH_CACHE` Hashes the visitor
681
+ # `accept`/`visit` dispatch through. These constants are NOT reachable
682
+ # from the frozen app graph (Ractor.make_shareable never touches them), so
683
+ # we must freeze them explicitly here (in main, before workers spawn).
684
+ def _freeze_journey_visitors!
685
+ return unless defined?(::ActionDispatch::Journey::Visitors)
686
+ v = ::ActionDispatch::Journey::Visitors
687
+ [[:Each, :INSTANCE], [:String, :INSTANCE], [:Dot, :INSTANCE]].each do |klass, const|
688
+ mod = v.const_get(klass) rescue nil
689
+ next unless mod && mod.const_defined?(const)
690
+ inst = mod.const_get(const)
691
+ inst.freeze if inst.respond_to?(:freeze) && !inst.frozen?
692
+ end
693
+ [[:Visitor, :DISPATCH_CACHE], [:FunctionalVisitor, :DISPATCH_CACHE]].each do |klass, const|
694
+ mod = v.const_get(klass) rescue nil
695
+ next unless mod && mod.const_defined?(const)
696
+ cache = mod.const_get(const)
697
+ cache.freeze if cache.respond_to?(:freeze) && !cache.frozen?
698
+ end
699
+ # GTG::Builder::DUMMY_END_NODE is a non-shareable instance referenced when
700
+ # a worker Ractor rebuilds the route simulator (e.g. if the warmed
701
+ # @simulator cache is missing on the frozen graph). Make it Ractor-shareable
702
+ # (deep-freeze) so workers can read the constant without
703
+ # Ractor::IsolationError. It is a stateless dummy node, so this is
704
+ # behavior-preserving.
705
+ if defined?(::ActionDispatch::Journey::GTG::Builder) &&
706
+ ::ActionDispatch::Journey::GTG::Builder.const_defined?(:DUMMY_END_NODE)
707
+ node = ::ActionDispatch::Journey::GTG::Builder.const_get(:DUMMY_END_NODE)
708
+ Ractor.make_shareable(node) rescue nil
709
+ end
710
+ end
711
+
712
+ # Pre-compute every Journey::Path::Pattern's lazy memoized ivars
713
+ # (@required_names, @optional_names, @offsets, @re,
714
+ # @requirements_for_missing_keys_check) on the LIVE (unfrozen) pattern,
715
+ # before Ractor.make_shareable freezes the graph. `Path::Pattern#match`
716
+ # (called on every request during route recognition) memoizes @offsets
717
+ # via `@offsets ||= ...`; on a frozen pattern that write raises
718
+ # FrozenError. By computing it now (and caching into the frozen object),
719
+ # the worker reads the cached value and never writes. We deliberately do
720
+ # NOT call the built-in `eager_load!`, which sets `@ast = nil` (the
721
+ # @ast/@spec are still read by `requirements_anchored?` and must survive).
722
+ def _warm_path_patterns!(routes)
723
+ return unless routes.respond_to?(:routes)
724
+ routes.routes.each do |r|
725
+ p = r.respond_to?(:path) ? r.path : nil
726
+ next unless p
727
+ begin
728
+ p.required_names
729
+ p.optional_names
730
+ p.send(:offsets)
731
+ p.to_regexp
732
+ p.requirements_for_missing_keys_check if p.respond_to?(:requirements_for_missing_keys_check)
733
+ rescue
734
+ # best-effort — a pattern we can't warm will fall back to its own
735
+ # (non-frozen) copy if one exists; ignore unusual shapes.
736
+ end
737
+ end
738
+ end
739
+
740
+ # Warm + cache `@ast` / `@simulator` on the live Routes graph. Called from
741
+ # make_app_shareable! AFTER the route precompute (which resets the routes)
742
+ # and BEFORE Ractor.make_shareable freezes the graph. Must run in the main
743
+ # Ractor. Idempotent (caches on the mutable object, then frozen in place).
744
+ def _warm_journey_routes!
745
+ return unless Ractor.main?
746
+ _freeze_journey_visitors!
747
+ _freeze_mime_negotiation!
748
+ begin
749
+ rs = ::Rails.application.routes rescue nil
750
+ # Navigate to the ActionDispatch::Journey::Routes object — the one that
751
+ # is frozen into the shared graph and read (via Router#simulator) on
752
+ # every request. Rails wraps it in a RouteSet (and sometimes a
753
+ # LazyRouteSet), neither of which defines #simulator, so calling
754
+ # `rs.routes.simulator` would silently NoMethodError and leave @simulator
755
+ # uncached — forcing worker Ractors to rebuild the simulator (and hit
756
+ # Ractor::IsolationError on GTG constants). Descend through #routes until
757
+ # we reach the Journey::Routes instance.
758
+ routes = rs
759
+ while routes.respond_to?(:routes) && !routes.is_a?(::ActionDispatch::Journey::Routes)
760
+ nxt = routes.routes
761
+ break if nxt.equal?(routes)
762
+ routes = nxt
763
+ end
764
+ if routes.is_a?(::ActionDispatch::Journey::Routes)
765
+ routes.ast
766
+ routes.simulator
767
+ _warm_path_patterns!(routes)
768
+ end
769
+ rescue => e
770
+ # best-effort
771
+ end
772
+ end
773
+
774
+ # ActionDispatch::Http::MimeNegotiation holds module-level constants
775
+ # (e.g. RESCUABLE_MIME_FORMAT_ERRORS, an Array of exception classes) that
776
+ # are referenced from the request path (params_readable? -> `rescue *
777
+ # RESCUABLE_MIME_FORMAT_ERRORS`). These Arrays are non-frozen, hence
778
+ # non-shareable, so a worker Ractor raises Ractor::IsolationError when it
779
+ # reads them. Freeze the mutable constant-containing modules so workers can
780
+ # read shareable copies. Regexp/Class constants are already shareable; only
781
+ # the wrapping Array/Hash need freezing.
782
+ def _freeze_mime_negotiation!
783
+ return unless defined?(::ActionDispatch::Http::MimeNegotiation)
784
+ mod = ::ActionDispatch::Http::MimeNegotiation
785
+ mod.constants.each do |name|
786
+ c = mod.const_get(name) rescue nil
787
+ next unless c.is_a?(::Array) || c.is_a?(::Hash)
788
+ next if c.frozen?
789
+ c.freeze
790
+ ::Ractor.make_shareable(c) rescue nil
791
+ end
792
+ rescue => e
793
+ warn "[ractor-rails-shim] _freeze_mime_negotiation!: #{e.class}: #{e.message}"
794
+ end
795
+
796
+ # ActionDispatch::Http::URL reads the `tld_length` class variable
797
+ # DIRECTLY (`@@tld_length`) in `normalize_host` and in the default-parameter
798
+ # of `domain`/`subdomains`/`subdomain`. Class variables are not readable from
799
+ # a non-main Ractor, so a worker raises
800
+ # "Ractor::IsolationError: can not access class variables ... @@tld_length".
801
+ # The shim routes the `mattr_accessor :tld_length` READER through IES, but the
802
+ # literal `@@tld_length` references bypass that reader. Replace them with the
803
+ # accessor method (which the shim's mattr_accessor rewrite makes
804
+ # worker-safe). `domain`/`subdomains`/`subdomain` live in the `Url` module
805
+ # mixed into ActionDispatch::Request, so patch that module too.
806
+ def _install_action_dispatch_http_url_patch
807
+ return if @action_dispatch_http_url_patched
808
+ @action_dispatch_http_url_patched = true
809
+ _register_patch :action_dispatch_http_url, "8.1"
810
+ return unless defined?(::ActionDispatch::Http::URL)
811
+
812
+ url = ::ActionDispatch::Http::URL
813
+ # normalize_host is a module_function: build_host_url calls the
814
+ # MODULE-LEVEL copy, so redefining the instance method alone leaves the
815
+ # original (@@tld_length-reading) one in place. Patch the singleton
816
+ # (module-level) method instead.
817
+ url.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
818
+ def normalize_host(_host, options)
819
+ return _host unless named_host?(_host)
820
+ tld_length = options[:tld_length] || tld_length()
821
+ subdomain = options.fetch :subdomain, true
822
+ domain = options[:domain]
823
+ host = +""
824
+ if subdomain == true
825
+ return _host if domain.nil?
826
+ host << extract_subdomains_from(_host, tld_length).join(".")
827
+ elsif subdomain
828
+ host << subdomain.to_param
829
+ end
830
+ host << "." unless host.empty?
831
+ host << (domain || extract_domain_from(_host, tld_length))
832
+ host
833
+ end
834
+ RUBY
835
+
836
+ if defined?(::ActionDispatch::Http::URL::Url)
837
+ ::ActionDispatch::Http::URL::Url.module_eval <<-RUBY, __FILE__, __LINE__ + 1
838
+ def domain(tld_length = tld_length())
839
+ ActionDispatch::Http::URL.extract_domain(host, tld_length)
840
+ end
841
+ def subdomains(tld_length = tld_length())
842
+ ActionDispatch::Http::URL.extract_subdomains(host, tld_length)
843
+ end
844
+ def subdomain(tld_length = tld_length())
845
+ ActionDispatch::Http::URL.extract_subdomain(host, tld_length)
846
+ end
847
+ RUBY
848
+ end
849
+ end
850
+ end
851
+ end