Concurrency conformance for Ruby gems. Built for the zoolutions gems and usable by any gem: audit a gem without adding anything to it, copy the checks into your own tests, or adopt the kit. A gem that adopts it is held to explicit tiers, each with a mechanical gate:
| Tier | Meaning | Gate |
|---|---|---|
| 1 thread-safe | no unguarded shared mutable state on the request/broadcast path | config/rubocop.yml (rubocop-thread_safety) + "thread-safe under contention" |
| 2 fiber-safe | correct under a fiber scheduler (Falcon/Async): request state fiber-local, no Thread.main[] misuse |
"fiber-safe under an Async reactor" |
| 3 Ractor-clean | the gem adds no Ractor blocker beyond its dependencies': config shareable after boot, per-Ractor caches, no locks in constants | "a boot-finalized configuration", conformance-audit --strict, the Ruby 4.0 "runs inside a non-main Ractor" canary (conformance.yml) |
| 4 compiler-probed | Spinel/Roundhouse readiness is measured, never gated | the probes job of conformance.yml (report-only) |
Background, the finalize-at-boot pattern and the settled decisions: zoolutions/phlex-reactive#363.
No Gemfile change and no .conformance.yml: install the kit, then name the gem to load and the namespace to audit.
gem install conformance-kit
conformance-audit --require loofah --namespace Loofahconformance-audit: 42 findings in Loofah (0 allowlisted main-only; 38 modules audited, 0 unloaded autoloads skipped)
Loofah::Elements::BLOCK_LEVEL (constant) Set: a Set that is not frozen: freeze it at boot (Ractor.make_shareable)
...
Loofah::Scrubbers::MAP (constant) Hash: a Hash that is not frozen: freeze it at boot (Ractor.make_shareable)
report only: --strict fails on these
That is loofah 2.25.2 on Ruby 4.0.7: every finding is state a non-main Ractor cannot read. --require takes a gem or feature name (required from the load path, so any installed gem) or a file path, and repeats (--require rack --require rack/mock_request). Without --strict it only reports. What it can and can't see, and its exit codes: conformance-audit.
When a new development dependency is not an option (a framework, a gem that keeps its bundle small), the two checks that matter most fit in the gem's own tests. This is the shape of the case to attach to a compatibility pull request: it fails before the fix, passes after it, and fails again on the next constant that stops being shareable.
# Minitest
require "minitest/autorun" # or your test_helper
class RactorConformanceTest < Minitest::Test
# State that is main-only on purpose, each with why.
MAIN_ONLY = { "MyGem::REGISTRY" => "apps add to it at boot" }.freeze
def setup
skip "needs Ruby 4.0 (Ractor#value)" unless defined?(Ractor) && Ractor.method_defined?(:value)
end
# The entry point runs inside a non-main Ractor and gives the main Ractor's answer.
def test_runs_inside_a_non_main_ractor
assert_equal MyGem.render("<p>hi</p>"), Ractor.new { MyGem.render("<p>hi</p>") }.value
end
# Every constant under MyGem is shareable, so the next mutable one fails here.
def test_constants_are_shareable
unshareable = constants_under(MyGem).reject { |name, value| MAIN_ONLY.key?(name) || Ractor.shareable?(value) }
assert_empty unshareable.map { |name, value| "#{name} (#{value.class})" }
end
private
def constants_under(mod)
mod.constants(false).reject { mod.autoload?(_1) }.flat_map do |name|
value = mod.const_get(name, false)
own = value.is_a?(Module) && value.name == "#{mod.name}::#{name}"
[["#{mod.name}::#{name}", value], *(own ? constants_under(value) : [])]
end
end
end# RSpec
RSpec.describe "MyGem in a Ractor" do
main_only = { "MyGem::REGISTRY" => "apps add to it at boot" }.freeze
before { skip "needs Ruby 4.0 (Ractor#value)" unless defined?(Ractor) && Ractor.method_defined?(:value) }
it "runs the entry point inside a non-main Ractor" do
expect(Ractor.new { MyGem.render("<p>hi</p>") }.value).to eq(MyGem.render("<p>hi</p>"))
end
it "keeps every constant under MyGem shareable" do
walk = lambda do |mod|
mod.constants(false).reject { mod.autoload?(_1) }.flat_map do |name|
value = mod.const_get(name, false)
own = value.is_a?(Module) && value.name == "#{mod.name}::#{name}"
[["#{mod.name}::#{name}", value], *(own ? walk.call(value) : [])]
end
end
unshareable = walk.call(MyGem).reject { |name, value| main_only.key?(name) || Ractor.shareable?(value) }
expect(unshareable.map { |name, value| "#{name} (#{value.class})" }).to be_empty
end
endA failure names the constant and its class, e.g. Expected ["MyGem::Html::ESCAPES (Hash)"] to be empty. The Ractor block must not use outer local variables: write its input inline, or reach it through a constant.
What these two leave out, and the kit covers:
- class and module instance variables (a
@cache ||= {}memo); - which value inside a constant is the problem: they name
MyGem::SETTINGS (Hash), the audit names[:hooks] is a Proc that is never shareable; - unloaded autoloads, which they skip without a word (the audit lists them);
- races between threads and fibers.
Neither sees a constant the gem defines under another namespace, such as an Active Job adapter in ActiveJob::QueueAdapters: walk that namespace too, or pass it to --namespace.
A development dependency only. Nothing here becomes a runtime dependency of your gem, and the kit adds none to your bundle: async, minitest, rubocop-thread_safety and rubocop_spinel are used only when you already have them. Ruby 3.2+; Ractor-only behaviour is gated on the running Ruby, never on a version floor.
# Gemfile
group :development, :test do
gem "conformance-kit", "~> 0.4", require: false
gem "rubocop-thread_safety", ">= 0.8", require: false # for config/rubocop.yml
gem "async", require: false # optional: runs the fiber example
end# spec/spec_helper.rb
require "conformance_kit/rspec"Each example calls subject_call, a callable for the operation under test:
RSpec.describe MyGem::Cache do
let(:subject_call) { -> { MyGem::Cache.for(:key) } }
it_behaves_like "thread-safe under contention", threads: 8, iterations: 50, consistent: :identical
it_behaves_like "fiber-safe under an Async reactor", fibers: 8, iterations: 50
end
RSpec.describe MyGem do
it_behaves_like "a boot-finalized configuration",
readers: [MyGem.method(:colors), MyGem.method(:adapters)],
finalize: -> { MyGem.finalize! }
end
RSpec.describe MyGem::Token do
let(:subject_call) { ->(payload) { MyGem::Token.verify(MyGem::Token.sign(payload)) } }
it_behaves_like "runs inside a non-main Ractor", inputs: [{ id: 1 }]
end| Example | Options | Fails when | Skipped when |
|---|---|---|---|
"thread-safe under contention" |
threads: (8), iterations: (50), consistent: |
a call raises, or the results disagree | — |
"fiber-safe under an Async reactor" |
fibers: (8), iterations: (50), consistent: |
a call raises inside an Async task, or the results disagree |
async is not loadable |
"a boot-finalized configuration" |
readers:, finalize: |
a reader returns a value that is not Ractor.shareable? (or raises) |
no Ractor (TruffleRuby, JRuby) |
"runs inside a non-main Ractor" (tagged :ractor) |
inputs: ([]) |
the block raises inside Ractor.new, captures outer variables, or returns something else than on the main Ractor |
below Ruby 4.0 (Ractor#value) |
consistent:is opt-in::identical(every result is the same object, e.g. a memoized cache),:equal(all==), or a callable that receives every result and returns truthy.readers:takesMethodobjects (reported asOwner.reader), a{ "name" => callable }Hash, or bare callables (reported by source location).- The Ractor block must be capture-free: pass values through
inputs:, reach everything else through constants. Before 4.0 a non-main Ractor cannot autoload, so load what it touches first.
Failures name the culprit, e.g.
a boot-finalized configuration: 1 of 2 readers returned a value that is not Ractor-shareable:
MyGem.colors returned Hash: [:primary] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
require "minitest/autorun" # the kit never loads Minitest itself
require "conformance_kit/minitest"
class CacheTest < Minitest::Test
include ConformanceKit::Minitest
def test_thread_safe = assert_thread_safe(threads: 8, iterations: 50, consistent: :identical) { MyGem::Cache.for(:key) }
def test_fiber_safe = assert_fiber_safe(fibers: 8) { MyGem::Cache.for(:key) }
def test_boot_finalized = assert_boot_finalized(readers: [MyGem.method(:colors)], finalize: -> { MyGem.finalize! })
def test_ractor = assert_runs_in_ractor(inputs: [2]) { |n| MyGem.double(n) }
endSame checks, same options and messages; skips use skip with the reason. Works on Minitest 5 and 6.
Per-Ractor lazy storage, the home for caches that must not live in a class ivar or a constant:
def self.cache = ConformanceKit::Store.fetch(:my_gem_cache) { {} }On Ruby 3.4+ this is Ractor.store_if_absent (atomic, one value per Ractor). Below 3.4 the main Ractor (and an engine without Ractor) uses one Mutex-guarded Hash, and a non-main Ractor keeps its own values in Ractor-local storage, unlocked: two threads of that one Ractor can race to build a value, as with ||=. Keys are Symbols; prefix them with your gem's name.
bundle exec conformance-audit --strict # audit.require + audit.namespace from .conformance.yml
bundle exec conformance-audit --require ./lib/my_gem --namespace MyGem --strict # flags override themRequires the files (an entry starting with ./, ../, ~ or / is a path; any other is required by name from the load path, as a gem, unless it names an existing file), walks every loaded module under the namespaces (--require and --namespace repeat; without them it reads audit.require and audit.namespace), and reports each public constant and class/module instance variable whose value is not Ractor.shareable?, with the path to the offending object and the fix:
conformance-audit: 2 findings in MyGem (1 allowlisted main-only; 14 modules audited, 0 unloaded autoloads skipped)
MyGem::LOCK (constant) Thread::Mutex: a Thread::Mutex that is main-only: keep per-Ractor state in ConformanceKit::Store, or allowlist it main_only
MyGem::Config.@colors (module ivar) Hash: [:primary] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
Limits, so a green run means what it says:
- Unloaded autoload constants are skipped and listed, never loaded mid-walk: require (or eager-load) everything the audit should see. The header counts the modules audited and the autoloads skipped. With
audit.strict_autoloads: true,--strictalso fails on every skipped autoload not listed inaudit.skip_autoloads, so a green run means every autoload not deliberately skipped was audited. private_constants are invisible to it (Ruby has no public way to enumerate them).config/rubocop.ymland the boot-finalized example are the gates for those.
| Exit | Meaning |
|---|---|
| 0 | no unallowlisted findings; or findings without --strict (report only); or no Ractor on this engine |
| 1 | unallowlisted findings under --strict (or, with audit.strict_autoloads, unlisted unloaded autoloads) |
| 2 | usage error, no namespace from either the flags or audit.namespace, a namespace that is not defined or not a module, a file that cannot be required, or an invalid .conformance.yml |
One file per repo says what it is held to. It is read from the working directory (or --config PATH; --allowlist PATH still works). audit.require and ractor.working_directory are relative to the file; ractor.setup, ractor.test_command, probes.paths and probes.roundhouse_app run in (or are relative to) ractor.working_directory. Every key is validated: an unknown key or a wrong type is an error (exit 2) naming the file and the key.
audit:
require: [./lib/my_gem, ./spec/support/eager_load.rb] # files to require (list)
namespace: [MyGem] # constants to audit (list)
strict_autoloads: true # optional, default false
skip_autoloads: # optional; each needs a reason
- name: MyGem::Optional
reason: only loads with pgbus present
ractor:
ruby: "4.0" # default "4.0" (quote it)
test_command: bundle exec rspec --tag ractor # default; Minitest gems set their own
env: # optional string map, applied before setup/test_command
PGBUS_DATABASE_URL: postgres://postgres:postgres@localhost:5432/pgbus_test
setup: bin/conformance-db-setup # optional command run once before test_command
working_directory: . # default "."
probes:
gem_name: my_gem # required for the probes job
paths: [lib] # default [lib]
roundhouse_app: spec/dummy # optional
ractorize: # optional: the report-only ractorize job (below)
app: spec/dummy # the Rails app to boot, relative to this file
requests: ["/", "/widgets/1"] # GET paths served inside a non-main Ractor
audit_namespaces: [MyGem] # default audit.namespace
setup: bin/rails db:prepare # optional, run in the app
main_only:
- name: MyGem::Registry.@lock # class/module ivar: Owner.@ivar
reason: guards boot-time registration; no non-main Ractor ever registers
- name: MyGem::LOCK # constant: Owner::NAME
reason: ...
- ancestor: Rails::Engine # framework state on the gem's own subclasses
ivars: ["@called_from", "@initializers", "@instance"] # only the framework's ivars
reason: Rails::Engine's own class state, set by Rails at boot on the main Ractor
- ancestor: ActionController::Base # without ivars: every ivar on its subclasses
reason: ActionController's own class state, set by Rails on the main Ractormain_only: is state that is deliberately main-only: every entry needs a reason:. Entries inside the audited namespaces that no longer turn up are listed as stale, and so are audit.skip_autoloads entries that are no longer unloaded autoloads.
An ancestor: entry covers the class state a framework keeps on the gem's own classes, which would otherwise take one name: entry per ivar per class (a Rails engine easily needs a hundred): every class/module ivar on a module that inherits from, includes or is extended with that constant. It never covers a constant. Prefer ivars:: it narrows the rule to the framework's own ivars, so state the gem keeps on the same classes is still audited. Without ivars:, that state is allowlisted too, so use the bare form only for classes the gem adds no state to. A name: entry for an ivar a rule also covers keeps its own reason. A rule that covered nothing, or whose constant isn't loaded (never loaded by the audit, even if it is a declared autoload), is listed as stale. BasicObject, Object, Kernel, Module and Class are rejected as ancestors (every module has them), and so is any unknown key in an entry, so a typo cannot widen a rule. audit.skip_autoloads takes name: entries only.
strict_autoloads: true needs audit.require to load everything, e.g. a file that calls your Zeitwerk loader's eager_load!. In a Rails engine, MyGem::Engine.eager_load! is a no-op (Rails 7+): call Zeitwerk::Loader.eager_load_namespace(MyGem) after booting the dummy app instead, and reference constants in autoload_once_paths explicitly.
# .rubocop.yml
inherit_gem:
conformance-kit: config/rubocop.ymlTurns on the six rubocop-thread_safety cops (ClassAndModuleAttributes, ClassInstanceVariable, MutableClassInstanceVariable with EnforcedStyle: literals, NewThread, DirChdir, RackMiddlewareInstanceVariable) plus LazySynchronizationPrimitive (which needs rubocop-thread_safety 0.8+). Exclude a file or line only with a comment saying why it is safe.
config/rubocop_spinel.yml runs only Spinel/Unsupported (rubocop_spinel). It is for the report-only probe, never your lint: it flags class << self.
Conformance runs as one reusable workflow in this repo, .github/workflows/conformance.yml. Any repo can call it, inside zoolutions or not. A repo opts in with
.conformance.yml(above),- the Gemfile lines (
conformance-kit ~> 0.4,rubocop-thread_safety >= 0.8), inherit_gem: { conformance-kit: config/rubocop.yml }in.rubocop.yml,- a caller, pinned to a release tag:
# .github/workflows/conformance.yml
name: Conformance
on:
pull_request:
merge_group:
permissions:
contents: read
jobs:
conformance:
uses: zoolutions/conformance-kit/.github/workflows/conformance.yml@v0.4.2To make it a merge requirement, add conformance / Conformance settings and conformance / Conformance (Ruby 4.0) to the required status checks in the repo's branch ruleset. Require both: if the settings job fails, the gate is skipped, and a skipped check does not block a merge. The prefix is the caller's job id (conformance above) and the Ruby is ractor.ruby, so the names change with either. Leave the probes out: they never fail. A repo or org that only allows selected actions must allow zoolutions/conformance-kit/.github/workflows/conformance.yml@*.
The workflow has four jobs:
- Conformance settings reads
ractor.ruby,ractor.working_directoryandractorize.app. - Conformance (Ruby
ractor.ruby), the gate: the version handshake, thenbundle exec conformance-kit check. Apostgres:18service (user and passwordpostgres, port 5432) is always up; the workflow exports nothing, soractor.envandractor.setupwire it. - Compiler probes (report-only):
rubocop_spinel(gem-installed in the probe, never in your Gemfile),spinel-compat probe <probes.gem_name> --dir .and, whenprobes.roundhouse_appis set,roundhouse check(the release tarball, checked against a pinned SHA-256). Every probe continues on error; the reports go to the run summary and aconformance-probesartifact. It never fails a build, and it skips with a notice whenprobes.gem_nameis unset. - Ractorize (report-only), only when
ractorize.appis set: the ractorize job on Rails main. It never fails the workflow; leave it out of the required checks.
Dependabot bumps both pins, the uses: tag (github-actions) and the gem (bundler), in one pull request through a multi-ecosystem group. It can still open one with a single pin when only one ecosystem has an update so far (the tag is out but RubyGems hasn't published the gem yet): the version handshake then fails that pull request until the other pin catches up, so check both before merging. The allow lists keep it to the kit; drop them to have Dependabot maintain everything else too.
# .github/dependabot.yml
version: 2
multi-ecosystem-groups:
conformance-kit:
schedule: { interval: weekly }
labels: ["dependencies", "devops"] # your repo's own type + area labels
updates:
- package-ecosystem: github-actions
directory: /
multi-ecosystem-group: conformance-kit
patterns: ["zoolutions/conformance-kit*"]
allow:
- dependency-name: "zoolutions/conformance-kit*"
- package-ecosystem: bundler
directory: /
multi-ecosystem-group: conformance-kit
patterns: ["conformance-kit"]
allow:
- dependency-name: conformance-kitbundle exec conformance-kit check # doctor, ractor.setup, ractor.test_command, then conformance-audit --strict
bundle exec conformance-kit check --ractorize # the ractorize job (below), with BUNDLE_GEMFILE=Gemfile.ractorize
bundle exec conformance-kit doctor # the static checks only
bundle exec conformance-kit config probes.gem_namecheck stops at the first failing stage, names it, and exits with its status. Run it before adding the caller: what passes here passes the workflow (on the same Ruby).
doctor checks that .conformance.yml exists, is valid and sets audit.namespace; that .rubocop.yml inherits conformance-kit: config/rubocop.yml; and that rubocop-thread_safety resolves at >= 0.8. Each problem names its fix. --expect X.Y also checks the resolved conformance-kit is at least X.Y.
config KEY prints a dotted key's value (a list joined with spaces, ractor.env as NAME=value lines, an unset key as an empty line); an unknown key exits 2.
| Exit | Meaning |
|---|---|
| 0 | ok |
| 1 | a check failed (check exits with the failing stage's status) |
| 2 | usage error or an invalid .conformance.yml |
Rails 8.2 can serve an app's requests from several Ractors: Rails.application.ractorize! makes the booted app shareable, and every gem on the request path has to cope. The Ruby 4.0 canary runs your gem's own code in a Ractor. This job runs it inside a real app that has been ractorized, where Rails' framework hooks, the app's boot order and other engines meet it.
# .conformance.yml
ractorize:
app: spec/dummy # the Rails app to boot (its config/environment.rb), relative to this file
requests: ["/", "/widgets/1"] # GET paths, each served inside a non-main Ractor after ractorize!
audit_namespaces: [MyGem] # audited after ractorize!; default audit.namespace
setup: bin/rails db:prepare db:schema:cache:dump assets:precompile # optionalconformance-kit check --ractorize runs it locally, and the workflow's Ractorize job runs the same when ractorize.app is set:
ractorize.setupruns in the app, withractor.env,RAILS_ENV=productionandSECRET_KEY_BASE_DUMMY=1.- A new process sets
ActiveSupport::Ractors.unshareable_proc_action = :raiseand boots the app in production. The test environment can't serve a ractorized request yet (rails/rails#59168). - It calls
Rails.application.ractorize!, thenrequire "i18n/ractorize". - Each request runs inside
Ractor.new. An exception fails it, and so does a 5xx; either way the report names the root cause and its first frame, including an exception the app's error page swallowed. - The audit runs on the namespaces in the same process, with
main_only:applied, so it sees what a request Ractor reads.
ractorize: boot: ok
ractorize: Rails.application.ractorize!: ok
GET /widget -> 500 (0 bytes): Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules from non-main Ractors (@registry from MyGem::Widget)
at /home/runner/work/my_gem/my_gem/spec/dummy/app/controllers/widgets_controller.rb:5:in 'WidgetsController#show'
ractorize: audit MyGem after ractorize!: 1 findings
MyGem::Widget.@registry (class ivar) Hash: [:names] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
ractorize failed: 2 failures (failed requests and audit findings)
A block Rails can't make shareable stops the boot, and Ruby's message names the block's file and line (Proc's self is not shareable: #<Proc:... lib/my_gem/engine.rb:8>). Below Ruby 4.0, or on a Rails without ractorize!, the job skips with the reason and exits 0.
What the app needs. Production settings that can be made shareable, as in Rails' own railties/test/application/ractors_test.rb:
config.enable_reloading = falseandconfig.eager_load = true(a generatedproduction.rbsets both);config.logger = ActiveSupport::Ractors::Logger.new;config.cache_store = :null_store,config.public_file_server.enabled = false,config.action_cable.mount_path = nilandconfig.active_job.queue_adapter = :inline.
With Active Record, it also needs a dumped schema cache (db:schema:cache:dump) and config.active_record.check_schema_cache_dump_version = false: without them, a model's first query inside a Ractor fails, and nothing warns. Precompile assets in setup. Framework state on your engine's classes is main-only, so list it once in main_only: (e.g. ancestor: Rails::Engine with ivars: ["@called_from", "@initializers"]).
The bundle. Until they ship, ractorize! needs:
- Rails main;
- Rack main, for
rack/ractorize; - Shopify's i18n branch, pinned to Shopify/i18n#5 until that merges: without it, every render fails on
I18n.fallbacks.
config/ractorize.gemfile swaps them into your Gemfile. It drops your own lines for those gems and Rails' component gems (Bundler refuses a gem declared twice) and keeps everything else. Copy it next to your Gemfile and bundle with it, as the workflow does; keep Gemfile.ractorize and its lock out of git.
cp "$(bundle exec ruby -e 'print Gem.loaded_specs["conformance-kit"].full_gem_path')/config/ractorize.gemfile" Gemfile.ractorize
BUNDLE_GEMFILE=Gemfile.ractorize bundle install
BUNDLE_GEMFILE=Gemfile.ractorize bundle exec conformance-kit check --ractorizeReport-only. Rails main moves under it, so the job is continue-on-error and never fails the workflow until Rails 8.2 ships ractorize!. This repo proves it the same way, with a "Ractorize lane" in its own CI: a failing and a passing fixture app on that bundle.
The behaviour a repo is held to comes from the conformance-kit in its lockfile; only the plumbing comes from the workflow tag. So that a stale lockfile cannot pass a newer workflow vacuously, the workflow reads its own tag from job.workflow_ref (the called workflow's ref; github.workflow_ref is the caller's) and fails with "bump conformance-kit to ~> X.Y" when the repo resolves anything older than X.Y. The version is derived, never stored. When the caller pins anything other than a vX.Y.Z tag (a branch, a commit SHA, or this repo's own ./ caller) the handshake is skipped with a notice, and so it is on GitHub Enterprise Server, which has no job.workflow_ref.
bundle exec rake # rspec + rubocop; CI runs Ruby 3.2, 3.4 and 4.0
# The ractorize lane (Ruby 4.0; CI's report-only "Ractorize lane" job)
cp config/ractorize.gemfile Gemfile.ractorize
BUNDLE_GEMFILE=Gemfile.ractorize bundle install
BUNDLE_GEMFILE=Gemfile.ractorize bundle exec rspec --tag ractorizeEvery check is proven both ways against spec/fixtures/: a violating fixture it must catch and a conforming one it must pass. The kit audits itself (.conformance.yml, strict, nothing skipped), and conformance.yml runs on its own pull requests.