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