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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rewrite Module.mattr_accessor (and friends) so the accessor methods
4
+ # route through IsolatedExecutionState. Uses prepend + module_eval with
5
+ # strings to avoid cross-ractor binding issues.
6
+
7
+ module RactorRailsShim
8
+ class << self
9
+ def install_mattr_accessor
10
+ _register_patch :mattr_accessor, "8.1"
11
+ return if @mattr_patched
12
+ @mattr_patched = true
13
+
14
+ ::Module.prepend(Module.new {
15
+ # The prepended module's body is evaluated in the main ractor at
16
+ # prepend time; the methods it defines are callable from any ractor
17
+ # because they're defined via string eval (no captured binding).
18
+ # But mattr_accessor itself runs at app boot in the main ractor, and
19
+ # the per-accessor redefinition must also use string eval.
20
+ #
21
+ # IMPORTANT: Rails' mattr_accessor/cattr_accessor stores values in
22
+ # CLASS VARIABLES (@@sym), not class instance variables (@sym). The
23
+ # default value is written via class_variable_set("@@sym", default).
24
+ # Class variables are also subject to Ractor::IsolationError from
25
+ # non-main ractors (verified on Ruby 4.0.5), so we route through IES
26
+ # the same way — but the main-ractor fallback must read @@sym, and
27
+ # the seed must run in the main ractor at define-time (via super).
28
+ def mattr_accessor(*syms, instance_reader: true, instance_writer: true,
29
+ instance_accessor: true, default: nil, **kwargs, &block)
30
+ shareable = kwargs[:shareable]
31
+ mod_name = name
32
+
33
+ # Compute the default value the same way Rails does, so we can
34
+ # seed worker-ractor IES slots with it (workers can't read @@sym).
35
+ # The block form is evaluated once here (in main ractor) like Rails.
36
+ sym_default = block_given? && default.nil? ? yield : default
37
+
38
+ super # define the methods via the original path (sets @@sym)
39
+
40
+ syms.each do |sym|
41
+ key = :"ractor_rails_shim_mattr_#{mod_name}_#{sym}"
42
+ key_str = key.inspect
43
+ cv = "@@#{sym}"
44
+ cv_str = cv.inspect
45
+
46
+ # Register so _build_shareable_fallback! can capture the main-ractor
47
+ # value (read from @@sym) at prepare_for_ractors! time. The label
48
+ # is just for diagnostics. The default is stored too so the
49
+ # fallback builder can use it when the live value can't be shared.
50
+ RactorRailsShim::CLASS_ATTRIBUTES << [mod_name, sym, key, sym_default]
51
+ # Store the default in a runtime registry (NOT inlined into the
52
+ # eval'd method body — arbitrary objects like Logger have invalid
53
+ # `.inspect` output). The reader looks it up by key.
54
+ RactorRailsShim::MATTR_DEFAULTS[key] = sym_default
55
+ # If the default is shareable, add to the shareable subset. We
56
+ # rebuild the constant as a new frozen shareable Hash each time
57
+ # (so workers can read the constant even before prepare_for_ractors!
58
+ # runs — e.g. unit tests). const_set warns "already initialized
59
+ # constant"; silence it.
60
+ if sym_default && Ractor.shareable?(sym_default)
61
+ h = RactorRailsShim::SHAREABLE_MATTR_DEFAULTS.dup
62
+ h[key] = sym_default
63
+ h.freeze
64
+ Ractor.make_shareable(h)
65
+ verbose, $VERBOSE = $VERBOSE, nil
66
+ begin
67
+ RactorRailsShim.const_set(:SHAREABLE_MATTR_DEFAULTS, h)
68
+ ensure
69
+ $VERBOSE = verbose
70
+ end
71
+ end
72
+
73
+ # Redefine the class reader via string eval (no captured binding).
74
+ # Class variables are only touched from the main ractor; worker
75
+ # ractors fall back to SHAREABLE_FALLBACK (built from main's @@sym
76
+ # at prepare_for_ractors! time) when their own IES slot is empty.
77
+ # NOTE: we deliberately do NOT inline the default value here —
78
+ # arbitrary objects (e.g. Logger) have invalid `.inspect` output.
79
+ # The fallback builder captures the live value (which may equal
80
+ # the default) at prepare time.
81
+ singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
82
+ def #{sym}
83
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
84
+ return v unless v.nil?
85
+
86
+ if #{!!shareable}
87
+ if class_variable_defined?(#{cv_str})
88
+ class_variable_get(#{cv_str})
89
+ end
90
+ elsif Ractor.main?
91
+ if class_variable_defined?(#{cv_str})
92
+ class_variable_get(#{cv_str})
93
+ else
94
+ nil
95
+ end
96
+ else
97
+ # Worker: try the shareable fallback (built from main's @@sym
98
+ # at prepare_for_ractors! time). If empty, try the
99
+ # definition-time default (only the shareable subset — the
100
+ # full MATTR_DEFAULTS holds unshareable defaults like Logger
101
+ # which workers can't read via the constant).
102
+ fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}]
103
+ return fb unless fb.nil?
104
+ RactorRailsShim::SHAREABLE_MATTR_DEFAULTS[#{key_str}]
105
+ end
106
+ end
107
+
108
+ def #{sym}=(val)
109
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = val
110
+ if Ractor.main?
111
+ class_variable_set(#{cv_str}, val) if class_variable_defined?(#{cv_str})
112
+ class_variable_set(#{cv_str}, val) unless class_variable_defined?(#{cv_str})
113
+ end
114
+ val
115
+ end
116
+ RUBY
117
+
118
+ # Instance readers/writers route through IES directly (NOT
119
+ # self.class.#{sym}). Rails' original uses @@sym (a class variable
120
+ # inherited by including classes); the shim routes through IES,
121
+ # so the instance reader must also use IES. Using self.class.sym
122
+ # would fail for mattr_accessor on Modules (e.g.
123
+ # ActionView::Helpers::FormHelper#form_with_generates_ids):
124
+ # self.class is the including class (ActionView::Base), which
125
+ # doesn't have the module's singleton method.
126
+ # Only redefine if instance_accessor is on (matches Rails).
127
+ if instance_reader && instance_accessor
128
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
129
+ def #{sym}
130
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
131
+ return v unless v.nil?
132
+ if Ractor.main?
133
+ self.class.class_variable_defined?(#{cv_str}) ? self.class.class_variable_get(#{cv_str}) : nil
134
+ else
135
+ RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}]
136
+ end
137
+ end
138
+ RUBY
139
+ end
140
+ if instance_writer && instance_accessor
141
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
142
+ def #{sym}=(val)
143
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = val
144
+ self.class.class_variable_set(#{cv_str}, val) if Ractor.main? && self.class.class_variable_defined?(#{cv_str})
145
+ val
146
+ end
147
+ RUBY
148
+ end
149
+ end
150
+ end
151
+
152
+ # cattr_accessor is an alias for mattr_accessor in Rails; route it too.
153
+ if method_defined?(:cattr_accessor, true)
154
+ alias_method :_unshimmed_cattr_accessor, :cattr_accessor
155
+ def cattr_accessor(*args, **kwargs, &block)
156
+ mattr_accessor(*args, **kwargs, &block)
157
+ end
158
+ end
159
+ })
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches for OpenSSL digest classes so they can be used from worker Ractors.
4
+ #
5
+ # `openssl/digest.rb` defines each algorithm class (SHA256, etc.) as:
6
+ #
7
+ # klass = Class.new(self) {
8
+ # define_method(:initialize, ->(data = nil) { super(name, data) })
9
+ # }
10
+ # singleton.class_eval {
11
+ # define_method(:digest) { |data| new.digest(data) }
12
+ # define_method(:hexdigest) { |data| new.hexdigest(data) }
13
+ # }
14
+ #
15
+ # The `->(data = nil) { ... }` lambda (and the singleton `digest`/`hexdigest`
16
+ # blocks) are compiled in the MAIN Ractor. Instantiating the class from a
17
+ # worker Ractor (`OpenSSL::Digest::SHA256.new`, called e.g. by
18
+ # `ActiveSupport::KeyGenerator#generate_key` -> `OpenSSL::PKCS5.pbkdf2_hmac`
19
+ # -> `@hash_digest_class.new`) invokes that lambda and raises
20
+ # "defined with an un-shareable Proc in a different Ractor". This blocks every
21
+ # request that touches the cookie/session key generator (i.e. all of them).
22
+ #
23
+ # Fix: redefine the methods with string-eval (no captured binding) so they are
24
+ # callable from any Ractor. Behaviour is identical. Applied at
25
+ # prepare_for_ractors! time (after OpenSSL is loaded).
26
+
27
+ module RactorRailsShim
28
+ class << self
29
+ def _install_openssl_digest_patch
30
+ return if @openssl_digest_patched
31
+ @openssl_digest_patched = true
32
+ _register_patch :openssl_digest, "8.1"
33
+ return unless defined?(::OpenSSL::Digest)
34
+
35
+ algos = %w(MD4 MD5 RIPEMD160 SHA1 SHA224 SHA256 SHA384 SHA512)
36
+ algos.each do |name|
37
+ klass_name = name.tr("-", "_")
38
+ next unless ::OpenSSL::Digest.const_defined?(klass_name)
39
+ klass = ::OpenSSL::Digest.const_get(klass_name)
40
+ next unless klass.is_a?(::Class)
41
+
42
+ # Replace the lambda-based `initialize` (super(name, data)) with a
43
+ # string-eval'd method that holds no captured Proc.
44
+ klass.class_eval "def initialize(data = nil); super(#{name.inspect}, data); end"
45
+
46
+ # Replace the singleton `digest`/`hexdigest` blocks the same way.
47
+ klass.singleton_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
48
+ def digest(data)
49
+ new.digest(data)
50
+ end
51
+ def hexdigest(data)
52
+ new.hexdigest(data)
53
+ end
54
+ RUBY
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch OrmAdapter::ToAdapter#to_adapter. The original memoizes the adapter on
4
+ # the model CLASS via `@_to_adapter ||= self::OrmAdapter.new(self)`. A worker
5
+ # Ractor cannot set an instance variable on a class/module, so it raises
6
+ # "Ractor::IsolationError: can not set instance variables of classes/modules by
7
+ # non-main Ractors". Route the per-class cache through IsolatedExecutionState,
8
+ # keyed by the class's (stable, shared) object_id. Each Ractor builds its own
9
+ # adapter and caches it locally; the adapter only holds a reference to the
10
+ # (shared) model class, so no cross-boundary objects are involved.
11
+
12
+ module RactorRailsShim
13
+ class << self
14
+ def _install_orm_adapter_patch
15
+ return if @orm_adapter_patched
16
+ @orm_adapter_patched = true
17
+ _register_patch :orm_adapter, "8.1"
18
+ return unless defined?(::OrmAdapter::ToAdapter)
19
+
20
+ ::OrmAdapter::ToAdapter.module_eval <<-RUBY, __FILE__, __LINE__ + 1
21
+ def to_adapter
22
+ key = :"ractor_rails_shim_orm_adapter_\#{object_id}"
23
+ v = ActiveSupport::IsolatedExecutionState[key]
24
+ return v unless v.nil?
25
+ adapter = self::OrmAdapter.new(self)
26
+ ActiveSupport::IsolatedExecutionState[key] = adapter
27
+ adapter
28
+ end
29
+ RUBY
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.
4
+ #
5
+ # Two Ractor-safety problems live here:
6
+ #
7
+ # 1. The class body populates a `CACHE` constant with HelperMethodBuilder
8
+ # instances that hold un-shareable lambdas (e.g. `->(name) { name.route_key }`).
9
+ # Because the constant lives on the shared class, a worker Ractor that reads
10
+ # `CACHE[:path][nil]` touches a main-Ractor-owned object and raises
11
+ # "defined with an un-shareable Proc in a different Ractor". Fix: stop reading
12
+ # `CACHE` — build a fresh builder in the *calling* Ractor on every lookup.
13
+ #
14
+ # 2. `HelperMethodBuilder#handle_model_call` calls `polymorphic_mapping`, which
15
+ # reads `target._routes.polymorphic_mappings` — a shared hash of main-built
16
+ # Procs. Reading it from a worker raises the same error. Fix: skip the shared
17
+ # mapping and build the route from the (worker-owned) builder's own logic.
18
+
19
+ module RactorRailsShim
20
+ class << self
21
+ def _install_polymorphic_routes_patch
22
+ return if @polymorphic_routes_patched
23
+ @polymorphic_routes_patched = true
24
+ _register_patch :polymorphic_routes, "8.1"
25
+ return unless defined?(::ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder)
26
+ hmb = ::ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder
27
+ hmb.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
28
+ def get(action, type)
29
+ type = type.to_sym
30
+ build action, type
31
+ end
32
+
33
+ def url
34
+ build nil, "url"
35
+ end
36
+
37
+ def path
38
+ build nil, "path"
39
+ end
40
+ RUBY
41
+ hmb.module_eval <<-RUBY, __FILE__, __LINE__ + 1
42
+ def handle_model_call(target, record)
43
+ if mapping = polymorphic_mapping(target, record) rescue nil
44
+ mapping.call(target, [record], suffix == "path")
45
+ else
46
+ method, args = handle_model(record)
47
+ target.public_send(method, *args)
48
+ end
49
+ end
50
+ RUBY
51
+
52
+ # The module-level `polymorphic_path` / `polymorphic_url` (called by
53
+ # `form_with` when it infers the URL from a model) invoke
54
+ # `mapping.call` directly. A custom `resolve` mapping Proc is built in
55
+ # the main Ractor and is un-shareable, so calling it from a worker
56
+ # Ractor raises "defined with an un-shareable Proc in a different
57
+ # Ractor". Rescue that and fall through to the (worker-safe)
58
+ # HelperMethodBuilder path, which derives the route from the record's
59
+ # model name — the same fallback the original code uses when no mapping
60
+ # is registered at all.
61
+ pm = ::ActionDispatch::Routing::PolymorphicRoutes
62
+ pm.module_eval <<-RUBY, __FILE__, __LINE__ + 1
63
+ def polymorphic_path(record_or_hash_or_array, options = {})
64
+ if ::Hash === record_or_hash_or_array
65
+ options = record_or_hash_or_array.merge(options)
66
+ record = options.delete :id
67
+ return polymorphic_path record, options
68
+ end
69
+
70
+ if mapping = (polymorphic_mapping(record_or_hash_or_array) rescue nil)
71
+ begin
72
+ return mapping.call(self, [record_or_hash_or_array, options], true)
73
+ rescue ::Ractor::Error
74
+ end
75
+ end
76
+
77
+ opts = options.dup
78
+ action = opts.delete :action
79
+ type = :path
80
+
81
+ HelperMethodBuilder.polymorphic_method self,
82
+ record_or_hash_or_array,
83
+ action,
84
+ type,
85
+ opts
86
+ end
87
+
88
+ def polymorphic_url(record_or_hash_or_array, options = {})
89
+ if ::Hash === record_or_hash_or_array
90
+ options = record_or_hash_or_array.merge(options)
91
+ record = options.delete :id
92
+ return polymorphic_url record, options
93
+ end
94
+
95
+ if mapping = (polymorphic_mapping(record_or_hash_or_array) rescue nil)
96
+ begin
97
+ return mapping.call(self, [record_or_hash_or_array, options], false)
98
+ rescue ::Ractor::Error
99
+ end
100
+ end
101
+
102
+ opts = options.dup
103
+ action = opts.delete :action
104
+ type = opts.delete(:routing_type) || :url
105
+
106
+ HelperMethodBuilder.polymorphic_method self,
107
+ record_or_hash_or_array,
108
+ action,
109
+ type,
110
+ opts
111
+ end
112
+ RUBY
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch for Propshaft (the default Rails 8 asset pipeline). The Assembly and
4
+ # LoadPath memoize their derived values in lazy instance ivars via `||=`:
5
+ #
6
+ # Propshaft::Assembly#load_path -> @load_path ||= Propshaft::LoadPath.new(...)
7
+ # Propshaft::Assembly#compilers -> @compilers ||= Propshaft::Compilers.new(...)
8
+ # Propshaft::LoadPath#assets_by_path -> @cached_assets_by_path ||= ...
9
+ # Propshaft::LoadPath#asset_paths_by_type -> (@cached_... ||= Hash.new)[ct] ||= ...
10
+ # Propshaft::LoadPath#asset_paths_by_glob -> (@cached_... ||= Hash.new)[g] ||= ...
11
+ #
12
+ # The Assembly/LoadPath live inside the frozen, shared Rails.application graph
13
+ # (set by the propshaft railtie at boot, then deep-frozen by
14
+ # make_app_shareable!). A worker Ractor calling `||=` to populate these ivars
15
+ # raises FrozenError because the host object is frozen.
16
+ #
17
+ # Fix: warm every Assembly/LoadPath lazy ivar in MAIN (before freezing) so the
18
+ # `||=` short-circuits in workers (the ivar is already set → truthy → no
19
+ # assignment). The two `asset_paths_by_*` methods additionally perform a
20
+ # *write* to their inner Hash even when the key already exists (`h[k] ||= v`
21
+ # is `h[k] = h[k] || v`, which assigns to a frozen Hash). Route those two
22
+ # through IsolatedExecutionState (each Ractor builds its own cache from
23
+ # `assets`, which is already warmed read-only).
24
+
25
+ module RactorRailsShim
26
+ class << self
27
+ def _install_propshaft_patch
28
+ return if @propshaft_patched
29
+ @propshaft_patched = true
30
+ _register_patch :propshaft, "1.3"
31
+ return unless defined?(::Propshaft::LoadPath)
32
+
33
+ lp = ::Propshaft::LoadPath
34
+ lp.class_eval <<-RUBY, __FILE__, __LINE__ + 1
35
+ def asset_paths_by_type(content_type)
36
+ cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_type_\#{object_id}"] ||= {})
37
+ if cache.key?(content_type)
38
+ cache[content_type]
39
+ else
40
+ cache[content_type] = extract_logical_paths_from(assets.select { |a| a.content_type == ::Mime::EXTENSION_LOOKUP[content_type] })
41
+ end
42
+ end
43
+
44
+ def asset_paths_by_glob(glob)
45
+ cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_glob_\#{object_id}"] ||= {})
46
+ if cache.key?(glob)
47
+ cache[glob]
48
+ else
49
+ cache[glob] = extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(glob) })
50
+ end
51
+ end
52
+ RUBY
53
+
54
+ if lp.method_defined?(:asset_paths_by_path)
55
+ lp.class_eval <<-RUBY, __FILE__, __LINE__ + 1
56
+ def asset_paths_by_path(path)
57
+ cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_propshaft_path_\#{object_id}"] ||= {})
58
+ if cache.key?(path)
59
+ cache[path]
60
+ else
61
+ cache[path] = extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(path) })
62
+ end
63
+ end
64
+ RUBY
65
+ end
66
+ end
67
+
68
+ # Warm every Propshaft Assembly/LoadPath lazy ivar in MAIN (before the app
69
+ # is frozen). Warmed ivars are truthy, so the `||=` memoization in workers
70
+ # short-circuits instead of attempting to assign onto a frozen object.
71
+ # Assets::by_path is itself memoized; warming `assets` populates it so
72
+ # workers only read the frozen cache.
73
+ def _precompute_propshaft!(app)
74
+ return unless Ractor.main?
75
+ assets = app.assets rescue nil
76
+ return unless assets
77
+ assets.load_path rescue nil
78
+ assets.compilers rescue nil
79
+ assets.resolver rescue nil
80
+ assets.prefix rescue nil
81
+ assets.processor rescue nil
82
+ # Build the asset map (LoadPath#assets -> assets_by_path) so the inner
83
+ # cache is populated and frozen before workers read it. Then warm each
84
+ # Asset's lazy ivars (@content_type, @digest) in MAIN — they are memoized
85
+ # via `||=` and the Asset objects live inside the frozen, shared app
86
+ # graph, so a worker reading them would raise FrozenError. Warming here
87
+ # (while still mutable) lets the `||=` short-circuit in workers.
88
+ assets.load_path.assets.each do |asset|
89
+ asset.content_type rescue nil
90
+ asset.digest rescue nil
91
+ end
92
+ # Warm the resolver manifest cache. Propshaft::Resolver::Static#manifest
93
+ # memoizes `@manifest ||= Propshaft::Manifest.from_path(...)`; the
94
+ # resolver is frozen in the shared graph, so without warming, a worker
95
+ # rendering a `stylesheet_link_tag` (or reading asset integrity) for a
96
+ # PRECOMPILED manifest raises FrozenError ("can't modify frozen
97
+ # Propshaft::Resolver::Static"). `#manifest` is private — invoke via
98
+ # send to trigger the `||=` in MAIN (while still mutable) so workers only
99
+ # read the frozen, cached value.
100
+ resolver = assets.resolver rescue nil
101
+ if resolver.respond_to?(:manifest, true)
102
+ begin
103
+ resolver.send(:manifest)
104
+ rescue
105
+ nil
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches for Rack: Rack::Request class-level attr_accessors and
4
+ # Rack::Utils singleton attr_accessors.
5
+
6
+ module RactorRailsShim
7
+ # Rack constants whose values are mutable and need to be made shareable.
8
+ SHAREABLE_CONSTANTS.concat([
9
+ "Rack::Utils::PATH_SEPS",
10
+ "Rack::Utils::HTTP_STATUS_CODES",
11
+ "Rack::Utils::COMMON_SEP",
12
+ "Rack::Utils::STATUS_WITH_NO_ENTITY_BODY",
13
+ "Rack::Utils::SYMBOL_TO_STATUS_CODE",
14
+ "Rack::MethodOverride::ALLOWED_METHODS",
15
+ "Rack::MethodOverride::METHOD_OVERRIDES",
16
+ "Rack::MethodOverride::HTTP_METHODS",
17
+ "Rack::Headers::KNOWN_HEADERS",
18
+ "Rack::Request::Helpers::FORM_DATA_MEDIA_TYPES",
19
+ "Rack::Request::Helpers::PARSEABLE_DATA_MEDIA_TYPES",
20
+ "Rack::Request::Helpers::DEFAULT_PORTS",
21
+ "Rack::Mime::MIME_TYPES",
22
+ "Rack::Files::ALLOWED_VERBS",
23
+ "Rack::Files::ALLOW_HEADER",
24
+ "Rack::Response::STATUS_WITH_NO_ENTITY_BODY",
25
+ # Multipart file-upload parser constants, read by worker Ractors on every
26
+ # POST (Rack::Request#POST -> parse_multipart -> Parser.parse). TEMPFILE_FACTORY
27
+ # is a lambda; EMPTY is a MultipartInfo sentinel instance; REENCODE_DUMMY_
28
+ # ENCODINGS is a mutable Hash. None are shareable by default.
29
+ "Rack::Multipart::Parser::TEMPFILE_FACTORY",
30
+ "Rack::Multipart::Parser::EMPTY",
31
+ "Rack::Multipart::Parser::REENCODE_DUMMY_ENCODINGS",
32
+ ])
33
+
34
+ # Source-location constant used by make_app_shareable!'s proc-replacement
35
+ # graph traversal (moved here from make_shareable.rb so the Rack concern's
36
+ # pieces live together).
37
+ FILES_LOC = "/rack/files.rb".freeze
38
+
39
+ class << self
40
+ # Patch Rack::Request's class-level attr_accessors (forwarded_priority,
41
+ # x_forwarded_proto_priority) to not read @ivars from a worker Ractor.
42
+ # The values are frozen-Symbol Arrays (shareable); route the cache
43
+ # through IES with the same default in workers. Read per-request via
44
+ # ActionDispatch::RemoteIp. Applied at prepare_for_ractors! time (after
45
+ # Rails boots, so Rack is loaded).
46
+ def _install_rack_request_patch
47
+ return if @rack_request_patched
48
+ @rack_request_patched = true
49
+ _register_patch :rack_request, "8.1"
50
+ return unless defined?(::Rack::Request)
51
+ req = ::Rack::Request
52
+ fp_key = :ractor_rails_shim_rack_forwarded_priority
53
+ xp_key = :ractor_rails_shim_rack_x_forwarded_proto_priority
54
+ fp_key_str = fp_key.inspect
55
+ xp_key_str = xp_key.inspect
56
+ req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
57
+ def forwarded_priority
58
+ v = ActiveSupport::IsolatedExecutionState[#{fp_key_str}]
59
+ return v unless v.nil?
60
+ if Ractor.main? && instance_variable_defined?(:@forwarded_priority)
61
+ @forwarded_priority
62
+ else
63
+ [:forwarded, :x_forwarded]
64
+ end
65
+ end
66
+ def forwarded_priority=(val)
67
+ ActiveSupport::IsolatedExecutionState[#{fp_key_str}] = val
68
+ @forwarded_priority = val if Ractor.main?
69
+ val
70
+ end
71
+ def x_forwarded_proto_priority
72
+ v = ActiveSupport::IsolatedExecutionState[#{xp_key_str}]
73
+ return v unless v.nil?
74
+ if Ractor.main? && instance_variable_defined?(:@x_forwarded_proto_priority)
75
+ @x_forwarded_proto_priority
76
+ else
77
+ [:proto, :scheme]
78
+ end
79
+ end
80
+ def x_forwarded_proto_priority=(val)
81
+ ActiveSupport::IsolatedExecutionState[#{xp_key_str}] = val
82
+ @x_forwarded_proto_priority = val if Ractor.main?
83
+ val
84
+ end
85
+ RUBY
86
+ end
87
+
88
+ # Patch Rack::Utils singleton attr_accessors (default_query_parser,
89
+ # multipart_total_part_limit, multipart_file_limit) to not read @ivars
90
+ # from a worker Ractor. The values are shareable once frozen (QueryParser,
91
+ # Integers). Route through IES; workers read the shareable fallback.
92
+ # `default_query_parser` is read per-request during POST parsing.
93
+ def _install_rack_utils_patch
94
+ return if @rack_utils_patched
95
+ @rack_utils_patched = true
96
+ _register_patch :rack_utils, "8.1"
97
+ return unless defined?(::Rack::Utils)
98
+ u = ::Rack::Utils
99
+ dqp_key = :ractor_rails_shim_rack_utils_default_query_parser
100
+ mtp_key = :ractor_rails_shim_rack_utils_multipart_total_part_limit
101
+ mfl_key = :ractor_rails_shim_rack_utils_multipart_file_limit
102
+ dqp_key_str = dqp_key.inspect
103
+ mtp_key_str = mtp_key.inspect
104
+ mfl_key_str = mfl_key.inspect
105
+ u.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
106
+ def default_query_parser
107
+ v = ActiveSupport::IsolatedExecutionState[#{dqp_key_str}]
108
+ return v unless v.nil?
109
+ if Ractor.main? && instance_variable_defined?(:@default_query_parser)
110
+ v = @default_query_parser
111
+ ActiveSupport::IsolatedExecutionState[#{dqp_key_str}] = v
112
+ v
113
+ else
114
+ RactorRailsShim::SHAREABLE_FALLBACK[#{dqp_key_str}] || ::Rack::QueryParser::QueryParser.make_default(32)
115
+ end
116
+ end
117
+ def multipart_total_part_limit
118
+ v = ActiveSupport::IsolatedExecutionState[#{mtp_key_str}]
119
+ return v unless v.nil?
120
+ if Ractor.main? && instance_variable_defined?(:@multipart_total_part_limit)
121
+ v = @multipart_total_part_limit
122
+ ActiveSupport::IsolatedExecutionState[#{mtp_key_str}] = v
123
+ v
124
+ else
125
+ RactorRailsShim::SHAREABLE_FALLBACK[#{mtp_key_str}] || 128
126
+ end
127
+ end
128
+ def multipart_file_limit
129
+ v = ActiveSupport::IsolatedExecutionState[#{mfl_key_str}]
130
+ return v unless v.nil?
131
+ if Ractor.main? && instance_variable_defined?(:@multipart_file_limit)
132
+ v = @multipart_file_limit
133
+ ActiveSupport::IsolatedExecutionState[#{mfl_key_str}] = v
134
+ v
135
+ else
136
+ RactorRailsShim::SHAREABLE_FALLBACK[#{mfl_key_str}] || 64
137
+ end
138
+ end
139
+ RUBY
140
+ CLASS_ATTRIBUTES << ["Rack::Utils", :default_query_parser, dqp_key, nil]
141
+ CLASS_ATTRIBUTES << ["Rack::Utils", :multipart_total_part_limit, mtp_key, nil]
142
+ CLASS_ATTRIBUTES << ["Rack::Utils", :multipart_file_limit, mfl_key, nil]
143
+ end
144
+
145
+ # Find the Rack::Files (asset) server in the middleware chain, used by
146
+ # make_app_shareable! when replacing the Rack::Head#@app lambda (whose
147
+ # binding receiver is the Rack::Files instance). Moved here from
148
+ # make_shareable.rb so the Rack concern's pieces live together.
149
+ def _find_files_server(mw)
150
+ cur = mw
151
+ while cur
152
+ if cur.class.name == "ActionDispatch::Static"
153
+ return cur.instance_variable_get(:@file_server)
154
+ end
155
+ cur = cur.instance_variable_get(:@app) rescue nil
156
+ end
157
+ nil
158
+ end
159
+ end
160
+ end