ractor-rails-shim 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +101 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +265 -0
  5. data/exe/ractor-rails-check +39 -0
  6. data/lib/ractor_rails_shim/check.rb +190 -0
  7. data/lib/ractor_rails_shim/fallback_ies.rb +33 -0
  8. data/lib/ractor_rails_shim/patches/action_controller.rb +301 -0
  9. data/lib/ractor_rails_shim/patches/action_dispatch.rb +851 -0
  10. data/lib/ractor_rails_shim/patches/action_view.rb +582 -0
  11. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +97 -0
  12. data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +58 -0
  13. data/lib/ractor_rails_shim/patches/active_support.rb +1085 -0
  14. data/lib/ractor_rails_shim/patches/activerecord.rb +1727 -0
  15. data/lib/ractor_rails_shim/patches/class_attribute.rb +194 -0
  16. data/lib/ractor_rails_shim/patches/core.rb +806 -0
  17. data/lib/ractor_rails_shim/patches/devise.rb +172 -0
  18. data/lib/ractor_rails_shim/patches/execution_wrapper.rb +76 -0
  19. data/lib/ractor_rails_shim/patches/kaminari.rb +82 -0
  20. data/lib/ractor_rails_shim/patches/make_shareable.rb +853 -0
  21. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +162 -0
  22. data/lib/ractor_rails_shim/patches/openssl.rb +58 -0
  23. data/lib/ractor_rails_shim/patches/orm_adapter.rb +32 -0
  24. data/lib/ractor_rails_shim/patches/polymorphic_routes.rb +115 -0
  25. data/lib/ractor_rails_shim/patches/propshaft.rb +110 -0
  26. data/lib/ractor_rails_shim/patches/rack.rb +160 -0
  27. data/lib/ractor_rails_shim/patches/rails_module.rb +192 -0
  28. data/lib/ractor_rails_shim/patches/route_helpers.rb +119 -0
  29. data/lib/ractor_rails_shim/patches/rubygems.rb +65 -0
  30. data/lib/ractor_rails_shim/patches/url_helpers.rb +78 -0
  31. data/lib/ractor_rails_shim/patches/warden.rb +195 -0
  32. data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +124 -0
  33. data/lib/ractor_rails_shim/patches.rb +67 -0
  34. data/lib/ractor_rails_shim/version.rb +5 -0
  35. data/lib/ractor_rails_shim/version_check.rb +88 -0
  36. data/lib/ractor_rails_shim.rb +33 -0
  37. metadata +104 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2f462e8a604fac024cc13b3f1667ec9e9c79207cb675a473e7f2f536e7ac4c8d
