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,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch the Rails module's class-level accessors (Rails.application,
4
+ # Rails.env, Rails.cache, etc.) to route through IsolatedExecutionState.
5
+ # The generic constant-sharing utilities (make_constant_shareable,
6
+ # split_const_path, do_install_shareable_constants, install_shareable_constants,
7
+ # shareable_constants) now live in core.rb (alongside SHAREABLE_CONSTANTS).
8
+
9
+ module RactorRailsShim
10
+ class << self
11
+ # Railties constants (the rest are in their per-concern files: rack.rb,
12
+ # action_view.rb, action_controller.rb, action_dispatch.rb,
13
+ # active_support.rb, warden.rb). Each file concats into
14
+ # RactorRailsShim::SHAREABLE_CONSTANTS (defined empty in core.rb).
15
+ SHAREABLE_CONSTANTS.concat([
16
+ "Rails::Railtie::ABSTRACT_RAILTIES",
17
+ "Rails::AppLoader::EXECUTABLES",
18
+ "Rails::Command::HELP_MAPPINGS",
19
+ "Rails::Command::VERSION_MAPPINGS",
20
+ "Rails::Application::INITIAL_VARIABLES",
21
+ ])
22
+
23
+ # Patch the Rails module's class-level accessors (Rails.application,
24
+ # Rails.env, Rails.cache, etc.) to route through IsolatedExecutionState.
25
+ # Defers via a load hook if Rails isn't defined yet (the config/boot.rb case).
26
+ def install_rails_module
27
+ _register_patch :rails_module, "8.1"
28
+ if defined?(::Rails)
29
+ patch_rails_module!(::Rails)
30
+ else
31
+ install_rails_load_hook
32
+ end
33
+ end
34
+
35
+ # Defer the Rails-module patch until Rails is defined. A TracePoint on
36
+ # :class fires when `rails.rb` opens `module Rails` (module bodies fire
37
+ # as :class); once the constant is assigned, we patch once and disable
38
+ # the hook.
39
+ def install_rails_load_hook
40
+ return if @rails_load_hook_installed
41
+ @rails_load_hook_installed = true
42
+
43
+ @rails_tp = TracePoint.new(:class) do |trace|
44
+ if defined?(::Rails) && !@rails_module_patched
45
+ @rails_tp.disable
46
+ patch_rails_module!(::Rails)
47
+ end
48
+ end
49
+ @rails_tp.enable
50
+ end
51
+
52
+ # The actual Rails-module patch. Idempotent. Must be called from the
53
+ # main Ractor (it prepends onto Rails.singleton_class).
54
+ def patch_rails_module!(mod)
55
+ return if @rails_module_patched
56
+ @rails_module_patched = true
57
+ do_install_shareable_constants
58
+ _patch_rails_module_body(mod)
59
+ end
60
+
61
+ def _patch_rails_module_body(mod)
62
+ k = KEYS
63
+
64
+ # Register the Rails module accessors in CLASS_ATTRIBUTES so the
65
+ # shareable-fallback builder captures their main-ractor values at
66
+ # prepare_for_ractors! / make_app_shareable! time and exposes them to
67
+ # worker Ractors (e.g. Rails.logger is read per-request by
68
+ # Rails::Rack::SilenceRequest). `application` is NOT registered — workers
69
+ # get the shared app via Ractor.new(app), not via Rails.application.
70
+ CLASS_ATTRIBUTES << ["Rails", :logger, k[:logger], nil]
71
+ CLASS_ATTRIBUTES << ["Rails", :cache, k[:cache], nil]
72
+ CLASS_ATTRIBUTES << ["Rails", :backtrace_cleaner, k[:backtrace_cleaner], nil]
73
+ CLASS_ATTRIBUTES << ["Rails", :app_class, k[:app_class], nil]
74
+
75
+ # We PREPEND a module onto Rails.singleton_class rather than redefine
76
+ # the methods directly, because Rails defines its own `application`,
77
+ # `env`, etc. LATER in rails.rb (`class << self; def application; ...`).
78
+ # A direct module_eval redefinition would be clobbered when Rails'
79
+ # own `def` runs afterward. A prepended module sits in front of the
80
+ # singleton class in the method lookup chain and survives a later
81
+ # `def` on the same class — so our IES-routed reader stays in front.
82
+ # We call `super` to fall back to Rails' original method for the
83
+ # main-ractor lazy-init path (which reads the @application ivar —
84
+ # safe in the main ractor).
85
+ patch = Module.new
86
+ patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1
87
+ # application: IES first; main ractor falls back to Rails' own
88
+ # lazy init via super. Worker ractors return nil (their own IES
89
+ # slot is empty until they boot their own app).
90
+ def application
91
+ v = ActiveSupport::IsolatedExecutionState[#{k[:application].inspect}]
92
+ return v unless v.nil?
93
+ if Ractor.main?
94
+ super
95
+ else
96
+ RactorRailsShim.const_defined?(:SHAREABLE_APP) ? RactorRailsShim::SHAREABLE_APP : nil
97
+ end
98
+ end
99
+
100
+ def application=(val)
101
+ ActiveSupport::IsolatedExecutionState[#{k[:application].inspect}] = val
102
+ super if Ractor.main?
103
+ val
104
+ end
105
+
106
+ # Simple accessors: app_class, cache, logger, backtrace_cleaner.
107
+ # Workers fall back to the shareable fallback (built from main's
108
+ # value at make_app_shareable! time) when their own IES slot is empty.
109
+ def app_class
110
+ v = ActiveSupport::IsolatedExecutionState[#{k[:app_class].inspect}]
111
+ return v unless v.nil?
112
+ return super if Ractor.main?
113
+ RactorRailsShim::SHAREABLE_FALLBACK[#{k[:app_class].inspect}]
114
+ end
115
+ def app_class=(val)
116
+ ActiveSupport::IsolatedExecutionState[#{k[:app_class].inspect}] = val
117
+ super if Ractor.main?
118
+ val
119
+ end
120
+
121
+ def cache
122
+ v = ActiveSupport::IsolatedExecutionState[#{k[:cache].inspect}]
123
+ return v unless v.nil?
124
+ return super if Ractor.main?
125
+ RactorRailsShim::SHAREABLE_FALLBACK[#{k[:cache].inspect}]
126
+ end
127
+ def cache=(val)
128
+ ActiveSupport::IsolatedExecutionState[#{k[:cache].inspect}] = val
129
+ super if Ractor.main?
130
+ val
131
+ end
132
+
133
+ def logger
134
+ v = ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}]
135
+ return v unless v.nil?
136
+ return super if Ractor.main?
137
+ # Loggers are intrinsically mutable (formatters hold tag stacks,
138
+ # logdevs hold IO + Mutex) and can't be shared read-only. Build a
139
+ # per-worker ActiveSupport::BroadcastLogger (which mixes in
140
+ # LoggerSilence#silence, used by Rails::Rack::SilenceRequest)
141
+ # writing to $stderr (each Ractor has its own $stderr stream) and
142
+ # cache it in IES so subsequent reads return the same instance.
143
+ built = ActiveSupport::BroadcastLogger.new(Logger.new($stderr))
144
+ ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}] = built
145
+ built
146
+ end
147
+ def logger=(val)
148
+ ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}] = val
149
+ super if Ractor.main?
150
+ val
151
+ end
152
+
153
+ def backtrace_cleaner
154
+ v = ActiveSupport::IsolatedExecutionState[#{k[:backtrace_cleaner].inspect}]
155
+ return v unless v.nil?
156
+ return super if Ractor.main?
157
+ RactorRailsShim::SHAREABLE_FALLBACK[#{k[:backtrace_cleaner].inspect}]
158
+ end
159
+ def backtrace_cleaner=(val)
160
+ ActiveSupport::IsolatedExecutionState[#{k[:backtrace_cleaner].inspect}] = val
161
+ super if Ractor.main?
162
+ val
163
+ end
164
+
165
+ # env: worker ractors build their own EnvironmentInquirer from ENV
166
+ # (no @ _env ivar to read). Main ractor falls back to super, which
167
+ # lazily builds and caches in @_env.
168
+ def env
169
+ v = ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}]
170
+ return v unless v.nil?
171
+ if Ractor.main?
172
+ super
173
+ else
174
+ built = ActiveSupport::EnvironmentInquirer.new(
175
+ ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development"
176
+ )
177
+ ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}] = built
178
+ built
179
+ end
180
+ end
181
+
182
+ def env=(val)
183
+ v = ActiveSupport::EnvironmentInquirer.new(val)
184
+ ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}] = v
185
+ super if Ractor.main?
186
+ v
187
+ end
188
+ RUBY
189
+ mod.singleton_class.prepend(patch)
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ractor-safe URL helper generation.
4
+ #
5
+ # Rails builds every URL helper method (`posts_path`, `_routes`,
6
+ # `_generate_paths_by_default`, the `direct`/`resolve` helpers, …) with
7
+ # `define_method(&block)` / `redefine_method(&block)`, and uses the
8
+ # `RouteSet::PATH` / `RouteSet::UNKNOWN` lambdas as the `url_strategy` callable.
9
+ # All of those are Procs created in the main Ractor; calling them from a worker
10
+ # Ractor that shares the frozen app graph raises
11
+ # "defined with an un-shareable Proc in a different Ractor".
12
+ #
13
+ # Fixes (must run BEFORE routes are drawn, i.e. before
14
+ # `Rails.application.initialize!`):
15
+ #
16
+ # * Replace `RouteSet::PATH` / `RouteSet::UNKNOWN` with shareable `Method`
17
+ # objects (a `Method` is Ractor-shareable, unlike a lambda).
18
+ # * Regenerate the named helpers and `_routes` / `_generate_paths_by_default`
19
+ # as compiled `def` methods (shareable) instead of `define_method` blocks.
20
+ # The per-helper `CustomUrlHelper`/`UrlHelper` object is stored on the
21
+ # module as a constant and looked up inside the `def`.
22
+ #
23
+ # NOTE: all `module_eval` bodies are built with string *concatenation* (no
24
+ # `#{}` inside string literals) so they are not interpolated at patch-apply
25
+ # time. Method-level `#{}` in symbol literals (e.g. `:"RRS_HELPER_#{name}"`)
26
+ # is ordinary Ruby interpolation evaluated when the generated method runs.
27
+
28
+ module RactorRailsShim
29
+ class << self
30
+ def install_route_helpers_patch
31
+ return if @route_helpers_patched
32
+ @route_helpers_patched = true
33
+ _register_patch :route_helpers, "8.1"
34
+ return unless defined?(::ActionDispatch::Routing::RouteSet)
35
+
36
+ rs = ::ActionDispatch::Routing::RouteSet
37
+
38
+ # 1. Replace the PATH / UNKNOWN lambdas with shareable Method objects.
39
+ begin
40
+ rs.send(:remove_const, :PATH) if rs.const_defined?(:PATH, false)
41
+ rescue
42
+ end
43
+ rs.const_set(:PATH, ::ActionDispatch::Http::URL.method(:path_for)) rescue nil
44
+ begin
45
+ rs.send(:remove_const, :UNKNOWN) if rs.const_defined?(:UNKNOWN, false)
46
+ rescue
47
+ end
48
+ rs.const_set(:UNKNOWN, ::ActionDispatch::Http::URL.method(:url_for)) rescue nil
49
+
50
+ # 2. Named route helpers -> compiled `def` (regular `resources` routes).
51
+ rs.class_eval <<-RUBY, __FILE__, __LINE__ + 1
52
+ def define_url_helper(mod, name, helper, url_strategy)
53
+ const_name = :"RRS_HELPER_\#{name}"
54
+ mod.const_set(const_name, helper)
55
+ strategy = url_strategy.equal?(PATH) ? :PATH : :UNKNOWN
56
+ body = "def " + name.to_s + "(*args)\\n" \
57
+ " last = args.last\\n" \
58
+ " options = case last\\n" \
59
+ " when Hash then args.pop\\n" \
60
+ " when ActionController::Parameters then args.pop.to_h\\n" \
61
+ " end\\n" \
62
+ " ::ActionDispatch::Routing::RouteSet.const_get(" + const_name.inspect + ").call(self, " + name.inspect + ", args, options, ::ActionDispatch::Routing::RouteSet.const_get(" + strategy.inspect + "))\\n" \
63
+ "end"
64
+ mod.module_eval(body, __FILE__, __LINE__ + 1)
65
+ end
66
+ RUBY
67
+
68
+ # 3. `direct` / `resolve` helpers -> compiled `def`.
69
+ if defined?(::ActionDispatch::Routing::RouteSet::NamedRouteCollection)
70
+ ::ActionDispatch::Routing::RouteSet::NamedRouteCollection.class_eval <<-RUBY, __FILE__, __LINE__ + 1
71
+ def add_url_helper(name, defaults, &block)
72
+ helper = CustomUrlHelper.new(name, defaults, &block)
73
+ path_name = :"\#{name}_path"
74
+ url_name = :"\#{name}_url"
75
+ @path_helpers_module.const_set(:"RRS_HELPER_\#{path_name}", helper)
76
+ @url_helpers_module.const_set(:"RRS_HELPER_\#{url_name}", helper)
77
+ pbody = "def " + path_name.to_s + "(*args)\\n const_get(:\\\"RRS_HELPER_" + path_name.to_s + "\\\").call(self, args, true)\\nend"
78
+ ubody = "def " + url_name.to_s + "(*args)\\n const_get(:\\\"RRS_HELPER_" + url_name.to_s + "\\\").call(self, args, false)\\nend"
79
+ @path_helpers_module.module_eval(pbody, __FILE__, __LINE__ + 1)
80
+ @url_helpers_module.module_eval(ubody, __FILE__, __LINE__ + 1)
81
+ @path_helpers << path_name
82
+ @url_helpers << url_name
83
+ self
84
+ end
85
+ RUBY
86
+ end
87
+
88
+ # 4. `_routes` / `_generate_paths_by_default` -> compiled `def`.
89
+ rs.class_eval <<-RUBY, __FILE__, __LINE__ + 1
90
+ alias_method :_rrs_orig_generate_url_helpers, :generate_url_helpers
91
+ def generate_url_helpers(supports_path)
92
+ routes = self
93
+ mod = _rrs_orig_generate_url_helpers(supports_path)
94
+ mod.module_eval("def _routes\n @_routes || ::Rails.application.routes\nend", __FILE__, __LINE__ + 1)
95
+ mod.module_eval("def _generate_paths_by_default\n " + supports_path.inspect + "\nend", __FILE__, __LINE__ + 1)
96
+ mod
97
+ end
98
+ RUBY
99
+ end
100
+
101
+ # The module-level singleton `_routes` (set via `redefine_singleton_method`
102
+ # in the gem's `included` block) is still a `define_method` block. It is not
103
+ # on the request hot-path, but make it shareable anyway. Called after the
104
+ # module has been included into ActionView/ActionController (i.e. from
105
+ # `prepare_for_ractors!`).
106
+ def fix_url_helpers_singleton_routes
107
+ return unless defined?(::Rails) && ::Rails.application
108
+ return unless ::Rails.application.routes.respond_to?(:url_helpers)
109
+ mod = ::Rails.application.routes.url_helpers
110
+ return unless mod.respond_to?(:singleton_class)
111
+ mod.singleton_class.class_eval do
112
+ def _routes
113
+ ::Rails.application.routes
114
+ end
115
+ end
116
+ rescue
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch RubyGems `Gem.paths` (and thus `Gem.path`) to be Ractor-safe.
4
+ #
5
+ # In development, Propshaft serves assets on the fly and, on every request,
6
+ # runs its cache sweeper. That calls ActiveSupport::FileUpdateChecker which
7
+ # reads `Gem.path` -> `Gem.paths` -> the unshareable `@paths` class ivar on
8
+ # `Gem`. Reading that ivar from a worker Ractor raises Ractor::IsolationError
9
+ # ("can not get unshareable values ... @paths from Gem"). Any other dev code
10
+ # path that reads `Gem.path` from a worker hits the same wall.
11
+ #
12
+ # Fix: in the main Ractor `Gem.paths` behaves exactly as before (aliased to
13
+ # the original). In a worker Ractor we return a shareable snapshot of the
14
+ # main Ractor's gem paths, captured at prepare_for_ractors! time (after
15
+ # Bundler has configured gem paths, so the snapshot is complete).
16
+
17
+ module RactorRailsShim
18
+ class << self
19
+ # Installed at `install` (boot) time. Only redefines the reader; the
20
+ # snapshot is filled in later by `snapshot_gem_paths!`.
21
+ def install_rubygems
22
+ return if @rubygems_patched
23
+ @rubygems_patched = true
24
+ _register_patch :rubygems, "all"
25
+ return unless defined?(::Gem)
26
+ patch_rubygems!
27
+ end
28
+
29
+ def patch_rubygems!
30
+ return if @rubygems_method_patched
31
+ @rubygems_method_patched = true
32
+ gem = ::Gem
33
+ unless gem.singleton_class.method_defined?(:__shim_original_gem_paths)
34
+ gem.singleton_class.alias_method :__shim_original_gem_paths, :paths
35
+ end
36
+ # `def` (not `define_method`) so the method has no captured binding and
37
+ # is callable from any Ractor. `Ractor.main?` + a shareable constant are
38
+ # both Ractor-safe.
39
+ gem.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
40
+ def paths
41
+ if Ractor.main?
42
+ __shim_original_gem_paths
43
+ else
44
+ ::RactorRailsShim::GEM_PATHS_SNAPSHOT
45
+ end
46
+ end
47
+ RUBY
48
+ end
49
+
50
+ # Called from prepare_for_ractors! (post-boot, main Ractor) so the
51
+ # snapshot reflects Bundler-configured gem paths. Also called from
52
+ # install as a safety net so the constant is never undefined.
53
+ def snapshot_gem_paths!
54
+ return unless defined?(::Gem)
55
+ return if defined?(::RactorRailsShim::GEM_PATHS_SNAPSHOT)
56
+ snap = ::Gem.paths
57
+ begin
58
+ Ractor.make_shareable(snap)
59
+ rescue StandardError
60
+ snap = Ractor.make_shareable(::Gem.path)
61
+ end
62
+ ::RactorRailsShim.const_set(:GEM_PATHS_SNAPSHOT, snap)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Make Rails' URL generation Ractor-safe.
4
+ #
5
+ # Two distinct problems exist in Rails' URL helpers under a frozen, shared
6
+ # (Ractor) graph:
7
+ #
8
+ # 1. Named route helpers, `_routes`, and `_generate_paths_by_default` are all
9
+ # defined with `define_method(&block)` / `redefine_method(&block)`, so their
10
+ # bodies are Procs created in the *main* Ractor. Any worker that calls them
11
+ # raises "defined with an un-shareable Proc in a different Ractor".
12
+ #
13
+ # 2. `RouteSet::PATH` / `RouteSet::UNKNOWN` are lambdas used as the
14
+ # `url_strategy` callable inside every helper; calling them from a worker is
15
+ # also un-shareable.
16
+ #
17
+ # These are addressed in `route_helpers.rb` (run *before* routes are drawn):
18
+ # the helper methods are regenerated as compiled `def`s and `PATH`/`UNKNOWN`
19
+ # are replaced with shareable `Method` objects.
20
+ #
21
+ # This file adds a defensive safety net: if any residual un-shareable error
22
+ # slips through, we re-dispatch the same call from this shim-defined method
23
+ # (whose scope is the worker's own Ractor).
24
+
25
+ module RactorRailsShim
26
+ class << self
27
+ def install_url_helpers_patch
28
+ return if @url_helpers_patched
29
+ @url_helpers_patched = true
30
+ _register_patch :url_helpers, "8.1"
31
+ return unless defined?(::ActiveRecord::Base)
32
+
33
+ if defined?(::ActionView::RoutingUrlFor)
34
+ ::ActionView::RoutingUrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1
35
+ alias_method :_rrs_orig_view_url_for, :url_for
36
+ def url_for(options = nil)
37
+ _rrs_orig_view_url_for(options)
38
+ rescue => e
39
+ raise unless e.message.include?("un-shareable Proc")
40
+ if options.is_a?(Hash) || options.is_a?(ActionController::Parameters)
41
+ full_url_for(options)
42
+ else
43
+ meth = _generate_paths_by_default ? :path : :url
44
+ builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.public_send(meth)
45
+ builder.handle_model_call(self, options)
46
+ end
47
+ end
48
+ RUBY
49
+ end
50
+
51
+ if defined?(::ActionDispatch::Routing::UrlFor)
52
+ ::ActionDispatch::Routing::UrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1
53
+ alias_method :_rrs_orig_full_url_for, :full_url_for
54
+ def full_url_for(options = nil)
55
+ _rrs_orig_full_url_for(options)
56
+ rescue => e
57
+ raise unless e.message.include?("un-shareable Proc")
58
+ if options.is_a?(Hash) || options.is_a?(ActionController::Parameters)
59
+ route_name = options.delete :use_route
60
+ merged = options.to_h.symbolize_keys.reverse_merge!(url_options)
61
+ # `self._routes` is an un-shareable `define_method` block in a
62
+ # worker Ractor, so calling it re-raises the same error. Route the
63
+ # fallback through the worker-safe shareable RouteSet instead.
64
+ if RactorRailsShim.const_defined?(:SHAREABLE_ROUTES)
65
+ RactorRailsShim::SHAREABLE_ROUTES.url_for(merged, route_name)
66
+ else
67
+ _routes.url_for(merged, route_name)
68
+ end
69
+ else
70
+ builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url
71
+ builder.handle_model_call(self, options)
72
+ end
73
+ end
74
+ RUBY
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch Warden::Hooks lazy class-ivar accessors (@_on_request ||= [] etc).
4
+ # Warden middleware holds 6 lazy-init class ivars for callback arrays.
5
+ # make_app_shareable! freezes the middleware instance; the ||= lazy init
6
+ # tries to WRITE on the frozen instance → IsolationError in workers.
7
+ # The callbacks (Procs) were registered at boot and already ran in main;
8
+ # workers treat them as empty (correct for a read-only shared app).
9
+
10
+ module RactorRailsShim
11
+ # Devise constants that need to be made shareable.
12
+ SHAREABLE_CONSTANTS.concat([
13
+ "Devise::ParameterSanitizer::DEFAULT_PERMITTED_ATTRIBUTES",
14
+ "Devise::Mapping::DEFAULTS",
15
+ "Devise::DEVS",
16
+ "Devise::URLS",
17
+ "Devise::STRATEGIES",
18
+ "Devise::CONTROLLERS",
19
+ "Devise::MODULES",
20
+ ])
21
+
22
+ # Source-location constant for the Devise scope constraint Proc (moved from
23
+ # make_shareable.rb so the Devise-related callable lives with the Devise patch).
24
+ DEVISE_SCOPE_LOC = "/devise/rails/routes.rb".freeze
25
+
26
+ class << self
27
+ def _install_warden_hooks_patch
28
+ return if @warden_patched
29
+ @warden_patched = true
30
+ _register_patch :warden_hooks, "8.1"
31
+ return unless defined?(::Warden::Hooks)
32
+ ::Warden::Hooks.module_eval <<-RUBY, __FILE__, __LINE__ + 1
33
+ def _after_set_user
34
+ if Ractor.main? && instance_variable_defined?(:@_after_set_user)
35
+ @_after_set_user
36
+ else
37
+ []
38
+ end
39
+ end
40
+ def _before_failure
41
+ if Ractor.main? && instance_variable_defined?(:@_before_failure)
42
+ @_before_failure
43
+ else
44
+ []
45
+ end
46
+ end
47
+ def _after_failed_fetch
48
+ if Ractor.main? && instance_variable_defined?(:@_after_failed_fetch)
49
+ @_after_failed_fetch
50
+ else
51
+ []
52
+ end
53
+ end
54
+ def _before_logout
55
+ if Ractor.main? && instance_variable_defined?(:@_before_logout)
56
+ @_before_logout
57
+ else
58
+ []
59
+ end
60
+ end
61
+ def _on_request
62
+ if Ractor.main? && instance_variable_defined?(:@_on_request)
63
+ @_on_request
64
+ else
65
+ []
66
+ end
67
+ end
68
+ RUBY
69
+ end
70
+
71
+ # Patch Warden::Strategies#_strategies. The strategy registry is a lazy
72
+ # class ivar (`@strategies ||= {}`) on the Warden::Strategies module; a
73
+ # worker Ractor reading it raises "can not get unshareable values from
74
+ # instance variables of classes/modules from non-main Ractors" (Devise's
75
+ # `current_user` / `user_signed_in?` in a layout hits
76
+ # Warden::Strategies[label] -> _strategies). Capture the (shareable)
77
+ # registry in main and expose it via a constant that workers read.
78
+ def _install_warden_strategies_patch
79
+ return if @warden_strategies_patched
80
+ @warden_strategies_patched = true
81
+ _register_patch :warden_strategies, "8.1"
82
+ return unless defined?(::Warden::Strategies)
83
+ ::Warden::Strategies.module_eval <<-RUBY, __FILE__, __LINE__ + 1
84
+ def _strategies
85
+ if Ractor.main?
86
+ @strategies ||= {}
87
+ else
88
+ RactorRailsShim::SHAREABLE_WARDEN_STRATEGIES || {}
89
+ end
90
+ end
91
+ RUBY
92
+ if Ractor.main?
93
+ begin
94
+ strat = ::Warden::Strategies.instance_variable_get(:@strategies)
95
+ strat = Ractor.make_shareable(strat) if strat && !Ractor.shareable?(strat)
96
+ RactorRailsShim.const_set(:SHAREABLE_WARDEN_STRATEGIES, strat) unless RactorRailsShim.const_defined?(:SHAREABLE_WARDEN_STRATEGIES)
97
+ rescue
98
+ nil
99
+ end
100
+ end
101
+ end
102
+
103
+ # Warden registers per-scope session serializers with
104
+ # `Warden::SessionSerializer.send(:define_method, method_name, &block)`
105
+ # (warden-1.2.9/lib/warden/manager.rb:71). The block is created while the
106
+ # app boots in the main Ractor, so the resulting method is Ractor-bound:
107
+ # invoking it (e.g. `user_serialize`) from a worker Ractor raises
108
+ # "defined with an un-shareable Proc in a different Ractor".
109
+ #
110
+ # Devise's block body is simply `mapping.to.serialize_into_session(record)`.
111
+ # We re-register the serializers as plain `def` methods (which are NOT
112
+ # Ractor-bound) that delegate to the model class's own
113
+ # `serialize_into_session` / `serialize_from_session` class methods — both
114
+ # worker-safe — so the chain is callable from any worker Ractor.
115
+ def _install_warden_serializer_patch
116
+ return if @warden_serializer_patched
117
+ @warden_serializer_patched = true
118
+ _register_patch :warden_serializer, "8.1"
119
+ return unless defined?(::Warden::SessionSerializer)
120
+
121
+ if defined?(::Devise) && ::Devise.respond_to?(:mappings)
122
+ ::Devise.mappings.each do |scope, mapping|
123
+ model = mapping.to
124
+ ::Warden::SessionSerializer.class_eval <<-RUBY, __FILE__, __LINE__ + 1
125
+ def #{scope}_serialize(record)
126
+ #{model}.serialize_into_session(record)
127
+ end
128
+
129
+ def #{scope}_deserialize(*keys)
130
+ # Devise's serialize_into_session returns [[id], salt]. The key
131
+ # passed by Warden::SessionSerializer#fetch is that value, but in
132
+ # the kino :ractor worker the per-request session that Warden's
133
+ # serializer sees can hold an extra wrapping layer
134
+ # ([[[id], salt]]). Flatten so serialize_from_session(key, salt)
135
+ # always receives exactly two arguments.
136
+ #{model}.serialize_from_session(*keys.flatten)
137
+ end
138
+ RUBY
139
+ end
140
+ end
141
+
142
+ ::Warden::SessionSerializer.class_eval do
143
+ unless method_defined?(:serialize)
144
+ def serialize(user)
145
+ user
146
+ end
147
+ end
148
+
149
+ unless method_defined?(:deserialize)
150
+ def deserialize(key)
151
+ key
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ # --- Devise scope-constraint callable (moved from make_shareable.rb) ---
158
+ # Defined via string eval on the singleton class so it's referenced the same
159
+ # way the original code did (RactorRailsShim.singleton_class.const_get).
160
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
161
+ class DeviseMappingCallable
162
+ def initialize(mapping); @mapping = mapping; end
163
+ def call(request)
164
+ request.env["devise.mapping"] = @mapping
165
+ true
166
+ end
167
+ end
168
+ RUBY
169
+
170
+ # Build a shareable replacement for a Devise scope constraint.
171
+ # The original Proc (devise/rails/routes.rb:363) does:
172
+ # request.env["devise.mapping"] = Devise.mappings[scope]
173
+ # true
174
+ # The scope is captured in the Proc's binding. We call the original
175
+ # Proc once in main with a mock request to capture the mapping, then
176
+ # make it shareable and wrap it in a DeviseMappingCallable.
177
+ def _devise_mapping_replacement(proc_obj, _parent)
178
+ mock_env = { "devise.mapping" => nil }
179
+ mock_req = Struct.new(:env).new(mock_env)
180
+ begin
181
+ proc_obj.call(mock_req)
182
+ rescue
183
+ end
184
+ mapping = mock_env["devise.mapping"]
185
+ if mapping
186
+ mapping = _devise_mapping_snapshot(mapping)
187
+ end
188
+ if mapping
189
+ DeviseMappingCallable.new(mapping)
190
+ else
191
+ CallableConst.new(true)
192
+ end
193
+ end
194
+ end
195
+ end