ractor-rails-shim 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +101 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +265 -0
  5. data/exe/ractor-rails-check +39 -0
  6. data/lib/ractor_rails_shim/check.rb +190 -0
  7. data/lib/ractor_rails_shim/fallback_ies.rb +33 -0
  8. data/lib/ractor_rails_shim/patches/action_controller.rb +301 -0
  9. data/lib/ractor_rails_shim/patches/action_dispatch.rb +851 -0
  10. data/lib/ractor_rails_shim/patches/action_view.rb +582 -0
  11. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +97 -0
  12. data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +58 -0
  13. data/lib/ractor_rails_shim/patches/active_support.rb +1085 -0
  14. data/lib/ractor_rails_shim/patches/activerecord.rb +1727 -0
  15. data/lib/ractor_rails_shim/patches/class_attribute.rb +194 -0
  16. data/lib/ractor_rails_shim/patches/core.rb +806 -0
  17. data/lib/ractor_rails_shim/patches/devise.rb +172 -0
  18. data/lib/ractor_rails_shim/patches/execution_wrapper.rb +76 -0
  19. data/lib/ractor_rails_shim/patches/kaminari.rb +82 -0
  20. data/lib/ractor_rails_shim/patches/make_shareable.rb +853 -0
  21. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +162 -0
  22. data/lib/ractor_rails_shim/patches/openssl.rb +58 -0
  23. data/lib/ractor_rails_shim/patches/orm_adapter.rb +32 -0
  24. data/lib/ractor_rails_shim/patches/polymorphic_routes.rb +115 -0
  25. data/lib/ractor_rails_shim/patches/propshaft.rb +110 -0
  26. data/lib/ractor_rails_shim/patches/rack.rb +160 -0
  27. data/lib/ractor_rails_shim/patches/rails_module.rb +192 -0
  28. data/lib/ractor_rails_shim/patches/route_helpers.rb +119 -0
  29. data/lib/ractor_rails_shim/patches/rubygems.rb +65 -0
  30. data/lib/ractor_rails_shim/patches/url_helpers.rb +78 -0
  31. data/lib/ractor_rails_shim/patches/warden.rb +195 -0
  32. data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +124 -0
  33. data/lib/ractor_rails_shim/patches.rb +67 -0
  34. data/lib/ractor_rails_shim/version.rb +5 -0
  35. data/lib/ractor_rails_shim/version_check.rb +88 -0
  36. data/lib/ractor_rails_shim.rb +33 -0
  37. metadata +104 -0
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches for AbstractController: controller_path, action_methods,
4
+ # abstract?, _prefixes, and ParameterEncoding.
5
+ # Each uses per-Ractor IES caches and guards Ractor.main? before reading
6
+ # class ivars.
7
+
8
+ module RactorRailsShim
9
+ # ActionController / AbstractController constants that need to be made shareable.
10
+ SHAREABLE_CONSTANTS.concat([
11
+ "ActionController::Rendering::RENDER_FORMATS_IN_PRIORITY",
12
+ "ActionController::Base::PROTECTED_IVARS",
13
+ "AbstractController::Rendering::DEFAULT_PROTECTED_INSTANCE_VARIABLES",
14
+ # Strong-parameters scalar allow-list, read by permitted_scalar? on every
15
+ # permit/require. An Array of classes -> not shareable by default.
16
+ "ActionController::Parameters::PERMITTED_SCALAR_TYPES",
17
+ ])
18
+
19
+ class << self
20
+ # Patch ActionController::ParameterEncoding::ClassMethods#action_encoding_template
21
+ # to not read @_parameter_encodings (a raw class ivar) from a worker
22
+ # Ractor. The default is an empty-ish Hash; for a frozen shared app workers
23
+ # get an empty frozen Hash (no per-action param encodings — correct for
24
+ # apps that don't declare `parameter_encoding`, e.g. the health controller).
25
+ def _install_parameter_encoding_patch
26
+ return if @param_encoding_patched
27
+ @param_encoding_patched = true
28
+ _register_patch :parameter_encoding, "8.1"
29
+ return unless defined?(::ActionController::ParameterEncoding)
30
+ pe = ::ActionController::ParameterEncoding::ClassMethods
31
+ pe.module_eval <<-RUBY, __FILE__, __LINE__ + 1
32
+ def action_encoding_template(action)
33
+ enc = if Ractor.main?
34
+ instance_variable_defined?(:@_parameter_encodings) ? @_parameter_encodings : nil
35
+ else
36
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_param_encodings]
37
+ end
38
+ if enc && enc.has_key?(action.to_s)
39
+ enc[action.to_s]
40
+ end
41
+ end
42
+ RUBY
43
+ end
44
+
45
+ # Patch AbstractController::Base.controller_path to not write/read the
46
+ # @controller_path class ivar from a worker Ractor. Also patches
47
+ # action_methods, clear_action_methods!, abstract!, abstract?, and
48
+ # _prefixes to route through IES or use the shareable fallback.
49
+ def _install_abstract_controller_patch
50
+ return if @abstract_controller_patched
51
+ @abstract_controller_patched = true
52
+ _register_patch :abstract_controller, "8.1"
53
+ return unless defined?(::AbstractController::Base)
54
+ ac = ::AbstractController::Base
55
+
56
+ # Populate the shareable abstract registry from every loaded controller
57
+ # class's @abstract ivar (set by abstract! / inherited at boot). Workers
58
+ # read this via the patched abstract? (per-class values can't live in
59
+ # per-Ractor IES).
60
+ registry = {}
61
+ ac.descendants.each do |klass|
62
+ begin
63
+ registry[klass] = klass.instance_variable_get(:@abstract) if klass.instance_variable_defined?(:@abstract)
64
+ rescue => e
65
+ # ignore — best-effort
66
+ end
67
+ end
68
+ registry[ac] = ac.instance_variable_get(:@abstract) if ac.instance_variable_defined?(:@abstract)
69
+ registry.freeze
70
+ Ractor.make_shareable(registry)
71
+ self._abstract_registry = registry
72
+ ac.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
73
+ def controller_path
74
+ cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_controller_path_cache] ||= {})
75
+ v = cache[self]
76
+ return v if v
77
+ if Ractor.main? && instance_variable_defined?(:@controller_path)
78
+ v = @controller_path
79
+ cache[self] = v
80
+ return v
81
+ end
82
+ computed = anonymous? ? nil : name.delete_suffix("Controller").underscore
83
+ cache[self] = computed
84
+ computed
85
+ end
86
+
87
+ # action_methods: `@action_methods ||= public_instance_methods(true) -
88
+ # internal_methods).map(&:name).to_set` — raw class-ivar lazy init.
89
+ # The value is a Set of Symbols (shareable once frozen). Route through
90
+ # IES; workers compute it from public_instance_methods (no ivar read)
91
+ # and cache in their own slot. Read per-request during dispatch.
92
+ def action_methods
93
+ cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_action_methods_cache] ||= {})
94
+ v = cache[self]
95
+ return v if v
96
+ if Ractor.main? && instance_variable_defined?(:@action_methods)
97
+ v = @action_methods
98
+ cache[self] = v
99
+ return v
100
+ end
101
+ methods = public_instance_methods(true) - internal_methods
102
+ methods.map!(&:name)
103
+ computed = methods.to_set
104
+ cache[self] = computed
105
+ computed
106
+ end
107
+
108
+ def clear_action_methods!
109
+ if Ractor.main?
110
+ @action_methods = nil
111
+ end
112
+ ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_action_methods_cache] = nil
113
+ end
114
+
115
+ # abstract! / abstract / abstract? — raw class ivar (@abstract), a
116
+ # per-CLASS boolean. IES is per-Ractor (single value), so we can't use a
117
+ # single IES key for all classes. Instead use a shareable registry
118
+ # (Hash class→bool) built at prepare_for_ractors! time. Workers read
119
+ # the registry; main reads its live @abstract ivar (set by abstract!
120
+ # / inherited). `internal_methods` loops on abstract?.
121
+ def abstract!
122
+ RactorRailsShim._abstract_registry[self] = true if Ractor.main?
123
+ @abstract = true if Ractor.main?
124
+ end
125
+
126
+ def abstract
127
+ if Ractor.main? && instance_variable_defined?(:@abstract)
128
+ @abstract
129
+ else
130
+ (RactorRailsShim._abstract_registry || RactorRailsShim::ABSTRACT_REGISTRY)[self] || false
131
+ end
132
+ end
133
+ alias_method :abstract?, :abstract
134
+ RUBY
135
+
136
+ # Patch ActionView::ViewPaths::ClassMethods#_prefixes (overrides any
137
+ # Base version). Original: `@_prefixes ||= begin; return local_prefixes
138
+ # if superclass.abstract?; local_prefixes + superclass._prefixes; end`.
139
+ # @_prefixes is a per-CLASS class ivar (workers can't read). Recurse
140
+ # using the patched abstract? and cache in a per-Ractor Hash by class.
141
+ if defined?(::ActionView::ViewPaths::ClassMethods)
142
+ vp = ::ActionView::ViewPaths::ClassMethods
143
+ vp.module_eval <<-RUBY, __FILE__, __LINE__ + 1
144
+ def _prefixes
145
+ cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_vp_prefixes_cache] ||= {})
146
+ v = cache[self]
147
+ return v if v
148
+ if Ractor.main? && instance_variable_defined?(:@_prefixes)
149
+ v = @_prefixes
150
+ cache[self] = v
151
+ return v
152
+ end
153
+ computed = if superclass.respond_to?(:abstract?) && superclass.abstract?
154
+ local_prefixes
155
+ elsif superclass.respond_to?(:_prefixes)
156
+ local_prefixes + superclass._prefixes
157
+ else
158
+ local_prefixes
159
+ end
160
+ cache[self] = computed
161
+ computed
162
+ end
163
+ RUBY
164
+ end
165
+
166
+ # AbstractController::UrlFor::ClassMethods#action_methods ALSO has a
167
+ # `@action_methods ||= ...` lazy init (it overrides Base.action_methods
168
+ # to subtract route helper names). Patch it the same way.
169
+ if defined?(::AbstractController::UrlFor::ClassMethods)
170
+ url_for_cm = ::AbstractController::UrlFor::ClassMethods
171
+ url_for_cm.module_eval <<-RUBY, __FILE__, __LINE__ + 1
172
+ def action_methods
173
+ cache = (ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_url_for_action_methods_cache] ||= {})
174
+ v = cache[self]
175
+ return v if v
176
+ if Ractor.main? && instance_variable_defined?(:@action_methods)
177
+ v = @action_methods
178
+ cache[self] = v
179
+ return v
180
+ end
181
+ # NOTE: the original reads `@action_methods ||= if _routes; super -
182
+ # _routes.named_routes.helper_names; else; super; end`. But
183
+ # `_routes` is a singleton method defined via `define_method` with
184
+ # a block (route_set.rb:610), capturing the defining Ractor's
185
+ # binding → "defined with an un-shareable Proc in a different
186
+ # Ractor" when called from a worker. Instead, read the route set
187
+ # directly from the shareable Rails.application (frozen, shared).
188
+ base = super
189
+ routes = Ractor.main? ? (respond_to?(:_routes) ? _routes : nil) : (defined?(::Rails) && ::Rails.application ? ::Rails.application.routes : nil)
190
+ computed = if routes
191
+ base - routes.named_routes.helper_names
192
+ else
193
+ base
194
+ end
195
+ cache[self] = computed
196
+ computed
197
+ end
198
+ RUBY
199
+ end
200
+ end
201
+
202
+ # Patch ActionController::Metal.controller_name (a class method). It
203
+ # memoizes its computed String in a lazy class ivar (`@controller_name ||=`),
204
+ # which a worker Ractor cannot write. Route the cache through
205
+ # IsolatedExecutionState keyed by the class name so each Ractor builds its
206
+ # own copy; the computation is deterministic from the class name.
207
+ def _install_action_controller_controller_name_patch
208
+ return if @action_controller_controller_name_patched
209
+ @action_controller_controller_name_patched = true
210
+ _register_patch :action_controller_controller_name, "8.1"
211
+ return unless defined?(::ActionController::Metal)
212
+ ::ActionController::Metal.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
213
+ def controller_name
214
+ key = :"ractor_rails_shim_controller_name_\#{name}"
215
+ v = ActiveSupport::IsolatedExecutionState[key]
216
+ return v if v
217
+ cn = (name.demodulize.delete_suffix("Controller").underscore unless anonymous?)
218
+ ActiveSupport::IsolatedExecutionState[key] = cn
219
+ cn
220
+ end
221
+ RUBY
222
+ end
223
+
224
+ # In the shared :ractor graph, Devise's engine controllers (e.g.
225
+ # Devise::SessionsController) end up with a nil `csrf_token_storage_strategy`
226
+ # at request time — the value is dropped when make_app_shareable! deep-freezes
227
+ # the app (RequestForgeryProtection sets it only on ActionController::Base.config,
228
+ # and the per-controller frozen config copy loses it). A worker then raises
229
+ # NoMethodError on `reset_csrf_token` during `reset_session` (logout /
230
+ # sign_out). Guard the reset so a missing strategy is a no-op — `reset_session`
231
+ # regenerates the session id anyway, discarding the CSRF token.
232
+ def _install_csrf_reset_patch
233
+ return if @csrf_reset_patched
234
+ @csrf_reset_patched = true
235
+ _register_patch :csrf_reset, "8.1"
236
+ return unless defined?(::ActionController::RequestForgeryProtection)
237
+ rfp = ::ActionController::RequestForgeryProtection
238
+ rfp.module_eval <<-RUBY, __FILE__, __LINE__ + 1
239
+ def reset_csrf_token(request) # :doc:
240
+ request.env.delete(CSRF_TOKEN)
241
+ strat = csrf_token_storage_strategy
242
+ strat.reset(request) if strat
243
+ end
244
+ RUBY
245
+
246
+ # `csrf_token_storage_strategy` and `forgery_protection_strategy` are
247
+ # both delegated to the controller `config` (a class_attribute). In the
248
+ # shared :ractor graph the per-controller frozen `config` copy loses
249
+ # them, so a worker reads nil and CSRF token handling raises
250
+ # ("undefined method 'fetch' for nil" / "undefined method 'new' for
251
+ # nil"). Default to the standard SessionStore / Exception strategies so
252
+ # token ISSUANCE and VALIDATION work in workers.
253
+ #
254
+ # NOTE: prepending to RequestForgeryProtection (a module) would NOT
255
+ # reach the controllers, because ActionController::Base already
256
+ # *included* it before this patch runs. Prepend to the BASE CLASS so
257
+ # every controller (incl. Devise subclasses) picks up the default.
258
+ # The defaults are referenced via their constant paths (not captured
259
+ # locals) because `def` bodies do not close over enclosing locals.
260
+ ::ActionController::Base.prepend(Module.new do
261
+ def csrf_token_storage_strategy
262
+ super || ::ActionController::RequestForgeryProtection::SessionStore.new
263
+ end
264
+
265
+ def forgery_protection_strategy
266
+ super || ::ActionController::RequestForgeryProtection::ProtectionMethods::Exception
267
+ end
268
+
269
+ # Delegated to `config` (a class_attribute) which loses its value when
270
+ # make_app_shareable! deep-freezes the shared graph, so a worker reads
271
+ # nil. With a nil param key, form_authenticity_param reads params[nil]
272
+ # and CSRF VALIDATION rejects every POST (even with a valid token),
273
+ # because the token is carried under the real key (:authenticity_token).
274
+ # Default to the standard param name so validation can find the token.
275
+ def request_forgery_protection_token
276
+ super || :authenticity_token
277
+ end
278
+ end)
279
+ end
280
+
281
+ # Patch the flash-type helper methods (`notice`, `alert`, ...) defined by
282
+ # `ActionController::Metal::Flash#add_flash_types` via
283
+ # `define_method(type) { request.flash[type] }`. That block is compiled
284
+ # in the MAIN Ractor, so calling it from a worker Ractor raises
285
+ # "defined with an un-shareable Proc in a different Ractor". Redefine each
286
+ # flash type as a string-eval'd method (no captured binding) so it is
287
+ # callable from any Ractor. Called at prepare_for_ractors! time, after
288
+ # the controllers are loaded and the types are known.
289
+ def _install_flash_helpers_patch
290
+ return if @flash_helpers_patched
291
+ @flash_helpers_patched = true
292
+ _register_patch :flash_helpers, "8.1"
293
+ return unless defined?(::ActionController::Base)
294
+ types = ::ActionController::Base._flash_types rescue []
295
+ types.each do |type|
296
+ ::ActionController::Base.class_eval "def #{type}; request.flash[#{type.inspect}]; end"
297
+ ::ActionController::Base.send(:private, type) if ::ActionController::Base.private_method_defined?(type) rescue nil
298
+ end
299
+ end
300
+ end
301
+ end