ractor-rails-shim 0.2.6 → 0.3.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -0
  3. data/README.md +113 -39
  4. data/lib/ractor_rails_shim/foundation/const_reassign.rb +26 -0
  5. data/lib/ractor_rails_shim/foundation/funnel.rb +47 -0
  6. data/lib/ractor_rails_shim/foundation/ies_accessor.rb +58 -0
  7. data/lib/ractor_rails_shim/foundation/registry.rb +77 -0
  8. data/lib/ractor_rails_shim/foundation/role_defaults.rb +58 -0
  9. data/lib/ractor_rails_shim/foundation/run_mode.rb +76 -0
  10. data/lib/ractor_rails_shim/foundation/storage.rb +99 -0
  11. data/lib/ractor_rails_shim/foundation/storage_strategy.rb +173 -0
  12. data/lib/ractor_rails_shim/foundation/version_policy.rb +147 -0
  13. data/lib/ractor_rails_shim/loader.rb +79 -0
  14. data/lib/ractor_rails_shim/patches/action_controller.rb +9 -9
  15. data/lib/ractor_rails_shim/patches/action_dispatch.rb +85 -30
  16. data/lib/ractor_rails_shim/patches/action_view.rb +27 -27
  17. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +62 -5
  18. data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +5 -5
  19. data/lib/ractor_rails_shim/patches/active_support.rb +49 -587
  20. data/lib/ractor_rails_shim/patches/activerecord.rb +97 -127
  21. data/lib/ractor_rails_shim/patches/callables.rb +209 -0
  22. data/lib/ractor_rails_shim/patches/class_attribute.rb +51 -101
  23. data/lib/ractor_rails_shim/patches/core.rb +160 -705
  24. data/lib/ractor_rails_shim/patches/devise.rb +11 -6
  25. data/lib/ractor_rails_shim/patches/execution_wrapper.rb +3 -3
  26. data/lib/ractor_rails_shim/patches/hash_compute_if_absent.rb +98 -0
  27. data/lib/ractor_rails_shim/patches/i18n.rb +455 -0
  28. data/lib/ractor_rails_shim/patches/kaminari.rb +6 -11
  29. data/lib/ractor_rails_shim/patches/make_shareable.rb +21 -907
  30. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +49 -33
  31. data/lib/ractor_rails_shim/patches/orm_adapter.rb +3 -3
  32. data/lib/ractor_rails_shim/patches/propshaft.rb +4 -4
  33. data/lib/ractor_rails_shim/patches/rack.rb +15 -15
  34. data/lib/ractor_rails_shim/patches/rails_module.rb +24 -24
  35. data/lib/ractor_rails_shim/patches/route_helpers.rb +4 -4
  36. data/lib/ractor_rails_shim/patches/url_helpers.rb +2 -2
  37. data/lib/ractor_rails_shim/patches/warden.rb +2 -2
  38. data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +4 -4
  39. data/lib/ractor_rails_shim/patches.rb +4 -64
  40. data/lib/ractor_rails_shim/roles/app_shareabilizer.rb +207 -0
  41. data/lib/ractor_rails_shim/roles/ar_model_walker.rb +93 -0
  42. data/lib/ractor_rails_shim/roles/callback_capture.rb +226 -0
  43. data/lib/ractor_rails_shim/roles/check.rb +210 -0
  44. data/lib/ractor_rails_shim/roles/constant_shareabilizer.rb +217 -0
  45. data/lib/ractor_rails_shim/roles/fallback_builder.rb +333 -0
  46. data/lib/ractor_rails_shim/roles/fallback_ies.rb +25 -0
  47. data/lib/ractor_rails_shim/roles/freezers.rb +341 -0
  48. data/lib/ractor_rails_shim/roles/install_strategy.rb +68 -0
  49. data/lib/ractor_rails_shim/roles/installer.rb +89 -0
  50. data/lib/ractor_rails_shim/roles/lifecycle.rb +39 -0
  51. data/lib/ractor_rails_shim/roles/logger_io_neutralizer.rb +146 -0
  52. data/lib/ractor_rails_shim/roles/pre_spawn_steps.rb +36 -0
  53. data/lib/ractor_rails_shim/roles/shareability_traversal.rb +488 -0
  54. data/lib/ractor_rails_shim/roles/worker_app.rb +77 -0
  55. data/lib/ractor_rails_shim/roles/worker_app_factory.rb +124 -0
  56. data/lib/ractor_rails_shim/version.rb +1 -1
  57. data/lib/ractor_rails_shim.rb +1 -2
  58. metadata +33 -6
  59. data/lib/ractor_rails_shim/check.rb +0 -200
  60. data/lib/ractor_rails_shim/fallback_ies.rb +0 -33
  61. /data/lib/ractor_rails_shim/{version_check.rb → foundation/version_check.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a4b7ce968f90326f2b476d713b7fab6c524f8f7b111f60a2ae582d1e162de88
4
- data.tar.gz: c144478be527ff98804db48add54d67edb4124514fba3670b970990ef09e36c0
3
+ metadata.gz: 6ebcbda319623aa7003caf170774074e19fdf2d6452ed086fbc5edbfec037fa5
4
+ data.tar.gz: 7ab60400652444ccbc5ca39ac403d387c156cc8a6178700f10c9d744db9c3fad
5
5
  SHA512:
6
- metadata.gz: 55cc0fb00ebd523182cb66f8ae7a9f270bf33bcaaf928af79d291edf3eeb0248246f956e082200ab74563c7afceee8520336f53373ed9d54ff73f4db9ce05f40
7
- data.tar.gz: b19280d1985a1e8b456a6a13750e277a49c5123998a4f7fab587518d3d6a717249d9490cfbe6d32751483d9c5e622373d861fbed9ce50d8daeabb09578b897e9
6
+ metadata.gz: 69433fe039a2e2bea0302e13117a0ba701b786eaaec6e2e636d1d4786d74428e11701db2c703bc6fdb10c698febb6d5bce646adba24011513802d76de237349f
7
+ data.tar.gz: 615e99053521f65e7aa013d8a5c23e97991bd8b3f8593a92c9c2cf57e2ab245796bde664731ac943fe6b52681a8416c4bddac9635a7c5be6b2c3cc6339ecbdd2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,163 @@ 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
+
10
167
  ## [0.2.6]
11
168
 
12
169
  ### Fixed
data/README.md CHANGED
@@ -64,49 +64,123 @@ kino `:ractor` vs Puma vs Falcon are documented in
64
64
  ## Repository layout
65
65
 
66
66
  ```
67
- lib/ractor_rails_shim.rb # entry point — autoload-install if Rails is loaded
67
+ lib/ractor_rails_shim.rb # entry point — autoload-install if Rails is loaded
68
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)
69
+ version.rb # VERSION constant (currently 0.3.0)
70
+ loader.rb # pure require hub one file, one job (POODR §1)
71
+ patches.rb # backward-compat redirect loader.rb
72
+
73
+ foundation/ # Layer 2: no internal deps
74
+ funnel.rb # debug-aware exception-swallowing role
75
+ registry.rb # canonical home for the nine shared registries
76
+ storage.rb # pluggable key-value store contract (IES / ThreadLocal)
77
+ storage_strategy.rb # composed Ractor/Thread strategy for class_attribute
78
+ ies_accessor.rb # 3-tier IES lookup pattern generator
79
+ const_reassign.rb # $VERBOSE-suppressed const_set utility
80
+ version_check.rb # Gem::Version-based Ruby/Rails detection
81
+ version_policy.rb # mismatch policy (:warn/:strict/:off) + patch registry
82
+ run_mode.rb # thread vs Ractor mode decision
83
+ role_defaults.rb # DRY mixin for shared default-proc patterns
84
+
85
+ roles/ # Layer 3: depend on foundation
86
+ lifecycle.rb # prepare_for_ractors! orchestration
87
+ installer.rb # install orchestrator + framework-patch dispatcher
88
+ install_strategy.rb # per-mode install bodies (Ractor / Thread)
89
+ pre_spawn_steps.rb # shared orchestration steps
90
+ ar_model_walker.rb # ActiveRecord model enumeration
91
+ constant_shareabilizer.rb # constant shareability
92
+ shareability_traversal.rb # app-graph traversal
93
+ callback_capture.rb # callback-declaration capture
94
+ fallback_builder.rb # shareable-fallback builder
95
+ worker_app.rb # shareable Rack wrapper (per-worker init)
96
+ worker_app_factory.rb # shareable-Rack-app factory
97
+ app_shareabilizer.rb # make_app_shareable! orchestrator
98
+ freezers.rb # freeze/warm sub-domain (6 freezer roles)
99
+ logger_io_neutralizer.rb # logger IO detachment
100
+ fallback_ies.rb # backward-compat alias Storage::ThreadLocal
101
+ check.rb # ractor-rails-check audit (Check.scan / report)
102
+
103
+ patches/ # Layer 4: monkey-patches (depend on roles + foundation)
104
+ core.rb # module skeleton, registries, facade delegations
105
+ callables.rb # NoOpProc, Callable, CallableConst, RequestCallable, …
106
+ make_shareable.rb # make_app_shareable!, proc/lock replacement
107
+ rails_module.rb # Rails.application / Rails.cache / Rails.logger / Rails.env …
108
+ mattr_accessor.rb # Module#mattr_accessor / cattr_accessor macro rewrite
109
+ class_attribute.rb # ActiveSupport class_attribute macro rewrite
110
+ zeitwerk_registry.rb # Zeitwerk::Registry class ivars
111
+ route_helpers.rb # ActionDispatch::Routing::RouteSet#generate_url_helpers
112
+ url_helpers.rb # URL helper singleton + module fixes
113
+ execution_wrapper.rb # ActiveSupport::ExecutionWrapper + callback replay
114
+ rack.rb # Rack::Request / Rack::Utils
115
+ action_view.rb # PathRegistry, LookupContext, Template handlers
116
+ action_controller.rb # AbstractController, ActionController name/encoding
117
+ action_dispatch.rb # ActionDispatch routing, http_url, journey
118
+ polymorphic_routes.rb # polymorphic_path(s) URL helpers
119
+ active_support.rb # Inflector, error_reporter, ExecutionContext, I18n, JSON
120
+ i18n.rb # I18n locale/fallback patches
121
+ warden.rb # Warden hooks / strategies / serializer
122
+ devise.rb # Devise url_helpers / authenticatable / failure_app
123
+ active_model_attribute.rb # ActiveModel::Attribute dup_or_share for frozen graphs
124
+ active_record_model_schema.rb # AR ModelSchema reload_schema_from_cache
125
+ activerecord.rb # AR connection handler, configurations, query caches
126
+ kaminari.rb # Kaminari config
127
+ propshaft.rb # Propshaft asset server
128
+ orm_adapter.rb # orm_adapter
129
+ openssl.rb # OpenSSL digest cache
130
+ rubygems.rb # Rubygems msgpack pre-check
131
+ hash_compute_if_absent.rb # Hash#compute_if_absent for Concurrent::Map replacement
132
+
133
+ exe/ractor-rails-check # CLI audit tool
134
+ spec/ # 724 unit tests (no Rails dep) + integration spec
135
+ script/make_test_app.sh # build the minimal Rails 8.1 test app (CI uses this)
136
+ script/make_full_test_app.sh # build the full-featured test app (Devise/PG/Kaminari)
137
+ .github/workflows/ci.yml # unit job + integration job (GET /up → 200 in a worker Ractor)
105
138
  ```
106
139
 
140
+ ### Architecture: layered, POODR-aligned design
141
+
142
+ The directory structure mirrors the dependency layers:
143
+
144
+ ```
145
+ Layer 1: Entry ractor_rails_shim.rb, loader.rb
146
+ Layer 2: Foundation foundation/ (no internal deps)
147
+ Layer 3: Roles roles/ (depend on foundation)
148
+ Layer 4: Patches patches/ (depend on roles + foundation)
149
+ ```
150
+
151
+ The gem follows **Practical Object-Oriented Design in Ruby** (POODR) principles:
152
+
153
+ - **SRP (§1):** Every file defines exactly one concern. `loader.rb` is a pure
154
+ require hub (no logic). `lifecycle.rb` owns `prepare_for_ractors!` orchestration.
155
+ Role objects (`Funnel`, `Registry`, `CallbackCapture`, etc.) each own one
156
+ responsibility.
157
+
158
+ - **Dependencies (§2):** Role objects receive collaborators via `configure` seams
159
+ (keyword arguments), defaulting to facade lookups. No role reaches past its
160
+ own namespace by name — the composition root (`Installer`) is the one place
161
+ allowed to resolve by name.
162
+
163
+ - **Interfaces (§3):** The `Storage` contract (`[]`, `[]=`, `key?`, `delete`)
164
+ is implemented by `Storage::IES` and `Storage::ThreadLocal`. The
165
+ `StorageStrategy` contract (`lookup`, `store`, `replay_callbacks?`,
166
+ `replay_callbacks!`) is implemented by `StorageStrategy::Ractor` and
167
+ `StorageStrategy::Thread`.
168
+
169
+ - **Duck Typing (§4):** `ShareabilityTraversal` uses a `CONTAINER_WALKERS`
170
+ dispatch table (Hash → lambda) instead of `is_a?` chains. Lock detection
171
+ uses `respond_to?(:synchronize)` instead of `is_a?(Mutex)`.
172
+
173
+ - **Composition (§5):** `AppShareabilizer` composes 15 collaborators via the
174
+ configure seam. `FallbackBuilder` uses a `ValueLookup` chain of responsibility.
175
+ `Installer` selects between `InstallStrategy::Ractor` and `InstallStrategy::Thread`.
176
+
177
+ - **Roles (§6):** Modules as roles (`Funnel`, `ARModelWalker`,
178
+ `ConstantShareabilizer`, `CallbackCapture`, etc.) — each `extend RoleDefaults`
179
+ for DRY default-proc patterns.
180
+
107
181
  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
182
+ their `_install_*` methods. `loader.rb` requires them in dependency order
183
+ (`core.rb` first — it defines the module skeleton + constants that others
110
184
  reference). Each `_install_*` method is idempotent (guarded by its own
111
185
  `@*_patched` flag), so `install`, `prepare_for_ractors!`, and
112
186
  `make_app_shareable!` can all call the full set safely.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ConstReassign (Issue #46 Step 46.2): the $VERBOSE-suppressed const_set
4
+ # utility extracted from the facade's _reassign_shareable_const.
5
+ #
6
+ # The facade's _reassign_shareable_const calls ConstReassign.call, then
7
+ # updates Registry for the four known registries (SHAREABLE_FALLBACK,
8
+ # SHAREABLE_MATTR_DEFAULTS, ABSTRACT_REGISTRY, VIEW_CONTEXT_REGISTRY).
9
+ # Role objects (CallbackCapture, FallbackBuilder, AppShareabilizer) inject
10
+ # ConstReassign.method(:call) directly via the configure seam.
11
+
12
+ module RactorRailsShim
13
+ module ConstReassign
14
+ # Set a constant on target_module with $VERBOSE suppressed.
15
+ # Returns the new value.
16
+ def self.call(target_module, name, value)
17
+ verbose, $VERBOSE = $VERBOSE, nil
18
+ begin
19
+ target_module.const_set(name, value)
20
+ ensure
21
+ $VERBOSE = verbose
22
+ end
23
+ value
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Funnel: the debug-aware exception-swallowing role extracted from the
4
+ # RactorRailsShim god module (Issue #31, step 31.1d; POODR §1 SRP).
5
+ #
6
+ # Owns the `swallow(label) { block }` contract: run the block, rescue
7
+ # StandardError, emit a labeled $stderr line when `debug?` is true,
8
+ # return nil on failure. Used by freeze/shareability paths where
9
+ # individual failures are expected (some ivars hold intrinsically
10
+ # unshareable values like Procs) but a worker crash on the same value
11
+ # later has no visible cause.
12
+ #
13
+ # `debug?` / `debug=` are the configuration seam. The facade
14
+ # `RactorRailsShim.debug?` / `debug=` delegate here so existing specs
15
+ # keep passing; role objects (ARModelWalker, LoggerIONeutralizer) reach
16
+ # the funnel via `configure(funnel:)`, defaulting to `Funnel.method(:swallow)`.
17
+
18
+ module RactorRailsShim
19
+ module Funnel
20
+ @debug = nil
21
+
22
+ class << self
23
+ # When true, swallowed exceptions are reported to $stderr so a worker
24
+ # Ractor that later crashes on an unshareable value has a traceable
25
+ # cause. Default false (silent, the historical behaviour).
26
+ def debug?
27
+ defined?(@debug) ? @debug : false
28
+ end
29
+
30
+ def debug=(value)
31
+ @debug = value
32
+ end
33
+
34
+ # Swallow an exception raised by the block, optionally logging it
35
+ # when `debug?` is on. `label` identifies the call site (e.g.
36
+ # "freeze AR ivar Post@column_defaults"). Keep the label short —
37
+ # it's only for grepping. Returns the block's value on success,
38
+ # nil on rescued StandardError.
39
+ def swallow(label)
40
+ yield
41
+ rescue StandardError => e
42
+ warn "[ractor_rails_shim] #{label}: #{e.class}: #{e.message[0, 120]}" if debug?
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # IESAccessor (Issue #45): generates reader/writer methods via the 3-tier
4
+ # IES lookup pattern. Extracts the repeated accessor boilerplate from
5
+ # patches/active_support.rb (20+ accessor patches that all follow the
6
+ # same shape).
7
+ #
8
+ # The generated methods use StorageStrategy for the actual lookup/store,
9
+ # so they inherit both Ractor-mode and Thread-mode behavior without
10
+ # duplicating the lookup logic.
11
+ #
12
+ # Usage:
13
+ # RactorRailsShim::IESAccessor.define_accessor(
14
+ # ActiveSupport::Inflector::Inflections,
15
+ # :instance,
16
+ # :ractor_rails_shim_inflections_en
17
+ # )
18
+ #
19
+ # This generates a reader (`instance`) and writer (`instance=`) on the
20
+ # target class that do the 3-tier lookup:
21
+ # 1. IES (per-Ractor IsolatedExecutionState)
22
+ # 2. SHAREABLE_FALLBACK (frozen config captured at prepare time)
23
+ # 3. main-Ractor default (CLASS_ATTR_VALUES or nil)
24
+
25
+ module RactorRailsShim
26
+ module IESAccessor
27
+ # Generate a reader method on `target` that does the 3-tier lookup.
28
+ # `target` is a Class or Module. `attr_name` is the method name (Symbol).
29
+ # `ies_key` is the storage key (Symbol).
30
+ def self.define_reader(target, attr_name, ies_key)
31
+ key_str = ies_key.inspect
32
+ target.class_eval <<-RUBY, __FILE__, __LINE__ + 1
33
+ def #{attr_name}
34
+ strategy = RactorRailsShim.storage_strategy
35
+ strategy.lookup(self, #{key_str}, nil)
36
+ end
37
+ RUBY
38
+ end
39
+
40
+ # Generate a writer method on `target` that stores via storage_strategy.
41
+ def self.define_writer(target, attr_name, ies_key)
42
+ key_str = ies_key.inspect
43
+ target.class_eval <<-RUBY, __FILE__, __LINE__ + 1
44
+ def #{attr_name}=(val)
45
+ strategy = RactorRailsShim.storage_strategy
46
+ strategy.store(self, #{key_str}, val)
47
+ val
48
+ end
49
+ RUBY
50
+ end
51
+
52
+ # Generate both reader and writer on `target`.
53
+ def self.define_accessor(target, attr_name, ies_key)
54
+ define_reader(target, attr_name, ies_key)
55
+ define_writer(target, attr_name, ies_key)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Registry: the canonical home for the shared registries that every role
4
+ # object reads/writes (Issue #25, POODR §1 SRP + §2 Dependencies).
5
+ #
6
+ # Issue #35 (Round 4): Registry OWNS the nine registries as instance
7
+ # variables. The facade constants in patches/core.rb are aliases that
8
+ # delegate to Registry. Reassignment of the frozen (shareable) registries
9
+ # goes through Registry.reassign_* so the facade alias tracks the new
10
+ # value without a $VERBOSE-suppressed const_set on the facade.
11
+ #
12
+ # Types:
13
+ # - Mutable Array: class_attributes, shareable_constants, shareable_class_ivars
14
+ # - Mutable Hash: mattr_defaults, class_attr_values, shareable_mattr_defaults
15
+ # - Frozen (Ractor.make_shareable): abstract_registry, view_context_registry,
16
+ # shareable_fallback
17
+ #
18
+ # The frozen registries start as empty shareable Hashes and are swapped
19
+ # (via reassign_*) at prepare_for_ractors! / make_app_shareable! time with
20
+ # the populated, frozen, shareable versions. The mutable registries are
21
+ # appended at boot (main Ractor) and read at request time (worker Ractors).
22
+
23
+ module RactorRailsShim
24
+ module Registry
25
+ @class_attributes = []
26
+ @mattr_defaults = {}
27
+ @class_attr_values = {}
28
+ @shareable_mattr_defaults = {}
29
+ @shareable_constants = []
30
+ @shareable_class_ivars = []
31
+ @abstract_registry = Ractor.make_shareable({})
32
+ @view_context_registry = Ractor.make_shareable({})
33
+ @shareable_fallback = Ractor.make_shareable({})
34
+
35
+ class << self
36
+ attr_reader :class_attributes, :mattr_defaults, :class_attr_values,
37
+ :shareable_mattr_defaults, :shareable_constants,
38
+ :shareable_class_ivars, :abstract_registry,
39
+ :view_context_registry, :shareable_fallback
40
+
41
+ # Swap the frozen (shareable) registries. The value MUST already be
42
+ # frozen + Ractor.make_shareable. Each reassign_* updates the
43
+ # instance variable; the facade alias (defined in patches/core.rb)
44
+ # re-reads it on every access, so the alias tracks the new value
45
+ # without a const_set on the facade singleton.
46
+ def reassign_shareable_fallback(value)
47
+ @shareable_fallback = value
48
+ end
49
+
50
+ def reassign_shareable_mattr_defaults(value)
51
+ @shareable_mattr_defaults = value
52
+ end
53
+
54
+ def reassign_abstract_registry(value)
55
+ @abstract_registry = value
56
+ end
57
+
58
+ def reassign_view_context_registry(value)
59
+ @view_context_registry = value
60
+ end
61
+
62
+ # Test seam: reset all nine registries to their boot defaults. Used
63
+ # by specs that mutate the registries and need a clean slate.
64
+ def reset_all
65
+ @class_attributes = []
66
+ @mattr_defaults = {}
67
+ @class_attr_values = {}
68
+ @shareable_mattr_defaults = {}
69
+ @shareable_constants = []
70
+ @shareable_class_ivars = []
71
+ @abstract_registry = Ractor.make_shareable({})
72
+ @view_context_registry = Ractor.make_shareable({})
73
+ @shareable_fallback = Ractor.make_shareable({})
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RoleDefaults — DRY mixin for the three shared default-proc patterns
4
+ # used by every role object with a configure seam (Issue #44, POODR §8e).
5
+ #
6
+ # Every role with a `funnel` seam copies:
7
+ # @funnel || RactorRailsShim::Funnel.method(:swallow)
8
+ # Every role with a `safe_const_get` seam copies:
9
+ # @safe_const_get || RactorRailsShim::ConstantShareabilizer.method(:safe_const_get)
10
+ # Every role with a `reassign_shareable_const` seam copies:
11
+ # @reassign_shareable_const || RactorRailsShim.method(:_reassign_shareable_const)
12
+ #
13
+ # `RoleDefaults` centralises the three defaults; each role `extend`s it
14
+ # and its `funnel` / `safe_const_get` / `reassign_shareable_const` reader
15
+ # calls `default_funnel` etc. The boilerplate collapses to one definition.
16
+ #
17
+ # Usage in a role module:
18
+ #
19
+ # module MyRole
20
+ # extend RoleDefaults
21
+ #
22
+ # @funnel = nil
23
+ # @safe_const_get = nil
24
+ #
25
+ # def self.configure(funnel: nil, safe_const_get: nil)
26
+ # @funnel = funnel
27
+ # @safe_const_get = safe_const_get
28
+ # end
29
+ #
30
+ # def self.reset_configuration
31
+ # @funnel = nil
32
+ # @safe_const_get = nil
33
+ # end
34
+ #
35
+ # def self.funnel
36
+ # @funnel || default_funnel
37
+ # end
38
+ #
39
+ # def self.safe_const_get
40
+ # @safe_const_get || default_safe_const_get
41
+ # end
42
+ # end
43
+ #
44
+ module RactorRailsShim
45
+ module RoleDefaults
46
+ def default_funnel
47
+ RactorRailsShim::Funnel.method(:swallow)
48
+ end
49
+
50
+ def default_safe_const_get
51
+ RactorRailsShim::ConstantShareabilizer.method(:safe_const_get)
52
+ end
53
+
54
+ def default_reassign_shareable_const
55
+ RactorRailsShim.method(:_reassign_shareable_const)
56
+ end
57
+ end
58
+ end