4
+ data.tar.gz: 76652b85a06f3c7e6cc50b7619f4b28c52da420f6f946f09518f01b5143c4094
5
+ SHA512:
6
+ metadata.gz: 83964fbfa3a113e70ca8bdfd5f6f69ea3598cf028207bb43971a9b6ed135e28f4b2f2a5b1d0bbcdf574a9a12313ff9ab6664182d22b8a3e00ab959105bdb72ce
7
+ data.tar.gz: be2ef63949ea84ac7f3dfa72a65d57f2687b2b1dc53de6f85a8709d42b84fcec5588f429e298beb8aa76e682b593ebfeb49dd292191cc620bd847b9b84e78c78
data/CHANGELOG.md ADDED
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ All notable changes to ractor-rails-shim are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added — ActiveRecord query-path ractor-safety (Blocker 1 deep work)
11
+ - `RactorRailsShim.worker_ar_init(app)` — a shareable Rack middleware that
12
+ calls `init_worker_ar_connections!` on each worker's first request. Kino's
13
+ `:ractor` mode has no worker-init hook, so `config_ractor.ru` (generated by
14
+ `make_full_test_app.sh`) now wraps the shareable app with it.
15
+ - `_share_relation_delegate_caches!` — deep-freezes each AR model class's
16
+ `@relation_delegate_cache` (mutable Hash of shareable delegate Classes) so a
17
+ worker Ractor can read it. (`patches/activerecord.rb`)
18
+ - `_share_model_classes!` — warms every AR model class in main (runs
19
+ `count`/`first`/`page`/`table_name`/...) to populate lazy `@ivar ||= ...`
20
+ class ivars, then makes each shareable (deep-freeze; Monitor/Mutex→
21
+ `NoOpLock`; `Concurrent::Map`→frozen Hash; unfreezable caches→frozen empty
22
+ container). Fixes `@table_name`, `@arel_table`, `@predicate_builder`, etc.
23
+ - `_share_active_record_internals!` — warms `.empty` + freezes class ivars on
24
+ the AR `*Clause` helper classes (`WhereClause`, `FromClause`).
25
+ - `_install_activerecord_configurations_patch` — routes the raw
26
+ `@@configurations` class var (`ActiveRecord::Base.configurations`) through
27
+ IES with a shareable deep-frozen `DatabaseConfigurations` fallback.
28
+ - ~30 non-shareable AR/Arel constants registered in `SHAREABLE_CONSTANTS`
29
+ (`VALID_UNSCOPING_VALUES`, `MULTI_VALUE_METHODS`, `STRING_OR_SYMBOL_CLASS`,
30
+ `NATIVE_DATABASE_TYPES`, ...). `make_constant_shareable` now special-cases
31
+ Monitor/Mutex constants → `NoOpLock` and rescues intrinsically-unshareable
32
+ values (Proc / `Concurrent::Map` / `TypeMap`) instead of crashing.
33
+ - `_capture_ar_configurations!` now reads
34
+ `Rails.application.config.database_configuration` (`DatabaseConfigurations`
35
+ has no `#each` in this Rails/Ruby).
36
+ - `verify_blockers.rb` (generated by `make_full_test_app.sh`) — end-to-end
37
+ data-layer + HTTP-dispatch check in a worker Ractor.
38
+
39
+ ### Known limitations (DB queries from worker Ractors)
40
+ - `Post.count` / `Post.page(1)` from a worker Ractor still fail at
41
+ `ActiveRecord::DatabaseConfigurations.db_config_handlers` (an Array of
42
+ **Procs** registered by adapters), and downstream at the SQLite
43
+ `TYPE_MAP` (Procs) and `QUOTED_*` `Concurrent::Map` quoting caches.
44
+ These are intrinsically unshareable and require upstream Rails changes
45
+ (shareable callables instead of Procs; per-Ractor quoting caches). See
46
+ `NEXT_STEPS.md` "The wall — deep AR ractor-unsafety".
47
+
48
+ ## [0.2.0] - 2026-07-09
49
+
50
+ ### Added — productionization (Phase 6)
51
+ - **Version detection infrastructure.** New `RactorRailsShim::Version` module
52
+ with `Gem::Version`-based runtime checks (`ruby`, `rails`, `rails_segment`,
53
+ `supported_ruby?`, `supported_rails?`, `satisfies?`). Replaces the string-
54
+ prefix compare so pre-release and patch versions sort correctly.
55
+ (`lib/ractor_rails_shim/version_check.rb`)
56
+ - **Version policy switch.** `RactorRailsShim.version_policy = :warn | :strict |
57
+ :off`. The default `:warn` preserves backward compatibility; `:strict`
58
+ raises `RactorRailsShim::UnsupportedVersionError` on untested Rails/Ruby;
59
+ `:off` silences. (`patches.rb`)
60
+ - **Patch version registry.** Every `install_*` / `_install_*` method registers
61
+ its tested Rails versions in `RactorRailsShim::PATCH_VERSIONS`. Use
62
+ `RactorRailsShim.applicable_patches` to see which patches applied to the
63
+ runtime (and which were skipped as untested). This is the "load different
64
+ patches for different Rails versions" extension point — to add 7.x support,
65
+ write version-specific variants and tag them in the registry.
66
+ (`patches.rb`)
67
+ - **CI.** GitHub Actions workflow (`.github/workflows/ci.yml`): a fast unit
68
+ job (no Rails) plus an integration job that builds the minimal Rails 8.1
69
+ test app, makes it shareable, and dispatches `GET /up` in a worker Ractor —
70
+ asserting HTTP 200. Also runs `ractor-rails-check` against the test app.
71
+ - **Unit specs** for the version infrastructure and the callable/lock
72
+ replacement classes (`NoOpProc`, `Callable`, `CallableConst`,
73
+ `RequestCallable`, `NoOpLock`, `NoOpLogDev`) — including cross-Ractor
74
+ callability. (`spec/version_spec.rb`; 31 specs total, up from 8.)
75
+ - **CHANGELOG.md.**
76
+
77
+ ### Changed
78
+ - `ractor-rails-shim.gemspec`: real metadata (`changelog_uri`,
79
+ `bug_tracker_uri`, `rubygems_mfa_required`), canonical repo URL, CHANGELOG
80
+ included in the gem package.
81
+ - `script/make_test_app.sh`: portable `sed` (was macOS-only `sed -i ''`,
82
+ failed on Linux CI).
83
+ - Bumped version `0.1.0` → `0.2.0`.
84
+
85
+ ### Fixed
86
+ - `.gitignore`: corrected `racker-rails-shim-*.gem` typo to
87
+ `ractor-rails-shim-*.gem`.
88
+
89
+ ## [0.1.0] - 2026-07-09
90
+
91
+ ### Added
92
+ - Initial proof-of-concept. Reroutes Rails class-level instance variables
93
+ (`Rails.application`, `Rails.cache`, `Rails.logger`, `mattr_accessor`,
94
+ `class_attribute`, `Zeitwerk::Registry`, unshareable constants) through
95
+ `IsolatedExecutionState` / `Ractor.make_shareable`.
96
+ - `make_app_shareable!` — replaces self-capturing Procs with callable objects,
97
+ Mutex/Monitor with no-op locks, `Concurrent::Map` with frozen Hashes, then
98
+ `Ractor.make_shareable(app)`. A worker Ractor dispatches `GET /up` → 200.
99
+ - `ractor-rails-check` CLI audit tool.
100
+ - Minimal unit specs (8) + an integration spec (self-skips without a test app).
101
+ - `VALIDATION.md`, `README.md`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # ractor-rails-shim
2
+
3
+ A monkey-patch shim that reroutes Rails' class-level instance variable accessors through `ActiveSupport::IsolatedExecutionState`, which is Ractor-safe. Lets a Rails app run in Ractor mode without forking Rails itself.
4
+
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
+
7
+ **Current status:** on a full Rails 8.1 app (Devise 5, Propshaft, Kaminari,
8
+ PG) under Ruby 4.0.5, 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.).
18
+
19
+ **Known limitation:** sustained *concurrent* writes in a worker Ractor can still
20
+ crash with a frozen-iseq SIGBUS (a Ruby 4.0 Ractor-model issue); reads and
21
+ single writes are stable.
22
+
23
+ ## Requirements
24
+
25
+ - **Ruby >= 4.0** — the shim relies on Ruby 4.0's Ractor semantics and
26
+ `Ractor.make_shareable`. It will not work (and refuses to install) on
27
+ earlier Ruby versions.
28
+ - **Rails ~> 8.1** — tested against Rails 8.1.x class layouts. Other versions
29
+ are not yet supported (see Version compatibility below).
30
+
31
+ **Ractor-mode server:** as of this writing, the only Ruby web server that can
32
+ run a Rails app in Ractor mode is
33
+ [kino](https://github.com/yaroslav/kino) (`kino -m ractor`). Other servers
34
+ (Puma, Falcon, …) run Rails in processes or threads, not Ractors. This shim was
35
+ developed and tested against a real Rails 8.1 app **served by kino**; that is
36
+ the configuration it is verified against.
37
+
38
+ ## Why
39
+
40
+ Rails stores global state in class-level instance variables:
41
+
42
+ ```ruby
43
+ class Rails
44
+ class << self
45
+ attr_accessor :app_class, :cache, :logger
46
+ def application; @application ||= ...; end
47
+ end
48
+ end
49
+ ```
50
+
51
+ From a non-main Ractor, these reads/writes raise `Ractor::IsolationError`:
52
+
53
+ ```
54
+ can not get unshareable values from instance variables of classes/modules
55
+ from non-main Ractors
56
+ ```
57
+
58
+ This is the primary blocker preventing Rails from running in Ractor mode (see the [kino](https://github.com/yaroslav/kino) project's `doc/rails-on-ractors.md` for the full diagnosis). The same pattern blocks Hanami, Padrino, Sinatra, and most Ruby web frameworks.
59
+
60
+ ## How it works
61
+
62
+ The shim reroutes the accessor methods through `ActiveSupport::IsolatedExecutionState`, which is thread-local storage (`Thread.current[:key]`). Each Ractor has its own threads, so each Ractor gets its own slot automatically — verified on Ruby 4.0.5. Rails already uses this primitive for `ActiveRecord::ConnectionHandling.connection_handler` (for thread safety), so the pattern is proven in production.
63
+
64
+ Two storage paths, depending on the state's shape:
65
+
66
+ | path | primitive | sharing | mutability | use case |
67
+ |---|---|---|---|---|
68
+ | **per-Ractor** | `IsolatedExecutionState` / `Ractor.store_if_absent` | none (each Ractor own copy) | mutable | connection pools, per-Ractor config, logger |
69
+ | **shareable** | `Ractor.make_shareable` + constant | one copy, by reference | frozen (read-only) | route tables, frozen config, templates |
70
+
71
+ The shim uses the per-Ractor path by default. For shareable state, use `Ractor.make_shareable` directly — that's not this gem's job.
72
+
73
+ **Load order.** `install` may be called either before or after Rails is defined — the normal `config/boot.rb` path calls it before `require "rails"`. The `mattr_accessor` macro patch applies regardless; the Rails-module accessor patch (`Rails.application`, `Rails.env`, ...) defers via a `TracePoint(:class)` load hook that fires when `module Rails` opens.
74
+
75
+ ## Version compatibility
76
+
77
+ The shim targets specific Rails class layouts (8.1.x) and Ruby Ractor semantics
78
+ (4.0.x). At install it runs a real `Gem::Version`-based check (not a string
79
+ compare) and applies a configurable policy on mismatch:
80
+
81
+ ```ruby
82
+ RactorRailsShim.version_policy = :strict # raise on untested versions
83
+ RactorRailsShim.version_policy = :warn # default: warn to $stderr, proceed
84
+ RactorRailsShim.version_policy = :off # silent (experimentation)
85
+ ```
86
+
87
+ Under `:strict` a mismatch raises `RactorRailsShim::UnsupportedVersionError`.
88
+ Each patch registers its tested Rails versions in `RactorRailsShim::PATCH_VERSIONS`;
89
+ query what applied to your runtime with:
90
+
91
+ ```ruby
92
+ RactorRailsShim.applicable_patches
93
+ # => { applied: [:mattr_accessor, :rails_module, ...], skipped: [{name: ...}] }
94
+ ```
95
+
96
+ Adding Rails 7.x support: the version-gated registry is the extension point —
97
+ write version-specific patch variants, tag them in `PATCH_VERSIONS`, and the
98
+ dispatcher applies only matching patches. (7.x is not yet supported; only 8.1
99
+ is tested today.)
100
+
101
+ ## Install
102
+
103
+ Add to your Gemfile:
104
+
105
+ ```ruby
106
+ gem "ractor-rails-shim", group: :production
107
+ ```
108
+
109
+ Then install early in boot, before `Rails.application` is first accessed:
110
+
111
+ ```ruby
112
+ # config/boot.rb
113
+ require "bundler/setup"
114
+ require "ractor_rails_shim"
115
+ RactorRailsShim.install
116
+ ```
117
+
118
+ After Rails is fully booted (after `Rails.application.initialize!`) and **before
119
+ spawning worker Ractors**, call `prepare_for_ractors!` to make the remaining
120
+ unshareable constants (e.g. `Rails::Railtie::ABSTRACT_RAILTIES`, which loads
121
+ after `module Rails` opens) shareable:
122
+
123
+ ```ruby
124
+ # config/environment.rb, or wherever you boot your app before spawning workers
125
+ Rails.application.initialize!
126
+ RactorRailsShim.prepare_for_ractors!
127
+ ```
128
+
129
+ To share the whole app across worker Ractors (the `:ractor` mode path), call
130
+ `make_app_shareable!` — it replaces every self-capturing Proc in the app graph
131
+ with a callable object, every Mutex/Monitor with a no-op lock, and every
132
+ `Concurrent::Map` with a frozen Hash, then calls `Ractor.make_shareable`:
133
+
134
+ ```ruby
135
+ Rails.application.initialize!
136
+ app = RactorRailsShim.make_app_shareable!(Rails.application)
137
+ # app is now frozen and Ractor.shareable? — pass it to worker Ractors:
138
+ worker = Ractor.new(app) { |a| a.call(env) }
139
+ ```
140
+
141
+ **Note:** `make_app_shareable!` is production-only (the app becomes read-only).
142
+ It also **detaches the logger IO from the app graph**: `app.config.logger`
143
+ (the broadcast target holding the real `$stdout`/`$stderr` IO) is swapped for a
144
+ frozen no-op `BroadcastLogger` so `Ractor.make_shareable(app)` doesn't freeze
145
+ the process's real IOs, and the main ractor's `Rails.logger` (the per-Ractor
146
+ module accessor, not in the app graph) is re-pointed at a fresh live
147
+ `BroadcastLogger` → `$stderr` so main keeps logging. Worker ractors build their
148
+ own per-Ractor `Rails.logger` (a `BroadcastLogger` → `$stderr`). To fix
149
+ unshareable constants in your own app/gems, add them to the registry before
150
+ `prepare_for_ractors!`:
151
+
152
+ ```ruby
153
+ RactorRailsShim.shareable_constants << "MyGem::MUTABLE_LIST"
154
+ RactorRailsShim.prepare_for_ractors!
155
+ ```
156
+
157
+ Or from a Rails console / runner for a quick check:
158
+
159
+ ```ruby
160
+ require "ractor_rails_shim"
161
+ RactorRailsShim.install
162
+ ```
163
+
164
+ ## Audit your app
165
+
166
+ ```sh
167
+ bundle exec ractor-rails-check
168
+ # or scoped:
169
+ bundle exec ractor-rails-check --rails # only Rails framework modules
170
+ bundle exec ractor-rails-check --app # only app + gems
171
+ ```
172
+
173
+ Reports class ivars holding unshareable values — the ones that would raise `Ractor::IsolationError` from a worker Ractor. Example:
174
+
175
+ ```
176
+ ractor-rails-shim check: 23 class-ivar blocker(s) found
177
+
178
+ === Rails framework (8) ===
179
+ Rails@application = nil
180
+ Rails@app_class = String
181
+ Rails@cache = NilClass
182
+ ...
183
+
184
+ === app + gems (15) ===
185
+ Devise@config = Devise::Config
186
+ Sidekiq@options = Hash
187
+ ...
188
+
189
+ hints:
190
+ - require "ractor_rails_shim" and call RactorRailsShim.install before
191
+ Rails.application is first accessed (early in config/boot.rb)
192
+ - class-var (@@foo) blockers from mattr_accessor/cattr_accessor are rerouted
193
+ by the shim automatically once installed
194
+ - raw class-ivar (@foo) blockers are NOT fixed by the shim; patch the gem
195
+ or use Ractor.make_shareable + a constant for shareable state
196
+ - for per-Ractor mutable state use Ractor.store_if_absent(key) { default }
197
+ ```
198
+
199
+ ## What this fixes
200
+
201
+ - `Rails.application`, `Rails.app_class`, `Rails.cache`, `Rails.logger`, `Rails.env`, `Rails.backtrace_cleaner` — rerouted through `IsolatedExecutionState`.
202
+ - `Module.mattr_accessor` / `cattr_accessor` — the macro is rewritten so all ~150 call sites in Rails inherit the fix without individual edits. Pass `shareable: true` to opt an accessor into the shareable-by-reference path instead.
203
+ - `Class.class_attribute` (ActiveSupport) — the macro is rewritten so `executor`, `check`, and every other `class_attribute`-defined accessor routes through `IsolatedExecutionState` via string-eval'd methods (no captured binding). Without this, a worker Ractor calling `app.reloader.executor = ...` during boot raises "defined with an un-shareable Proc in a different Ractor".
204
+ - `Zeitwerk::Registry` class ivars (`@loaders`, `@mutex`, `@autoloads`, etc.) — routed through `IsolatedExecutionState` so a worker Ractor can create autoloaders (each Ractor gets its own registry).
205
+ - Unshareable constants (`Rails::Railtie::ABSTRACT_RAILTIES`, `ActiveSupport::EnvironmentInquirer::DEFAULT_ENVIRONMENTS`, etc.) — made shareable once at boot via `Ractor.make_shareable` + `const_set`. Add your own via `RactorRailsShim.shareable_constants << "MyGem::CONST"` then call `prepare_for_ractors!`.
206
+ - **Framework class config fallback** (`SHAREABLE_FALLBACK`) — at `make_app_shareable!` time the main ractor's live `class_attribute` / `mattr_accessor` values are captured, made shareable (callable-replacement for any Procs), and exposed as a read-only fallback. Worker readers return it when their per-Ractor IES slot is empty — this is what fixes `ActionController::Base.config` being nil in workers.
207
+ - **Raw class-ivar/cvar accessors Rails reads per-request** (the long tail beyond `mattr_accessor`) — patched individually: `ExecutionWrapper.active_key`, `ActiveSupport::Notifications.notifier`, `ActiveSupport.error_reporter`, `ActiveSupport::ExecutionContext` (after_change_callbacks / nestable), `ActiveSupport::Inflector::Inflections`, `ActionView::PathRegistry`, `ActionView::LookupContext` + `DetailsKey` (`view_context_class` built per-controller in main & shared via `VIEW_CONTEXT_REGISTRY`), `ActionView::Template::Handlers`, `AbstractController::Base` (`controller_path` / `action_methods` / `abstract` / `_prefixes` via per-Ractor Hash caches keyed by class), `AbstractController::UrlFor#action_methods` (sidesteps the unshareable `_routes` define_method-block), `ActionController::ParameterEncoding#action_encoding_template`, `Rack::Request` (`forwarded_priority` / `x_forwarded_proto_priority`), `Rack::Utils` (`default_query_parser` / multipart limits), `ActionDispatch::Request.parameter_parsers`, `I18n::Config` (`default_locale` / `locale`) + `I18n.fallbacks` + `I18n::Locale::Tag.implementation`.
208
+ - **`ActiveSupport::Callbacks#run_callbacks`** — made nil-safe so a worker Ractor whose `__callbacks` couldn't be shared (frozen, self-capturing-Proc callback chains) treats callbacks as empty. Correct for a read-only shared app where boot-time callbacks already ran in main.
209
+
210
+ ## What this does NOT fix
211
+
212
+ - **Gems.** Every gem your app depends on (Devise, Sidekiq, redis-rb, pg, etc.) has its own class ivars. The shim's `mattr_accessor` rewrite helps gems that use that macro, but gems using raw `@ivar ||= ...` need their own patches. The `--check` script surfaces these.
213
+ - **App code** holding mutable state in closures (`cache = {}; ->(env){ cache[...] }`). The shim can't see into closures; `--check` finds class ivars only.
214
+ - **define_method-block methods.** A handful of Rails methods are defined via `define_method` with a block capturing the defining Ractor (e.g. `ActionDispatch::Routing::RouteSet`'s `_routes` singleton helper, `ActionView::Base.with_empty_template_cache`'s `compiled_method_container`). The shim works around these by building the affected classes (e.g. `view_context_class`) in the main Ractor and sharing them, or by reading the route set directly from the shared `Rails.application`. More complex apps may hit additional `define_method`-block call sites that need similar treatment.
215
+
216
+ ## Limitations
217
+
218
+ - **Per-Ractor means N copies.** Each Ractor gets its own `Rails.application`, `Rails.cache`, etc. — same shape as forking N processes, but cheaper (no heap duplication). For large read-only state, use `Ractor.make_shareable` instead.
219
+ - **The mattr_accessor rewrite is broad.** It reroutes *all* `mattr_accessor`-defined accessors through `IsolatedExecutionState`, including ones that were legitimately class-global. This may change semantics for code that sets a value in main Ractor expecting worker Ractors to see it. Audit with `--check` and use `shareable: true` for accessors that should be shared.
220
+ - **Fragile across Rails versions.** This is a monkey-patch. Rails releases that touch `rails.rb` or `mattr_accessor` may break it. When upstream fixes it, delete the gem.
221
+
222
+ ## When to delete this gem
223
+
224
+ This shim is a **stopgap**. Delete it (remove from the Gemfile) when Rails
225
+ natively supports Ractor mode — i.e. when **all** of these land upstream:
226
+
227
+ 1. Class-level instance variables / class variables backing `mattr_accessor`
228
+ and `class_attribute` are migrated to `IsolatedExecutionState` (or
229
+ equivalent ractor-safe storage). Rails already does this for
230
+ `ActiveRecord::ConnectionHandling.connection_handler`, proving the pattern.
231
+ 2. The `Zeitwerk::Registry` class ivars route through ractor-safe storage.
232
+ 3. Unshareable constants (`EnvironmentInquirer::DEFAULT_ENVIRONMENTS`, etc.)
233
+ are made shareable (deep-frozen) at boot.
234
+ 4. The **7 self-capturing Procs** in the app graph are restructured to not
235
+ capture `self` — e.g. the `Rack::Files` head lambda, `ActionDispatch::SSL`
236
+ exclude proc, `CookieStore` same-site proc, the message-verifier secret
237
+ generator, and the routes-reloader blocks. These block
238
+ `Ractor.make_shareable(Rails.application)` today.
239
+ 5. Initializer blocks (`Rails::Initializable::Initializer#block`) are
240
+ shareable (defined as methods, not closures) so per-Ractor boot works.
241
+
242
+ Until then, the shim is required. A simple canary: with the gem removed,
243
+ `Ractor.make_shareable(Rails.application)` fails (it raises on a Mutex or a
244
+ "Proc's self is not shareable"). When that call succeeds unshimmed, the gem
245
+ is obsolete. See `UPSTREAM_ISSUE.md` for the full blocker map and the
246
+ proposed incremental upstream merge plan.
247
+
248
+ ## Publishing (maintainers)
249
+
250
+ The gemspec is publish-ready (metadata, MFA required, CHANGELOG packaged).
251
+ To release a new version:
252
+
253
+ ```sh
254
+ # Bump lib/ractor_rails_shim/version.rb and add a CHANGELOG entry, then:
255
+ gem build ractor-rails-shim.gemspec
256
+ gem push ractor-rails-shim-<version>.gem
257
+ ```
258
+
259
+ CI (`.github/workflows/ci.yml`) gates merges: unit specs (no Rails) + an
260
+ integration job that builds the minimal Rails 8.1 app and dispatches `GET /up`
261
+ in a worker Ractor. Don't publish from a red build.
262
+
263
+ ## License
264
+
265
+ MIT
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ractor-rails-check — audit a loaded Rails app (or any Ruby app) for
5
+ # class-level instance variables that would block Ractor mode.
6
+ #
7
+ # Usage:
8
+ # ractor-rails-check # scan loaded modules, print report
9
+ # ractor-rails-check --rails # only Rails framework modules
10
+ # ractor-rails-check --app # only app + gems (exclude Rails)
11
+ #
12
+ # Typically run from a Rails console or a boot script:
13
+ # bundle exec rails runner ractor-rails-check
14
+
15
+ require "ractor_rails_shim/check"
16
+
17
+ scope = ARGV.find { |a| a.start_with?("--") }&.sub(/^--/, "")&.to_sym || :all
18
+
19
+ case scope
20
+ when :rails
21
+ findings = RactorRailsShim::Check.scan_rails
22
+ when :app
23
+ findings = RactorRailsShim::Check.scan_app
24
+ else
25
+ RactorRailsShim::Check.report(print: true)
26
+ exit 0
27
+ end
28
+
29
+ # Scoped report
30
+ if findings.empty?
31
+ puts "no blockers found in scope :#{scope}"
32
+ exit 0
33
+ end
34
+
35
+ puts "=== scope :#{scope} (#{findings.size} blockers) ==="
36
+ findings.each do |f|
37
+ puts " #{f.owner}#{f.ivar} = #{f.value_class}"
38
+ puts " #{f.source}" if f.source
39
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module RactorRailsShim
6
+ # Audit an app for Ractor blockers. Inspects loaded classes/modules for
7
+ # class-level instance variables and mattr_accessor usage that would
8
+ # raise Ractor::IsolationError from a non-main Ractor.
9
+ #
10
+ # Modeled on Kino's `kino --check` — tell the user *exactly* what blocks
11
+ # their app, instead of leaving them to decode IsolationError at runtime.
12
+ class Check
13
+ # kind: :ivar (class-level instance variable, @foo) or
14
+ # :cvar (class variable, @@foo — mattr_accessor/cattr_accessor)
15
+ Finding = Data.define(:owner, :ivar, :value_class, :shareable, :source, :kind)
16
+
17
+ class << self
18
+ # Scan all loaded classes/modules and report class ivars AND class
19
+ # variables holding unshareable values. Returns an array of Finding.
20
+ def scan
21
+ findings = []
22
+ seen = {}
23
+
24
+ ObjectSpace.each_object(Module) do |mod|
25
+ next if mod.name.nil? || mod.name.empty?
26
+ next if mod.name.start_with?("#", "Ractor::", "Thread::", "Fiber::", "ObjectSpace::")
27
+
28
+ # Inspect class-level instance variables.
29
+ mod.instance_variables.each do |ivar|
30
+ next if ivar == :@_sandbox # Rails internal, ignored
31
+ begin
32
+ val = mod.instance_variable_get(ivar)
33
+ rescue => e
34
+ next
35
+ end
36
+
37
+ shareable = begin
38
+ Ractor.shareable?(val)
39
+ rescue => e
40
+ false
41
+ end
42
+ next if shareable
43
+
44
+ key = "#{mod.name}#{ivar}"
45
+ next if seen[key]
46
+ seen[key] = true
47
+
48
+ source = locate(mod, ivar)
49
+ findings << Finding.new(
50
+ owner: mod.name,
51
+ ivar: ivar.to_s,
52
+ value_class: val.class.name || val.class.to_s,
53
+ shareable: shareable,
54
+ source: source,
55
+ kind: :ivar
56
+ )
57
+ end
58
+
59
+ # Inspect class variables (@@foo) — these back mattr_accessor /
60
+ # cattr_accessor and are ALSO subject to Ractor::IsolationError
61
+ # from non-main Ractors (verified on Ruby 4.0.5).
62
+ begin
63
+ mod.class_variables.each do |cvar|
64
+ begin
65
+ val = mod.class_variable_get(cvar)
66
+ rescue => e
67
+ next
68
+ end
69
+
70
+ shareable = begin
71
+ Ractor.shareable?(val)
72
+ rescue => e
73
+ false
74
+ end
75
+ next if shareable
76
+
77
+ key = "#{mod.name}#{cvar}"
78
+ next if seen[key]
79
+ seen[key] = true
80
+
81
+ source = locate(mod, cvar)
82
+ findings << Finding.new(
83
+ owner: mod.name,
84
+ ivar: cvar.to_s,
85
+ value_class: val.class.name || val.class.to_s,
86
+ shareable: shareable,
87
+ source: source,
88
+ kind: :cvar
89
+ )
90
+ end
91
+ rescue => e
92
+ # Some modules raise on class_variables enumeration; skip.
93
+ next
94
+ end
95
+ end
96
+
97
+ findings.sort_by { |f| [f.owner, f.ivar] }
98
+ end
99
+
100
+ # Scan only Rails framework modules (Railties, ActiveRecord, etc.)
101
+ # — the ones the shim targets. Useful for "is Rails itself clean?"
102
+ def scan_rails
103
+ scan.select { |f| f.owner.start_with?("Rails", "ActiveRecord", "ActiveSupport",
104
+ "ActionController", "ActionView", "ActionDispatch", "ActionMailer",
105
+ "ActiveJob", "ActionCable", "ActionText", "ActionMailbox", "ActiveStorage") }
106
+ end
107
+
108
+ # Scan app + gem classes outside the Rails framework.
109
+ def scan_app
110
+ scan - scan_rails
111
+ end
112
+
113
+ # Human-readable report. Returns a string; also prints to $stderr
114
+ # if `print:` is true (default).
115
+ def report(print: true)
116
+ findings = scan
117
+ rails_findings = findings.select { |f| rails_namespace?(f.owner) }
118
+ app_findings = findings - rails_findings
119
+
120
+ lines = []
121
+ cvar_count = findings.count { |f| f.kind == :cvar }
122
+ ivar_count = findings.count { |f| f.kind == :ivar }
123
+ lines << "ractor-rails-shim check: #{findings.size} blocker(s) found" \
124
+ " (#{ivar_count} class-ivar, #{cvar_count} class-var)"
125
+ lines << " (unshareable values in @ivar and @@cvar; reads/writes from a non-main Ractor"
126
+ lines << " would raise Ractor::IsolationError)"
127
+ lines << ""
128
+
129
+ %i[rails app].each do |group|
130
+ grp = group == :rails ? rails_findings : app_findings
131
+ next if grp.empty?
132
+
133
+ label = group == :rails ? "Rails framework" : "app + gems"
134
+ lines << "=== #{label} (#{grp.size}) ==="
135
+ grp.first(50).each do |f|
136
+ tag = f.kind == :cvar ? " (mattr/cattr — shim targets)" : ""
137
+ lines << " #{f.owner}#{f.ivar} = #{f.value_class}#{tag}"
138
+ lines << " #{f.source}" if f.source
139
+ end
140
+ if grp.size > 50
141
+ lines << " ... and #{grp.size - 50} more (use Check.scan to see all)"
142
+ end
143
+ lines << ""
144
+ end
145
+
146
+ if findings.empty?
147
+ lines << "no blockers found — app may be Ractor-compatible"
148
+ else
149
+ lines << "hints:"
150
+ lines << " - require \"ractor_rails_shim\" and call RactorRailsShim.install before"
151
+ lines << " Rails.application is first accessed (early in config/boot.rb)"
152
+ lines << " - class-var (@@foo) blockers from mattr_accessor/cattr_accessor are"
153
+ lines << " rerouted by the shim automatically once installed"
154
+ lines << " - raw class-ivar (@foo) blockers are NOT fixed by the shim; patch the"
155
+ lines << " gem or use Ractor.make_shareable + a constant for shareable state"
156
+ lines << " - for per-Ractor mutable state use Ractor.store_if_absent(key) { default }"
157
+ end
158
+
159
+ out = lines.join("\n")
160
+ $stderr.puts(out) if print
161
+ out
162
+ end
163
+
164
+ private
165
+
166
+ def rails_namespace?(name)
167
+ name.start_with?("Rails", "ActiveRecord", "ActiveSupport", "ActionController",
168
+ "ActionView", "ActionDispatch", "ActionMailer", "ActiveJob", "ActionCable",
169
+ "ActionText", "ActionMailbox", "ActiveStorage")
170
+ end
171
+
172
+ # Best-effort: locate where the ivar is set in source. Not always
173
+ # possible (set via mattr_accessor macro), but useful when it is.
174
+ def locate(mod, ivar)
175
+ # Check if it's a method-defined accessor (mattr_accessor etc.)
176
+ reader = ivar.to_s.delete("@").to_sym
177
+ if mod.singleton_class.method_defined?(reader)
178
+ m = mod.singleton_class.instance_method(reader)
179
+ if m.source_location
180
+ file, line = m.source_location
181
+ return "#{file}:#{line}"
182
+ end
183
+ end
184
+ nil
185
+ rescue => e
186
+ nil
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
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
9
+ KEY = :active_support_execution_state_fallback
10
+
11
+ class << self
12
+ def [](key)
13
+ Thread.current[KEY]&.[](key)
14
+ end
15
+
16
+ def []=(key, value)
17
+ (Thread.current[KEY] ||= {})[key] = value
18
+ end
19
+
20
+ def key?(key)
21
+ Thread.current[KEY]&.key?(key)
22
+ end
23
+
24
+ def delete(key)
25
+ Thread.current[KEY]&.delete(key)
26
+ end
27
+
28
+ def clear
29
+ Thread.current[KEY] = nil
30
+ end
31
+ end
32
+ end
33
+ end unless defined?(ActiveSupport::IsolatedExecutionState)