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