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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +101 -0
- data/LICENSE.txt +21 -0
- data/README.md +265 -0
- data/exe/ractor-rails-check +39 -0
- data/lib/ractor_rails_shim/check.rb +190 -0
- data/lib/ractor_rails_shim/fallback_ies.rb +33 -0
- data/lib/ractor_rails_shim/patches/action_controller.rb +301 -0
- data/lib/ractor_rails_shim/patches/action_dispatch.rb +851 -0
- data/lib/ractor_rails_shim/patches/action_view.rb +582 -0
- data/lib/ractor_rails_shim/patches/active_model_attribute.rb +97 -0
- data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +58 -0
- data/lib/ractor_rails_shim/patches/active_support.rb +1085 -0
- data/lib/ractor_rails_shim/patches/activerecord.rb +1727 -0
- data/lib/ractor_rails_shim/patches/class_attribute.rb +194 -0
- data/lib/ractor_rails_shim/patches/core.rb +806 -0
- data/lib/ractor_rails_shim/patches/devise.rb +172 -0
- data/lib/ractor_rails_shim/patches/execution_wrapper.rb +76 -0
- data/lib/ractor_rails_shim/patches/kaminari.rb +82 -0
- data/lib/ractor_rails_shim/patches/make_shareable.rb +853 -0
- data/lib/ractor_rails_shim/patches/mattr_accessor.rb +162 -0
- data/lib/ractor_rails_shim/patches/openssl.rb +58 -0
- data/lib/ractor_rails_shim/patches/orm_adapter.rb +32 -0
- data/lib/ractor_rails_shim/patches/polymorphic_routes.rb +115 -0
- data/lib/ractor_rails_shim/patches/propshaft.rb +110 -0
- data/lib/ractor_rails_shim/patches/rack.rb +160 -0
- data/lib/ractor_rails_shim/patches/rails_module.rb +192 -0
- data/lib/ractor_rails_shim/patches/route_helpers.rb +119 -0
- data/lib/ractor_rails_shim/patches/rubygems.rb +65 -0
- data/lib/ractor_rails_shim/patches/url_helpers.rb +78 -0
- data/lib/ractor_rails_shim/patches/warden.rb +195 -0
- data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +124 -0
- data/lib/ractor_rails_shim/patches.rb +67 -0
- data/lib/ractor_rails_shim/version.rb +5 -0
- data/lib/ractor_rails_shim/version_check.rb +88 -0
- data/lib/ractor_rails_shim.rb +33 -0
- 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
|