Ractor-unsafety ledger: campfire's CRuby emit
Snapshot 2026-10-08. Roundhouse 194f26cf, once-campfire 90b3300 (2026-09-26), Ruby 4.0.5 arm64-darwin. A dated measurement, not a status page: re-run the probes before quoting it.
This ledger answers one question. What stands between the emitted Ruby campfire and the deployment shape of Tobi's campfire-once-ruby-ractor: boot once in the main Ractor, deep-freeze what can be frozen, then serve requests from N worker Ractors. Each entry below is something a worker Ractor raises on, with the evidence that it is actually reached. Entries that a boot-time freeze clears are not listed as blockers.
How it was measured
Two probes, both run inside the emitted tree:
roundhouse --target ruby ~/git/once-campfire -o EMIT --allow-unsupported
cd EMIT && bundle install
cp <seeded campfire DB> storage/development.sqlite3 # user1@example.com / secret123456
bundle exec ruby scripts/ractor-probe.rb all OUT.json # path relative to this repo
bundle exec ruby scripts/ractor-probe-calls.rb
scripts/ractor-probe.rbboots the emit the wayconfig.rudoes and signs in. It serves the seven dynamic bench routes in the main Ractor (all 200), then:- traces which third-party methods each route reaches. The room page is also traced cold: a warm render hides the rich-text path behind fragment caches, and under Ractors every worker starts cold.
- counts the constants and module ivars a worker could not read.
- serves each route inside a worker Ractor.
scripts/ractor-probe-calls.rbfreezes every freezable constant, then makes one representative call per library in a fresh worker Ractor. A FAIL is therefore not an unfrozen constant. It is a C extension that doesn't declare Ractor safety, a mutable registry, a lock, or a Proc.
The full route walk stops at the first wall, which is our own Db pool
on every route (see Ours). The
third-party entries therefore rest on the trace plus isolated calls.
They do not come from one end-to-end Ractor serve. That end-to-end serve
is the closing check, once our own walls are down.
Summary: blockers on the bench routes
| # | Blocker | Kind | Routes that reach it | Owner |
|---|---|---|---|---|
| 1 | sqlite3 2.9.3 | C ext not declared Ractor-safe; module-level ForkSafety registry |
every route that touches the DB (all but /up) |
sqlite3-ruby (Mike Dalessio) |
| 2 | nokogiri 1.19.2, and through it loofah 2.25.1 and rails-html-sanitizer 1.7.0 | C ext not declared Ractor-safe | cold room page, messages page, POST message (rich-text sanitize) | Mike Dalessio (all three) |
| 3 | connection_pool 3.0.2, via net-http-persistent 4.0.8 | global INSTANCES WeakMap mutated in #initialize |
POST message (first one builds the Web Push pool) | connection_pool (Mike Perham); net-http-persistent |
| 4 | openssl 4.0.2 OpenSSL::Digest::SHA256.new |
subclass #initialize is a define_method lambda (unshareable Proc) |
sign-in page, cold room page (key derivation) | ruby/openssl; ours to route around |
| 5 | our runtime: Db, caches, lazy loads |
module-level mutable state | every route | us |
Off the bench routes but on campfire's paths (verified by isolated call unless noted):
| # | Blocker | Kind | Path | Owner |
|---|---|---|---|---|
| 6 | resolv 0.8.0 Resolv::DefaultResolver |
lazily built mutable resolver; freezing it breaks the main Ractor too (FrozenError) |
outbound HTTP through our SSRF guard (runtime/surfguard.rb): unfurl, bot webhooks, Web Push |
Ruby core (default gem). Tobi hit the same wall. |
| 7 | websocket-driver 0.8.0 WebSocket::Mask.mask |
C ext not declared Ractor-safe | Action Cable frames, if a worker Ractor owns sockets | faye / websocket-driver |
| 8 | sentry-ruby 7.1.0 | Sentry::MUTEX, Sentry::Hub::MUTEX constants |
error reporting (Sentry.get_main_hub) |
getsentry |
| 9 | ruby-vips 2.3.0 | Procs in constants (GLib::LOG_HANDLER, Vips::MARSHAL_*), FFI struct layouts in class ivars |
image variants (job path). Static only, not called in a Ractor. | libvips / ruby-vips |
Third-party detail
1. sqlite3. SQLite3::Database#open_v2 raises Ractor::UnsafeError
in a worker. The extension never calls rb_ext_ractor_safe(true), and
that's still true in 2.9.6, the newest installed. Every DB route goes
through Database#execute/#prepare and the C methods on Statement
(prepare, step, column_*). This is the central blocker. Tobi's port
switched to extralite, whose Init declares Ractor safety, and still
had to patch it (a double finalize of FTS5 statements on close).
Past the declaration there is a second wall in Ruby code:
SQLite3::ForkSafety.track appends a WeakRef to every opened database
onto the module-level @databases array (lib/sqlite3/fork_safety.rb).
Ask: declare the C extension Ractor-safe after auditing its global
state, and make ForkSafety tracking work outside the main Ractor
(skip it there, or keep it per Ractor). A database handle would stay
owned by one Ractor; nothing here asks for sharing a connection.
2. nokogiri → loofah → rails-html-sanitizer. HTML5 (gumbo), HTML4,
and XML parsing all raise Ractor::UnsafeError in a worker, and Loofah
and the safe-list sanitizer fail through it. Not declared in 1.19.4,
the newest installed, either. Reached through
ActionText::ContentHelper.sanitizer on every message body that isn't
already cached. Under Ractors every worker starts cold, and new messages
always miss the cache. Tobi's port rewrote the rich-text pipeline in
pure Ruby to avoid it.
Ask: Nokogiri Ractor safety, at least for parse → traverse → serialize
of a document owned by one Ractor. That covers everything Loofah and
the sanitizer need. Our fallback, which wouldn't keep the real gem:
the pure-Ruby safe-list port the Spinel lane already uses
(runtime/rails_html_sanitizer_spinel.rb). The ledger records this as
a fallback, not as coverage.
3. connection_pool via net-http-persistent. ConnectionPool#initialize
writes self into ConnectionPool::INSTANCES, a process-wide
ObjectSpace::WeakMap kept so pools can be reloaded after fork
(auto_reload_after_fork, default true). Any pool built in a worker
raises. net-http-persistent builds one and doesn't pass that option
through. Campfire's WebPush::Pool builds its Net::HTTP::Persistent
on the first POST.
Ask: skip or localize the fork registry outside the main Ractor. Fork and Ractors don't mix anyway.
4. openssl Digest::SHA256. lib/openssl/digest.rb defines each
named digest class with
define_method(:initialize, ->(data = nil) { super(name, data) }). The
lambda isn't shareable, so .new raises "defined with an un-shareable
Proc in a different Ractor". OpenSSL::Digest.new("SHA256"),
OpenSSL::Digest.hexdigest, HMAC, PBKDF2, and EC all work. Ours to fix
in runtime/message_digest.rb:82. The upstream report is still worth
filing.
Ruby and stdlib
- resolv (#6 above).
- Fine in a worker, though a symbol-table check (
nm) suggested otherwise:digest/md5,digest/sha1,io/wait, encoding transcode tables,zlib,json,bcrypt,securerandom,base64,Addrinfo.getaddrinfo, andNet::HTTPagainst a local server. Check any extension by calling it; the symbol check gives false alarms. - Rack 3.2.6 is fine once eager-loaded and frozen (
Rack::Filesserves). Its constants are autoloaded, so the boot freeze must force them first. OnlyRack::MockRequest(a test helper) keeps a module-level parser.
Ours: roundhouse runtime and emit
Every route stops first at Db.@pool. The census lists 19 module
ivars in runtime/, 2 in cable.rb, and 1 in main.rb that a worker
can't read. Our constants account for 299 unshareable values, 293 of
which a boot freeze fixes. The work splits into five kinds:
| Kind | Instances | What the emit would do |
|---|---|---|
| Process-wide pool and locks | Db.@pool/@mutex/@cv/@permit_lock/@permit_cv/@quarantined, ActiveJob.@drain_mutex and QUEUE_LOCK, GzipCache.@mutex/@pieces_mutex |
Per-Ractor DB pool. SQLite writes serialized by a writer Ractor or by BEGIN IMMEDIATE, the job Tobi's Writer Ractor does. Jobs drained by a job Ractor. |
| Per-process caches and memos | MessageVerifier.@derived_keys, ViewHelpers.@slots, TypedStore.@cache_val, Rails.@cache_store, GzipCache.@store/@pieces/@runs |
Ractor-local (`Ractor[:k] |
| Boot-time config | Rails.@secret_key_base, ActiveRecord::Registry.@entries, Main.@route_table |
Ractor.make_shareable at boot. |
| Lazy loading | Main.instantiate_controller writes @__ctl_* and calls require_relative on first use |
Eager-load at boot. |
| Cross-worker fan-out | Cable::Reactor.@todo/@connections, Cable::Workers::QUEUE, Broadcasts transport |
A hub Ractor for pub/sub (Tobi's Bus). The 4-worker Puma emit already lacks cross-worker cable delivery; this is the same gap. |
The six constants a boot freeze cannot fix are Cable::Workers::QUEUE,
four mutexes (Cable::Workers::START_MUTEX, Cable::Reactor::START_MUTEX,
Rails::Application::CONFIG_LOCK, ActiveJob::QUEUE_LOCK), and
Rails::Application::X_WEB_PUSH_POOL_SLOT. They belong to the rows
above.
Campfire's own source
Nothing here needs a campfire change. Campfire has no class variables,
and its class << self blocks define methods without holding state.
Its unfrozen constants (TRANSLATIONS, REACTIONS, VERSIONS,
HUMANIZE_INVOLVEMENT, Sound::BUILTIN) are cleared by the boot
freeze. The blockers are in its dependencies and in our runtime.
Limits
- One platform (macOS arm64) and one Ruby (4.0.5).
- Bench routes only: no
/cablesocket path, no job path, and no outbound HTTP traced. Rows 6–9 come from isolated calls or the census. - The census counts every loaded module. It is an upper bound; the trace is what says something is reached.
- Not measured: whether Ractors are faster for this app. This ledger only says what stands in the way.