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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch for Devise (5.0). Two blockers for worker Ractors:
4
+ #
5
+ # 1. Devise::Controllers::UrlHelpers#generate_helpers! defines every helper
6
+ # (session_path, new_session_path, user_password_path, ...) via
7
+ # `define_method method do |resource_or_scope, *args| ... end` — a block
8
+ # that captures the MAIN ractor's binding. Calling `session_path(...)` from
9
+ # a worker raises "defined with an un-shareable Proc in a different Ractor".
10
+ # Fix: redefine each helper via string eval (no captured binding); the body
11
+ # replicates the original exactly, delegating to the underlying Rails route
12
+ # helper via `context.send(method, *args)` (the real helpers are already
13
+ # patched in action_dispatch.rb).
14
+ #
15
+ # 2. The helper body (and Devise::Mapping.find_scope!) reads `Devise.mappings`,
16
+ # which reads the `@@mappings` class variable — unreadable from a worker
17
+ # Ractor. Fix: snapshot `Devise.mappings` as a shareable Hash at prepare
18
+ # time and have the patched `Devise.mappings` reader return it in workers
19
+ # (skipping the main-ractor route reload).
20
+
21
+ module RactorRailsShim
22
+ # Devise defines several top-level mutable Array/Hash constants
23
+ # (TRUE_VALUES, FALSE_VALUES, NO_INPUT, ALL, CONTROLLERS, ROUTES,
24
+ # STRATEGIES, URL_HELPERS) that are NOT frozen, hence non-shareable — a
25
+ # worker Ractor raises "can not access non-shareable objects in constant
26
+ # Devise::TRUE_VALUES ..." when it reads them (e.g. remember_me? ->
27
+ # TRUE_VALUES). These are populated during boot and never mutated in
28
+ # :ractor mode (frozen graph, no reloading), so deep-freeze + const_set
29
+ # them shareable at prepare time.
30
+ SHAREABLE_CONSTANTS.concat([
31
+ "Devise::TRUE_VALUES",
32
+ "Devise::FALSE_VALUES",
33
+ "Devise::NO_INPUT",
34
+ "Devise::ALL",
35
+ "Devise::CONTROLLERS",
36
+ "Devise::ROUTES",
37
+ "Devise::STRATEGIES",
38
+ "Devise::URL_HELPERS",
39
+ ])
40
+
41
+ class << self
42
+ # Devise::FailureApp.call memoizes its Rack endpoint in a class-level ivar
43
+ # (`@respond ||= action(:respond)`). A worker Ractor cannot set instance
44
+ # variables on a shared class/module, so calling it raises "can not set
45
+ # instance variables of classes/modules by non-main Ractors". Route the
46
+ # memoized endpoint through IsolatedExecutionState (per-Ractor), so each
47
+ # worker builds and caches its own copy without mutating the shared class.
48
+ def _install_devise_failure_app_patch
49
+ return if @devise_failure_app_patched
50
+ @devise_failure_app_patched = true
51
+ _register_patch :devise_failure_app, "5.0"
52
+ return unless defined?(::Devise::FailureApp)
53
+ ::Devise::FailureApp.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1
54
+ def call(env)
55
+ respond = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_devise_failure_respond] ||= action(:respond)
56
+ respond.call(env)
57
+ end
58
+ RUBY
59
+ # Devise::FailureApp#relative_url_root reads `config.action_controller
60
+ # .try(:relative_url_root)`. `config.action_controller` is a
61
+ # Rails::Railtie::Configuration whose `relative_url_root` is NOT a real
62
+ # method, so `try` triggers `method_missing`, which reads the `@@options`
63
+ # class variable — unreadable from a worker Ractor. `Rails.application
64
+ # .config.relative_url_root` IS a real method on
65
+ # Rails::Application::Configuration (no method_missing, no class var), so
66
+ # use just that; for the common case (no relative root) it returns nil.
67
+ if defined?(::Devise::FailureApp)
68
+ ::Devise::FailureApp.module_eval <<-RUBY, __FILE__, __LINE__ + 1
69
+ def relative_url_root
70
+ @relative_url_root ||= Rails.application.config.relative_url_root
71
+ end
72
+ RUBY
73
+ end
74
+ end
75
+
76
+ # Devise::Models::Authenticatable::ClassMethods#devise_parameter_filter
77
+ # memoizes `@devise_parameter_filter ||= Devise::ParameterFilter.new(...)`
78
+ # on the MODEL CLASS. A worker Ractor cannot set an instance variable on a
79
+ # shared class/module, so calling it raises "can not set instance variables
80
+ # of classes/modules by non-main Ractors". Route the memoized filter through
81
+ # IsolatedExecutionState (per-Ractor), keyed by the (shared, stable) class
82
+ # object_id. `case_insensitive_keys` / `strip_whitespace_keys` are
83
+ # class_attribute values the shim already routes through IES, so they read
84
+ # fine in a worker.
85
+ def _install_devise_authenticatable_patch
86
+ return if @devise_authenticatable_patched
87
+ @devise_authenticatable_patched = true
88
+ _register_patch :devise_authenticatable, "5.0"
89
+ return unless defined?(::Devise::Models::Authenticatable::ClassMethods)
90
+ ::Devise::Models::Authenticatable::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
91
+ def devise_parameter_filter
92
+ key = :"ractor_rails_shim_devise_param_filter_\#{object_id}"
93
+ v = ActiveSupport::IsolatedExecutionState[key]
94
+ return v unless v.nil?
95
+ f = Devise::ParameterFilter.new(case_insensitive_keys, strip_whitespace_keys)
96
+ ActiveSupport::IsolatedExecutionState[key] = f
97
+ f
98
+ end
99
+ RUBY
100
+ end
101
+
102
+ def _install_devise_url_helpers_patch
103
+ return if @devise_url_helpers_patched
104
+ @devise_url_helpers_patched = true
105
+ _register_patch :devise_url_helpers, "5.0"
106
+ return unless defined?(::Devise::Controllers::UrlHelpers)
107
+
108
+ mod = ::Devise::Controllers::UrlHelpers
109
+
110
+ # Patch Devise.mappings FIRST (before the snapshot below) so every
111
+ # subsequent read — including the snapshot — does NOT trigger a route
112
+ # reload. Routes are fully drawn by Rails.application.initialize! before
113
+ # prepare_for_ractors! runs, so @@mappings is already populated in main;
114
+ # workers read the shareable snapshot instead. The ORIGINAL
115
+ # Devise.mappings calls reload_routes_unless_loaded, which during
116
+ # prepare collapses the RouteSet to a single railtie route (rails/info)
117
+ # that then gets frozen into the shared app graph — breaking routing for
118
+ # every worker Ractor.
119
+ if defined?(::Devise) && ::Devise.respond_to?(:mappings)
120
+ ::Devise.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
121
+ def mappings
122
+ return RactorRailsShim::DEVISE_MAPPINGS if !Ractor.main? && RactorRailsShim.const_defined?(:DEVISE_MAPPINGS)
123
+ ::Devise.class_variable_get(:@@mappings)
124
+ end
125
+ RUBY
126
+ end
127
+
128
+ # Capture a shareable snapshot of Devise.mappings in MAIN (after routes
129
+ # are drawn). The real Mapping objects hold an unshareable failure-app
130
+ # lambda and a default-proc Hash, so we can't share them directly; build
131
+ # a DeviseMappingSnapshot per scope (see make_shareable.rb). Workers read
132
+ # this via the patched Devise.mappings reader.
133
+ if Ractor.main? && defined?(::Devise)
134
+ begin
135
+ h = ::Devise.mappings
136
+ snap = {}
137
+ h.each { |scope, mapping| snap[scope] = _devise_mapping_snapshot(mapping) }
138
+ snap = Ractor.make_shareable(snap) rescue nil
139
+ const_set(:DEVISE_MAPPINGS, snap) if snap
140
+ rescue
141
+ nil
142
+ end
143
+ end
144
+
145
+ # Redefine each generated helper via string eval (no captured binding).
146
+ # Replicate the EXACT body of Devise::Controllers::UrlHelpers.generate_helpers!
147
+ # (lib/devise/controllers/url_helpers.rb): the helper does NOT call the
148
+ # alias method on the context — it reconstructs the REAL route helper name
149
+ # from `action` + `scope` + `module_name` (e.g. alias `session_path` ->
150
+ # real `user_session_path`) and sends THAT to the context. Bake in
151
+ # `action`/`module_name`/`path_or_url` as literals; `scope` is resolved
152
+ # per-call from the argument, so interpolate it at runtime (\\#{scope}).
153
+ routes = ::Devise::URL_HELPERS.slice(*(::Devise.mappings.values.map(&:used_helpers).flatten.uniq))
154
+ routes.each do |module_name, actions|
155
+ [:path, :url].each do |path_or_url|
156
+ actions.each do |action|
157
+ action_prefix = action ? "#{action}_" : ""
158
+ method = :"#{action_prefix}#{module_name}_#{path_or_url}"
159
+ mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
160
+ def #{method}(resource_or_scope, *args)
161
+ scope = Devise::Mapping.find_scope!(resource_or_scope)
162
+ router_name = Devise.mappings[scope].router_name
163
+ context = router_name ? send(router_name) : _devise_route_context
164
+ context.send("#{action_prefix}\#{scope}_#{module_name}_#{path_or_url}", *args)
165
+ end
166
+ RUBY
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch ActiveSupport::ExecutionWrapper.active_key to not write a class
4
+ # ivar from a worker Ractor. The original is `@active_key ||= :"..."`,
5
+ # a raw class-ivar write — illegal from non-main Ractors. The value is a
6
+ # frozen Symbol (pure function of object_id), so we route the cache
7
+ # through IES (per-Ractor; each Ractor computes the same Symbol from the
8
+ # same object_id, so the cached value is identical across Ractors).
9
+ # ExecutionWrapper is the base for Reloader/Executor; `active_key` is
10
+ # called on every request via ActionDispatch::Executor middleware.
11
+ #
12
+ # Also patches ActiveSupport::Callbacks#run_callbacks to tolerate nil
13
+ # __callbacks, and ActiveSupport::Notifications.notifier to not read @notifier.
14
+
15
+ module RactorRailsShim
16
+ class << self
17
+ def install_execution_wrapper
18
+ return if @exec_wrapper_patched
19
+ @exec_wrapper_patched = true
20
+ _register_patch :execution_wrapper, "8.1"
21
+ if defined?(::ActiveSupport::ExecutionWrapper)
22
+ patch_execution_wrapper!
23
+ else
24
+ @ew_tp = TracePoint.new(:class) do |trace|
25
+ if defined?(::ActiveSupport::ExecutionWrapper) && !@exec_wrapper_registry_patched
26
+ @ew_tp.disable
27
+ patch_execution_wrapper!
28
+ end
29
+ end
30
+ @ew_tp.enable
31
+ end
32
+ end
33
+
34
+ def patch_execution_wrapper!
35
+ return if @exec_wrapper_registry_patched
36
+ @exec_wrapper_registry_patched = true
37
+ ew = ::ActiveSupport::ExecutionWrapper
38
+ key = :ractor_rails_shim_exec_wrapper_active_key
39
+ key_str = key.inspect
40
+ # active_key returns :"active_execution_wrapper_<object_id>"; a frozen
41
+ # Symbol is shareable. Compute it once per Ractor and cache in IES.
42
+ ew.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
43
+ def active_key
44
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
45
+ return v unless v.nil?
46
+ sym = :"active_execution_wrapper_\#{object_id}"
47
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = sym
48
+ sym
49
+ end
50
+ RUBY
51
+
52
+ # Patch ActiveSupport::Callbacks#run_callbacks to tolerate a nil
53
+ # __callbacks (the case in worker Ractors whose class_attribute fallback
54
+ # couldn't be made shareable because callback chains hold frozen,
55
+ # self-capturing Procs). For a frozen, read-only shared app the boot-time
56
+ # callbacks (ExecutionContext push/pop, CurrentAttributes clear) already
57
+ # ran in the main Ractor at boot; worker Ractors don't need to re-run
58
+ # them per request (CurrentAttributes/ExecutionContext are thread-local,
59
+ # hence per-Ractor, and start empty in a fresh worker). When __callbacks
60
+ # is nil, run_callbacks just yields the block — matching the empty-chain
61
+ # fast path in the original. (Method body lives in active_support.rb.)
62
+ _install_callbacks_nil_safe_patch
63
+
64
+ # Patch ActiveSupport::Notifications.notifier to not read the @notifier
65
+ # class ivar from a worker Ractor. The original is `attr_accessor
66
+ # :notifier` with `@notifier = Fanout.new` set at module load — a raw
67
+ # class ivar holding a Fanout (which has a Mutex + subscriber Procs,
68
+ # both unshareable). Workers get their own per-Ractor Fanout (no
69
+ # subscribers — instrumentation is a no-op in workers, which is correct
70
+ # for a read-only shared app where log subscribers already ran in main).
71
+ # `notifier` is read by `instrumenter` (per-request via Rails::Rack::Logger).
72
+ # (Method body lives in active_support.rb.)
73
+ _install_notifications_notifier_patch
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch Kaminari::config to not read the @_config class ivar from a worker
4
+ # Ractor.
5
+ #
6
+ # Blocker 2 (from NEXT_STEPS.md):
7
+ # Kaminari.config reads @_config directly at kaminari/config.rb:14:
8
+ # def self.config; @_config ||= Kaminari::Configuration.new end
9
+ # Not mattr_accessor-backed, so the shim's mattr rewrite doesn't catch it.
10
+ # Reading @_config from a worker raises
11
+ # Ractor::IsolationError (@_config from Kaminari).
12
+ #
13
+ # Fix: Targeted patch like the Warden hooks patch. Route Kaminari.config
14
+ # through IES; in the main ractor return the existing @_config; in workers
15
+ # return a shareable fallback (the config object made shareable via
16
+ # Ractor.make_shareable). The config is a simple value object (integers,
17
+ # symbols, nils) — all shareable when frozen.
18
+
19
+ module RactorRailsShim
20
+ class << self
21
+ def _install_kaminari_config_patch
22
+ return if @kaminari_patched
23
+ @kaminari_patched = true
24
+ _register_patch :kaminari_config, "8.1"
25
+ return unless defined?(::Kaminari)
26
+
27
+ # In the main ractor, capture the config object and make it shareable.
28
+ shareable_config = nil
29
+ if Ractor.main?
30
+ begin
31
+ cfg = ::Kaminari.instance_variable_get(:@_config) rescue nil
32
+ if cfg
33
+ begin
34
+ Ractor.make_shareable(cfg)
35
+ shareable_config = cfg
36
+ rescue => e
37
+ # If the config can't be made shareable (unlikely — it's all
38
+ # integers/symbols/nil), build a fresh one with defaults.
39
+ shareable_config = Ractor.make_shareable(::Kaminari::Config.new)
40
+ end
41
+ else
42
+ shareable_config = Ractor.make_shareable(::Kaminari::Config.new)
43
+ end
44
+ rescue => e
45
+ # Best-effort
46
+ end
47
+ end
48
+ shareable_config ||= Ractor.make_shareable(::Kaminari::Config.new) rescue nil
49
+
50
+ # Store the shareable config as a constant so workers can read it.
51
+ if shareable_config
52
+ verbose, $VERBOSE = $VERBOSE, nil
53
+ begin
54
+ const_set(:KAMINARI_SHAREABLE_CONFIG, shareable_config)
55
+ ensure
56
+ $VERBOSE = verbose
57
+ end
58
+ end
59
+
60
+ k_key = :ractor_rails_shim_kaminari_config
61
+ k_key_str = k_key.inspect
62
+ ::Kaminari.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
63
+ def config
64
+ v = ActiveSupport::IsolatedExecutionState[#{k_key_str}]
65
+ return v unless v.nil?
66
+ if Ractor.main? && instance_variable_defined?(:@_config)
67
+ @_config
68
+ else
69
+ RactorRailsShim::KAMINARI_SHAREABLE_CONFIG
70
+ end
71
+ end
72
+
73
+ def config=(val)
74
+ ActiveSupport::IsolatedExecutionState[#{k_key_str}] = val
75
+ end
76
+ RUBY
77
+
78
+ # Register so the shareable fallback builder knows about it.
79
+ CLASS_ATTRIBUTES << ["Kaminari", :config, k_key, nil] if shareable_config
80
+ end
81
+ end
82
+ end