ractor-rails-shim 0.2.5 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 267a3734452aa4069b68599b193d8bebd3fa6e386836ec9ec4238d9667f6b37a
4
- data.tar.gz: 748ee127a59a4e7cc0b90b16ff7339edb2dfd6cde3b15df01c1ff29d910d05c3
3
+ metadata.gz: 21002fee5a30f663904441d8c016c035564095bf456048a25917657e9adab5fc
4
+ data.tar.gz: dcb6764139de152ae7b958112503efc7951d59ae211c04ee268da6bf63a2be51
5
5
  SHA512:
6
- metadata.gz: fdd580e1b1c24d324dfc9067a323dff580acd612ac8cfc2551424d4d359d5fc93d55c7ae7a9825efecad3103ef2c59b1b481a34d9270471f23fd19747dc9a52c
7
- data.tar.gz: '0523817853ccdfc9444c0815fcb943b7cb3f745519b5baf6683e269e60c81889f97500e7e9e34da7bd3da7e74a3fd3033c5bbd25c4de6296337735ed6fd8301c'
6
+ metadata.gz: 28cf75ae0f4972142cee3965d5c68fd8b70c09b901dc60c186f178ac403fc7695c3f7e2e77e4591c9aa6aa0fc9c6d1ba5eafa8e3c64d5abad545b50159159d6c
7
+ data.tar.gz: b1bf30685c4b7082c5930a63693571e4d3affff2fa0b530fd355aaf5a57f5cf42280ff5a590f099705e8af573ae953d654ace88fbb813dc05e463379fa0c09c7
data/CHANGELOG.md CHANGED
@@ -7,6 +7,268 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0]
11
+
12
+ ### Changed — breaking (public API)
13
+ - **`worker_app` renamed to `worker_app!`.** The factory freezes +
14
+ `Ractor.make_shareable`s the `WorkerApp` — a destructive build — so it
15
+ now carries the bang the naming convention reserves for "mutates /
16
+ produces a frozen shareable." Callers following the README's
17
+ `Ractor.new(worker_app) { |a| a.call(env) }` example must update to
18
+ `worker_app!`. The old name is gone. (`patches/core.rb`)
19
+
20
+ - **`fix_url_helpers_singleton_routes` renamed to
21
+ `fix_url_helpers_singleton_routes!`.** Mutates the global routes
22
+ singleton; now bang-suffixed per the convention documented at the top
23
+ of `patches.rb`. (`patches/route_helpers.rb`)
24
+
25
+ - **Tightened `activesupport` dependency to `>= 8.1`.** The gemspec
26
+ previously declared `>= 7.0`, but the shim only supports Rails 8.1
27
+ (per `Version::TESTED_RAILS` / `SUPPORTED_RAILS`). The looser bound let
28
+ Bundler resolve against AS 7.x where the class-layout patches
29
+ (`class_attribute`, `Callbacks`, `PathRegistry`, …) would silently miss
30
+ blockers or redefine the wrong methods. Bound at `>= 8.1` so Bundler
31
+ fails fast on an unsupported Rails. (`ractor-rails-shim.gemspec`)
32
+
33
+ ### Fixed
34
+ - **`_apply_shareable_constants!` set the done-flag on undefined
35
+ constants.** It set `@shareable_constants_done = true` after the first
36
+ run unconditionally, even when registered constants didn't exist yet
37
+ (`make_constant_shareable` returned `false`). A later call (from
38
+ `make_app_shareable!` or `prepare_for_ractors!`) short-circuited on the
39
+ flag and never retried the now-loadable constants — workers then hit
40
+ `Ractor::IsolationError` on unshareable values (e.g.
41
+ `Rack::Utils::PATH_SEPS`, a Regexp not deep-frozen until
42
+ `make_shareable` runs on it). The flag now sets only when every
43
+ registered constant was made shareable; any `false` return leaves it
44
+ unset so the next call retries. Caught by the integration spec (boots
45
+ a real Rails 8.1 app, dispatches `GET /up` in a worker Ractor); pinned
46
+ by `apply_shareable_constants_retry_spec.rb`.
47
+
48
+ - **ActionFilter private ivar reads now gated through the `_swallow`
49
+ debug funnel.** The eval'd `set_callback` interceptor inlined
50
+ `af.instance_variable_get(:@conditional_key) rescue nil`, but
51
+ `instance_variable_get` returns nil for a missing ivar *without*
52
+ raising — the rescue never fired, and a silent Rails internal rename
53
+ (`@conditional_key` / `@actions`) would leave `only`/`except` nil,
54
+ making callbacks run for actions they shouldn't (security-relevant)
55
+ with no visible cause. New `_read_action_filter_constraints(af)` checks
56
+ `instance_variable_defined?` and emits a labeled
57
+ `[ractor_rails_shim] action filter constraints: missing ivar …`
58
+ warning under `debug=true`. Returns `[nil, nil]` for a bare object,
59
+ `[key, symbols]` when the ivars are present.
60
+
61
+ - **Dead ternary in thread-mode `class_attribute` reader.** The reader
62
+ carried `:__class_attr___callbacks == :__callbacks ? {} : nil`, but
63
+ the namespaced name is always `__class_attr_<name>` (never `<name>`),
64
+ so the check was always false — dead code that allocated a `Symbol`
65
+ via `inspect` on every read AND never delivered the intended `{}`
66
+ default for `__callbacks` (callers index the result, so `nil` would
67
+ `NoMethodError`). Replaced with a static decision based on the public
68
+ name, inlining the shared frozen `EMPTY_CALLBACKS_HASH` constant.
69
+
70
+ - **Redundant `class_variable_set` pair in `mattr_accessor` writer.**
71
+ The shim-generated writer had two complementary guarded lines
72
+ (`set if defined?` + `set unless defined?`) that together were one
73
+ unconditional set, with a wasted `class_variable_defined?` per write.
74
+ Replaced with a single `class_variable_set(cv, val) if Ractor.main?`.
75
+
76
+ - **`WorkerApp#setup_once!` race-spec cleanup leak.** The race spec's
77
+ `ensure` block restored `Thread::Mutex.new` via
78
+ `define_method { orig_new.call }`, leaving a block that captures a
79
+ main-Ractor-bound `Method`. When a later spec's worker Ractor called
80
+ `Thread::Mutex.new` it raised `IsolationError` (flaky
81
+ `WorkerAppSpec#test_0002`). Replaced with `remove_method(:new)` so the
82
+ call falls back to the class-defined `new` (no captured binding).
83
+
84
+ ### Changed
85
+ - **Centralized `const_set`-with-suppressed-`$VERBOSE` into
86
+ `_reassign_shareable_const(name, value)`.** `SHAREABLE_FALLBACK`,
87
+ `SHAREABLE_MATTR_DEFAULTS`, `SHAREABLE_APP`, and
88
+ `SHAREABLE_DECLARED_CALLBACKS` were each rebuilt + reassigned via an
89
+ inlined `$VERBOSE = nil … const_set … ensure $VERBOSE = …` dance. The
90
+ dance now lives in one helper on `RactorRailsShim`; all rebuild sites
91
+ (including the 6 `activerecord.rb` sites + 1 `kaminari.rb` site) call
92
+ it. The constants stay frozen shareable `Hash`es (always readable from
93
+ any Ractor) — a mutable-then-frozen registry would not be shareable
94
+ until `freeze!`, breaking workers that read early.
95
+
96
+ - **Callable/lock object model extracted to `patches/callables.rb`.**
97
+ `NoOpProc` / `Callable` / `CallableConst` / `DeviseMappingSnapshot` /
98
+ `NoOpLock` / `NoOpLogDev` + the `_devise_mapping_snapshot` helper moved
99
+ out of the 964-line `make_shareable.rb` into their own file. Plain
100
+ class definitions replace the string-eval indirection (the eval was
101
+ stylistic, not behavioral). `make_shareable.rb`: 964 → 822 lines.
102
+
103
+ - **`VersionPolicy` module extracted from the `RactorRailsShim` god
104
+ module.** The version-policy + patch-registry concern (`:warn` /
105
+ `:strict` / `:off` switch, `PATCH_VERSIONS`, `_register_patch` /
106
+ `applicable_patches` / `_version_mismatch`) moved from the
107
+ `RactorRailsShim` singleton in `core.rb` to
108
+ `RactorRailsShim::VersionPolicy` (`version_policy.rb`). `core.rb` keeps
109
+ the public facade (`RactorRailsShim.version_policy`, `.applicable_patches`,
110
+ `::PATCH_VERSIONS`, `::UnsupportedVersionError`) delegating to the
111
+ module. Orthogonal to the already-extracted `RactorRailsShim::Version`
112
+ (detection).
113
+
114
+ - **`mattr_accessor` split into single-responsibility helpers.** The
115
+ per-symbol body did four things (call super, push to `CLASS_ATTRIBUTES`,
116
+ seed the default + rebuild the shareable constant, redefine the
117
+ reader/writer). Extracted `_seed_mattr_default(key, default)` and
118
+ `_register_for_fallback(mod_name, sym, key, default)`; `mattr_accessor`
119
+ now reads `super, register, seed, redefine`.
120
+
121
+ - **Thread-mode vs Ractor-mode `class_attribute` heredocs de-duplicated.**
122
+ Each branch inlined a near-identical ~25-line reader/writer heredoc,
123
+ differing only in the method name. Extracted
124
+ `_class_attr_thread_methods` / `_class_attr_ractor_methods` (each builds
125
+ one reader+writer pair); `redefine` calls the helper twice. One source
126
+ of truth per mode. Also fixed a misaligned `else` (12-space indent in a
127
+ 10-space block). `class_attribute.rb`: 232 → 224 lines.
128
+
129
+ - **Freeze-path bare rescues routed through the `_swallow` debug funnel.**
130
+ `_freeze_shareable_class_ivars!`, `_freeze_declared_callbacks!`,
131
+ `_collect_controller_classes`, `_replace_locks_and_concurrent_maps!`,
132
+ `_replace_one_proc`, `_neutralize_logger_io!`,
133
+ `DeviseMappingSnapshot`, `_devise_mapping_snapshot`, `_warm_journey_routes`,
134
+ url-helper freeze, `DUMMY_END_NODE`, `MimeNegotiation`, AR db-config
135
+ handlers, AR query transformers, devise mappings, and the view-context
136
+ fallback now emit a labeled `[ractor_rails_shim] <label>: …` line to
137
+ `$stderr` under `debug=true` instead of bare `rescue; nil`. Silent by
138
+ default (backward compatible).
139
+
140
+ - **`fallback_ies.rb` moved into the `RactorRailsShim` namespace.** It
141
+ previously defined `ActiveSupport::IsolatedExecutionState` directly
142
+ inside the `ActiveSupport` namespace (guarded by `defined?`). Defining
143
+ an upstream-namespaced constant from a third-party gem is a
144
+ namespace-patch smell — if a future AS lazy-loads IES, load order
145
+ decides which definition wins. The fallback now lives at
146
+ `RactorRailsShim::FallbackIES` and is aliased onto
147
+ `ActiveSupport::IsolatedExecutionState` only when the real AS IES is
148
+ absent. The ~270 string-eval'd references across the patch files
149
+ resolve to the alias when AS is absent and to the real one when present.
150
+
151
+ - **Stream-of-consciousness comments trimmed to one-line invariants.**
152
+ Working-notes-style comments that narrated history ("actually it IS a
153
+ constant holding…", "Pre-fix the traversals only handled…") replaced
154
+ with one-line invariant statements. The history is in git.
155
+
156
+ ### Documentation
157
+ - **`WorkerApp#setup_once!` race comment corrected (again).** A prior
158
+ 0.2.6 entry claimed `Ractor.current[:key] ||= Thread::Mutex.new` is
159
+ atomic in Ruby 4.0.6. It is NOT — `||=` is a read-then-write, and N
160
+ racing threads produce N distinct mutexes (verified by spec under a
161
+ widened window). The code is saved by (1) MRI's GIL serializing the
162
+ flag check inside `synchronize` and (2) `rebind_constants` /
163
+ `init_worker_ar_connections!` both being idempotent. Comment now states
164
+ the real contract + the escape hatch if either property changes. Two
165
+ specs pin the idempotency contract.
166
+
167
+ ## [0.2.6]
168
+
169
+ ### Fixed
170
+ - **`WorkerApp` from `worker_app` not `Ractor.shareable?`.** The factory
171
+ returned a bare `WorkerApp` instance without freezing or making it
172
+ shareable, so any caller following the README's
173
+ `Ractor.new(worker_app) { |a| a.call(env) }` example would raise
174
+ `Ractor::IsolationError` at spawn. `worker_app` now freezes +
175
+ `Ractor.make_shareable`s the instance, enforcing the shareability
176
+ contract at the boundary instead of pushing it onto every caller.
177
+ (`patches/core.rb`)
178
+
179
+ - **`Hash#compute_if_absent` nil-caching divergence from
180
+ `Concurrent::Map`.** The shim replaces `Concurrent::Map` instances in
181
+ the frozen app graph with plain `Hash`es (Concurrent::Map isn't
182
+ Ractor-shareable) and adds a compatible `compute_if_absent`. The
183
+ frozen-Hash branch used `IES[key] ||= yield`, which SKIPS nil results
184
+ — but `Concurrent::Map#compute_if_absent` STORES nil (verified: the
185
+ key is present after a nil-result block call), so the shim's
186
+ divergent semantics re-invoked the block on every subsequent lookup
187
+ for any cache slot whose computed value was nil. The frozen branch
188
+ now uses a two-level IES bucket (`slot[object_id][key]`) with a
189
+ proper `key?` presence check, matching `Concurrent::Map` exactly.
190
+ Also fixed: an existing key on a frozen Hash now returns without
191
+ invoking the block (was always treating the receiver as empty), and
192
+ per-Hash isolation is now keyed by `object_id` so two frozen hashes
193
+ with the same key don't collide. (`patches/core.rb`)
194
+
195
+ - **Magic `line == 32` strategy dispatch replaced with identity check.**
196
+ The Proc-replacement pass distinguished
197
+ `ActionDispatch::Routing::Mapper::Constraints::SERVE` from `CALL` by
198
+ hard-coding the `source_location` line number (32). Any Rails patch
199
+ release that shifted the constant definitions by even one line would
200
+ silently swap the two strategies and break routing (a dispatcher
201
+ endpoint called as `app.call(req.env)` instead of `app.serve(req)`).
202
+ Replaced with an `equal?` check against the actual constants — the
203
+ value stored in `@strategy` IS the constant object (Rails assigns it
204
+ by reference), so identity is the robust identifier, independent of
205
+ source layout. Falls through to `NoOpProc` for an unknown Proc
206
+ (defensive; never mis-routes). Extracted into
207
+ `_strategy_replacement_for`. (`patches/make_shareable.rb`)
208
+
209
+ - **`capture_app_constants` crash on non-Zeitwerk autoloaders.** The
210
+ method called `Rails.autoloaders.main` / `.once` directly, which
211
+ `NoMethodError`s for apps in classic-loader mode or with
212
+ `config.autoloaders = false` (the autoloaders object exists but
213
+ doesn't expose `main`/`once`). Now filters to the loaders that
214
+ actually expose `all_expected_cpaths` (the Zeitwerk introspection
215
+ API), and tolerates autoloaders objects that only respond to `each`.
216
+ Also fixed a pre-existing bug: the early-return path returned an
217
+ UNFROZEN map, violating the method's documented frozen-map contract.
218
+ (`patches/core.rb`)
219
+
220
+ ### Changed
221
+ - **`_replace_unshareable_procs!` 3.times magic replaced with a
222
+ fixed-point loop.** The original `3.times` had no justification and
223
+ an arbitrary constant. Replaced with a loop-to-fixed-point (break
224
+ when `_collect_procs` returns empty) plus a safety cap of 8 passes
225
+ to guard against a pathological graph where replacement keeps
226
+ introducing new Procs (the replacements are `NoOpProc`/`Callable`,
227
+ not `Proc`, so this shouldn't happen, but the cap prevents an
228
+ infinite loop). Documented the multi-occurrence rationale (the same
229
+ Proc object can live in many containers, e.g. shared deprecation
230
+ behaviors). Observed real graphs converge in 2 passes.
231
+ (`patches/make_shareable.rb`)
232
+
233
+ - **Graph traversals now walk Set/Struct/Enumerable.**
234
+ `_collect_procs` and `_replace_locks_and_concurrent_maps!` only
235
+ handled `Array` and `Hash` as recursive containers. Rails uses `Set`
236
+ in several caches (e.g. `ActionDispatch::Journey::Routes`), and
237
+ `Struct`/`Enumerable` mixes appear in framework internals — a
238
+ `Mutex` or `Proc` nested inside one of those was missed and left
239
+ unshareable, breaking `make_app_shareable!` downstream. Extracted
240
+ `_each_ivar_and_child` (single responsibility: enumerate every child
241
+ reference of an object) shared by both traversals, handling `Hash`,
242
+ `Array`, `Set`, `Struct`, and a generic `Enumerable` fallback
243
+ (`Range`, `Enumerator`, custom mixins). The `Hash#default_proc`
244
+ branch is preserved so `_collect_procs` still flags default-proc
245
+ Procs. (`patches/make_shareable.rb`)
246
+
247
+ ### Added
248
+ - **Debug funnel for freeze-path silent rescues.** The
249
+ freeze/shareability paths (`_freeze_active_record_class_ivars!`,
250
+ `_freeze_global_class_ivars!`, `_make_value_shareable`) rescue all
251
+ exceptions silently with bare `rescue; nil`. Individual failures are
252
+ expected (some ivars hold intrinsically unshareable values like
253
+ Procs), but a worker Ractor that later crashes on the same
254
+ unshareable value had no traceable cause. New `_swallow(label) { ... }`
255
+ funnel is silent by default (preserves backward compat) and emits a
256
+ labeled `[ractor_rails_shim] <label>: <class>: <msg>` line to
257
+ `$stderr` when `RactorRailsShim.debug = true`. The three freeze-path
258
+ rescues are routed through it; the bare rescues in non-freeze paths
259
+ (require `LoadError`, optional `const_get`, etc.) are left as-is —
260
+ those are genuinely expected and a `$stderr` line would be noise.
261
+ (`patches/core.rb`)
262
+
263
+ ### Documentation
264
+ - **`WorkerApp#setup_once!` race comment corrected.** The previous
265
+ comment claimed `Ractor.current[:key] ||= Thread::Mutex.new` was racy
266
+ under "extreme contention" and dismissed it as harmless because the
267
+ init steps are idempotent. Verified on Ruby 4.0.6 that
268
+ `Ractor.current[:key] ||= X` is atomic (100 concurrent threads
269
+ produce exactly one mutex object), so the TOCTOU race does not
270
+ occur. Replaced the misleading comment with the verified finding.
271
+
10
272
  ## [0.2.5]
11
273
 
12
274
  ### 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:
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Fallback IsolatedExecutionState when ActiveSupport is not available.
4
- # This is a simple thread-local storage that mimics the ActiveSupport API
5
- # enough for the shim to work. In production (with Rails loaded), the real
6
- # ActiveSupport::IsolatedExecutionState is used instead.
7
- module ActiveSupport
8
- module IsolatedExecutionState
4
+ #
5
+ # The shim's string-eval'd patch methods reference the literal constant
6
+ # `ActiveSupport::IsolatedExecutionState` (no captured binding → callable
7
+ # from any Ractor). To avoid opening the ActiveSupport namespace from a
8
+ # third-party gem, the fallback is defined in the shim's own namespace
9
+ # (RactorRailsShim::FallbackIES) and aliased onto
10
+ # ActiveSupport::IsolatedExecutionState only when the real AS IES is absent.
11
+ # When AS is loaded, the real one wins untouched and no alias is created.
12
+ module RactorRailsShim
13
+ module FallbackIES
9
14
  KEY = :active_support_execution_state_fallback
10
15
 
11
16
  class << self
@@ -30,4 +35,13 @@ module ActiveSupport
30
35
  end
31
36
  end
32
37
  end
33
- end unless defined?(ActiveSupport::IsolatedExecutionState)
38
+ end
39
+
40
+ # Alias onto ActiveSupport::IsolatedExecutionState only when the real AS IES
41
+ # is absent, so the shim's string-eval'd references resolve to the fallback
42
+ # without the shim opening the ActiveSupport namespace to define a module.
43
+ unless defined?(ActiveSupport::IsolatedExecutionState)
44
+ module ActiveSupport
45
+ IsolatedExecutionState = RactorRailsShim::FallbackIES
46
+ end
47
+ end
@@ -293,7 +293,7 @@ module RactorRailsShim
293
293
  # while the objects are still mutable) populates the ivars so the frozen,
294
294
  # shared copies already hold the values and workers only read them.
295
295
  if Ractor.main? && defined?(::Rails) && ::Rails.application
296
- begin
296
+ _swallow("warm journey routes") do
297
297
  rset = ::Rails.application.routes
298
298
  all = []
299
299
  all.concat(rset.named_routes.send(:routes).values) rescue nil
@@ -310,8 +310,6 @@ module RactorRailsShim
310
310
  p.required_names rescue nil
311
311
  p.optional_names rescue nil
312
312
  end
313
- rescue
314
- nil
315
313
  end
316
314
  end
317
315
  # Capture the (shareable) RouteSet so workers can build URLs without
@@ -422,15 +420,15 @@ module RactorRailsShim
422
420
  nrc.alias_method(:define_url_helper_without_shim, :define_url_helper)
423
421
  end
424
422
  nrc.define_method(:define_url_helper) do |mod, name, helper, url_strategy|
425
- begin
426
- # Detach the helper from the live route object before deep-freezing
427
- # it for cross-Ractor sharing. The non-optimized UrlHelper#call only
428
- # needs @options / @segment_keys / @route_name to build the options
429
- # hash and then delegates to `t._routes.url_for(route_name, ...)`,
430
- # which looks the route up in the (shareable) RouteSet by name. The
431
- # @route reference would pull the whole route graph into the freeze,
432
- # freezing objects that make_app_shareable! must still be able to
433
- # mutate (e.g. Devise route constraints) -> FrozenError.
423
+ # Detach the helper from the live route object before deep-freezing
424
+ # it for cross-Ractor sharing. The non-optimized UrlHelper#call only
425
+ # needs @options / @segment_keys / @route_name to build the options
426
+ # hash and then delegates to `t._routes.url_for(route_name, ...)`,
427
+ # which looks the route up in the (shareable) RouteSet by name. The
428
+ # @route reference would pull the whole route graph into the freeze,
429
+ # freezing objects that make_app_shareable! must still be able to
430
+ # mutate (e.g. Devise route constraints) -> FrozenError.
431
+ RactorRailsShim._swallow("freeze url helper") do
434
432
  if helper.respond_to?(:instance_variable_get)
435
433
  helper.instance_variable_set(:@route, nil) rescue nil
436
434
  opts = helper.instance_variable_get(:@options)
@@ -439,8 +437,6 @@ module RactorRailsShim
439
437
  helper.instance_variable_set(:@segment_keys, segs.dup.freeze) rescue nil
440
438
  end
441
439
  helper = Ractor.make_shareable(helper)
442
- rescue
443
- nil
444
440
  end
445
441
  RactorRailsShim::URL_HELPERS[name] = helper
446
442
  strategy_const = url_strategy.equal?(::ActionDispatch::Routing::RouteSet::PATH) ?
@@ -705,7 +701,7 @@ module RactorRailsShim
705
701
  if defined?(::ActionDispatch::Journey::GTG::Builder) &&
706
702
  ::ActionDispatch::Journey::GTG::Builder.const_defined?(:DUMMY_END_NODE)
707
703
  node = ::ActionDispatch::Journey::GTG::Builder.const_get(:DUMMY_END_NODE)
708
- Ractor.make_shareable(node) rescue nil
704
+ _swallow("make journey dummy end node shareable") { Ractor.make_shareable(node) }
709
705
  end
710
706
  end
711
707
 
@@ -787,7 +783,7 @@ module RactorRailsShim
787
783
  next unless c.is_a?(::Array) || c.is_a?(::Hash)
788
784
  next if c.frozen?
789
785
  c.freeze
790
- ::Ractor.make_shareable(c) rescue nil
786
+ _swallow("make mime negotiation constant shareable") { ::Ractor.make_shareable(c) }
791
787
  end
792
788
  rescue => e
793
789
  warn "[ractor-rails-shim] _freeze_mime_negotiation!: #{e.class}: #{e.message}"
@@ -244,7 +244,7 @@ module RactorRailsShim
244
244
  (defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application) ? ::Rails.application.routes : nil,
245
245
  nil
246
246
  )
247
- Ractor.make_shareable(fallback) rescue nil
247
+ fallback = _swallow("make view context fallback shareable") { Ractor.make_shareable(fallback) }
248
248
  self._view_context_fallback = fallback
249
249
  end
250
250
 
@@ -199,12 +199,7 @@ module RactorRailsShim
199
199
  end
200
200
  snapshot.freeze
201
201
  Ractor.make_shareable(snapshot)
202
- verbose, $VERBOSE = $VERBOSE, nil
203
- begin
204
- const_set(:AR_CONFIGURATIONS_SNAPSHOT, snapshot)
205
- ensure
206
- $VERBOSE = verbose
207
- end
202
+ _reassign_shareable_const(:AR_CONFIGURATIONS_SNAPSHOT, snapshot)
208
203
  rescue => e
209
204
  # Best-effort; if we can't capture configs, workers won't be able
210
205
  # to auto-init connections. They can call init_worker_ar_connections!
@@ -459,12 +454,7 @@ module RactorRailsShim
459
454
  pk_map[n] = pk if pk
460
455
  end
461
456
  shareable = Ractor.make_shareable(pk_map)
462
- verbose, $VERBOSE = $VERBOSE, nil
463
- begin
464
- const_set(:AR_PRIMARY_KEYS_SHAREABLE, shareable)
465
- ensure
466
- $VERBOSE = verbose
467
- end
457
+ _reassign_shareable_const(:AR_PRIMARY_KEYS_SHAREABLE, shareable)
468
458
  rescue => e
469
459
  # best-effort
470
460
  end
@@ -916,12 +906,7 @@ module RactorRailsShim
916
906
  cfg = ::ActiveRecord::Base.configurations
917
907
  cfg = Ractor.make_shareable(cfg) if cfg
918
908
  if cfg
919
- verbose, $VERBOSE = $VERBOSE, nil
920
- begin
921
- const_set(:AR_CONFIGURATIONS_SHAREABLE, cfg)
922
- ensure
923
- $VERBOSE = verbose
924
- end
909
+ _reassign_shareable_const(:AR_CONFIGURATIONS_SHAREABLE, cfg)
925
910
  end
926
911
  rescue => e
927
912
  # best-effort
@@ -977,14 +962,9 @@ module RactorRailsShim
977
962
  handlers = ::ActiveRecord::DatabaseConfigurations.db_config_handlers
978
963
  # Make each handler Proc shareable (freezes its binding). A shareable
979
964
  # Proc is callable from any Ractor.
980
- handlers.each { |h| Ractor.make_shareable(h) rescue nil }
965
+ handlers.each { |h| _swallow("make ar db config handler shareable") { Ractor.make_shareable(h) } }
981
966
  shareable = Ractor.make_shareable(handlers.dup)
982
- verbose, $VERBOSE = $VERBOSE, nil
983
- begin
984
- const_set(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable)
985
- ensure
986
- $VERBOSE = verbose
987
- end
967
+ _reassign_shareable_const(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable)
988
968
  rescue => e
989
969
  # best-effort
990
970
  end
@@ -1031,14 +1011,9 @@ module RactorRailsShim
1031
1011
  if Ractor.main?
1032
1012
  begin
1033
1013
  transformers = ::ActiveRecord.query_transformers
1034
- transformers.each { |t| Ractor.make_shareable(t) rescue nil }
1014
+ transformers.each { |t| _swallow("make ar query transformer shareable") { Ractor.make_shareable(t) } }
1035
1015
  shareable = Ractor.make_shareable(transformers.dup)
1036
- verbose, $VERBOSE = $VERBOSE, nil
1037
- begin
1038
- const_set(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable)
1039
- ensure
1040
- $VERBOSE = verbose
1041
- end
1016
+ _reassign_shareable_const(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable)
1042
1017
  rescue => e
1043
1018
  # best-effort
1044
1019
  end
@@ -1085,12 +1060,7 @@ module RactorRailsShim
1085
1060
  begin
1086
1061
  val = ::ActiveRecord.public_send(method_name)
1087
1062
  shareable = Ractor.make_shareable(val.is_a?(::Array) ? val.dup : val)
1088
- verbose, $VERBOSE = $VERBOSE, nil
1089
- begin
1090
- const_set(const_name, shareable)
1091
- ensure
1092
- $VERBOSE = verbose
1093
- end
1063
+ _reassign_shareable_const(const_name, shareable)
1094
1064
  rescue => e
1095
1065
  # best-effort
1096
1066
  end