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 +4 -4
- data/CHANGELOG.md +105 -0
- data/README.md +79 -17
- data/lib/ractor_rails_shim/patches/core.rb +119 -49
- data/lib/ractor_rails_shim/patches/make_shareable.rb +117 -36
- data/lib/ractor_rails_shim/version.rb +1 -1
- data/lib/ractor_rails_shim/version_check.rb +10 -6
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a4b7ce968f90326f2b476d713b7fab6c524f8f7b111f60a2ae582d1e162de88
|
|
4
|
+
data.tar.gz: c144478be527ff98804db48add54d67edb4124514fba3670b970990ef09e36c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
8
|
-
PG) under **official Ruby 4.0.6**, worker Ractors serve **every**
|
|
9
|
-
`GET /up`, full ERB view rendering, Devise sign-in/sign-out
|
|
10
|
-
**and** validation), and authenticated Devise **writes**
|
|
11
|
-
row persisted). A worker Ractor dispatches `GET /up` →
|
|
12
|
-
`RactorRailsShim.make_app_shareable!`. The shim builds a
|
|
13
|
-
table for framework class config (`class_attribute` /
|
|
14
|
-
and patches the raw class-ivar accessors Rails reads
|
|
15
|
-
(`ExecutionWrapper.active_key`, `Notifications.notifier`,
|
|
16
|
-
`PathRegistry`, `I18n`, `AbstractController` lazy ivars,
|
|
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
|
|
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
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
438
|
+
end
|
|
420
439
|
|
|
421
|
-
|
|
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
|
|
438
|
-
"
|
|
439
|
-
"
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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.
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
|
770
|
-
#
|
|
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.
|
|
788
|
-
#
|
|
789
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
@@ -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;
|
|
28
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
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:
|
|
102
|
+
version: 4.0.6
|
|
103
103
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
104
|
requirements:
|
|
105
105
|
- - ">="
|