ractor-rails-shim 0.2.5 → 0.2.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 267a3734452aa4069b68599b193d8bebd3fa6e386836ec9ec4238d9667f6b37a
4
- data.tar.gz: 748ee127a59a4e7cc0b90b16ff7339edb2dfd6cde3b15df01c1ff29d910d05c3
3
+ metadata.gz: 4a4b7ce968f90326f2b476d713b7fab6c524f8f7b111f60a2ae582d1e162de88
4
+ data.tar.gz: c144478be527ff98804db48add54d67edb4124514fba3670b970990ef09e36c0
5
5
  SHA512:
6
- metadata.gz: fdd580e1b1c24d324dfc9067a323dff580acd612ac8cfc2551424d4d359d5fc93d55c7ae7a9825efecad3103ef2c59b1b481a34d9270471f23fd19747dc9a52c
7
- data.tar.gz: '0523817853ccdfc9444c0815fcb943b7cb3f745519b5baf6683e269e60c81889f97500e7e9e34da7bd3da7e74a3fd3033c5bbd25c4de6296337735ed6fd8301c'
6
+ metadata.gz: 55cc0fb00ebd523182cb66f8ae7a9f270bf33bcaaf928af79d291edf3eeb0248246f956e082200ab74563c7afceee8520336f53373ed9d54ff73f4db9ce05f40
7
+ data.tar.gz: b19280d1985a1e8b456a6a13750e277a49c5123998a4f7fab587518d3d6a717249d9490cfbe6d32751483d9c5e622373d861fbed9ce50d8daeabb09578b897e9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.6]
11
+
12
+ ### Fixed
13
+ - **`WorkerApp` from `worker_app` not `Ractor.shareable?`.** The factory
14
+ returned a bare `WorkerApp` instance without freezing or making it
15
+ shareable, so any caller following the README's
16
+ `Ractor.new(worker_app) { |a| a.call(env) }` example would raise
17
+ `Ractor::IsolationError` at spawn. `worker_app` now freezes +
18
+ `Ractor.make_shareable`s the instance, enforcing the shareability
19
+ contract at the boundary instead of pushing it onto every caller.
20
+ (`patches/core.rb`)
21
+
22
+ - **`Hash#compute_if_absent` nil-caching divergence from
23
+ `Concurrent::Map`.** The shim replaces `Concurrent::Map` instances in
24
+ the frozen app graph with plain `Hash`es (Concurrent::Map isn't
25
+ Ractor-shareable) and adds a compatible `compute_if_absent`. The
26
+ frozen-Hash branch used `IES[key] ||= yield`, which SKIPS nil results
27
+ — but `Concurrent::Map#compute_if_absent` STORES nil (verified: the
28
+ key is present after a nil-result block call), so the shim's
29
+ divergent semantics re-invoked the block on every subsequent lookup
30
+ for any cache slot whose computed value was nil. The frozen branch
31
+ now uses a two-level IES bucket (`slot[object_id][key]`) with a
32
+ proper `key?` presence check, matching `Concurrent::Map` exactly.
33
+ Also fixed: an existing key on a frozen Hash now returns without
34
+ invoking the block (was always treating the receiver as empty), and
35
+ per-Hash isolation is now keyed by `object_id` so two frozen hashes
36
+ with the same key don't collide. (`patches/core.rb`)
37
+
38
+ - **Magic `line == 32` strategy dispatch replaced with identity check.**
39
+ The Proc-replacement pass distinguished
40
+ `ActionDispatch::Routing::Mapper::Constraints::SERVE` from `CALL` by
41
+ hard-coding the `source_location` line number (32). Any Rails patch
42
+ release that shifted the constant definitions by even one line would
43
+ silently swap the two strategies and break routing (a dispatcher
44
+ endpoint called as `app.call(req.env)` instead of `app.serve(req)`).
45
+ Replaced with an `equal?` check against the actual constants — the
46
+ value stored in `@strategy` IS the constant object (Rails assigns it
47
+ by reference), so identity is the robust identifier, independent of
48
+ source layout. Falls through to `NoOpProc` for an unknown Proc
49
+ (defensive; never mis-routes). Extracted into
50
+ `_strategy_replacement_for`. (`patches/make_shareable.rb`)
51
+
52
+ - **`capture_app_constants` crash on non-Zeitwerk autoloaders.** The
53
+ method called `Rails.autoloaders.main` / `.once` directly, which
54
+ `NoMethodError`s for apps in classic-loader mode or with
55
+ `config.autoloaders = false` (the autoloaders object exists but
56
+ doesn't expose `main`/`once`). Now filters to the loaders that
57
+ actually expose `all_expected_cpaths` (the Zeitwerk introspection
58
+ API), and tolerates autoloaders objects that only respond to `each`.
59
+ Also fixed a pre-existing bug: the early-return path returned an
60
+ UNFROZEN map, violating the method's documented frozen-map contract.
61
+ (`patches/core.rb`)
62
+
63
+ ### Changed
64
+ - **`_replace_unshareable_procs!` 3.times magic replaced with a
65
+ fixed-point loop.** The original `3.times` had no justification and
66
+ an arbitrary constant. Replaced with a loop-to-fixed-point (break
67
+ when `_collect_procs` returns empty) plus a safety cap of 8 passes
68
+ to guard against a pathological graph where replacement keeps
69
+ introducing new Procs (the replacements are `NoOpProc`/`Callable`,
70
+ not `Proc`, so this shouldn't happen, but the cap prevents an
71
+ infinite loop). Documented the multi-occurrence rationale (the same
72
+ Proc object can live in many containers, e.g. shared deprecation
73
+ behaviors). Observed real graphs converge in 2 passes.
74
+ (`patches/make_shareable.rb`)
75
+
76
+ - **Graph traversals now walk Set/Struct/Enumerable.**
77
+ `_collect_procs` and `_replace_locks_and_concurrent_maps!` only
78
+ handled `Array` and `Hash` as recursive containers. Rails uses `Set`
79
+ in several caches (e.g. `ActionDispatch::Journey::Routes`), and
80
+ `Struct`/`Enumerable` mixes appear in framework internals — a
81
+ `Mutex` or `Proc` nested inside one of those was missed and left
82
+ unshareable, breaking `make_app_shareable!` downstream. Extracted
83
+ `_each_ivar_and_child` (single responsibility: enumerate every child
84
+ reference of an object) shared by both traversals, handling `Hash`,
85
+ `Array`, `Set`, `Struct`, and a generic `Enumerable` fallback
86
+ (`Range`, `Enumerator`, custom mixins). The `Hash#default_proc`
87
+ branch is preserved so `_collect_procs` still flags default-proc
88
+ Procs. (`patches/make_shareable.rb`)
89
+
90
+ ### Added
91
+ - **Debug funnel for freeze-path silent rescues.** The
92
+ freeze/shareability paths (`_freeze_active_record_class_ivars!`,
93
+ `_freeze_global_class_ivars!`, `_make_value_shareable`) rescue all
94
+ exceptions silently with bare `rescue; nil`. Individual failures are
95
+ expected (some ivars hold intrinsically unshareable values like
96
+ Procs), but a worker Ractor that later crashes on the same
97
+ unshareable value had no traceable cause. New `_swallow(label) { ... }`
98
+ funnel is silent by default (preserves backward compat) and emits a
99
+ labeled `[ractor_rails_shim] <label>: <class>: <msg>` line to
100
+ `$stderr` when `RactorRailsShim.debug = true`. The three freeze-path
101
+ rescues are routed through it; the bare rescues in non-freeze paths
102
+ (require `LoadError`, optional `const_get`, etc.) are left as-is —
103
+ those are genuinely expected and a `$stderr` line would be noise.
104
+ (`patches/core.rb`)
105
+
106
+ ### Documentation
107
+ - **`WorkerApp#setup_once!` race comment corrected.** The previous
108
+ comment claimed `Ractor.current[:key] ||= Thread::Mutex.new` was racy
109
+ under "extreme contention" and dismissed it as harmless because the
110
+ init steps are idempotent. Verified on Ruby 4.0.6 that
111
+ `Ractor.current[:key] ||= X` is atomic (100 concurrent threads
112
+ produce exactly one mutex object), so the TOCTOU race does not
113
+ occur. Replaced the misleading comment with the verified finding.
114
+
10
115
  ## [0.2.5]
11
116
 
12
117
  ### Fixed
data/README.md CHANGED
@@ -4,31 +4,34 @@ A monkey-patch shim that reroutes Rails' class-level instance variable accessors
4
4
 
5
5
  **Status:** proof-of-concept / stopgap. The goal is for Rails to do this upstream, at which point this gem becomes a no-op and can be removed.
6
6
 
7
- **Current status:** on a full Rails 8.1 app (Devise 5, Propshaft, Kaminari,
8
- PG) under **official Ruby 4.0.6**, worker Ractors serve **every** routable action —
9
- `GET /up`, full ERB view rendering, Devise sign-in/sign-out (CSRF issuance
10
- **and** validation), and authenticated Devise **writes** (`POST /posts` → 302,
11
- row persisted). A worker Ractor dispatches `GET /up` → **HTTP 200** via
12
- `RactorRailsShim.make_app_shareable!`. The shim builds a shareable fallback
13
- table for framework class config (`class_attribute` / `mattr_accessor` values)
14
- and patches the raw class-ivar accessors Rails reads per-request
15
- (`ExecutionWrapper.active_key`, `Notifications.notifier`, `Inflections`,
16
- `PathRegistry`, `I18n`, `AbstractController` lazy ivars, `Rack::Request`/`Utils`,
17
- `ExecutionContext`, etc.).
7
+ **Current status (v0.2.5):** on a full Rails 8.1 app (Devise 5, Propshaft,
8
+ Kaminari, PG) under **official Ruby 4.0.6**, worker Ractors serve **every**
9
+ routable action — `GET /up`, full ERB view rendering, Devise sign-in/sign-out
10
+ (CSRF issuance **and** validation), and authenticated Devise **writes**
11
+ (`POST /posts` → 302, row persisted). A worker Ractor dispatches `GET /up` →
12
+ **HTTP 200** via `RactorRailsShim.make_app_shareable!`. The shim builds a
13
+ shareable fallback table for framework class config (`class_attribute` /
14
+ `mattr_accessor` values) and patches the raw class-ivar accessors Rails reads
15
+ per-request (`ExecutionWrapper.active_key`, `Notifications.notifier`,
16
+ `Inflections`, `PathRegistry`, `I18n`, `AbstractController` lazy ivars,
17
+ `Rack::Request`/`Utils`, `ExecutionContext`, etc.). 61 unit specs across 7
18
+ spec files pass (no Rails dependency); an integration spec dispatches `GET /up`
19
+ → 200 in a worker Ractor.
18
20
 
19
21
  **No patched Ruby or kino required.** Official Ruby 4.0.6 ships the frozen-iseq
20
22
  call-cache fix (#22075) and the cross-ractor env-string fix, so the SIGBUS crashes
21
23
  that previously required the DDKatch patched Ruby/kino forks are resolved in core.
22
24
  Both SIGBUS classes were the only reason a patched build was ever needed; on 4.0.6
23
- the shim runs `kino -m ractor` on the official `kino` gem under sustained read
24
- **and** write load (verified: 0 transport failures / 0 server errors across
25
+ the shim runs `kino -m ractor` on the official `kino` gem (0.1.3) under sustained
26
+ read **and** write load (verified: 0 transport failures / 0 server errors across
25
27
  `/up`, `GET /posts`, `POST /posts` in the benchmark matrix).
26
28
 
27
29
  ## Requirements
28
30
 
29
- - **Ruby >= 4.0** — the shim relies on Ruby 4.0's Ractor semantics and
30
- `Ractor.make_shareable`. It will not work (and refuses to install) on
31
- earlier Ruby versions.
31
+ - **Ruby >= 4.0.6** — the shim relies on Ruby 4.0's Ractor semantics and
32
+ `Ractor.make_shareable`, plus the frozen-iseq call-cache fix (#22075) and
33
+ the cross-ractor env-string fix that both shipped in 4.0.6. It will not
34
+ work (and refuses to install) on earlier Ruby versions.
32
35
  - **Rails ~> 8.1** — tested against Rails 8.1.x class layouts. Other versions
33
36
  are not yet supported (see Version compatibility below).
34
37
 
@@ -43,12 +46,71 @@ the configuration it is verified against.
43
46
  [kino](https://github.com/yaroslav/kino) (`kino -m ractor`). On official Ruby
44
47
  4.0.6 the **upstream `kino` gem (0.1.3) works as-is** — no fork or patch is
45
48
  required. (Historically a DDKatch/kino fork carried a per-ractor env-string
46
- cache fix; that fix is in official 4.0.6, so the fork is obsolete.)
49
+ cache fix; that fix landed in official 4.0.6, so the fork is obsolete.)
50
+
51
+ **Threaded servers (Puma/Falcon/Thin/Webrick):** set
52
+ `RactorRailsShim.thread_mode = true` (or `ENV["SERVER"]` to one of
53
+ `puma|falcon|thin|webrick|thread*`) and the shim installs a **minimal**
54
+ patch set — only the `class_attribute` isolation fix + nil-safe callback
55
+ replay. The per-Ractor IES-routing patches are skipped because IES is empty
56
+ on a thread server's request threads and would break the app; Rails' own
57
+ class globals are thread-safe and used as-is. Use this when you want the
58
+ callback-correctness fixes without running in Ractor mode.
47
59
 
48
60
  **Benchmarks:** throughput/latency/memory of this shim + the test app under
49
61
  kino `:ractor` vs Puma vs Falcon are documented in
50
62
  [ractor-rails-shim-test-app/BENCHMARKS.md](https://github.com/DDKatch/ractor-rails-shim-test-app/blob/main/BENCHMARKS.md).
51
63
 
64
+ ## Repository layout
65
+
66
+ ```
67
+ lib/ractor_rails_shim.rb # entry point — autoload-install if Rails is loaded
68
+ lib/ractor_rails_shim/
69
+ version.rb # VERSION constant (currently 0.2.5)
70
+ version_check.rb # Gem::Version-based Ruby/Rails detection + policy
71
+ fallback_ies.rb # thread-local IES shim when ActiveSupport is absent
72
+ check.rb # the ractor-rails-check audit (Check.scan / report)
73
+ patches.rb # requires all per-concern patch files in order
74
+ patches/
75
+ core.rb # module skeleton, registries, install, prepare_for_ractors!, WorkerApp
76
+ make_shareable.rb # make_app_shareable!, callable/lock replacement, shareable fallback
77
+ rails_module.rb # Rails.application / Rails.cache / Rails.logger / Rails.env …
78
+ mattr_accessor.rb # Module#mattr_accessor / cattr_accessor macro rewrite
79
+ class_attribute.rb # ActiveSupport class_attribute macro rewrite
80
+ zeitwerk_registry.rb # Zeitwerk::Registry class ivars
81
+ route_helpers.rb # ActionDispatch::Routing::RouteSet#generate_url_helpers
82
+ url_helpers.rb # URL helper singleton + module fixes
83
+ execution_wrapper.rb # ActiveSupport::ExecutionWrapper + callback replay
84
+ rack.rb # Rack::Request / Rack::Utils
85
+ action_view.rb # PathRegistry, LookupContext, Template handlers, compiled_method_container
86
+ action_controller.rb # AbstractController, ActionController name/encoding
87
+ action_dispatch.rb # ActionDispatch routing, http_url, journey, mounted helpers
88
+ polymorphic_routes.rb # polymorphic_path(s) URL helpers
89
+ active_support.rb # Inflector, error_reporter, ExecutionContext, I18n, JSON encoding, Reloader
90
+ warden.rb # Warden hooks / strategies / serializer
91
+ devise.rb # Devise url_helpers / authenticatable / failure_app
92
+ active_model_attribute.rb # ActiveModel::Attribute dup_or_share for frozen graphs
93
+ active_record_model_schema.rb # AR ModelSchema reload_schema_from_cache (worker-safe)
94
+ activerecord.rb # AR connection handler, configurations, query caches, …
95
+ kaminari.rb # Kaminari config
96
+ propshaft.rb # Propshaft asset server
97
+ orm_adapter.rb # orm_adapter
98
+ openssl.rb # OpenSSL digest cache
99
+ rubygems.rb # Rubygems msgpack pre-check
100
+ exe/ractor-rails-check # CLI audit tool
101
+ spec/ # 7 spec files, 61 unit tests (no Rails dep) + integration spec
102
+ script/make_test_app.sh # build the minimal Rails 8.1 test app (CI uses this)
103
+ script/make_full_test_app.sh # build the full-featured test app (Devise/PG/Kaminari)
104
+ .github/workflows/ci.yml # unit job + integration job (GET /up → 200 in a worker Ractor)
105
+ ```
106
+
107
+ Per-concern patch files reopen `RactorRailsShim`'s singleton class to add
108
+ their `_install_*` methods. `patches.rb` requires them in dependency order
109
+ (`core.rb` first — it defines the module skeleton + registries the rest
110
+ reference). Each `_install_*` method is idempotent (guarded by its own
111
+ `@*_patched` flag), so `install`, `prepare_for_ractors!`, and
112
+ `make_app_shareable!` can all call the full set safely.
113
+
52
114
  ## Why
53
115
 
54
116
  Rails stores global state in class-level instance variables:
@@ -120,6 +120,29 @@ module RactorRailsShim
120
120
  end
121
121
  attr_writer :version_policy
122
122
 
123
+ # When true, swallowed exceptions in freeze/shareability paths are
124
+ # reported to $stderr so a worker Ractor that later crashes on an
125
+ # unshareable value has a traceable cause. Default false (silent, the
126
+ # historical behaviour). Enable for diagnosis:
127
+ # RactorRailsShim.debug = true
128
+ def debug?
129
+ defined?(@debug) ? @debug : false
130
+ end
131
+ attr_writer :debug
132
+
133
+ # Swallow an exception raised by the block, optionally logging it when
134
+ # `debug?` is on. Used by freeze/shareability paths where individual
135
+ # failures are expected (some ivars hold intrinsically unshareable
136
+ # values like Procs) but a worker crash on the same value later has no
137
+ # visible cause. `label` identifies the call site (e.g. "freeze AR ivar
138
+ # Post@column_defaults"). Keep the label short — it's only for grepping.
139
+ def _swallow(label)
140
+ yield
141
+ rescue => e
142
+ warn "[ractor_rails_shim] #{label}: #{e.class}: #{e.message[0, 120]}" if debug?
143
+ nil
144
+ end
145
+
123
146
  SUPPORTED_RUBY = RactorRailsShim::Version::SUPPORTED_RUBY
124
147
  SUPPORTED_RAILS = "8.1"
125
148
 
@@ -283,11 +306,7 @@ module RactorRailsShim
283
306
  # with `equal?`, and a frozen Symbol is always shareable.
284
307
  Ractor.make_shareable(:"__shim_unshareable_sentinel__")
285
308
  else
286
- begin
287
- Ractor.make_shareable(val)
288
- rescue => e
289
- nil
290
- end
309
+ _swallow("make_value_shareable #{val.class}") { Ractor.make_shareable(val) }
291
310
  end
292
311
  end
293
312
 
@@ -301,21 +320,6 @@ module RactorRailsShim
301
320
  [parent, parts.last.to_sym]
302
321
  end
303
322
 
304
- # Public API: run after Rails.application.initialize! and BEFORE spawning
305
- # worker Ractors. Makes every registered constant shareable (deep-freeze).
306
- # Constants that didn't exist at install time (e.g. Rails::Railtie, loaded
307
- # after `module Rails` opens) get fixed here. Idempotent; safe to call
308
- # multiple times. Must run in the main Ractor.
309
- #
310
- # NOTE: this does NOT build the framework-config shareable fallback. That
311
- # step is folded into `make_app_shareable!` because some class_attribute /
312
- # mattr_accessor values reference the app graph, and making them shareable
313
- # must happen AFTER the app itself is already frozen (otherwise the app
314
- # gets frozen prematurely and precompute/proc-replacement can't mutate
315
- # it). If you call prepare_for_ractors! standalone (without
316
- # make_app_shareable!), worker Ractors will see nil for framework config
317
- # values that couldn't be shared without freezing the app — set them
318
- # explicitly per worker, or use make_app_shareable!.
319
323
  # Shared list of every framework patch install method. Both
320
324
  # `prepare_for_ractors!` (pre-worker boot) and `make_app_shareable!`
321
325
  # (post-boot, pre-freeze) call this, so the two no longer duplicate the
@@ -403,6 +407,21 @@ module RactorRailsShim
403
407
  _install_hash_compute_if_absent_patch
404
408
  end
405
409
 
410
+ # Public API: run after Rails.application.initialize! and BEFORE spawning
411
+ # worker Ractors. Makes every registered constant shareable (deep-freeze).
412
+ # Constants that didn't exist at install time (e.g. Rails::Railtie, loaded
413
+ # after `module Rails` opens) get fixed here. Idempotent; safe to call
414
+ # multiple times. Must run in the main Ractor.
415
+ #
416
+ # NOTE: this does NOT build the framework-config shareable fallback. That
417
+ # step is folded into `make_app_shareable!` because some class_attribute /
418
+ # mattr_accessor values reference the app graph, and making them shareable
419
+ # must happen AFTER the app itself is already frozen (otherwise the app
420
+ # gets frozen prematurely and precompute/proc-replacement can't mutate
421
+ # it). If you call prepare_for_ractors! standalone (without
422
+ # make_app_shareable!), worker Ractors will see nil for framework config
423
+ # values that couldn't be shared without freezing the app — set them
424
+ # explicitly per worker, or use make_app_shareable!.
406
425
  def prepare_for_ractors!
407
426
  do_install_shareable_constants
408
427
  RactorRailsShim._freeze_shareable_class_ivars! if RactorRailsShim.respond_to?(:_freeze_shareable_class_ivars!)
@@ -416,9 +435,9 @@ module RactorRailsShim
416
435
  _freeze_global_class_ivars!
417
436
  _freeze_global_constants!
418
437
  _freeze_messages_constants!
419
- end
438
+ end
420
439
 
421
- # Verify the runtime matches the versions the shim was developed against.
440
+ # Verify the runtime matches the versions the shim was developed against.
422
441
  # The shim's patches target specific Rails 8.1 class layouts and Ruby 4.0
423
442
  # Ractor semantics. On other versions, the patches may silently miss or
424
443
  # break things. Behavior on mismatch is governed by `version_policy`:
@@ -434,9 +453,10 @@ module RactorRailsShim
434
453
  def _check_version_support
435
454
  @version_policy ||= :warn
436
455
  unless RactorRailsShim::Version.supported_ruby?
437
- msg = "ractor-rails-shim: Ruby #{RUBY_VERSION} — shim developed " \
438
- "against Ruby #{SUPPORTED_RUBY}. Ractor semantics may differ; " \
439
- "the shim may break. Proceeding anyway."
456
+ msg = "ractor-rails-shim: Ruby #{RUBY_VERSION} — shim requires " \
457
+ "Ruby >= #{SUPPORTED_RUBY} (frozen-iseq call-cache fix #22075 " \
458
+ "and cross-ractor env-string fix both shipped in 4.0.6). " \
459
+ "Proceeding anyway."
440
460
  _version_mismatch(msg)
441
461
  end
442
462
  if RactorRailsShim::Version.rails &&
@@ -508,9 +528,27 @@ module RactorRailsShim
508
528
  # `Zeitwerk::Loader.new` raises IsolationError off the main Ractor).
509
529
  def capture_app_constants
510
530
  map = {}
511
- return map unless defined?(::Rails) && Rails.respond_to?(:autoloaders)
512
- [Rails.autoloaders.main, Rails.autoloaders.once].each do |loader|
513
- next unless loader.respond_to?(:all_expected_cpaths)
531
+ unless defined?(::Rails) && Rails.respond_to?(:autoloaders)
532
+ return map.freeze
533
+ end
534
+ autoloaders = Rails.autoloaders
535
+ # Guard against non-Zeitwerk configurations: Rails.autoloaders may be
536
+ # present (the method exists) but not expose `main`/`once` (e.g. classic
537
+ # loader mode, or `config.autoloaders = false` returning a null object).
538
+ # `main`/`once` may also individually be nil when only one loader is
539
+ # configured. Filter to the loaders that actually expose
540
+ # `all_expected_cpaths` (the Zeitwerk introspection API the capture
541
+ # relies on).
542
+ loaders =
543
+ if autoloaders.respond_to?(:main) && autoloaders.respond_to?(:once)
544
+ [autoloaders.main, autoloaders.once]
545
+ elsif autoloaders.respond_to?(:each)
546
+ autoloaders.to_a
547
+ else
548
+ []
549
+ end
550
+ loaders.each do |loader|
551
+ next unless loader && loader.respond_to?(:all_expected_cpaths)
514
552
  begin
515
553
  loader.all_expected_cpaths.values.each do |cpath|
516
554
  obj = Object.const_get(cpath) rescue next
@@ -532,10 +570,16 @@ module RactorRailsShim
532
570
  # constants in the main Ractor and wraps the frozen, shareable app in a
533
571
  # WorkerApp that rebinds those constants (and initializes the worker's
534
572
  # ActiveRecord connection) on the first request served by each worker
535
- # Ractor. Returns a shareable WorkerApp instance.
573
+ # Ractor. Returns a shareable WorkerApp instance — frozen and
574
+ # `Ractor.make_shareable`'d — so it can be passed directly to
575
+ # `Ractor.new(worker_app) { |a| a.call(env) }` without the caller having
576
+ # to know the shareability contract.
536
577
  def worker_app(frozen_app)
537
578
  bindings = capture_app_constants
538
- WorkerApp.new(frozen_app, bindings)
579
+ wa = WorkerApp.new(frozen_app, bindings)
580
+ wa.freeze
581
+ Ractor.make_shareable(wa)
582
+ wa
539
583
  end
540
584
 
541
585
  # See patches/active_model_attribute.rb. When the frozen `:ractor` graph is
@@ -575,11 +619,25 @@ module RactorRailsShim
575
619
  # them. But Rails code calls Concurrent::Map#compute_if_absent on these
576
620
  # caches (e.g. ActiveModel::AttributeMethods' attribute_method_patterns_
577
621
  # cache). Plain Hash lacks that method, so we add a compatible definition.
622
+ #
623
+ # Semantics MUST match Concurrent::Map#compute_if_absent exactly:
624
+ # - if the key is present (even with a nil value), return the stored
625
+ # value WITHOUT invoking the block;
626
+ # - otherwise invoke the block, STORE the result (including nil), and
627
+ # return it.
628
+ # The nil-storing behaviour is load-bearing: Concurrent::Map stores nil
629
+ # results so the block doesn't re-run; the original shim implementation
630
+ # used `IES[key] ||= yield` in the frozen branch, which SKIPS nil — a
631
+ # divergent semantics that re-invokes the block on every lookup for any
632
+ # cache slot whose computed value is nil.
633
+ #
578
634
  # For a MUTABLE cache the shim freezes the replaced Hash (so it is shareable
579
635
  # across workers); mutating it would raise FrozenError. So when the receiver
580
636
  # is frozen we route the store to per-Ractor IES keyed by the Hash identity
581
637
  # and the key — giving each worker its own cache entry without mutating the
582
- # shared object. Semantics otherwise match Concurrent::Map.
638
+ # shared object. The frozen branch mirrors the mutable one: check presence
639
+ # first (via IES key?), invoke the block only on miss, store the result
640
+ # (including nil) via IES[]=.
583
641
  def _install_hash_compute_if_absent_patch
584
642
  return @hash_compute_if_absent_patched if defined?(@hash_compute_if_absent_patched) && @hash_compute_if_absent_patched
585
643
  @hash_compute_if_absent_patched = true
@@ -587,8 +645,17 @@ module RactorRailsShim
587
645
  ::Hash.prepend(Module.new do
588
646
  def compute_if_absent(key)
589
647
  if frozen?
590
- ies_key = :"rrs_cia_#{object_id}_#{key}"
591
- ActiveSupport::IsolatedExecutionState[ies_key] ||= yield(key)
648
+ # Check the receiver first — a present key (even nil-valued)
649
+ # returns without invoking the block, matching Concurrent::Map.
650
+ return self[key] if key?(key)
651
+ # Per-Ractor IES slot, namespaced by this Hash's object_id so two
652
+ # frozen hashes with the same key don't collide. Use a two-level
653
+ # structure (Hash per receiver) so the presence check (`key?`)
654
+ # and the store share the same slot.
655
+ slot = ActiveSupport::IsolatedExecutionState[:rrs_cia] ||= {}
656
+ bucket = slot[object_id] ||= {}
657
+ return bucket[key] if bucket.key?(key)
658
+ bucket[key] = yield(key)
592
659
  elsif key?(key)
593
660
  self[key]
594
661
  else
@@ -618,10 +685,8 @@ module RactorRailsShim
618
685
  klass.instance_variables.each do |ivar|
619
686
  val = klass.instance_variable_get(ivar)
620
687
  next if Ractor.shareable?(val)
621
- begin
688
+ _swallow("freeze AR ivar #{klass.name}#{ivar}") do
622
689
  Ractor.make_shareable(val)
623
- rescue
624
- nil
625
690
  end
626
691
  end
627
692
  end
@@ -639,10 +704,8 @@ module RactorRailsShim
639
704
  klass.instance_variables.each do |ivar|
640
705
  val = klass.instance_variable_get(ivar)
641
706
  next if Ractor.shareable?(val)
642
- begin
707
+ _swallow("freeze global ivar #{klass}#{ivar}") do
643
708
  Ractor.make_shareable(val)
644
- rescue
645
- nil
646
709
  end
647
710
  end
648
711
  end
@@ -699,11 +762,14 @@ module RactorRailsShim
699
762
  # non-frozen arrays; load hooks never fire again in workers.
700
763
  # Gem::LoadError is a ScriptError (not StandardError), so a bare
701
764
  # `rescue nil` on the require would NOT catch a missing msgpack gem.
702
- begin
703
- require "active_support/message_pack"
704
- rescue LoadError
705
- return
706
- end
765
+ # Pre-check for the msgpack gem: active_support/message_pack loads
766
+ # without it but prints a "requires the msgpack gem" warning to $stderr
767
+ # before raising LoadError. Checking first avoids the warning entirely
768
+ # (no stderr suppression needed) and skips the freeze step cleanly when
769
+ # the C extension isn't installed (e.g. in the gem's no-Rails unit specs).
770
+ return unless Gem::Specification.find_all_by_name("msgpack").any?
771
+
772
+ require "active_support/message_pack"
707
773
 
708
774
  mod = (Object.const_get(:ActiveSupport) rescue nil)&.const_get(:Messages, false) rescue nil
709
775
  mod = mod&.const_get(:Metadata, false) rescue nil
@@ -766,8 +832,9 @@ module RactorRailsShim
766
832
  # 2. ensures the worker's ActiveRecord connection handler is initialized.
767
833
  #
768
834
  # The wrapper holds only shareable state (@app, @bindings), so the instance
769
- # is Ractor.make_shareable. `Ractor.current` provides per-worker storage for
770
- # the one-time guard, avoiding any top-level constant reference.
835
+ # is `Ractor.make_shareable`'d by `worker_app` before being handed to worker
836
+ # Ractors. `Ractor.current` provides per-worker storage for the one-time
837
+ # guard, avoiding any top-level constant reference.
771
838
  class WorkerApp
772
839
  def initialize(app, bindings)
773
840
  @app = app
@@ -784,9 +851,12 @@ module RactorRailsShim
784
851
  def setup_once!
785
852
  # All threads inside a worker Ractor share Ractor.current, so a single
786
853
  # per-Ractor mutex serializes the one-time init across the worker's
787
- # threads. (The mutex is created under ||= which is racy under extreme
788
- # contention, but both init steps below are themselves idempotent, so a
789
- # rare double-init is harmless.)
854
+ # threads. `Ractor.current[:key] ||= Thread::Mutex.new` is atomic in
855
+ # Ruby 4.0.6 (verified: 100 concurrent threads produce exactly one
856
+ # mutex object), so the TOCTOU race that would let two threads each
857
+ # create their own mutex does not occur. The `:rrs_worker_ready` guard
858
+ # inside `synchronize` then ensures `rebind_constants` runs exactly
859
+ # once per worker; later threads return early.
790
860
  m = Ractor.current[:rrs_worker_mutex] ||= Thread::Mutex.new
791
861
  m.synchronize do
792
862
  return if Ractor.current[:rrs_worker_ready]
@@ -547,7 +547,18 @@ module RactorRailsShim
547
547
  # Doesn't dedup procs — must replace every occurrence.
548
548
  def _replace_unshareable_procs!(app)
549
549
  mw = (app.instance_variable_get(:@app) rescue nil)
550
- 3.times do
550
+ # Replace every Proc in the graph. The same Proc object can live in
551
+ # many containers (e.g. deprecation behaviors shared across
552
+ # deprecators), and replacing one occurrence doesn't replace the
553
+ # others — so we loop until a fixed point (no Procs left). A safety
554
+ # cap guards against a pathological graph where replacement keeps
555
+ # introducing new Procs (the replacements themselves are NoOpProc/
556
+ # Callable instances, not Procs, so this shouldn't happen, but the
557
+ # cap prevents an infinite loop if a future callable class leaks a
558
+ # Proc). 3 passes was the original magic number; observed real graphs
559
+ # converge in 2.
560
+ max_passes = 8
561
+ max_passes.times do
551
562
  procs = _collect_procs(app)
552
563
  break if procs.empty?
553
564
  procs.each { |proc_obj, _path, parent, ivar| _replace_one_proc(proc_obj, parent, ivar, mw) }
@@ -581,28 +592,76 @@ module RactorRailsShim
581
592
  next if seen[o.object_id]
582
593
  seen[o.object_id] = true
583
594
  next if o.is_a?(Mutex) || o.is_a?(Monitor)
584
- begin
585
- o.instance_variables.each do |iv|
586
- begin; v = o.instance_variable_get(iv); rescue; next; end
587
- stack << [v, "#{_path}.#{iv}", o, iv] if v
588
- end
589
- rescue => e
590
- # Some objects (BasicObject) don't support instance_variables
591
- end
592
- if o.is_a?(Array)
593
- o.each_with_index { |e, i| stack << [e, "#{_path}[#{i}]", o, nil] if e }
594
- elsif o.is_a?(Hash)
595
- o.each do |k, val|
596
- stack << [k, "#{_path}.key", o, nil] if k
597
- stack << [val, "#{_path}[#{k.inspect}]", o, nil] if val
595
+ _each_ivar_and_child(o, _path) do |child, child_path, child_ivar|
596
+ if child_ivar == :__default_proc__
597
+ procs << [child, child_path, o, :__default_proc__]
598
+ else
599
+ stack << [child, child_path, o, child_ivar] if child
598
600
  end
599
- dp = o.default_proc
600
- procs << [dp, "#{_path}.default_proc", o, :__default_proc__] if dp
601
601
  end
602
602
  end
603
603
  procs
604
604
  end
605
605
 
606
+ # Enumerate every child reference of `o` for the graph traversals:
607
+ # - instance variables (yields [value, "path.iv", iv])
608
+ # - Array / Set / Enumerable members (yields [member, "path[i]", nil])
609
+ # - Hash pairs (yields [key, "path.key", nil] and [val, "path[k]", nil])
610
+ # - Hash#default_proc (yields [proc, "path.default_proc", :__default_proc__])
611
+ # - Struct members (yields [value, "path.member", nil] via #each_pair)
612
+ #
613
+ # Pre-fix the traversals only handled Array and Hash, so a Mutex or Proc
614
+ # nested inside a Set (Rails uses Set in several caches, e.g.
615
+ # ActionDispatch::Journey::Routes) or a Struct was missed and left
616
+ # unshareable. Centralized here so _collect_procs and
617
+ # _replace_locks_and_concurrent_maps! share the same container coverage.
618
+ def _each_ivar_and_child(o, path)
619
+ begin
620
+ o.instance_variables.each do |iv|
621
+ begin; v = o.instance_variable_get(iv); rescue; next; end
622
+ yield v, "#{path}.#{iv}", iv
623
+ end
624
+ rescue => e
625
+ # BasicObject or frozen objects don't support instance_variables
626
+ end
627
+ if o.is_a?(Hash)
628
+ o.each do |k, val|
629
+ yield k, "#{path}.key", nil
630
+ yield val, "#{path}[#{k.inspect}]", nil
631
+ end
632
+ dp = o.default_proc
633
+ yield dp, "#{path}.default_proc", :__default_proc__ if dp
634
+ elsif o.is_a?(Array)
635
+ o.each_with_index { |e, i| yield e, "#{path}[#{i}]", nil }
636
+ elsif o.is_a?(::Set)
637
+ o.each_with_index { |e, i| yield e, "#{path}.set[#{i}]", nil }
638
+ elsif o.is_a?(::Struct)
639
+ o.each_pair { |name, val| yield val, "#{path}.#{name}", nil }
640
+ elsif _enumerable_but_not_basic?(o)
641
+ # Generic Enumerable fallback (Range, Enumerator, custom Enumerable
642
+ # mixes). Skip String/Hash/Array/Set/Struct — already handled. Best
643
+ # effort; rescue per-element in case #each raises for some members.
644
+ idx = -1
645
+ o.each do |e|
646
+ idx += 1
647
+ yield e, "#{path}.each[#{idx}]", nil
648
+ end rescue nil
649
+ end
650
+ end
651
+
652
+ # True if `o` is Enumerable but NOT one of the container types with a
653
+ # dedicated branch in _each_ivar_and_child. Used to gate the generic
654
+ # Enumerable fallback so we don't double-walk Array/Hash/etc.
655
+ def _enumerable_but_not_basic?(o)
656
+ return false unless o.is_a?(::Enumerable)
657
+ return false if o.is_a?(::Array) || o.is_a?(::Hash) || o.is_a?(::Set) || o.is_a?(::Struct)
658
+ return false if o.is_a?(::String) # String is Enumerable (chars) but not a container of refs we want
659
+ true
660
+ rescue NoMethodError
661
+ # BasicObject without Kernel — not Enumerable.
662
+ false
663
+ end
664
+
606
665
  def _replace_one_proc(proc_obj, parent, ivar, mw)
607
666
  src = proc_obj.source_location&.first || ""
608
667
  replacement =
@@ -627,8 +686,18 @@ module RactorRailsShim
627
686
  elsif src.end_with?(DEVISE_SCOPE_LOC)
628
687
  _devise_mapping_replacement(proc_obj, parent)
629
688
  elsif src.end_with?(MAPPER_LOC) && ivar == :@strategy
630
- line = proc_obj.source_location[1]
631
- line == 32 ? StrategyServe.new : StrategyCall.new
689
+ # Identify SERVE vs CALL by OBJECT IDENTITY against the actual
690
+ # ActionDispatch constants, NOT by source_location line number.
691
+ # The value stored in @strategy IS the constant object (Rails
692
+ # assigns it via `@strategy = strategy` where `strategy` is passed
693
+ # as Constraints::SERVE or Constraints::CALL), so `equal?` is the
694
+ # robust identifier — it survives any Rails patch release that
695
+ # shifts the constant definitions by a line or two, where the old
696
+ # `line == 32` check would silently swap the two strategies and
697
+ # break routing. Falls through to NoOpProc if the Proc isn't
698
+ # either constant (defensive: shouldn't happen, but never
699
+ # mis-route).
700
+ _strategy_replacement_for(proc_obj)
632
701
  else
633
702
  NoOpProc.new
634
703
  end
@@ -661,6 +730,22 @@ module RactorRailsShim
661
730
  # DeviseMappingCallable) now lives in warden.rb; `_find_files_server`
662
731
  # (Rack::Files target for the asset stack) now lives in rack.rb.
663
732
 
733
+ # Identify which ActionDispatch strategy a Proc is and return its
734
+ # shareable replacement. The Proc stored in
735
+ # `ActionDispatch::Routing::Mapper::Constraints#@strategy` is the
736
+ # constant `SERVE` or `CALL` (assigned by reference), so `equal?` is
737
+ # the robust identifier — independent of the source line number, which
738
+ # any Rails patch release can shift. Returns NoOpProc for an unknown
739
+ # Proc (defensive; never mis-route).
740
+ def _strategy_replacement_for(proc_obj)
741
+ constraints = defined?(::ActionDispatch::Routing::Mapper::Constraints) ?
742
+ ::ActionDispatch::Routing::Mapper::Constraints : nil
743
+ return NoOpProc.new unless constraints
744
+ return StrategyServe.new if proc_obj.equal?(constraints::SERVE)
745
+ return StrategyCall.new if proc_obj.equal?(constraints::CALL)
746
+ NoOpProc.new
747
+ end
748
+
664
749
  def _replace_locks_and_concurrent_maps!(app)
665
750
  seen = {}
666
751
  stack = [[app, "app", nil, nil]]
@@ -671,24 +756,20 @@ module RactorRailsShim
671
756
  next if seen[o.object_id]
672
757
  seen[o.object_id] = true
673
758
  next if o.is_a?(Mutex) || o.is_a?(Monitor)
674
- begin
675
- o.instance_variables.each do |iv|
676
- begin; v = o.instance_variable_get(iv); rescue; next; end
677
- if v.is_a?(Mutex) || v.is_a?(Monitor)
678
- o.instance_variable_set(iv, NoOpLock.new) rescue nil
679
- elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map)
680
- hash_copy = {}
681
- v.each_pair { |k, val| hash_copy[k] = val }
682
- o.instance_variable_set(iv, hash_copy) rescue nil
683
- elsif v
684
- stack << [v, "#{_p}.#{iv}", o, iv]
685
- end
759
+ _each_ivar_and_child(o, _p) do |child, child_path, child_ivar|
760
+ next if child_ivar == :__default_proc__
761
+ if child.is_a?(Mutex) || child.is_a?(Monitor)
762
+ o.instance_variable_set(child_ivar, NoOpLock.new) rescue nil if child_ivar
763
+ # If the lock is in an Array/Set/Hash (no ivar), we can't swap
764
+ # it in place here — leave it; make_shareable will handle the
765
+ # frozen container. The ivar case is the load-bearing one.
766
+ elsif defined?(::Concurrent::Map) && child.is_a?(::Concurrent::Map) && child_ivar
767
+ hash_copy = {}
768
+ child.each_pair { |k, val| hash_copy[k] = val }
769
+ o.instance_variable_set(child_ivar, hash_copy) rescue nil
770
+ elsif child
771
+ stack << [child, child_path, o, child_ivar]
686
772
  end
687
- rescue => e
688
- # BasicObject or frozen objects don't support instance_variables
689
- end
690
- if o.is_a?(Array); o.each_with_index { |e, i| stack << [e, "#{_p}[#{i}]", o, nil] if e }
691
- elsif o.is_a?(Hash); o.each { |k, val| stack << [k, "#{_p}.key", o, nil] if k; stack << [val, "#{_p}[#{k.inspect}]", o, nil] if val }
692
773
  end
693
774
  end
694
775
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RactorRailsShim
4
- VERSION = "0.2.5"
4
+ VERSION = "0.2.6"
5
5
  end
@@ -23,9 +23,11 @@
23
23
  # exactly what applied to their runtime.
24
24
  module RactorRailsShim
25
25
  module Version
26
- # Ruby version the shim was developed against (major.minor). Ractor
27
- # semantics are still settling; non-4.0 Rubies may behave differently.
28
- SUPPORTED_RUBY = "4.0"
26
+ # Ruby version the shim was developed against (major.minor.patch). Ractor
27
+ # semantics are still settling; the frozen-iseq call-cache fix (#22075) and
28
+ # the cross-ractor env-string fix that the shim relies on both shipped in
29
+ # 4.0.6, so earlier 4.0.x patch releases are NOT supported.
30
+ SUPPORTED_RUBY = "4.0.6"
29
31
  # Rails versions each patch was tested against. Patches are registered
30
32
  # with one or more entries from this list; the dispatcher applies a patch
31
33
  # only if the runtime Rails version matches one of its tags. To add 7.x
@@ -33,7 +35,7 @@ module RactorRailsShim
33
35
  TESTED_RAILS = ["8.1"].freeze
34
36
 
35
37
  class << self
36
- # Runtime Ruby version as a Gem::Version (e.g. "4.0.5"). Always
38
+ # Runtime Ruby version as a Gem::Version (e.g. "4.0.6"). Always
37
39
  # available — Ruby is obviously loaded.
38
40
  def ruby
39
41
  Gem::Version.new(RUBY_VERSION)
@@ -64,9 +66,11 @@ module RactorRailsShim
64
66
  "#{v.segments[0]}.#{v.segments[1]}"
65
67
  end
66
68
 
67
- # True if the runtime Ruby's major.minor matches the supported segment.
69
+ # True if the runtime Ruby is >= the supported version (4.0.6). Uses a
70
+ # full Gem::Version compare (not a segment match) so the minimum patch
71
+ # release is enforced — the shim relies on fixes that shipped in 4.0.6.
68
72
  def supported_ruby?
69
- ruby_segment == SUPPORTED_RUBY
73
+ ruby >= Gem::Version.new(SUPPORTED_RUBY)
70
74
  end
71
75
 
72
76
  # True if the runtime Rails major.minor is in the tested set (or Rails
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ractor-rails-shim
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
- - dev
7
+ - Daniil Kachur
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -99,7 +99,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - ">="
101
101
  - !ruby/object:Gem::Version
102
- version: '4.0'
102
+ version: 4.0.6
103
103
  required_rubygems_version: !ruby/object:Gem::Requirement
104
104
  requirements:
105
105
  - - ">="