feat(ractorize): the ractorize job, proven by a report-only lane on Rails main
Summary
Closes #22. conformance-kit check --ractorize boots a gem's test app the way Rails 8.2 runs an app across Ractors. The reusable workflow gains a report-only Ractorize job. The kit proves the check against fixture apps in a report-only "Ractorize lane" on Rails main: option (a) from the issue discussion.
The job. For ractorize.app it:
- runs
ractorize.setupin the app (withractor.env,RAILS_ENV=production); - in a new process, sets
ActiveSupport::Ractors.unshareable_proc_action = :raiseand boots in production; - calls
Rails.application.ractorize!andrequire "i18n/ractorize"; - serves each
ractorize.requestspath insideRactor.new; - audits
ractorize.audit_namespaces(defaultaudit.namespace) in the same process.
A failed request names the root cause and its first frame, also when the app's error page swallowed the exception (env["action_dispatch.exception"]). Below Ruby 4.0, or on a Rails without ractorize!, it skips with the reason and exits 0.
What the lane proves (Ruby 4.0.7, Rails main 0d022e0, Rack main a9833c8, Shopify/i18n#5 1873db3):
violating_boot (a route block appended from an initializer): exit 1
ractorize: boot failed: Ractor::IsolationError: Proc's self is not shareable: #<Proc:0x… spec/fixtures/ractorize/violating_boot/lib/violating_boot_engine.rb:8>
violating_render (a shallow-frozen class ivar read at render): exit 1
GET /widget -> 500 (0 bytes): Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules from non-main Ractors (@registry from ViolatingRenderEngine::Widget)
at …/violating_render/app/controllers/widgets_controller.rb:5:in 'WidgetsController#show'
ractorize: audit ViolatingRenderEngine after ractorize!: 1 findings
ViolatingRenderEngine::Widget.@registry (class ivar) Hash: [:names] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
conforming: exit 0
ractorize: Rails.application.ractorize!: ok
GET /widget -> 200 (10 bytes)
ractorize: audit ConformingEngine after ractorize!: 0 findings
ractorize passed
Changes
.conformance.yml: new keysractorize.app,ractorize.requests,ractorize.audit_namespacesandractorize.setup. They are validated like the rest: an unknown key exits 2, and so does--ractorizewithout the app or itsconfig/environment.rb.config/ractorize.gemfile: the repo's own Gemfile with Rails main, Rack main and Shopify/i18n#5 swapped in. It drops the repo's lines for those gems and Rails' component gems, since Bundler refuses a gem declared twice. Consumers copy it toGemfile.ractorize; the lane uses the same file.conformance.yml: aractorizejob, only whenractorize.appis set,continue-on-erroruntil Rails 8.2. It changes no existing job or required check name.main.yml: the report-only Ractorize lane, which runsrspec --tag ractorizeon Ruby 4.0 with that gemfile.- README: "The ractorize job" covers what the app needs (production settings, schema cache, assets,
main_onlyfor Rails::Engine state), the bundle, and that the job is report-only. CHANGELOG under the unreleased 0.5.0.
Test plan
- TDD: specs for the config keys,
check --ractorize(exit 2 cases, setup stage, skip), the runner's skip gates, the request layer (serve inside a Ractor, root cause, swallowed exception), the gemfile override, and both workflows. Each was watched failing first. The gemfile spec also fails if the override is removed (Bundler: "You cannot specify the same gem twice"). - Fixture specs in the lane: two violating apps fail, naming file and line, and the conforming app passes (3 examples, 0 failures).
-
bundle exec rakeon Ruby 3.4.2: 243 examples, 0 failures; RuboCop clean. -
bundle exec rspecon 4.0.7 (243, 0 failures) and 3.2.8 (242, 0 failures). The lane's full suite: 243, 0 failures. - The kit's strict self-audit stays clean with the runner in
audit.require. - Released Rails 8.1.4 on Ruby 4.0.7:
check --ractorizeskips ("ActiveSupport::Ractors is unavailable"), exit 0. - The Ractorize lane on this PR's CI (first live run).
- The consumer job: first live run when phlex-reactive sets
ractorize.app(the issue's last criterion, a follow-up PR there).
fable-validator: PASS WITH NOTES. I fixed three notes in the second commit: the skip ordering, the KeyError in the consumer step, and the README frame.
Accepted risks:
- A 4xx passes. A typo'd request path prints
404and never exercises the render path; an auth redirect is a legitimate answer. refs/pull/5/headmoves with that PR and there is no committed lock, so both lanes re-resolve every run. Expected while report-only.- The consumer job runs on
ractor.ruby. A repo on 3.4 bundles Rails main and then skips. "500 (0 bytes)"in the render fixture spec depends on Rails' default error body (lane only).
Not verified:
- Both Actions jobs live. The lane runs on this PR; the consumer job runs once a consumer adopts it.
- The README's Active Record advice (
db:schema:cache:dump,check_schema_cache_dump_version = false): no Active Record fixture here. It comes from the issue and Rails'ractors_test.rb, not from a run in this PR.
Deviations & judgment calls
- Judgment: the Gemfile overrides ship as a static
config/ractorize.gemfile(copied toGemfile.ractorize) instead of a generated file; it drops the repo's own lines for rails/rack/i18n and Rails' component gems (Bundler refuses a gem declared twice), then declares the branches. The kit's lane uses the same file, so the consumer path is the one exercised. - Judgment:
ractorize.audit_namespacesdefaults toaudit.namespace. - Judgment: added
ractorize.setup(a command run in the app directory with RAILS_ENV=production before boot: schema load, schema cache dump, asset precompile);ractor.envapplies to the ractorize stages too, so one env map per repo. - Discovery: Ruby 4.0's own message for an unshareable block names its file:line (
Proc's self is not shareable: #<Proc:... initializer.rb:3>), so the root cause alone names the culprit for boot failures. - Discovery: the request env can be built on the main Ractor and its
rack.input/rack.errorsStringIOs created inside the request Ractor, so the runner needs neitherRack::MockRequestnor rack#2523. - Deviation: i18n is pinned to Shopify/i18n#5's head (
ref: "refs/pull/5/head"on Shopify's repo), not theractor_supportbranch. Discovery behind it: ActiveSupport always requiresi18n/backend/fallbacks, soActionView::LookupContextreadsI18n.fallbacks(a class variable on the branch) on every render, and every request in a Ractor fails on it. On the branch the job would fail every consumer whatever their gem does. Switch back tobranch: "ractor_support"when #5 merges. - Discovery:
config.enable_reloadingdefaults to true outside a generated production.rb, and the i18n railtie then registers a reloader that can't be made shareable: boot fails under :raise. Fixture apps set it to false; the README lists it. - Discovery: with the app's default
show_exceptions, an exception inside the Ractor comes back as a 500 with an empty body;ShowExceptionskeeps it inenv["action_dispatch.exception"], so the runner reports it from there instead of changing the app's config. - Judgment: a request fails on an exception or a 5xx only; 4xx and redirects are reported with their status but pass (an auth redirect is a legitimate answer).
- Judgment: the fixture apps'
.conformance.ymlallowlist Rails::Engine's own@called_from/@initializerswith anancestor:rule, as a real engine's would; the audit otherwise reports them on every engine. - Judgment: the violating fixture is two apps (boot and render), since the route-block violation stops the boot before any request runs.
- Discovery:
attr_readeris a C method, so a render violation's first frame is the line that calls the reader (the controller), not theattr_readerline.
https://claude.ai/code/session_01JCaQrTh5yGHrueUmq69PKs
Summary by cubic
Adds conformance-kit check --ractorize, which boots a gem's test app the way Rails 8.2 runs an app across Ractors: production boot with ActiveSupport::Ractors.unshareable_proc_action = :raise, then Rails.application.ractorize!, each ractorize.requests path served inside a non-main Ractor, and the audit run in the same process. The reusable workflow gains a report-only Ractorize job, and the kit is proven against fixture apps on Rails main by a report-only "Ractorize lane" in this repo's CI.
New Features
check --ractorizerunsractorize.setup, then the boot/request/audit runner in its own process; it skips with the reason below Ruby 4.0 or on a Rails withoutractorize!, exiting 0.- A failing request or 5xx names the root cause and its first frame, including an exception the app's error page swallowed.
- New
.conformance.ymlkeysractorize.app,.requests,.audit_namespacesand.setup, validated like the rest: an unknown key exits 2, and so does--ractorizewithout an app or itsconfig/environment.rb. config/ractorize.gemfileswaps Rails main, Rack main and Shopify/i18n#5 into a repo's Gemfile, dropping the repo's own lines for them and for Rails' component gems because Bundler refuses a duplicate.
Report-only rollout (until Rails 8.2)
conformance.ymlgains a Ractorize job only whenractorize.appis set,continue-on-errorand not a required check, so no existing job changes.main.ymlgains the Ractorize lane that runs the fixture specs on Ruby 4.0 with that gemfile: a boot violation and a render violation must fail naming file and line, and a conforming engine must pass.