Each emitted project links against a per-target runtime that ships in two layers: target primitives (hand-written) and the framework runtime (transpiled from Ruby).
Source: runtime/<target>/ for primitives; runtime/ruby/ for
the framework runtime (consumed by all non-Ruby targets via the same
roundhouse pipeline that compiles user apps).
runtime/ruby/ ← single source of framework Ruby
active_record/ transpiled into the emit pipeline
action_controller/ ↓
action_view/ framework-runtime files appear in
action_dispatch/ the emitted project (e.g. TS emit
inflector.rb writes src/active_record_base.ts,
json_builder.rb src/action_controller_base.ts, …)
...
runtime/<target>/ ← per-target primitives, hand-written
db.<ext> DB connections, HTTP server,
server.<ext> WebSocket plumbing, test harness —
cable.<ext> genuinely target-idiomatic glue
test_support.<ext> that no IR-level lowering captures
...
- Framework runtime is target-uniform Rails surface. Models'
validates, controller action helpers, view helpers likelink_to/form_with/pluralize— these have one canonical Ruby implementation. Authoring them N times in N target languages is exactly the duplication the lowering layer was built to eliminate; transpiling them once subsumes it. - Target primitives are unavoidably idiomatic. Wiring axum
middleware vs. Node's
httpmodule vs. Plug's pipeline DSL vs. Crystal'sHTTP::Serverlooks different in a way no IR captures. Hand-writing these stays cheap because each target's primitive layer is small (a few hundred lines).
The forcing function: any target that compiles user Rails apps
must also compile runtime/ruby/. If your emitter can transpile
ApplicationRecord + FormBuilder + Inflector, it can transpile a
real Rails app. Phase 1 of the unified-IR plan front-loaded this
risk by transpiling runtime/ruby/ to TypeScript before any user-
app emission depended on the result.
Conventional file roles (file names vary by target):
| Role | Description |
|---|---|
| Model base / shims | ActiveRecordAdapter trait / adapter interface, validation error type |
| DB connection | Lifecycle (open, with_conn borrow, test-mode database — in-memory on the typed targets, a per-test file on the ruby family since the shared-cache lock, see runtime/spinel/test/test_helper.rb) |
| HTTP server | Production HTTP entry — listens on a port, dispatches through Router |
| Action Cable | WebSocket endpoint |
| View helpers | Delegates into transpiled framework Ruby (where present) or implements helpers directly (legacy) |
| Test support | TestClient + TestResponse + Rails-shaped assertions |
The exact file set varies per target and rots fast in prose; the
authoritative inventories are the include_str! tables in each
target's emitter (src/emit/typescript.rs, src/emit/go.rs,
src/emit/elixir.rs, …) and, for the Ruby family, the runtime walk
in src/project.rs. Shape notes worth knowing:
runtime/go/andruntime/elixir/keep their primitives under av2/sublayout (a strangler-era name the emitted tree still uses) — the Go/Elixir emitters copy from there.runtime/typescript/carries db/server variants selected by theDeploymentProfile(sync sqlite, libsql, worker), the worker bridge (juntos*.ts), and async/sync minitest adapters. Framework-runtime files (active_record_base.ts,action_controller_base.ts, …) are emitter-generated fromruntime/ruby/and appear undersrc/in emitted projects, not in this directory.runtime/spinel/is by far the largest: per-target Ruby primitives (DB adapters per interpreter, CGI/ERB shims, message digests, …) plustep/(an embedded HTTP/WebSocket server),facades/(hand-written typed stand-ins, each with an RBS sidecar), ascaffold/tree overlaid into every emitted Ruby/Spinel project, and atest/tree of target-specific test files.runtime/spinel/db_pg.rbimplements the sameDbcontract over PostgreSQL, on the pure-Ruby spinel-pg driver (no libpq). No target selects it yet.tests/spinel_pg_db.rscompiles it with Spinel and runs it against a live server.Db.exec_returninganswers the returned rows.Db.last_insert_rowidreads the inserted table's own sequence, so it works for serial and identity keys only; any other key raises.- Server errors whose SQLSTATE ActiveRecord names are raised as that
class through
runtime/spinel/pg_errors.rb. - A transaction keeps its connection, and a lease that ends inside one rolls it back.
- The pool is sharded per thread, as
runtime/spinel/db.rb's is.Db.prepareruns through a named statement cached per connection (at most 128, closed on the server when evicted); finalizing a read releases its handle and keeps the statement. There is no request query cache yet. - The SQLite-only boot hooks (read snapshot, checkpointer) do
nothing there, and
seed_from_fileraises.
Ruby authoritative source for the Rails surface every emitted app calls into:
runtime/ruby/
active_record/ ApplicationRecord, validations, querying
action_controller/ Base controller, Parameters
action_view/ link_to, form_with, FormBuilder, pluralize, ...
action_dispatch/ Routing helpers
action_text.rb Content (the has_rich_text coder), Attachment
inflector.rb camelize / pluralize / dasherize
... plus mailer/job/storage/params modules and the
runtime's own test suite — see the directory
runtime/ruby/test/ is the framework runtime's own test suite, run
per target by the framework-tests-<target> CI jobs.
action_text.rb holds only Action Text's VALUE layer.
ActionText::RichText is absent because it has a table: it is
synthesized as an ordinary model by lower::rich_text and reaches
every target through the model machinery. That split — table-backed
things are models, values are framework Ruby — is the general rule,
not an Action Text special case.
Nearly every .rb ships with a .rbs sidecar declaring the public typed
surface (see analyze.md on RBS-paired ingestion). The .rbs is
what makes the framework runtime typeable without annotating every
internal expression — only the public boundary commits to a type.
Target primitives ship via Rust's include_str! at emitter compile
time:
const RUNTIME_SOURCE: &str = include_str!("../../runtime/rust/runtime.rs");
const DB_SOURCE: &str = include_str!("../../runtime/rust/db.rs");
// ...etc.These strings are written verbatim into the generated project as
src/runtime.rs, src/db.rs, etc.
Framework runtime files ship via the same emit pipeline that compiles
user apps — runtime/ruby/active_record/ is ingested with its RBS
sidecar, lowered, and emitted into the generated project as
src/active_record_base.ts (etc.) by the same code path that
compiles user controllers and models. The driver is
src/runtime_loader.rs: a per-target TargetEmit hook set plus
per-target entry points (typescript_units, crystal_units,
rust_units, go_units, elixir_units, kotlin_units,
swift_units, csharp_units, python_units, …) — most targets have
one today. The parse layer underneath is src/runtime_src.rs. There
is no separate bin/build-runtime binary; emission runs inline as
part of cargo run --bin roundhouse -- --target <t> (or --site for
the full archive matrix).
The Ruby family is the asymmetry: Spinel / Ruby / JRuby receive the
framework Ruby VERBATIM — the Ruby-family assembly in
src/project.rs walks runtime/ruby/ into the emitted tree as text,
and a text-level tree-shake (emit::ruby::shake, run from
src/project.rs::target_files) trims what the app doesn't reference
— while every other target gets the IR transpile described above.
- Framework integration is language-idiomatic. axum middleware,
Node's event loop, Plug's pipeline, Crystal's
HTTP::Handlerchain each look different in a way no higher-level IR captures. - Editability. When a primitive needs to grow (new middleware,
new helper hook), editing a normal
.rs/.tsfile with IDE tooling is faster than editing a string inside aformat!-driven emitter.
The tradeoff: emitters and primitives stay in lockstep. If
view_helpers.rs adds a function, the corresponding emitter (or, for
helpers, the runtime/ruby/action_view/ source) has to learn to call
it. Snapshot tests + toolchain tests catch drift.
Ruby-family lowered equality reads select SQL from the runtime value: a
non-nil value uses col = ? and a bind; nil uses col IS NULL without a slot.
The same branch selects the fragment and reserves its running bind position,
so later predicates cannot shift out of alignment. Inline emission uses
col = <escaped value> or col IS NULL. IS ? is deliberately avoided:
SQLite excludes IS from its partial-index non-null implication rule.
Each nullable predicate contributes two possible fragments. Code size stays
linear: no query variants are enumerated in the compiler. A bound query with
up to seven nullable predicates has at most 128 shapes; queries above that
budget use prepare_uncached, avoiding exponential growth within a long
lease. Existing per-connection cache limits still apply across query sites.
Strict-target lowering retains its existing predicates and lifecycle.
Generated Ruby-family reads use ensure Db.finalize(stmt) around binding,
serialization after prepare, stepping and hydration, including reload and
preloads. Text preprocessing failures also release the binder's checkout.
Cleanup therefore completes before a caller rescues within an ongoing lease.
For each target:
- Emitter assumes specific function names, signatures, and imports from the runtime (both layers).
- Runtime guarantees those functions exist and behave.
- Snapshot tests catch drift in emitter output.
- Toolchain tests catch drift in the runtime — if it no longer
compiles, or if
cargo test/tsc --strict/crystal buildfails, the gate blocks the merge.
When adding a new helper: land the runtime change and the emitter change in the same commit. Runtime that ships without emitter uptake is dead code; emitter output that references a non-existent runtime function is a compile failure in the generated project.
| Directory | Role |
|---|---|
runtime/ruby/ |
Framework runtime — Ruby source + RBS sidecars |
runtime/rust/ |
Rust primitives |
runtime/typescript/ |
TS primitives (framework runtime is emitter-generated from runtime/ruby/) |
runtime/crystal/ |
Crystal primitives |
runtime/{go,python,elixir,kotlin,swift,csharp,spinel}/ |
Sibling targets (go/elixir under a v2/ sublayout) |
src/emit/<target>.rs |
Emitter side that reads + embeds the runtime |
src/runtime_loader.rs |
Framework-transpile driver — TargetEmit hooks + per-target *_units entry points |
src/runtime_src.rs |
Parse layer: runtime Ruby + RBS → MethodDefs (no emission) |
Places where the runtime knowingly answers differently from Rails. Each is a decision, not a gap — a gap belongs in the diagnostics ledger instead. A divergence must be recorded here when it is chosen: an undocumented one reads as intent to the next session precisely because it is applied consistently, and the emit gives no signal that anyone weighed it.
Rails' ParamsWrapper copies a JSON request body under the controller's
model name (params[:article]) when the app's load_defaults is 7.0
or later, an initializer asks for it, or the controller says so with
wrap_parameters. The ruby family does the same: the compiler decides
each controller's key and copied keys
(lower::controller_to_library::params_wrapper), the generated
process_action opens with Params.wrap (runtime/ruby/params.rb),
and the dispatchers hand the request its body params apart from the
query string and the path (request_parameters).
The strict targets do not: Rust and Python read no JSON body at all,
and the TypeScript server merges one into params without keeping the
body apart. A client posting the fields at the top level gets them at
the top level only, so params.expect(article: …) finds nothing there.
The capability is FormatBreadth::wraps_json_params.
Rails refuses params.expect(article: [...]) when article is missing,
blank, a scalar, an array, or a hash of only unpermitted keys, and
params.require(:article) when it is missing or blank (a scalar reaches
permit and is a 500): both raise ActionController::ParameterMissing,
which an unrescued request answers with 400. The ruby family does the
same: the typed factory is handed Params.expect_present(@params, …) /
Params.require_present(@params, …) (runtime/ruby/params.rb), and both
dispatchers answer an unrescued ParameterMissing with 400.
The strict targets have no exception control flow and hand-written
Params primitives, so their factory keeps reading @params: a missing
resource is an empty one, the model's validation usually refuses it, and
the request answers 422 where Rails says 400. The capability is
FormatBreadth::raises_param_missing (src/lower/controller/body.rs).
The Spinel target defines a small Date class in
runtime/spinel/date.rb for Rails date columns. It stores a Gregorian
year, month, and day; database storage and JSON use YYYY-MM-DD, with
no clock or zone. Its ISO parser accepts only that exact format and
validates the calendar date.
This is not Ruby's stdlib date package. DateTime, Julian/Italian
calendar modes, natural-language and non-ISO parsing, schema date
defaults, ActiveSupport date extensions beyond Date.current and the
month/day edges time_calendar lowers, date picker helpers, and
require "date" are not included. strftime implements the date
directives used by the admitted runtime contract and raises on other
directives. The compiler continues diagnosing those unsupported paths.
JRuby and other targets keep their existing Date boundary until they
have their own runtime.
The Date package — runtime/spinel/date.rb, date parse/format
(active_support_date_parsing.rb), the date JSON rewrite
(active_record_date_serialization.rb), matching RBS, and boot
requires — is injected only when app_uses_date is true (schema date
columns or date values in emitted roots). Default as_json stays
always-on via active_record_serialization.rb. Loading
Date#strftime into every Spinel app currently breaks poly
Time | Date receivers for Time#strftime (matz/spinel#7334);
Campfire has no date columns and must not pay that cost. Once upstream
fixes the poly method table, unconditional load is safe again.
Date-column JSON is rewritten in the omit-gated Spinel reopen (after
the shared time-aware _as_json_only), not in runtime/ruby/ active_record/connection.rb — the CRuby overlay has its own
reflection-aware Date path, and a shared date branch would tax Bar B /
AR RBS probes for every app. Raw where(due_on: some_date) predicates
format through SqliteAdapter.escape_value →
ActiveSupport.format_db_date so the SQL compares against
YYYY-MM-DD text, not a timestamp.
Each model's own initialize seeds self.id = attrs[:id] || 0 — || "" when the key column is a string or uuid. Rails answers
Article.new.id == nil (measured against Rails 8.1).
Why. A nullable primary key means Option<i64> in Rust and Int?
in Kotlin/Swift/C#, with an unwrap at every foreign-key comparison, path
helper and join. The sentinel keeps ids plain machine integers across
every target. Foreign keys follow the same convention — the
synthesized belongs_to readers test @creator_id == 0 (== "" for
a uuid foreign key).
Where the slot lives. On the model, never on ActiveRecord::Base.
The base's id/id= are a raise-bodied contract; under Spinel a
base-class ivar is the union of every subclass's writes, so a base
@id let one String-keyed model widen every model's key to poly and
drop each one's --rbs pin (roundhouse#90). On the transpile path
the property-typed targets see the contract as the attr_accessor
plus @id = 0 it stands in for (runtime_src:: reclassify_abstract_attributes), so their base is unchanged.
What depends on it. ty_of_column_slot excludes the primary key
from nullability, so the RBS declares id: Integer (non-null) while a
genuinely nullable column declares String?. The two must agree: a
write of nil into @id contradicts both the sentinel and the
signature, and spinel widens the slot on the possibility — one
unreachable nil arm in []= boxed @id on every model in the corpus.
Where it is visible. Only where app code reads id on an unsaved
record. The framework never does: form_with picks its action from
persisted?, and so does dom_id. record.id.nil? is false here
where Rails says true.
Rails casts on assignment: Message.create!(client_message_id: 999)
into a t.string column stores the String "999", and
record.client_message_id reads back "999" before and after the
INSERT. The writer this runtime generates is a bare @col = value, so
the attribute holds the Integer 999 until the row is written, and the
adapter's escape_string is what finally renders it.
Why. A cast per write means every generated writer carries the
column's coercion, in every target — and the coercions do not agree
across them (999.to_s is not String(999) is not "" + 999). The
value reaches the database through one escaper per column type, which
is already the single place that knows the column's type, so a write
that goes straight to storage arrives correct without the writer
knowing anything.
What it costs. Anything that reads the attribute BETWEEN the
assignment and the INSERT sees the uncast value — which in practice
means a before_save/before_create callback, and app code that reads
back what it just assigned. campfire's suite writes
create! client_message_id: 999 into a string column
(test/controllers/users/sidebars_controller_test.rb:17), and its
before_create guard reads that attribute.
What depends on it. Any synthesized guard over a string column must
branch on the VALUE, not on the column type — which is why
lower::blank::synthesized_string_blank grounds to
ActiveSupport.blank? rather than to the String form
(r || "").strip.empty?. The String form raises undefined method 'strip' for an instance of Integer on exactly the case above. A
schema type describes the column, not what the attribute holds before
the INSERT, and a lowering that reads attributes.fields is reading
the former.
The fix, when it is worth making, belongs in the writer — one cast per string column, at the one site that already knows the type — and it changes what every reader sees, so it is a change to make deliberately rather than as a side effect.
cookies[:missing] answers ""; Rails answers nil.
The SIGNED read is nullable and no longer diverges —
cookies.signed[:missing] answers nil, and so does anything that does
not verify (a tampered payload, a bad signature, a value signed for a
different cookie name). That is the read where the difference was
visible to an app rather than absorbed by a .to_s: if token = cookies.signed[:session_token] is campfire's SessionLookup, and an
empty String is TRUTHY in Ruby, so a signed-out request took the
signed-in branch and queried for token: "" — right by accident, one
query Rails never makes, and simply wrong for a call site that only
checked presence. Everything below is about the unsigned jar.
Why. The store is Hash[String, String], and a nullable String puts
every read on spinel's nullable path: cookies[k].to_s.split(",")
yields a null array there, which is how lobsters'
remove_unknown_cookies first met this. Every call site in the corpus
coerces with .to_s, under which "" and nil are identical.
What depends on it. raw returns "" as its final fallback, and
delete records a cleared write as "" rather than a tombstone, so
@out stays a plain String→String map — the harness and both
dispatchers read that empty as "expire this cookie". SignedCookieJar#[]
sits ON TOP of raw and maps its "" back to nil, so the nullable read
costs the store nothing.
Where it is still visible. cookies[:missing] is truthy where Rails
is falsy. No corpus call site reads the unsigned jar for presence — every
one coerces with .to_s — which is why the signed read was closed and
this one was not.
user.status answers 0 where Rails answers "active". The generated
predicates and scopes carry the stored value too, which is what makes
them correct with no enum type at runtime. Fixing the reader means
mapping at every read; do it only if an app is found that reads the raw
attribute.
ActionText::Content#attachables — the untyped list of every record a
fragment's <action-text-attachment sgid="…"> nodes point at — still
answers []. Two typed routes exist instead. Where the caller names
its class, attachable_ids("User") verifies each node's sgid and
answers the ids minted for that model, and lower::attachables_grep
rewrites the shape app code actually writes into a query over them:
body.attachables.grep(User) → User.where(id: body.attachable_ids("User")).to_a
Where it does not — ActionText::Attachment.from_node(node).attachable,
the shape campfire's tests build — the sgid's verified model NAME is
turned into a finder by ActionText::Attachable.locate(model_name, id),
which project::apply_attachable_locate GENERATES into the emitted
tree's global_id_locator.rb (runtime/spinel/global_id_locator.rb,
the ruby family's) as one when "<Model>" then <Model>.find_by(id: id) per model that mixes ActionText::Attachable
in. Nothing answering — no sgid, a signature that does not verify, a
model no arm names, a row since deleted — is an
ActionText::Attachables::MissingAttachable, as Rails' rescue of
RecordNotFound makes it.
Why generated. Dereferencing an arbitrary sgid needs a
name-to-class map, and building one at run time means reflection
(constantize from a wire string) or per-model registration at load.
The includer set is closed at ingest, so the switch IS the registry:
no const_get, nothing a crafted name can steer, and an app with no
attachable model keeps a body that answers nil. A bare attachables
has no such caller and keeps the [].
The wire format is Rails'. ActionText::SignedGlobalId mints
the bytes SignedGlobalID mints: gid://<app>/<Model>/<id>?expires_in
as the data of a _rails envelope, url-safe base64 WITH padding,
HMAC-SHA1 under the key derived from the signed_global_ids salt —
each of those a measurement against campfire under Rails 8.2, not a
reading of the gem, and runtime/ruby/test/action_text_test.rb pins
the literal that process produced so the two ends stay
interoperable by test (MessageVerifier.gid_envelope holds the
derivation). This is the third envelope the verifier writes, beside
the cookie's and the signed id's, and the reason it matters is a
database Rails wrote: every @mention in action_text_rich_texts.body
is one of these sgids, and under the <Model>/<id> shape this
runtime used to sign they all read as missing. (The ?expires_in is
globalid serializing attachable_sgid's expires_in: nil as a bare
key; it is inside the signed bytes, so the mint writes it and the
readers strip any query.)
The app's tolerance for a rotated secret. campfire's
lib/rails_ext/action_text_attachables.rb reopens
Attachment.from_node, inside an ActiveSupport.on_load block, to
accept a User sgid whose signature FAILS — so rotating
SECRET_KEY_BASE does not orphan every @mention — by decoding the
_rails envelope by hand. The class finder skips blocks, so that
file was dropped without a word. ingest::on_load_reopen now reads
the shape (the ActionText::Attachment reopen, a class-side
from_node, the "_rails" key in its body) for the one fact it
holds, the %w[ User ] list, and project::apply_attachable_locate
writes it into global_id_locator.rb as
Attachment.permitted_without_signature; the hand-decode — Rails'
7.1+ data string and the Rails-7 Marshal shape behind it — is
transcribed once, as SignedGlobalId.unverified_uri, and
Attachment#attachable consults it only for the listed models and
only after the signed read has failed. The thirty lines of
JSON.parse(…).dig, rescue-chained base64 and a regex over Marshal
bytes are not carried: the fact is the list, as the mint's fact is
the model name. Any other declaration inside an on_load block is a
survey line naming the hook and the class, not a silent drop.
RichText#to_trix_html hands back the stored markup instead of
rendering attachment previews into it, so an editor loads the text and
shows attachment nodes bare.
Attachment nodes ARE rendered on the serving side. Content#to_s
is Rails' render_action_text_attachments then the layout: each
<action-text-attachment> gets its attachable's partial as its inner
html — the node stays, the markup goes inside — through
Content.render_attachment, GENERATED per app beside the layout
(project::apply_content_layout): one arm per attachable model whose
to_attachable_partial_path names a partial the tree carries, keyed on
the node's resolved model name with the finder and the view module as
literals (when "User" … Views::Users.mention(user)). The order is
load-bearing for what an app does next: campfire hands the result to
auto_link, whose safe-list pass strips the attachment tag it does not
allow and keeps the children, so a mention reaches the page as
users/_mention — with no render that strip left Hey @bender served
as Hey , and a lane that does not strip served a bare element a
browser draws as nothing. The scanner is quote-aware, because
campfire's own mention nodes carry the rendered mention in a content
attribute with a > inside the quotes; scan_tags took the first >
as the end of the tag and read past the sgid by luck.
Link previews render the same way, by content type. An opengraph
embed is not an sgid but a content-type
(application/vnd.actiontext.opengraph-embed) that campfire's
from_node reopen dispatches FIRST, building an OpengraphEmbed from
the node's attributes with web_url dropping any href/url that is
not a web URL on another host. The generated render_attachment has
that arm ahead of the sgid case: a class that answers
attachable_content_type and defines from_node is asked to build
itself from the node, and its partial is rendered when it does. Three
facts the framework's own render site states are read off the class
rather than guessed: the partial's LOCAL is the class's
model_name.element (opengraph_embed, where the directory rule
said attachable and the body read neither), its type is the class,
and the Attachment's own readers the partial uses (caption) are
delegations the other way — the record carries the node in an
attachment= slot the seam fills, and caption reads through it
(lower::attachable::push_attachment_delegation). link_to_if, which
that partial writes, lowers as an if over the two link_to halves so
a truncated label is escaped once.
The harness's default request host is Rails' www.example.com now,
not example.org: the own-host preview test mints
room_url(host: "www.example.com") and expects the app to know it as
itself.
auto_link sanitizes the body first, as Rails does. The shared
auto_link used to skip Rails' safe-list pass on the reasoning that
the shared sanitize was a raising façade; it is an engine now, and
the pass runs (sanitize: false skips it, as in the gem). This is what
strips the <action-text-attachment> and <figure> around a rendered
partial — neither is on the default list — so a mention or a preview
reaches the page as the partial alone on every lane, which is what
the ruby lane's real gem chain already did. Measured against the gem's
default under the oracle's bundle: 30 of 31 probes in
tests/shared_autolink.rb byte for byte; the one difference is an
unterminated tag the HTML5 parser closes and the scanner drops.
Plain text asks the attachable too. Rails' Attachment#to_plain_text
is the attachable's attachable_plain_text_representation(caption)
when it defines one and caption.to_s otherwise, and the node is
REPLACED whole — children included, and Trix stores an unfurled link's
rendered <figure> inside its node. So an opengraph embed is ""
(campfire says so), a mention is "@name", a blob is "[caption or filename]" and an unresolved sgid is its caption alone. That dispatch
is per app for the same reason the render is:
Content.attachment_plain_text is generated beside render_attachment
with an arm for each attachable class that defines the hook, falling
through to the framework's own attachables (blob, remote image,
caption). Until 2026-09-21 every attachment answered caption || filename and the scanner walked into its children, so a solo
unfurled link never equalled its own plain text and
RemoveSoloUnfurledLinkText never fired — served bodies kept the URL
text Rails strips. Attachment#attachable reads the content-type
class first as well (Content.content_type_attachable, generated), so
a test that builds an embed node by hand and asks for its attachable
gets the OpengraphEmbed, not a MissingAttachable.
The fragment can be written, not only read. A Node is its open
tag, inner html and close tag; inner_html= and node["k"] = v rewrite
the piece they touch (an attribute is spliced into the open tag as it
was spelled, so an untouched node is still the source bytes), and
Fragment#update yields a copy whose css / at_css bind nodes for
write-through — the two shapes campfire's mutating filters use
(fragment.replace("div") { |n| n.tap { |x| x.inner_html = … } },
fragment.update { |s| s.at_css("div")["class"] = … }). find_all
stays a read. An update block can also scan css("*") and call
Node#remove; that removes each disallowed element with its contents while
leaving allowed nodes in the copied fragment. This is a removal primitive,
not a general sanitizer: the caller supplies the allowlist, and allowed-node
attributes are not filtered. Every expectation in
runtime/ruby/test/action_text_test.rb for these was measured against Rails'
Nokogiri-backed Fragment.
What always worked. The PARSE: #attachments returns every node
with every attribute it carried (sgid, content_type, caption,
filename, url).
Content#to_plain_text decodes & < > " ' ' and passes anything else through verbatim — ∗ stays
∗ where Rails (via Nokogiri) yields ∗.
Why. Decoding an arbitrary reference needs a codepoint-to-character intrinsic the framework runtime does not carry, and a full named-entity table is ~2000 entries for a case no corpus app produces.
Where it is visible. Plain-text projections only — search
indexing, the to_plain_text.presence fallbacks. The round trip that
matters is closed: every entity ViewHelpers.html_escape can emit is
in the table, so escape-then-extract recovers the original text.
with_attached_<attr> / with_rich_text_<attr> are generated as class
methods on the model. They are now ALSO generated as ActiveRecord:: Relation delegates that answer self, so a mid-chain call
(find_autocompletable_users.with_attached_avatar.ordered) resolves.
Why. These scopes are synthesized at emit time beside the attachment macro, not declared by the app. So the class-side method existed and the relation-side one did not, and a chain on a relation VALUE was a NoMethodError on a method that plainly exists.
build_scope_registry carries them NOW — that entry is what lets the
scope-body rewriter thread __rel through a bare
with_attached_attachment, and without it the relation was silently
replaced by a fresh one and every accumulated where was lost.
campfire's /rooms/1 served another room's messages on exactly that.
The delegate still bypasses the registry's general __scope_ path and
stays identity, because a hop to a body that returns its argument is a
dispatch for nothing.
Where it is visible. Nowhere in the results: the delegate is identity for the same reason the class-side body is (below).
with_rich_text_<attr> and with_rich_text_<attr>_and_embeds return
the relation unchanged where Rails adds an includes.
Why. The synthesized reader fetches per record, so there is no preloaded association for the hint to attach to.
Where it is visible. Query COUNT, not query results: a page rendering N records issues N rich-text queries where Rails issues one. The methods exist rather than being dropped so that call sites chaining through them keep working.
MEASURED, so the cost is a number rather than a shrug: campfire's
/rooms/1 with 40 messages makes 172 database round trips where Rails
makes 13. 161 of the 172 are four readers at ~40 each — the rich text
here, the message's boosts, and the two ActiveStorage lookups
(attached? and filename, two because each call builds a fresh
Attached). That is why the emitted tree serves that page at 1.34x
Rails' latency while beating it on every page with no message list
(/searches: 0.77 ms against 4.5 ms). scripts/bench-campfire is what
measures it; count CACHE MISSES, not Db.prepare calls, or the
per-request query cache flatters the number by half.
has_json :settings, restrict: false gives Rails a DataAccessor
object out of account.settings. Here the reader gives the SERIALIZED
JSON text; the schema's keys are reached through the flat accessors
lower::has_json synthesizes (account.settings_restrict?), and the
two-hop source spelling rewrites to them. The seam itself is
runtime/spinel/schematized_json.rb plus its CRuby overlay twin —
per-target like TypedStore, so the strict targets carry the calls as
one named unresolved seam until a native implementation lands.
Why. The accessor object answers through method_missing and would
need a live back-reference into the record for a write through it to be
visible in the record — neither survives static resolution. The schema
is a compile-time fact, so it expands instead.
The whole column, both directions, is the schema applied again.
record[:settings] answers a Hash, as Rails does: Rails' accessor
reverse-merges the declared defaults into the stored data the first time
it is read (and before_save reads it), so the object is one entry per
schema key, typed — which is what the flat readers compute, gathered into
a literal by synth_index_read. Assigning the column a whole Hash —
update!(settings: { … }), new(settings: { … }), self[:settings] = { … }, Rails' settings= — is assign_data_with_type_casting: each
supplied key cast through its declared type and MERGED over the stored
object, a key outside the schema raising as the accessor's
method_missing does. The attrs-hash writers route the column through
SchematizedJson.assign(@settings, value, { "restrict" => "boolean" })
with the schema as data. The value stays untyped there on purpose: the
same writers carry hydration's serialized text (from_row assigns the
stored column straight through []=), and the seam — ruby-family
runtime, where an is_a?(Hash) over an untyped value resolves — is what
tells the two shapes apart. The lowering cannot, and that is why the
test lives in the runtime rather than in generated per-model code.
Where it is still visible. attributes keeps the serialized text
(the STORAGE view, as created_at reads back through it as raw ISO
text), and an integer key gets no ? predicate, because Rails'
present? on any Integer — 0 included — is unconditionally true, and a
method that always answers the same thing is worse than an honest gap.
The permit-list path is a separate, ledgered gap: permit(:name, settings: {}) drops the nested key from the typed params class under
the params_nested_filter warning, so a form's settings never reach
update_from_<params>! — the attrs-Hash writers above are the ones that
model the assignment.
Analyze additionally types the column reader untyped where the emitted
reader returns String: that is the source-shaped accessor object, and
it exists only between the two hops the lowering erases.
Model.insert_all(rows) is INLINED at the call site (Ruby family,
scope_chain.rs) as rows.each { |a| Model.new(a)._insert_row }, and
Model.insert_all!(rows, returning: cols) as
ActiveRecord::Result.new(rows.map { |a| r = Model.new(a); r._insert_row; { "id" => r.id, … } }). Rails issues ONE multi-row INSERT; this issues
one per row. Like Rails, neither runs validations or callbacks, and both
fill timestamps.
Why _insert_row. It is the raw insert the fixture loader uses too
(Rails' insert_fixtures_set is the same kind of write): timestamps the
attributes left out, then the INSERT, nothing else. Until 2026-10-09 both
bulk inserts entered at save_after_validation and so ran the save
callbacks Rails skips.
Why inlined, not a synthesized method. A per-model insert_all
would land on every model of every app to serve the handful that call
it, and its parameter is an untyped attribute Hash — the shape the
has_json work established no target's Hash surface resolves portably. A
shared ActiveRecord::Base version is worse still: it would call
new(attrs) polymorphically against a Base#initialize taking no
attributes, in a file that prices every target. Inlining costs nothing
to an app that never calls it, and leaves strict targets an honest
unsupported diagnostic rather than a method that compiles and misbehaves.
Conflicts are SKIPPED, and that took a fix. Measured against
ActiveRecord 8.1.3: insert_all renders INSERT … ON CONFLICT DO NOTHING, so a row that already exists is a silent no-op — only
insert_all! raises RecordNotUnique. A bare per-row save raises, i.e.
it implemented insert_all! under the other name, and campfire's
memberships.revise (re-granting a membership to a user who already has
one) died on a UNIQUE index where Rails does nothing at all. Each row is
now guarded by an existence check on the table's unique keys, built from
the schema by lower::scope_chain::build_unique_keys.
The guard is a pre-check, not the database's atomic DO NOTHING — the
same read-then-write shape increment! below already carries, and under
single-threaded dispatch the window it opens is not observable. A unique
index over a NULLABLE column is skipped when building the guard:
where(col: nil) asks whether a row holds SQL NULL, which is a
different question, and in SQLite such rows never conflict anyway. A
partial unique index (where:) adds its predicate to the check,
.where("(revoked_at IS NULL)"), so only a row the index covers counts
as a conflict. The check reads the existing row, not the new one, so a
new row the predicate does not cover is still skipped when a covered row
shares its key; Rails inserts it.
insert_all! has no guard. A duplicate raises, as Rails'
RecordNotUnique does. Its value is Rails': an ActiveRecord::Result
of the RETURNING columns (the primary key when returning: is left
out, SQLite's default), whose rows are Arrays of values. Only a
literal column list lowers; anything else declines.
What it costs. N statements instead of one, plus, for insert_all,
one SELECT per row for the conflict check.
And insert_all answers a different value. Rails returns an
ActiveRecord::Result; the inlined rows.each { … } returns rows,
the Array of attribute hashes it was given. The catalog says
ArrayOfUntyped for that reason — the type of what this pipeline
actually produces, not of what Rails produces. Both corpus call sites
discard the value. Saying nothing was not the neutral choice: a catalog
entry carrying no return kind falls through to the same place an
UNKNOWN method name does, so campfire's Membership.insert_all(…)
reported no known method insert_all on Class { Membership } while the
emitted code was the inlined loop, correct all along.
runtime/ruby/active_record/connection.rb's upsert_all issues one
INSERT … ON CONFLICT (target) DO UPDATE SET … and answers the
affected-row count, where Rails answers an ActiveRecord::Result; the
catalog types the call Integer, what the emitted code returns.
Timestamps are stamped as Rails 8.1's SQLite adapter stamps them
(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'), updated_at moved only when
an assigned column changed). No strict runtime transpiles
connection.rb; the call is reported there.
runtime/ruby/active_record/errors.rb carries Rails 8.1's statement-error
hierarchy: ActiveRecordError, StatementInvalid,
WrappedDatabaseException (RecordNotUnique, InvalidForeignKey),
NotNullViolation, CheckViolation, ExclusionViolation, RangeError,
ValueTooLong, TransactionRollbackError (SerializationFailure,
Deadlocked), QueryAborted (StatementTimeout, QueryCanceled),
LockWaitTimeout and ReadOnlyError. The existing record errors now sit
under ActiveRecordError, so a rescue ActiveRecord::StatementInvalid
catches what Rails' does.
Each ruby-family SQLite Db (db.rb, db_cruby.rb, db_jruby.rb) raises
them through Db.raise_statement_error, keyed on SQLite's own message as
Rails' translate_exception is: UNIQUE, NOT NULL, FOREIGN KEY and CHECK
failures, SQLITE_BUSY as StatementTimeout, anything else as
StatementInvalid. The PostgreSQL shims map Rails' SQLSTATE table
(pg_errors.rb), and a code it does not list is StatementInvalid.
Foreign keys are enforced on SQLite as Rails enforces them. The SQLite DDL
(emit::shared::schema_sql) writes each add_foreign_key as a FOREIGN KEY … REFERENCES … clause, with its on_delete/on_update action, and the
three SQLite Dbs turn on PRAGMA foreign_keys, as Rails' DEFAULT_PRAGMAS
do. A test's fixture setup truncates and loads inside
Db.without_foreign_keys (Rails' disable_referential_integrity:
defer_foreign_keys on, foreign_keys off), so fixtures load in any order,
and then runs Db.check_foreign_keys! (PRAGMA foreign_key_check), which
raises StatementInvalid naming the tables, as Rails'
verify_foreign_keys_for_fixtures does.
Three gaps remain:
- Foreign keys off SQLite. The PostgreSQL DDL writes no foreign keys
(Rails adds them with
ALTER TABLE, and its fixture loader then disables triggers to load out of order), soInvalidForeignKeyis not raised there. The strict targets share the SQLite DDL but not theDbs: each enforces the clauses only if its driver turnsforeign_keyson, and their fixture loaders do not defer it. - Optimistic locking.
lock_versionis not modeled, soStaleObjectErroris left undefined and a reference to it stays an error. ReadOnlyErroris defined, but nothing here prevents writes, so it is never raised. That matches Rails outside awhile_preventing_writesblock.
A has_many/has_one dependent: runs in the owner's before_destroy,
in declaration order. :destroy destroys each child, running its
callbacks. has_many … dependent: :delete_all and has_one … dependent: :delete delete the children in one statement, and :nullify sets their
foreign key (and the type column for as:) to NULL, both without
callbacks. Both are the reader's own query (Target.where(fk: @key)) with
delete_all / update_all on it. A where ahead of a bulk write
(delete_all, update_all, destroy_all, touch_all) stays a relation,
not a SELECT folded into its rows (lower::arel::requires_relation_receiver).
Three gaps remain: a through: association's dependent: (Rails deletes
or nullifies the join rows), :restrict_with_exception /
:restrict_with_error, and :destroy_async all do nothing.
ingest::current_attributes turns class Current < ActiveSupport::CurrentAttributes into a plain singleton, and reset
used to set the slot to nil. Rails' CurrentAttributes.reset puts every
attribute back to its default, which a fresh instance also does — so at
runtime the two agree, at the cost of one allocation per request.
They do not agree in the type system, and that is why it changed.
@__instance's type is the union of what the class assigns it, so the
single = nil made self.instance answer Current | Nil; the
class-level forwarders are all Current.instance.<name>, so every one
of them registered Untyped; and campfire routes essentially all
per-request state through those forwarders. One nil in a synthesized
method, and Current.user.rooms — plus every ivar downstream of one —
had no shape.
Current.<attr> keeps its Nil arm, unlike the controller-wide ivar
seed, which strips it. The write sites are the seed (Current.user = bot in the authentication concern says User | Nil), and something
reads that arm: def signed_in?; Current.user.present?; end folds to
the constant true against a non-nilable type — a correct fold of an
incorrect type, which signed every visitor in and stopped the join-code
page from 404ing. A lie the type system can act on is worse than a gap.
Another Enumerable core_ext reopen the transpiled runtimes cannot host
— same home and same rule as index_by beside it, the receiver
evaluated exactly once. Rails writes the no-block form as a
short-circuiting any? with a counter so it stops at the second hit;
the receivers that reach here are already materialized, so length > 1
answers the same question.
The BLOCK form (many? { … }) is not rewritten: it counts matches
rather than elements, which is a different question no corpus app asks,
so it stays visible. Neither is a receiver that is not an Array —
ActiveSupport.many? names an Array parameter, and a Relation has its
own surface.
Hash#to_json is a core_ext reopen — Ruby's json library adds it,
ActiveSupport replaces it — and a reopened builtin is the one shape no
strict target can host and spinel cannot dispatch on. lower::to_json
rewrites it to the bundled JSON package every emitted tree already
requires, which takes the same collection and answers the same String.
The gate is the receiver's type: Ty::Hash or Ty::Array only. A MODEL
receiver is deliberately excluded — record.to_json is Rails'
as_json-then-encode, which the as_json_* passes own and which
answers the model's declared shape rather than its ivars — and so is an
untyped receiver, where the rewrite would be a guess.
The divergence: ActiveSupport walks as_json first, so a Time
value renders in Rails' ISO-8601 form where JSON.generate refuses it.
The corpus receiver (campfire's Webhook#payload, which is the entire
request body a bot receives) holds strings, integers and nested hashes
of the same; a value JSON cannot encode raises rather than rendering
wrongly.
Inline render json: <object> is written down, not walked. Rails
encodes a plain object through Object#as_json — instance_values,
reflection over every ivar — and then a structural walk of the Hash.
The runtime's ActionController::JsonRender.encode reproduces that walk
(respond_to?(:as_json), case … when Hash). No strict target can bind
it, and until the spinel tree loaded it too, the constant was unresolved
there and the site was a 500 (campfire's POST /unfurl_links, every
pasted link).
The compiler already knows the key set — it is the class's declared
attr_* readers — so lower::as_json_poro writes both halves down: an
as_json Hash over those readers, and an as_json_str writer in the
same io << "\"title\":" << JsonBuilder.encode_value(…) accumulator
shape jbuilder templates lower to. The site becomes the Rails idiom for
an already-encoded body, render plain: v.as_json_str, content_type: "application/json", which the controller rewrite lowers on every
target; JsonBuilder is shared runtime, so the ruby lane runs the very
writer the compiled lane does. Demand-gated and type-gated: only a class
the analyzer typed at a render json: site is given the pair. A value
with no writer — a collection containing objects or temporal values, a Relation of a class with no writer, a class with its own
as_json (whose pairs as_json_shape recognizes but whose computed
values are not yet typed, see that module) — keeps the runtime
encoder, which the strict targets do not have; the suite ledger's
render-json-encoder rule is the tripwire for it.
A table-backed record with no as_json is its columns. Rails
serializes one through serializable_hash: every attribute in
attribute_names order (the primary key, then the schema's columns), each
as as_json writes it, except the inheritance column (type), which
ActiveRecord's serializable_hash always excepts. lower::as_json_record writes that down as the same
as_json_str writer over the columns, so render json: @article, an Array
of records and a Relation (render json: @stories) are the writer's text
on the ruby family, spinel, Rust, TypeScript, Python, Crystal, C#, Kotlin, Swift, Elixir and Go. The other strict targets report a call
to the writer as json_writer unsupported: no other strict toolchain has
built it yet, and an unsupported error is what tells the ledger so. Rust
reports a methods: writer the same way: a model method's return reaches
JsonBuilder::encode_value untyped, where Rust wants a serde_json::Value.
The writer is HTML-entity escaped and writes a datetime as
the jbuilder writer does (Z for a UTC app, the zone's offset otherwise),
both of which the runtime encoder got wrong for a record, and it encoded a
Relation as its to_s. A model keeps the runtime encoder when Rails would
answer differently from the columns alone. That covers a time, decimal,
json/jsonb or binary column, a references column the schema keeps
under its logical name, and an attribute, serialize, store,
store_accessor, encrypts or composed_of declaration, a renamed
inheritance_column or ignored_columns, and an overridden reader of a date
or datetime column (the writer reads the stored value, while Rails calls the
reader). It also covers a column whose reader is not typed as a JSON scalar.
A value holding a record, a Time or a Date is written down too. A Hash
or Array literal, or a typed Array/Hash with String or Symbol keys, that
reaches a record with a writer, a Time or a Date is respelled by
lower::as_json_value as the text Rails' to_json writes. That text is
built from each part's static type: a record through its writer, a
collection as […]/{…}, a Time through ActiveSupport::JSON.time_fragment
(xmlschema(3), Z at offset zero), a Date as its ISO date and nil as
null, and a scalar through JsonBuilder.encode_value, HTML-escaped. A
nullable record read from a variable is null or its writer's text. A part
with no encoding (an untyped value, a nullable record computed in place, a
key that needs escaping) leaves the whole site to the runtime encoder. The
text goes through ActiveSupport::JSON.written, an identity that only the
ruby family and spinel load, so a strict target reports the site unsupported
instead of emitting code no strict toolchain has built.
<value>.to_json anywhere in the app writes the same text, and falls back to
JSON.generate for what has no encoding here. A String handed to render json: is the body as it is, as Rails' json renderer leaves a String
unencoded; a value that is a String only at run time goes through
JsonRender.body, which tests for one. JsonBuilder.encode_value writes a
non-finite Float as null. time_json writes Z only for a UTC Time or a
UTC app zone, so London in winter is +00:00, as TimeWithZone#as_json
writes it. A payload of primitives alone goes through JSON.generate
(below), which raises on a non-finite Float where Rails writes null, so
each Float a literal or a typed Array[Float] holds is handed over through
JsonBuilder.finite_float, nil for NaN and the infinities; to_json on a
Hash or Array does the same, including the operand of a splat
([*ratios, 2.5]). Three divergences remain. A Float inside a Hash or
nested Array held in a variable is not reached, and still raises when it
is not finite. On Spinel a splatted Float? Array spreads its nil as
0.0 until matz/spinel#9114 lands. A runtime Time carries an offset and
no zone, so in a UTC app zone a Time moved to London with in_time_zone
is written Z in winter, where Rails writes +00:00.
only:, except: and methods: given as literal Symbol lists, either to
render json: <record> (Rails hands a render's other options to to_json)
or to <record>.to_json(…), get a writer of their own, named for the
options. It keeps attribute_names order, or the only: list's order as
Rails' Array(only) & attribute_names does, lets only: override the
excepted inheritance column, and appends each methods: reader whose type
is a JSON scalar. Its own columns decide whether it is built, so only:
that leaves out a decimal column is written down even though the
full-column writer is not. A site with any other serialization option (include:,
root:, a list that is not literal) keeps the runtime encoder rather than
the full-column writer.
Inline Hash/Array payloads whose inferred contents are JSON primitives use
the target's existing JSON.generate encoder, including nested primitive
collections. The shared JsonBuilder.escape_html_entities helper then
escapes <, > and & to their JSON Unicode forms, matching Rails' default
HTML-entity escaping without re-escaping the encoded document. Unknown values
and values requiring Rails as_json hooks do not take this path. The generic
render_json_primitives regression runs on CRuby and compiled Spinel, checks
the exact bytes for <b>&</b>, and retains a CRuby nested-Time serialization
control.
Remaining divergences: non-finite Float values (NaN and positive/negative
Infinity) still raise JSON::GeneratorError instead of Rails' null because
this path delegates primitive encoding to JSON.generate. The helper applies
the Rails 8.1+ defaults: HTML-entity escaping enabled, U+2028/U+2029 escaping
disabled. Per-application changes to those Rails encoder settings are not
reflected here.
The CRuby, JRuby and spinel trees load the same ActionController::JsonRender
(runtime/spinel/action_controller_json_render.rb), so they write the same
bytes for a value outside the primitive path. Its divergences from Rails: an
object with no as_json is written as its to_s, where Rails walks its
instance variables, and the text is not HTML-entity escaped, unlike the
primitive path above.
runtime/ruby/active_storage.rb models the attachment ROWS and the
BLOB (ActiveStorage::Blob: key, filename, content type, byte size,
the metadata column's width/height); ActiveStorage::Service is the
bytes seam, whose methods RAISE in the shared runtime (it does no file
I/O) and are reopened by the ruby family in
runtime/spinel/active_storage_disk.rb with Rails' own
storage/files/<xx>/<yy>/<key> disk layout (tmp/storage under
RAILS_ENV=test). Blob.create_and_upload! writes the row and hands the
bytes to the service; Attached#attach / attach_blob write the
attachment row. A has_one_attached model gets an <attr>= writer
(lower::attached) that stages an attachable — an
ActionDispatch::Http::UploadedFile from a multipart part, a Blob, a
signed id — and an after_save that attaches it once the record has
an id, so create!(attachment: file), update!(avatar: file) and a
permitted :avatar param all reach the store.
Variants are Rails' VariantWithRecord; the pixels are a seam. The
has_one_attached … do |attachable| attachable.variant :thumb, resize_to_limit: [w, h], format: :webp end block is lowered into the
reader's constructor as ActiveStorage::Variation values
(lower::attached; the dimensions ride as the expressions the source
wrote, so a concern's constant resolves inside the model that includes
it). variant(:thumb) answers a VariantWithRecord over the blob and
that variation; .processed finds the active_storage_variant_records
row by (blob_id, variation_digest) or makes it — download,
ActiveStorage::Processor.transform, Blob.create_and_upload!, the
record and its image attachment row — exactly Rails' find-or-create.
Processor RAISES in the shared runtime (decoding an image is not
something it can do, and serving the original under a thumbnail's name
would be the failure that looks like success); the ruby family reopens
it over ruby-vips — runtime/spinel/facades/active_storage_processor _vips.rb, swapped in for the comment-only runtime/active_storage _processor.rb by project.rs when the app declares any variant, with
ruby-vips added to spin.toml (the spinel-ruby-vips spin package, a
subset of the gem over the system libvips) or the Gemfile (the gem).
resize_to_limit / resize_to_fit lower (libvips thumbnail, fit
within, never enlarge); resize_to_fill, rotate and saver options do
not, and a variant carrying one is left undeclared so the runtime's
ArgumentError names it. A variation with no format: keeps a web
image's own format and transcodes anything else to PNG, Rails'
default_variant_format. The representation route carries
Variation#encode where Rails carries a signed transformation key,
decodes it and processes on request, as Rails' controller does; the
digest is that same encoding, so a database shared with a Rails
process keeps one record per digest scheme. Purging a blob purges its
variant records, their image blobs and their files first. The app's
libvips loader policy (Vips.block_untrusted(true), Vips.block(op, true) in an initializer) is lifted at ingest onto the Rails ::Application reopen and applied when the processor loads. The
processor also wraps Vips.vips_foreign_find_load so a blocked
loader is not selected: libvips 8.15+ skips BLOCKED classes in
vips_foreign_map, but 8.14 still names Magick/Svg after
block_untrusted even though load itself raises. The wrap reaches
the C finder through VipsExt (the spinel package's binding, or an
FFI stand-in on the gem). Wrapping the Ruby finder in place re-enters
the wrapper on the spinel package. variable?
is Rails' content-type question, answered from variable_content_types
minus what the app's initializer subtracts (ingest lifts
config.active_storage.variable_content_types -= %w[…] onto the same
reopen), so a bmp avatar falls back to initials exactly as in campfire.
preview (a video poster) is Rails' Preview: the poster is drawn once
by ActiveStorage::Previewer.poster — the ruby family's reopen runs the
same ffmpeg command Rails' VideoPreviewer does, with the same frame-
selection filter (runtime/spinel/active_storage_previewer.rb) — stored
as the blob's own preview_image attachment, and served as a VARIANT of
that image under the transformations the call named (preview(format: :webp, resize_to_limit: [w, h]), which lower::attached turns into a
Variation at the site). previewable? is video?; a PDF has no
previewer here. ffmpeg is a runtime prerequisite the way libvips is:
campfire's Dockerfile installs it, so does the archive's, and the
conformance job. Without it the previewer raises, as Rails does. The
image DIMENSIONS are read from the file header at upload
(ImageAnalyzer, ruby family: PNG/GIF/JPEG/BMP/WebP), and a video's
from ffprobe on its first stream (Rails' VideoAnalyzer; no ffprobe
leaves them empty, as a failed analyzer does), so metadata[:width]
answers what Rails' analyzer would. A named preview (preview(:poster))
resolves the variant the has_one_attached block declares, and
processed draws the frame and then makes that variant, as Rails'
variant.processed if variant? does. processed? and a variant's
image look the record up and make nothing, so a view that asks "was it
made?" never makes one.
Where the preview still differs. The poster is drawn by
Previewer.poster, not through the app's previewer class: an app
subclass that overrides Rails' private capture (campfire's
TimeLimitedVideoPreviewer, which kills ffmpeg past a time limit) is
not what runs, and ActiveStorage.paths[:ffmpeg] is not modeled. representation(...) answers the
variant whatever the blob is, where Rails answers a Preview for a
previewable one — kept so the reader has one type (a union would box
every image on the room page); campfire only asks for a representation
off its video?-guarded branch. The account-logo tests that decode the
served PNG and assert 512×512 / 192×192 pass on both lanes, on the real
variant's bytes — the numbers ruby-vips and the port answer for the
same input are byte-identical (spinel-ruby-vips' oracle lane holds it
to that).
Rails' per-request query cache keeps every SELECT's result and replays
an identical SELECT from it. The three ruby-family Db shims keep the
same discipline with one bound: a result that grows past
QC_CAPTURE_ROWS (16) is not kept, and an identical SELECT later in the
same request runs again rather than replaying. The lookups a page
repeats — campfire's Account.first 22 times on a room page, a
message's room 20 times — are a row each and replay exactly as before;
what changed is that the 100-message and 100-rich-text results are no
longer copied cell by cell for a replay nothing asks for (measured:
~2,700 String allocations per /rooms/1 request, a quarter of the
total). The only observable difference from Rails is a repeated
large SELECT within one request costing a round trip, which
capture_sql would count where Rails' SQLCounter would not.
RQRCode::QRCode.new(text).as_svg(…) — campfire's QrCodeController
renders a room's join link this way, lobsters its 2FA enrollment — was a
raising façade on spinel (runtime/ruby/rqrcode_facade.rb, the
write-path rule every gem façade follows) and the real gem on CRuby and
JRuby. It is now real on spinel too: when an app names RQRCode,
spin_shape swaps the façade file for require "rqrcode" and declares
the spinel-rqrcode spin package (github.com/rubys/spinel-rqrcode;
matz/spin-index#8 is the registration, so the manifest uses the git form
until it merges), the same seam bcrypt and ruby-vips use.
The package carries its encoder — Project Nayuki's QR-Code-generator,
the single MIT-licensed C file Debian ships as libqrcodegen — so unlike
ruby-vips it asks nothing of the machine. Its output is byte-identical
to the gem's, and that took two rules, not a library swap: Nayuki's
encoder and rqrcode_core agree on the modules for a given text, level,
version and mask, but the gem picks its VERSION with a strict-less-than
against capacity (an exact fit goes up a version) and its MASK with its
own penalty scoring on a matrix whose format/version modules are blank.
The package's glue reproduces both, and its oracle lane holds the
compiled port to the gem across three modes, four levels and versions
1–35 (a randomised 800-pair sweep across 1–40 agreed while it was
built). Measured on the compiled campfire binary: GET /qr_code/:id
serves the same 24,518 bytes the gem renders for the same link.
What is still not on the wire from that endpoint is the Cache-Control
header — expires_in records it and emits nothing, the entry below —
which the suite's test reads through the harness rather than the wire.
runtime/spinel/action_cable.rb has had ActionCable.server,
Server#broadcast, #remote_connections, RemoteConnections#where and
RemoteConnection#disconnect since the cable work. None was in the
analyzer's class registry, so campfire's
User#close_remote_connections — the one caller — read out as
no known method server on Class { ActionCable } about a chain the
emitted tree resolves. Registered, not implemented: the divergence that
the selected connection set is always EMPTY is recorded with the cable
notes, unchanged.
runtime/ruby/ipaddr.rb implements the slice of Ruby's stdlib class the
corpus reaches: IPAddr.new(str), loopback?, private?,
link_local?, ipv4?/ipv6?/ipv4_mapped?, to_s, and
IPAddr::InvalidAddressError. campfire's Ban#ip_address_is_public
is the caller.
The rule table is the stdlib's, verbatim — every prefix copied from
ipaddr.rb's own masks and comments (127.0.0.0/8; 10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16, fc00::/7; 169.254.0.0/16, fe80::/10; and
the IPv4-mapped form of each). Deriving them would be the inflector
mistake in a more expensive place: an address wrongly called public is a
ban that does not take, and nothing raises. runtime/ruby/test/ ipaddr_test.rb asserts the stdlib's own answers over 42 addresses that
sit on both sides of every boundary, generated by running Ruby's
IPAddr over the list.
The representation is not the stdlib's. Ruby keeps one Integer, 128 bits wide for IPv6; no strict target has that, and a bignum here would cost nine runtimes a primitive to answer three predicates. The port keeps octets — every rule in the table is a prefix test, so they answer it exactly as well.
What it does not do, and where that shows: no prefix/netmask
(IPAddr.new("10.0.0.0/8") raises InvalidAddressError where Ruby
masks the address), no include?/to_range/succ/arithmetic, and
to_s renders IPv6 uncompressed. Rejecting a prefix loudly is the
deliberate choice over answering about 10.0.0.0 when the caller wrote
10.0.0.0/8.
The CRuby and JRuby trees get Ruby's own instead. project:: ruby_runtime_files rewrites the emitted tree's ipaddr.rb to a one-line
require "ipaddr" — the same "one require path, target-appropriate
implementation" split the emitted db.rb carries
(runtime/spinel/db_cruby.rb here). That is not tidiness:
something on the CRuby side already loads the stdlib's ipaddr, and two
definitions of IPAddr::InvalidAddressError with different superclasses
is a TypeError: superclass mismatch at REQUIRE time. campfire's suite
went 219/240 to 0/240 on it, every file dying on the same line.
securerandom.rb defines one module, Random::Formatter, and extends
BOTH Random and SecureRandom with it — uuid is the same code
either way, and only the byte source underneath differs. (It is also why
Random.uuid is undefined until something requires securerandom;
campfire's Message#client_message_id ||= Random.uuid works because
Rails already has.) Nothing on any target defines it, so
lower::random_formatter rewrites the receiver to SecureRandom, the
name the emitted tree already carries.
The divergence is the generator: Rails' call reads Random's default
Mersenne Twister, ours reads the OS CSPRNG. The value is a v4 UUID
string either way and every consumer treats it as an opaque id, so this
is the safe direction — but an app that wanted a reproducible uuid out
of a seeded Random would not get one. Only the names Random cannot
answer on its own are rewritten: Random.rand, Random.bytes,
Random.new_seed, Random.srand and Random.random_number are real
methods on the PRNG class with their own meaning.
Rails issues UPDATE … SET col = col + 1, so two concurrent callers
both land. lower::column_ops rewrites the call site to self.col = self.col + 1; touch, which reads, adds and writes — the same answer
with one writer, a LOST UPDATE with two.
Why the call site at all. The alternative is a shared
increment!(name, by, touch:), whose column parameter means
self[name] = … — an index write through a variable key, the shape
that keeps touch no-arg (see below). At the call site the column is a
literal, so the write is an ordinary typed attribute assignment.
Only the touch: true spelling is claimed, which is what the corpus
writes. The bare increment!(:col) keeps its NoMethodError: reproducing
it would mean persisting the counter WITHOUT stamping updated_at, and
a silently wrong timestamp is worse than a missing method.
lower::model_to_library::markers expands the option onto the four
hooks Rails registers it on — after_create, after_update,
after_destroy and after_touch — each binding the belongs_to reader
to a local, guarding it for nil and calling the parent's no-arg
touch. touch: :some_column stamps that column through its writer
first, from ActiveSupport.db_now, so it and updated_at carry one
instant.
after_touch is the load-bearing one. It is what makes the cascade
TRANSITIVE, and ActiveRecord::Base#touch fires it: campfire's boost
touches its Message, whose own belongs_to :room, touch: true carries
the stamp on to the Room. Without it the chain stops one level short —
and no behavioral test can see that, because nothing renders
updated_at. What does see it is a fragment cache keyed on
cache_key_with_version.
Two residues, both smaller than they look:
touchruns COMMIT callbacks on the Ruby family only. Rails firesafter_update_commit/after_commitafter a touch, held, like every commit callback, until the enclosing transaction's COMMIT; the Ruby family'stouchdoes (connection.rb), the strict targets' firesafter_touchonly. On the Ruby family a save, destroy and touch each run insideModel.transaction, an exception from their callbacks rolls the whole write back, and a ROLLBACK drops the held commit callbacks.- The update hook is unguarded. Rails registers its touch with
if: :saved_changes?and skips the parent when the child's save changed nothing. This runtime'ssaveissues an unconditional UPDATE, so there is no no-op save for the guard to catch and the condition would never be false. A save that writes the same bytes therefore moves the parent'supdated_atwhere Rails leaves it — which busts a cache entry early rather than serving a stale one.
Rails also SKIPS the create and destroy registrations when the
association carries a counter_cache: (the counter update touches on
its own). This runtime has no counter-cache support, so it registers
all four unconditionally, which reaches the same observable row.
record.signed_id(purpose:) and Model.find_signed(id, purpose:) are
rewritten at the call site by lower::signed_id — Rails'
combine_signed_id_purposes reads self.class.name, and this runtime
is deliberately reflection-free, so the model name is folded into the
purpose string at compile time (signed_id(purpose: :avatar) in
User::Avatar becomes the literal "user/avatar").
The WIRE FORMAT is not a divergence: ActiveRecord::SignedId signs
through the same envelope the cookie jar uses, and
runtime/ruby/test/action_controller/message_verifier_test.rb pins the
emitted bytes against tokens minted by a real ActiveSupport 8.1.3. A
token this runtime writes is one Rails reads, and vice versa — which is
what a migration needs, because campfire puts a signed id in the URL of
every rendered avatar.
What diverges is the FAILURE. ActiveRecord::SignedId.verified_id
answers 0 for a token that does not verify — tampered, wrong purpose,
or expired — the same non-nil-sentinel posture as the verifier's "".
The lowered find_signed! is therefore Model.find(0), which raises
ActiveRecord::RecordNotFound where Rails raises
ActiveSupport::MessageVerifier::InvalidSignature.
CLOSED for the BANG form. find_signed! verifies through
SignedId.verified_id!, which raises
ActiveSupport::MessageVerifier::InvalidSignature for a token that does
not verify and leaves RecordNotFound to a token that DOES and names no
row — Rails' own split. The name is the whole point: campfire's
Users::AvatarsController rescues the signature error BY NAME over an
avatar URL carrying a signed id, and against a RecordNotFound that
rescue never fired.
Still divergent: the non-bang find_signed reads the same 0
sentinel through find_by(id: 0), which answers nil — the same answer
Rails gives, by a different route. A row whose id really is 0 would
be indistinguishable, and no schema here mints one.
Rails' expires_at: (an absolute instant) is not claimed; only
expires_in:. A call passing it is left alone rather than rewritten,
so it fails by name instead of silently minting a token that never
expires.
has_secure_password generates <attr>_reset_token,
find_by_<attr>_reset_token(!) and <attr>_reset_token_expires_in
(lower::secure_password, over runtime/ruby/active_record/token_for.rb).
The WIRE FORMAT is Rails' own, and tests/rails8_authentication.rs holds
it to a token minted by Rails 8.1.4: the signed-GlobalID envelope (url-safe
base64 with padding, HMAC-SHA1) under the active_record/token_for salt,
data [id, password_salt.last(10)], purpose "User\npassword_reset\n900".
Only reset_token: true, the default, is reproduced. reset_token: false
defines nothing, as in Rails; a custom expires_in: hash is left without
methods rather than given the default lifetime. General
generates_token_for :purpose do … end is still analyzer-typed with no
runtime. <attr>_reset_token_expires_in answers the Integer 900 where
Rails answers the Duration 15.minutes; every reader in the generator
takes seconds.
normalizes :email_address, with: ->(e) { … } (lower::normalizes)
becomes Model._normalize_<attr>(value), called by the column writer and
wrapped around the matching keyword of find_by/where/exists?/
find_or_*_by. Hydration assigns through that same writer, so a row
loaded from the database is normalized too, where Rails leaves an
existing row alone until the attribute is reassigned. The two agree on
every row the app wrote after the declaration. A with: that is not a
one-parameter lambda literal, and apply_to_nil: true, are not
reproduced (the declaration is left without effect).
assert_enqueued_emails and assert_enqueued_email_with read
ActionMailer::Base.deliveries, which deliver_later appends to
immediately (there is no queue in one process). So a deliver_now
counts as enqueued too, and assert_enqueued_email_with checks that a
mail went out, not the mailer, action, or arguments — the narrowing
assert_enqueued_with already documents for jobs.
On the CRuby overlay disconnect broadcasts on the selected identifier's
internal channel and the matching sockets close themselves (see "An open
socket outlives the authorization that opened it" below). On spinel
ActionCable.server.remote_connections.where(current_user: user) .disconnect still returns without closing or broadcasting anything: no
tep socket registers its identifier anywhere a broadcast could find it.
What it costs. campfire calls this from User#deactivate and
User#reset_remote_connections: on spinel a deactivated or banned
user's live socket stays open, and campfire's room-destroy test, which
asserts the internal-channel broadcast, fails there.
ActionCable.server.broadcast(stream, payload) — the low-level publish
that is NOT the Turbo Stream family — carries its payload differently on
the two Action Cable substrates.
The CRuby overlay keeps the Ruby Hash the whole way to the transport and
lets it serialize. runtime/spinel/action_cable.rb cannot: a payload
parameter that must hold {room_id: 1} today and whatever the next app
writes tomorrow is exactly the untyped bag a strict target has no lane
for. So the Hash is rendered to JSON text at the boundary
(ActionCable.payload_json) and everything downstream carries String.
Cable.publish_raw splices that text UNQUOTED into the envelope's
message field, which is what keeps the wire shape a JSON object
rather than a JSON string — the silent failure the overlay's own header
warns about.
What it costs. Broadcasts::LOG records the rendered text where the
overlay records the Hash, so a test that reads entry[:payload] and
subscripts it passes on CRuby and does not on spinel. Both entries carry
action: :message and the stream, which is what assert_broadcasts
reads, so the test helper itself agrees across the two.
The text is what Rails writes (#619). Rails encodes a broadcast with
ActiveSupport::JSON, so every value is JSON (a String, nil, a Float, a
Symbol, a nested Hash or Array) and <, > and & inside strings come
out as \u003c, \u003e, \u0026. Both lanes write exactly that with
JsonBuilder.escape_html_entities(JSON.generate(...)): spinel's
payload_json and Turbo Transport, the overlay's Registry.deliver,
and both lanes' pubsub.broadcasts. payload_json used to write
Integer values only (value.to_s), enough for campfire's two call sites;
a String value - a terminal relayed over a channel broadcasts its output
as one - came out as invalid JSON, which the client drops.
Active Storage's route helpers are not in the app's config/routes.rb,
so the generator that reads it never emits them. RouteHelpers .rails_blob_path / _url and rails_representation_path / _url
live on the RouteHelpers reopen in runtime/ruby/active_storage.rb
and answer Rails' own URL shapes:
/rails/active_storage/blobs/redirect/:signed_id/*filename
/rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename
/rails/active_storage/disk/:encoded_key/*filename
The three controllers behind them (ActiveStorage::Blobs:: RedirectController, Representations::RedirectController,
DiskController) are ruby-family runtime (active_storage_disk.rb);
ActiveStorage::Routes.table is appended to Main.route_table by
both dispatchers and the test harness, and their instantiate_ controller falls through to ActiveStorage::Routes.instantiate_ controller for a symbol no app arm names. The first two answer a 302
to the disk route, as Rails does; the disk route serves the bytes with
Content-Disposition (what makes a Download link download) and the
hour of public caching the app's initializer asks for. The router
gained *glob segments for the trailing filename.
Signed ids are the signed_id envelope under Active Storage's own
salt (blob_id for the blob, disk_key with a five-minute expiry
for the disk token); Blob.find_signed verifies them.
Direct uploads. The same table mounts Rails' direct-upload pair:
POST /rails/active_storage/direct_uploads
PUT /rails/active_storage/disk/:encoded_token
DirectUploadsController#create allocates the row from what the
browser declared (Blob.create_before_direct_upload! — key, size,
MD5 checksum, type; no bytes yet) and answers Rails' JSON: the blob's
attributes, its signed_id, and under direct_upload the PUT url
and the Content-Type header to send. That url carries a second
token (blob_token) signing the key, type, length and checksum, and
DiskController#update holds the PUT to it: a token that does not
verify is a 404, a body whose type or length differs from the
declaration is a 422, and a checksum that does not match after the
write is Rails' IntegrityError — the file is removed and the answer
is 422. Both tokens are minted by ActiveStorage::DiskKey, which is
shared runtime (runtime/ruby/active_storage.rb) so that Blob#url
can be Rails' SERVICE url — the disk route, absolute — on every
target; only serving the routes is the ruby family's.
Blob#url is absolute through ActiveStorage::Current.url_options,
which the engine controllers set from the request (Rails'
SetCurrent) and a caller outside a request sets itself. Divergence,
named: Rails raises when the disk service is asked for a url with no
options set; here the url is path-only, which a browser resolves
against the page. The checksum is Digest::MD5.base64digest on both
lanes — spinel's digest package bound it the day it was asked for
(matz/spinel#4631, 00e00631); a pure-Ruby port stood in for one
commit.
The guard. Rails mounts these two endpoints on every app whether
or not its forms use direct uploads, so campfire — whose composer
uploads through MessagesController — guards them in
config/initializers/active_storage_authentication.rb: an include
of its session check into each controller and a before_action that
answers an anonymous caller 401. Both lines are the initializer's own
and both are performed. lower::module_mixins already kept an
include/prepend onto a runtime class it credits; it now keeps a
before_action too, when the target is a runtime controller that
asks initializer_filters(action_name) before its action (the two
direct-upload controllers) and a kept mixin onto that target defines
the method. The emit writes one reopen per target at the end of
boot.rb redefining that seam — only: rendered as the lowered
controllers spell it — so the framework controller runs the app's
filter with the app's cookies and models, on both lanes. A filter
nothing supplies is dropped and reported, as a dropped mixin is.
url_for(attachment) / image_tag(attachment) — Rails'
polymorphic_url on an attachment — are grounded at the call site by
lower::attached_url: an attachment-shaped argument (a has_one_ attached reader by NAME, or a variant chained off one) in a URL
position becomes <expr>.url, so the String-typed helper gets a
String. A helper parameter that only HOLDS one (campfire's
broadcast_image_tag(image, …)) reaches the ruby family's
polymorphic_url reopen, which narrows by class at run time.
Multipart. runtime/spinel/multipart.rb parses
multipart/form-data on both serving paths (tep's three body drains,
CgiIo.parse_request); file parts land in the params tree as
UploadedFile objects under their bracket-nested name, text parts as
Strings. <Resource>Params types a permitted has_one_attached field
as UploadedFile? and reads it through UploadedFile.from_params —
the one narrowing of a params value to that class, kept out of
Params because the strict targets' ParamValue union has no file
arm. form_with renders enctype="multipart/form-data" when its block
(or a partial the block renders) calls file_field, which is what
Rails' builder does for it. ActionController::Base#headers now reach
the wire on both dispatchers (nil values dropped — campfire's X-Rev
is an unset ENV outside its deploy).
Rails 7.1+ defaults has_secure_token to on: :initialize, so
Session.new.token already holds a token. lower::secure_token
expands the macro into a before_create default instead, so the token
is readable from the moment the record is SAVED. Every corpus call site
reads it after a save, which is what a session token is for. on: :create is exactly this lowering, and on: :initialize gets it too —
keeping half of Rails' own default would be the worse divergence.
The generator is SecureRandom.alphanumeric(<length>), not Rails'
SecureRandom.base58: base58 is not core Ruby (it arrives with
active_support/core_ext/securerandom, which the emitted app does not
load), while alphanumeric is core and already carried by every target.
Same length, different alphabet.
Why it matters that this expands at all. A dropped
has_secure_token is not an absent method, it is a wrong column value:
this runtime defaults a string slot to "", so every unsaved record
carries the same token and the second INSERT dies on the table's UNIQUE
index.
Rails matches both job: and args:. This runtime's helper
(runtime/spinel/test/test_helper.rb) matches the job class only.
Why. Matching the arguments needs the enqueue log to carry them, and a record argument would then compare by object identity — the test's fixture and the controller's freshly-loaded row are different objects where Rails compares serialized GlobalIDs. A check that fails for the wrong reason is worse than a narrower one that says so.
What it costs. A job enqueued with the RIGHT class and the WRONG arguments passes here and fails in Rails. Nothing in the corpus depends on the distinction today; campfire's one site asserts a ban job for a user, and the class is unique to that path.
Rails picks its adapter per environment: :test enqueues without
running, the app's runs the job for real. This runtime does the same
with one seam. lower::job_class_side synthesizes
def self.perform_later(a, b)
ActiveJob.record_performed("X")
new.perform(a, b) if !ActiveJob.enqueue_only
nil
end
ActiveJob::ENQUEUE_ONLY is an empty suspension stack by default, so a
running app dispatches at the call site — there is no queue daemon
in-process, which is the :inline adapter's semantics. The emitted
test harness pushes onto that stack at load, so the suite enqueues.
Why the difference is load-bearing. campfire's Message carries
after_create_commit -> { room.receive(self) }, whose tail is
Room::PushMessageJob.perform_later. Under inline dispatch every
message a FIXTURE loads runs Room::MessagePusher, and the suite dies
in that job's unresolvable nested join before a single assertion runs.
Rails' own suite never reaches the code, for exactly this reason.
What it costs. perform_later answers nil rather than the
perform's value. Rails answers the job (or false), never the result,
so nothing portable reads it — and a Nil return is what lets the
guarded call sit in statement position instead of forcing a
<perform-return> | nil union on the strict targets.
perform_now is ungated in both environments: its Rails semantics is
already "run now".
AND A SERVED APP IS THE THIRD ENVIRONMENT, which this framing missed.
Rails' production adapter does not run the job in the request either — it
enqueues, and a worker runs it. Inline dispatch is therefore closest to
Rails only for an app with no queue at all; for campfire it puts
Room::PushMessageJob inside POST /rooms/:id/messages, where it dies on
uninitialized constant Net::HTTP::Persistent and 500s a request whose
broadcast had already gone out. scripts/campfire-cable-walk splices
ActiveJob.enqueue_without_running for that reason and says so in its
header. What this runtime does not have is the third option Rails
actually uses: enqueue now, run elsewhere, later.
On the ruby, jruby and spinel lanes, a served app's perform_later
queues an ActiveJob-format JSON payload: the job.serialize hash of
activejob 8.1.3, with records as GlobalIDs (lower::job_payload,
runtime/spinel/active_job_serialization.rb). The drain looks the
records up again when it runs the job, so the job sees committed state,
and a record deleted in between raises
ActiveJob::DeserializationError, which discard_on drops. The codecs
come from the job's perform_later call sites, and
tests/active_job_payload.rs holds the format to Rails' own output.
What is still missing.
- Durability. The payloads sit in a process-local Array. Nothing is
retried (
retry_onis inert), nothing is scheduled (set(wait:)is dropped), and nothing survives a restart. A payload is what an external adapter such as Sidekiq would store; no such adapter exists yet. - Some argument types. A job whose call sites pass a Hash, a Date,
an untyped value, or nothing typed at all keeps its closure, with a
job-closure-fallbackwarning naming the parameter. Lobsters'ResticJob,WalCheckpointJobandMastodonSyncListJobare the corpus cases. - One queue.
queue_nameis recorded, but every entry drains in arrival order. - The test adapter keeps closures.
assert_enqueued_withstill checks the job and not its arguments (above). enqueue_after_transaction_commitis honored for payload jobs insideModel.transaction, held until the outermost COMMIT. A nestedrequires_new:block runs in a SAVEPOINT, and a job held inside one that rolls back is still queued when the outer transaction commits (Rails drops it). A closure job that sets it is still queued at once.
ActiveJob::PERFORMED is the queue-inspection seam, appended by the
perform_later wrapper before the adapter gate — so it is an ENQUEUE
log in both environments. It holds class NAMES, not arguments (a class
is not a first-class value on the strict targets, and the call sites
that name one are rewritten to the string by lower::job_test_only).
perform_enqueued_jobs { … } therefore cannot replay a queue: it holds
no arguments. It switches back to inline dispatch for the block
instead, so the jobs the block enqueues run as it enqueues them — the
same observable behaviour for a block that enqueues and then asserts,
and different only for one that enqueued BEFORE the block opened.
pluck / ids / pick set a projection (or a limit) on the relation
to build their query. They now RESTORE it. This is a note about an
invariant rather than a divergence, because the alternative was not a
divergence either — it was silent corruption.
What it looked like. pluck wrote @select_sql = "users.id AS v"
and left it there. The next to_a on that same object hydrated whole
records out of a one-column row: every field blank, every id 0, no
error raised, nothing logged. campfire's
Rooms::Direct.find_or_create_for plucks user ids in find_for and
then hands the SAME relation to grant_to, which duly created
memberships for user 0.
The invariant. This Relation is deliberately mutate-and-return-self
for CHAIN methods (where, order, limit …) — the class doc says so,
and lowered chains rely on it. A TERMINAL is the other kind: it runs a
query and answers a value, and Rails builds it a query of its own. Any
terminal that has to touch relation state to build its SQL must put that
state back.
first and find(id) were the same shape and now restore too — first
puts the limit back and drops the one-row @records (a cache holding
the single row it asked for would answer a later each with one row out
of many), find pops its id predicate. Neither moved a test; they are
here so the invariant above has no known live exceptions.
The restore is not exception-safe: a raise inside the terminal leaves
the state set, because begin/ensure is not available in this file
(see the note on Model.connection in base.rb — the rescue-carrying
surface lives in the ruby-family connection.rb reopen). The push and
the pop are symmetric on every path that returns.
user.rooms, room.users, user.reachable_messages: a reader for an
association declared through: returns an ActiveRecord::Relation, not
an Array of rows. Its declared type says so, and the chains campfire
writes on top of it — Current.user.rooms.find_by(id: …),
room.users.where.not(id: …), Current.user.rooms.original — resolve
against the relation surface because of it.
Why. The direct-fk query the shared lowering synthesizes for every
has_many (Room.where(user_id: @id)) is simply wrong when the key lives
on the join table, so the Ruby family rebuilds the body as
ActiveRecord::Relation.new(Room).joins("INNER JOIN memberships …") .where("memberships.user_id = ?", @id)
(emit::ruby::library::apply_through_assoc_lowering). That rebuilt body
returns a relation. Declaring Array[Room] over it was a signature that
disagreed with the method, and nothing raised: Current.user.rooms
typed as an Array means .find_by is not a known method, so the
controller method wrapping it registered -> untyped, so the
route-param lowering had no model type to see and left the RECORD in the
path — /rooms/#<Room:0x…> where Rails writes /rooms/1.
What it costs — and who pays. The rebuild is Ruby-family only. Every
other target still carries the direct-fk body, which on Rust reads
Story::where(category_id: self.id) for lobsters'
has_many :stories, through: :tags — a column stories does not have.
That emit compiled and returned the wrong rows. With the reader typed
Ty::Relation, those targets now meet a relation at emit and report
relation_type unsupported instead, which is the ledger doing its job:
a named gap where there was a silent wrong answer. Closing it means
moving the through rebuild into the shared lowering, where every target
gets the join.
fresh_when(record) is a no-op and stale?(etag:) answers true, so
every conditional-GET request renders instead of ever answering 304.
Why. Both halves of Rails' comparison are missing. The controller
has no request object — only @request_format — so there is nothing to
read If-None-Match / If-Modified-Since FROM, and the extra-header
hash on the buffered response is never sent by the CGI harness, so
there is nothing to write ETag / Last-Modified TO. Wiring either
one is a change to the harness, not to this method.
What it costs. Bandwidth, not behavior. Always-render is the answer
Rails itself gives when a client sends no conditional header, so no
response is ever WRONG — the 304 is simply never earned. That is why
this can ship ahead of the plumbing, where a raise or a missing method
could not: three campfire controllers gate real work on stale?, and
without it they answered nothing at all.
Shape. Monomorphic on what the corpus writes — fresh_when @messages, stale?(etag: record). Rails accepts more
(fresh_when(etag:, last_modified:), stale?(record)); each gets its
own method here when a call site asks, rather than one method with a
union parameter no strict target can narrow. fresh_when's body is
EMPTY rather than a bare nil — a lone nil gives Rust an Option
with nothing to infer from (E0282 on None;), where an empty body is
a plain void.
FIXED 2026-10-09 (roundhouse-rb#679). expires_in 1.year, public: true — and response.cache_control.replace(private: true, no_store: true), the shape a before_action :set_cache_control_defaults filter
actually writes — now both reach the wire. The two TYPED
cache_control_max_age / cache_control_public readers remain, now
delegating to ActionController::CacheControlStore
(action_controller/base.rb): one bool/Integer field per Rails option
(public, private, no_store, no_cache, must_revalidate,
must_understand, immutable, max_age, stale_while_revalidate,
stale_if_error, extras) rather than Rails' one mixed Hash, so no
strict target pays for an untyped cache_control. to_header composes
the Cache-Control string in Rails 8.1.4's own branch order (pinned
against the gem in cache_control_test.rb); commit_cache_control! (a
ruby-family reopen, action_controller/cache_control.rb, beside
cookies.rb) writes it onto the buffered header store right before
each wire path's existing header copy — the CRuby overlay's main.rb,
the spinel scaffold's main.rb, and the spinel test harness all call
it. That reopen is also where response.cache_control's Hash-like
[]/[]=/delete/merge!/replace surface lives, over the typed
store.
Before this, the previously-described emitter gap (@headers[k] = v
not surviving the Rust emitter as a Hash index-assign) was the blocker
for EVERY strict target having a header-writing seam at all, not just
Cache-Control; it stands as a separate gap for those targets, which
expires_in still records into the typed store (reachable from
cache_control_max_age / cache_control_public) without writing a
header, same as before.
stale_if_error: reaches the runtime the same way
stale_while_revalidate: and the seconds argument itself do: grounded
to seconds at the CALL SITE by lower::duration::rewrite_expires_in
(1.day → 86400), the same .to_i unwrap signed_id(expires_in:)
gets, so the runtime signature stays Integer and no strict target
pays for an untyped parameter.
Recorded because it USED to answer nil, and code written against the
old behavior would now see the raise.
find(id) raises when there is no such row; find_by(conditions) still
answers nil. That is Rails' own split, and the raise is what turns a
missing record into a 404 rather than a nil that NoMethodErrors a few
frames later — campfire's Current.user.rooms.find(params[:room_id]) .users on a non-member room read "undefined method 'users' for nil",
against a test asserting assert_raises ActiveRecord::RecordNotFound.
Still divergent: the message. Rails names the model and the id
(Couldn't find Room with 'id'=3); this names the TABLE, because the
model is held as an untyped class value here and reading .name off it
would make the message a gradual site.
Rails builds a record through a relation — User.active_bots.new,
room.memberships.new — seeded from the relation's equality conditions.
There is no Relation#new here, and there cannot be one.
Why. Under spinel a class's constructor is already
sp_<Class>_new, so an instance method named new on Relation
compiles to a second sp_Relation_new and the C compiler rejects the
program outright (conflicting types for 'sp_Relation_new'). The name
is spoken for on any target that derives a constructor symbol from the
class name; a runtime method cannot claim it. Measured, not predicted:
one landed briefly and turned every spinel job red.
What it costs. Nothing at the call sites the corpus writes, because
the call never reaches a method: lower::scope_chain rewrites it away.
Inside an association-scoped class method, new becomes
new(__rel.scope_attributes) — that is where user.sessions.create!
gets its user_id. On a relation-valued RECEIVER, the same rewrite one
layer out moves the constructor to the model:
User.active_bots.new -> User.new(User.active_bots.scope_attributes)
room.memberships.new(attrs) -> Membership.new(__rel.scope_attributes.merge(attrs))
The receiver stays where it is — a relation is lazy, so reading
scope_attributes off it runs no query — and the caller's own
attributes ride on the OUTSIDE of the merge, which is Rails' order.
Supported find-or-create subset: a concrete model, or a chain of
Model.where(column: scalar_literal) calls, followed by
find_or_create_by / find_or_create_by! with a literal Symbol-keyed
scalar conditions Hash, is expanded at the call site. Keys must be
ordinary schema columns (not the primary key) and literal types must fit
the column; this pass does not cast attributes. The lookup retains every
predicate. On a miss, the concrete constructor receives a typed literal
Hash of the scope defaults and the explicit conditions (which win), so
supported constructor callbacks (block-form after_initialize, or an
instance after_initialize method) see the initialized attributes. A
directly attached single-parameter initialization block runs only for a
new record, before validation/save; an existing match is returned
without yielding. Supported positions are a statement, a local or
instance-variable assignment, or the whole method body. Shadowing an
outer local with the initialization parameter, or rebinding that
parameter, still returns the original saved record.
Still divergent: the general create seed. Rails' scope_for_create
is where_values_hash, so EVERY equality condition on the relation
pre-fills the record; the runtime still records only an association
seed (where_scope). User.active_bots.new comes back without its
role.
Outside the find-or-create subset, each with an explicit error:
find_or_create_by!with nonliteral conditions — the runtime has no bang form;- general relations, associations, and nonliteral, collection, OR or range predicates (OR/removed predicates are not guessed from an old create seed);
- initialization blocks with control flow, compound or parallel assignment, new local variables, rescue exception bindings, nested blocks, or rest/block parameters;
- block locals and optional, post, keyword, block and anonymous-rest parameters, rejected at ingest before their unrepresented signature fields are lost;
- forwarded initialization blocks (
&proc,&lambda,&->and other block arguments); - effectful attribute or index assignment targets.
Symbol-form after_initialize callbacks are not yet lowered and keep a
model out of this path. Blockless non-bang lowering and the runtime
association finders are unchanged. An argument shape the
plain-constructor rewrite does not admit — a positional value, a splat —
is left alone and still raises.
Rails runs User.active.find_by_transfer_id(id) with the relation as
the current scope, so any query the body makes is filtered by it. Here
the call reaches a class method that some call site named through a
relation chain (scope_chain::collect_relation_class_method_demand),
and the method grows the same trailing __rel an association-scoped one
does — defaulted to Relation.new(self), so a direct Model.x call is
unchanged.
What it costs. The parameter is only READ when the body's own shape
says it should be: a constructor merges __rel.scope_attributes, a
query at implicit self roots on __rel. A body that does neither —
campfire's find_by_transfer_id, which is find_signed(id, purpose: :transfer) — takes the relation and ignores it, so its lookup runs
against the whole table. An inactive user found by a valid transfer id
comes back here where Rails would answer nil.
The classification is made at SURVEY time, before find_signed has been
lowered to the find_by it becomes, which is why this one reads as
indifferent. Recognizing the sugar earlier would close it.
Rails STREAMS the file at path; lower::send_file grounds the call to
send_data File.binread(path) — the whole file in memory, because this
controller IS its own buffered response and its body is a String, so
there is no handle to pass down. Every corpus call site sends an image
measured in kilobytes.
The read is at the CALL SITE rather than in runtime/ruby/, and that is
deliberate: the shared runtime does no file I/O anywhere, because every
file under it transpiles to every target. See the pass's own header.
What it costs. :filename, :status, :url_based_filename,
:stream and :buffer_size are not reproduced, and a call carrying one
is LEFT ALONE rather than silently stripped — it fails by name, which is
a ledger entry rather than a response quietly missing a header. A
missing path raises Errno::ENOENT from the read where Rails raises
ActionController::MissingFile.
Rails' attached.destroy falls through to the ATTACHMENT record, whose
destroy removes the join row and leaves the blob to a purge_later job.
There is no job here and an orphaned blob row would make attached?
answer for a file no longer attached, so destroy is purge — the same
reasoning attach's replace-first already carries.
Current.account&.logo_variant(size) is logo.variant(:large) .processed / (:small), and campfire's logo endpoint send_files the
variant record's image blob — a 512×512 or 192×192 PNG made by the
processor (see "Active Storage: rows and bytes are modeled, variants
are a seam"). Worth keeping in the ledger because of how it MEASURES:
campfire's own tests decode the response and assert the dimensions.
They first passed on the stock-icon fallback (exactly those sizes)
while a custom logo was never served at all; then, with uploads landed
and variants identity, they failed on the real divergence (the bytes
were the upload); now they pass on the variant's bytes. A dimension
assertion could not see the first gap; it saw the second, and it is
what says the third state is the right one.
message.body answers an ActionText::RichText whether or not a row
exists — that is what makes body.to_plain_text safe on a message with
none. Rails AUTOSAVES that built record, putting an empty row in the
table for a message nobody ever gave a body; here the autosave skips a
record that is still unsaved AND still blank.
Why the difference shows up here and not in Rails. Rails' fixture
loader inserts rows with raw SQL and runs no callbacks, so a Message
never reads its own body during a load. Ours loads THROUGH the model,
so campfire's search-index callback read body, the read materialized
an empty rich text, and after_save claimed the
(record_type, record_id, name) unique key — before the rich-text
fixture, whose thirteen records are every message's actual text, could
insert its own row.
What it costs. An app that reads record.<rich_text> and then saves,
without ever assigning, gets no row where Rails would leave a blank one.
Nothing can observe the difference through the reader (both answer a
blank content); a direct query for the row can.
Rails raises RecordNotDestroyed when a before_destroy callback
throws :abort. destroy! here is destroy.
Why. This runtime has no abort channel: before_destroy returns
into the void and destroy always completes. The bang form is kept as
its own method so the raise has a home when the channel exists, rather
than aliasing the two names together.
What it costs. Nothing today — no corpus app halts a destroy from a callback. An app that did would see the row deleted where Rails would have raised.
record.becomes!(Rooms::Open) in Rails hands the sibling the SAME
attribute hash, so writes through either object are visible in both.
Here (src/lower/sti_scope.rs, which unrolls the copy column by
column into the synthesized becomes_from) the sibling gets a COPY.
Why. Shared mutable attribute state across two objects is the shape the typed targets have no representation for — each carries its columns as its own typed slots, not as a hash one can hand around.
What it costs. Code that keeps the pre-recast object and writes
through it loses those writes. The Rails idiom reassigns
(@room = @room.becomes!(Rooms::Closed)), which is what campfire's two
sites do; a site that kept both handles would diverge silently.
Rails' error accumulator holds one ActiveModel::Error per message:
the attribute, the error type and the message without the attribute's
name. Here errors stays a plain Array[String] of FULL messages
("Url is not public"), and an index-for-index companion,
error_details, holds an ActiveRecord::ErrorDetail for each:
attribute "url", kind "not_public" (the message itself when
errors.add was given a String, as Rails' type is), message
"is not public", full message "Url is not public", whether the
type is a Symbol (errors.add given a String message records false),
and whether Rails' details keeps nothing beside the type (length
keeps count:, format, inclusion and numericality keep
value:, confirmation keeps attribute:). errors.add and every validates rule push to both
(src/lower/errors_add.rs, model_to_library/validations.rs), and
errors.clear empties both. src/lower/errors_index.rs grounds
errors[:url] as error_messages_for("url"), a Base method that
picks the details on that attribute and answers their messages, so the
read is exact under any errors.format, and :base is an attribute
like any other. The form builder's field_with_errors wrapper asks the
same question with any?.
errors.to_hash groups the same details by attribute:
ActiveSupport.errors_to_hash(error_details), in the order of each
attribute's first error, as Rails orders it. The helper lives in
runtime/ruby/active_support_ext.rb, so a strict target ledgers the
call as a bundled_method gap. A message pushed with errors << has
no detail, so to_hash leaves it out; Rails' Errors has no <<.
errors.messages, when it is read as the Hash rather than through
[:field], key? or the emptiness questions, is
ActiveSupport.errors_messages(error_details): the same Hash with
Rails' [] default, frozen. errors.attribute_names is
errors_attribute_names, each attribute once, frozen. Both are gaps on
a strict target as to_hash is, except under render json:, which
writes Base#errors_json on every target. errors.include?, key?
and has_key? ask error_messages_for(...).any?, so they reach every
target; a field that is not a Symbol or String literal is an error,
because the accumulator's own include? would ask its full messages
and answer false.
Iterating errors (each, map, collect, select, filter,
reject, find, detect, all?, first, and any?, none? and
count with a block) iterates error_details instead, because Rails
yields each ActiveModel::Error, not its full message. On what they
yield, attribute reads attribute.to_sym, Rails' Symbol, and
message and full_message read the detail's. type reads
kind.to_sym when the type is a Symbol and kind when it is not, so
only on an error read more than once without effect: a block parameter,
a local or errors.first. errors.of_kind?(:field, :type) asks for an
error on that field with that Symbol type, and with a String type asks
the field's messages, as Rails does. errors.where(:field, :type) is
the error_details on that field, of that type when one is given, so
what it answers reads like an iteration's. errors.delete(:field) is
ActiveSupport.errors_delete(errors, error_details, "field"), which
drops the field's entries from both, index for index, and answers their
messages or nil; like to_hash, it is a gap on a strict target. Without a block, any?, none? and count keep
asking the full messages, which answer the same.
errors.details is ActiveSupport.errors_details(error_details), the
details grouped by attribute with Rails' [] default, frozen. An
entry's [:error] reads as type does; the detail keeps no option
values, so any other key is an error. errors.added?(:field, :type) is
of_kind? asked only of errors with nothing beside their type, which
is Rails' strict_match? with no options: added?(:body, :too_short)
is false, because length keeps count:. With options it is an error.
errors.import(error, attribute: :field) and errors.merge!(other)
add another record's errors, and Rails names each imported attribute
as the importing model does: a Comment's body error merged into an
Article reads Body can't be blank even when the locale names the
comment's body "Remark". The attribute is only known at run time, so
the lowering passes ActiveSupport.errors_import / errors_merge the
importing model's translated attribute names, folded from the locale,
and errors.format split around the name and the message; any other
attribute is humanized, as human_attribute_name falls back to.
merge! of the errors themselves adds nothing, as in Rails. An
errors.format that does not name the attribute and then the message
once each, or an import with options beyond a Symbol attribute:,
is an error. Both helpers are gaps on a strict target.
A belongs_to checks its record where it is declared, among the
validates, so its error lists in Rails' order, and its type is
:blank: Rails validates the association's presence with message: :required, which picks the text and leaves the type.
Every one of these rewrites matches an errors send as its receiver,
which &. hides behind a local ((t = record.errors) && t.m).
src/lower/errors_safe_nav.rs reads record.errors&.m as
record.errors.m, and record&.errors&.m as
record && record.errors.m, because Rails' errors is never nil.
Why full messages stay in errors. The runtime has no i18n to
humanize an attribute with, and the lowering already bakes the full
text; full_messages and the views keep reading it as they did, and
each detail keeps its own full message for an iteration to read.
Why ErrorDetail under ActiveRecord, with kind. The strict
targets emit the class unqualified, where Error is the language's own
(Swift, Kotlin, Go, TypeScript), and a field named type does not
compile in Rust or Go. The class sits in active_record/base.rb so
Base's own bodies type against it.
What it costs. A message pushed by anything other than those two
lowerings has no detail beside it. errors.delete with a type is not
modeled, nor are the option values details would answer; an
errors << written by hand pushes a message no field claims. A dotted
attribute (comments.body) is named by humanizing it, where Rails
looks under the association's namespace first. errors.add reads its record twice
(once for each push), so one whose record is a call with arguments or a
receiver (Draft.latest.errors.add(...)) stays residue, and so does a
message that is a call (errors.add(:title, reason_for(x))), which the
push would evaluate twice.
CLOSED 2026-09-22 on the ruby family (spinel + CRuby overlay).
Turbo::Streams::StreamName.signed / .verified
(runtime/spinel/turbo_streams.rb) are turbo-rails' verifier
reproduced: ActiveSupport::MessageVerifier.new(key, digest: "SHA256", serializer: JSON) with the key derived from secret_key_base under
the gem's own salt (turbo/signed_stream_verifier_key), and no
_rails metadata envelope because turbo signs with no purpose and no
expiry. The bytes are pinned against campfire's Rails —
runtime/spinel/test/turbo_streams_test.rb holds two names Rails
minted for a known secret — so a name this runtime writes verifies
under Rails and a name Rails signed verifies here.
Both ends in one file. ActionView::ViewHelpers.turbo_stream_from
spells the attribute through ViewHelpers.signed_stream_name, which
turbo_streams.rb reopens for this family to write the signed name;
verified is the reader a subscribe reaches through the channel it
named. A spelled name, a tampered digest, a name signed under another
secret, and the old --unsigned encoding are all refused before any
channel's guard runs (tests/overlay_cable_dispatch.rb). The
decode_stream_name this section used to name as a third end was
removed by channel dispatch (below).
What is still open — the strict targets. The shared
runtime/ruby/action_view/view_helpers.rb writes
signed-stream-name="<base64-of-JSON>--unsigned" for the targets
that have no MessageVerifier (rust, go, typescript, python, crystal,
kotlin, swift, csharp, elixir — none carries message_verifier.rb),
and their cable glue, where it exists, ignores the suffix. On those
lanes a client can still subscribe to any stream it can spell. Signing
there is a per-target port of PBKDF2 + HMAC-SHA256, and it should
arrive with the rest of the verifier (signed cookies, signed ids), not
alone.
CLOSED 2026-08-30 on BOTH lanes. Cable.subscribe
(runtime/spinel/cable.rb) now reads the channel the identifier
names, builds it through the generated ActionCable::Channel.build
(project::apply_cable_channels — one eager arm per class descending
from ActionCable::Channel::Base, found by transitive descent, plus
Turbo::StreamsChannel), runs the app's own subscribed, and
registers only the streams that method asked for. A reject — or a
channel name nothing defined, or a raise inside subscribed — sends
reject_subscription and registers nothing.
That puts campfire's RoomStreamsAreAuthorized in the lookup chain on
the spinel binary: scripts/campfire-cable-drive.rb against the
binary now reports "the stock channel refuses the room's :messages
stream", which is the probe this entry existed for.
The rest of the entry is kept as the record of what the gap WAS, since the shape recurs: identity without dispatch does not authorize anything by itself.
The two runtimes had parted company here, and the description of each follows.
- CRuby overlay — CLOSED.
Cable::Connection#handle_message(runtime/spinel/scaffold/ruby_overlay/cable.rb) reads thechannelthe identifier names, resolves it throughActionCable::Channel::Base::REGISTRY, and runs the app's ownsubscribedon a worker thread holding a database handle. Only what that method asked for throughstream_from/stream_foris registered, and arejectregisters nothing.current_usercomes off the identityCable.identifyresolved from the handshake. - spinel — HALF CLOSED, and the half that remains is the one that
costs. The HANDSHAKE is now identified:
Cable.identify(runtime/spinel/cable.rb) builds the app's ownApplicationCable::Connectionagainst anActionController::CookieJarover the handshake'sreq.cookies, runs itsconnect, and answers areject_unauthorized_connectionwith 401 beforeres.start_websocketrather than an anonymous socket. The identified connection is carried on the per-upgradeCable::WsMessagehandler, which the driver holds, so it lives exactly as long as the connection. The class is reached through a generated eager arm (project::apply_cable_connection), notconst_get— an app with noapp/channels/keeps the default arm and connects anonymously, because Turbo fan-out predates identity. SUBSCRIBE is still unrouted:Cable.handle_messagereadsidentifier["signed_stream_name"], decodes it, and callsTep::Broadcast.subscribe_ws(stream, ws.fd)without instantiating a channel, so nosubscribedruns and thecurrent_userthat now EXISTS on the connection is never consulted. A client that can spell a stream name still receives that stream's fan-out — identity without dispatch does not authorize anything by itself.
Why the split. The two lanes share runtime/spinel/turbo_streams.rb
and the channel classes, but not the transport: the overlay rides Puma's
rack-hijack plus a nio4r reactor, spinel rides its own server, a green
thread per connection (Tep::Server::Threaded). Dispatch was built on
the overlay first on purpose — if the
frames do not match between Rails and the Ruby emit they were never
going to match on spinel, and that is far cheaper to find while
debugging one runtime instead of two (roundhouse-rb#71).
What it costs on spinel. The path implemented is precisely the path
an app's channel guard exists to close. campfire prepends
RoomStreamsAreAuthorized onto Turbo::StreamsChannel:
def subscribed
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
reject # ...so the stock channel isn't a way aroundand its comment states the reason — "authorizing room messages only in
RoomMessagesChannel would leave the stock channel as a way around it:
same signed stream name, no membership check." On spinel that prepend is
now EMITTED (the constant exists) and still never reached, because
nothing routes a subscribe frame to a channel.
The prepend is now IN THE SPINEL BINARY'S LOOKUP CHAIN, where it used
to be a commented-out line in boot.rb: spinel refused
X.prepend Y through an explicit receiver, and the class-reopen form its
own diagnostic recommended compiled and did nothing. Fixed upstream in
matz/spinel a7b6f726, so apply_module_mixins emits the reopen for
that target rather than a comment. It changes no behaviour YET, and that
is the point of saying so here: the guard is installed and unreachable,
because handle_message still never builds a channel to run
subscribed on. Identity does not move this either — connect running
gives the guard a user to test against, and the guard is still not in any
path.
The name is not a secret either: it is a GlobalID
(GlobalID::Locator.locate gid_param, only: Room), an identifier rather
than a capability. That is literally true of the names this runtime
mints: a record streamable contributes GlobalID.param("Room", id) —
Base64.urlsafe_encode64 of gid://<app>/Room/<id>, no padding — which
is byte-identical to what to_gid_param produces in a real Rails
process. It is spelled that way so the app's own channel code can read
it back, the same rule the /cable handshake follows by running the
app's connect.
Not a mitigation, but bounds on the blast radius: fan-out is
in-process and single-worker, and the only frames published are those an
after_commit hook records. A subscriber learns nothing about streams no
hook writes to.
This is not fixed by signing. Signing decides whether the name was tampered with; authorization decides whether the named stream may be joined. campfire's own channel comment makes the point — Turbo's stock channel "verifies only the signature on the stream name. That name carries no expiry and no binding to a user." Both ends of that need closing; neither lane signs, and that half is the entry below.
A {"command":"message"} frame - the client's
subscription.perform(action, data): campfire's typing indicator
(TypingNotificationsChannel#start/#stop) and its presence refresh,
which a browser sends every 50 seconds so a tab open longer than
CONNECTION_TTL keeps counting as present (roundhouse-rb#71 item 6).
Both lanes used to drop it; both now run it on the channel object the
subscription holds, so whatever subscribed left in its ivars is there.
Rails' rules, on both lanes (pinned by
emit_and_run::a_cable_action_runs_with_rails_rules and its native twin
in spinel_toolchain, against actioncable 8.1.4's own output):
- An action is a public method of the channel, its app ancestors or its
mixins, less
Channel::Base's; a private method, a framework method and an unknown name run nothing and are logged. - A method of arity exactly 1 is passed
data, with its"action"key still in it; anything else is called with no arguments, sodef optional(data = {})gets{}anddef kw(data, k: 1)raises the ArgumentError Rails raises. - A frame without an action, or with an empty one, is
receive. datakeeps its JSON types:falsestays false. Channelparams, by contrast, read every value as a String (ActionCable::Channel::Parameters);datadoes not follow them, because an action that tests a boolean would otherwise take"false"as true.- A rejected subscription runs nothing.
- An action that raises is reported on stderr and contained.
How each lane finds the method. The CRuby overlay runs Rails' own
algorithm in Channel::Base#perform_action - action_methods by
reflection, then public_send. Spinel has no reflection, so
project::apply_cable_actions writes ActionCable::Channel.perform: one
is_a? arm per channel class, most derived first, with one arm per
action and the arity decided at build time. The lists of Rails' base
methods the two lanes exclude are the same list, kept in both files.
Where it differs from Rails, and why:
- The methods this pipeline writes into a channel are never actions.
after_subscribe,after_unsubscribeandchannel_nameare Rails class machinery thatingest::channel_callbacksturns into instance methods; a channel that defines one of those names itself loses it as an action too. - Frame order on spinel is strict. The socket's own green thread
runs one frame after another, so a client's actions run in the order
sent. Rails, and the CRuby overlay, run commands on a worker pool and
two actions from one client may overlap. The overlay's pool is sized
by
CABLE_WORKERS/RAILS_MAX_THREADS, not byconfig.action_cable.worker_pool_size; an app that sets the pool to 1 to keep a client's actions in order does not get that on the overlay. unsubscribeframes are still ignored on spinel (the overlay handles them). A subscription ends when its socket closes.- The strict targets (Go, Rust, TypeScript, ...) do not run actions; their cable glue drops the frame, as before.
ActionCable.server.remote_connections.where(current_user: user) .disconnect(reconnect: true) — campfire's User#deactivate and
#reset_remote_connections — closes that user's sockets on the CRuby
lane and on no other.
The CRuby overlay does what Rails does. RemoteConnection#disconnect
broadcasts {type: "disconnect", reconnect:} on the internal channel
action_cable/<connection identifier> (the identifier is Rails'
connection_gid: each identifier's to_gid_param, sorted, joined by
:). An identified Cable::Connection subscribes to its own internal
channel when it starts, and Cable::Registry.deliver hands a message on
it to process_internal_message, which sends the client
{"type":"disconnect","reason":"remote","reconnect":…} and closes the
socket. The client then replays its subscriptions, and membership is
checked again at SUBSCRIBE time.
Spinel still selects an empty set. runtime/spinel/action_cable.rb
neither broadcasts nor subscribes: the tep lane's sockets are fds in
Tep::Broadcast, with no index from identity to driver, so a
broadcast on the internal channel would reach nobody. An already-open
socket there keeps delivering to a user whose membership was just
revoked, until they reconnect for some other reason, and campfire's
test_a_room_destroyed_later_resets_the_connections_of_its_former_members…
stays red on that lane because the broadcast it asserts is not made.
campfire's message presentation ends in ContentFilters::SanitizeAttributes,
which calls ActionText::ContentHelper.sanitizer.sanitize(html, tags:, attributes:). That reaches ActionView::ViewHelpers.sanitize_allowing,
which on the ruby family is the real rails-html-sanitizer and on every
other target raises NotImplementedError for input containing markup —
the same limit sanitize above already carries, for the same reason: the
allow-list is a rule table, and both ways to fake it are wrong in a way
nobody would see.
What it costs. campfire wraps its filter chain in its own rescue Exception and returns "", so on those targets a message body renders
EMPTY: the record has the text, the database has the text, the page
returns 200, and <div id="presentation_message_N"> is blank. Nothing is
logged either — the app's own log line runs through a no-op
Rails.logger.
Where it bites. The spinel campfire binary. The ruby lane is correct:
scripts/campfire-cable-walk asserts the posted body arrives in the
broadcast frame, and the room page carries it too.
How it was found, which is the part worth keeping. Not here — behind
it. An inherited class-side new was binding to the LEXICAL class, so
ContentFilters::*.apply built the abstract ActionText::Content::Filter
and applicable? raised NotImplementedError, which the same rescue Exception turned into the same "". Every lane rendered empty bodies,
under a green 255/288 suite, until a live GET /rooms/1. That one is
fixed (lower::class_body_new monomorphizes the method into each
descendant); this is what was standing behind it.
The fix is the sanitizer, not this seam: port the safe-list rule table (42 tags, 13 attributes, per-attribute URL protocols, CSS behaviour) the way the inflector tables were ported, rather than deriving one.
Room#memberships (has_many :memberships) lowers to a reader that runs
the query and hydrates: it answers Array[Membership]. Room#users
(has_many :users, through: :memberships) lowers to an
ActiveRecord::Relation over a joins chain. Both are spelled
owner.name, and only the second answers the Relation API.
What still costs. Every Relation terminal but pluck — count,
exists?, ids, where — is a NoMethodError on a plain has_many
reader, as is any scope (room.users.without(x) works only because
users is a :through). pluck is closed, and closed narrowly:
lower::assoc_pluck expands room.memberships.pluck(:user_id) into
room.memberships.map { |__pluck| __pluck.user_id }, which is the
projection it is. That was a 500 on every POST /rooms/:id/messages in
a served app — the broadcast still went out, because after_create_commit
runs before the line, so the message reached subscribers and the request
that made it then failed. The suite never saw it: under the test adapter
the job that reaches the line is enqueued rather than run.
What the projection costs. The reader hydrates every row, which is what it ALREADY does — the projection is the only new work. Rails reads one column. So this is a fix for the crash, not for the query.
What would close the rest is the association proxy: an association
reader that IS a chain root, so room.memberships.pluck(:user_id) folds
to SELECT user_id FROM memberships WHERE room_id = ? and every other
terminal follows. That is designed and not built. lower::assoc_pluck
is deliberately not a down payment on it — a half-built chain root that
handles one terminal is worse than none, because the next terminal to
arrive looks supported until it is not.
A note on what the type says. Ty::Array does NOT distinguish the
two: the analyzer types a :through reader Array[Room] as well, its
approximation of "a collection". The association KIND is what determines
which reader gets emitted, and it is what the pass reads.
config.x.web_push_pool = WebPush::Pool.new(...) in an initializer is an
ASSIGNMENT: Rails evaluates the right-hand side once at boot and every
Rails.configuration.x.web_push_pool reads that one object. Ingest lifts
it to a reader on the Application reopen:
def x_web_push_pool
WebPush::Pool.new(invalid_subscription_handler: ->(id) { ... })
endwhich re-runs the expression on every read.
What it costs. For a literal (config.x.vapid.public_key = "…", the
common case) nothing at all. For a constructor it is a new object per
read, and campfire's is the bad kind: WebPush::Pool.new builds a
50-thread executor, a 1-thread pool and a 150-connection HTTP pool, and
the shutdown the app's at_exit calls reaches only the last one. One
per message created.
Not the reason campfire's push path fails today — that is
uninitialized constant Net::HTTP::Persistent, which the pool's
constructor hits first. Memoizing the reader would not fix that; it is a
separate entry because it would still be wrong once the constant exists.
config/initializers/turbo_streams_authorization.rb — campfire's
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized — is emitted as
a live line at the end of the ruby family's boot.rb and as a COMMENT in
the spinel tree's.
Why. Spinel refuses the explicit-receiver form outright: "the class
graph, ancestor chain, and method/ivar layout are baked at compile time,
so a class cannot be restructured through an explicit receiver." Its
diagnostic recommends moving the call inside a class X ... end reopen,
and that form compiles and does nothing — a prepended hello calling
super prints guarded hi under CRuby and hi from the binary, with no
warning. Emitting it would put the guard back in the tree, tested, and
out of the lookup chain, which is the failure lower::module_mixins
exists to prevent, minus the report. Filed as matz/spinel#4200.
A prepend inside the class's ORIGINAL body works on spinel; only a
reopen is silent. That is no help here — the target class is turbo-rails'.
What it costs today: nothing, and that is a fact about a second gap
rather than a defence. The spinel lane does not dispatch a subscribe
frame to a channel at all (see above), so Turbo::StreamsChannel #subscribed never runs and a guard prepended onto it would not run
either. The two have to close in that order.
Where it is visible. The emitted boot.rb carries the commented
line and the reason, so the absence names itself in the file someone
would read.
CLOSED 2026-09-22. See "A Turbo stream name is not signed" above:
the ruby family signs and verifies with Rails' bytes, and the channel
guard (RoomStreamsAreAuthorized) now stands BEHIND a signature check
rather than instead of one. What remains is the strict targets' half,
recorded there.
runtime/ruby/logger.rb is Ruby's Logger::Formatter plus
ActiveSupport::Logger and TaggedLogging — the stack a Rails app's
config.logger = builds, and the parent campfire's
LogScrubbingFormatter subclasses so a bot key in a request path
(/rooms/1/5-Ab3xK9mQz1Rt/messages) is redacted before the line is
written. It renders Ruby's own line format, pinned against bytes ruby
minted (runtime/ruby/test/logger_test.rb).
What is not wired. Rails.logger still answers the small
stderr-prefixing Rails::Logger in runtime/ruby/rails.rb, and
config.logger = … in an app's production.rb is not lifted at
ingest. So an emitted binary's own log lines (Rails.logger.error "…",
eight sites in campfire) are neither formatted by this stack nor passed
through the app's formatter.
What that costs today: nothing measurable, and the reason matters.
The formatter exists to scrub REQUEST log lines, and this runtime
writes none — there is no ActionDispatch request-logging middleware,
so the path that would carry a bot key is never logged in the first
place. The exposure appears the day request logging does, which is why
the two belong in one commit: a request log wired up without
config.logger lifted would write bot keys verbatim, and that is the
failure that looks like a feature.
runtime/ruby/tempfile.rb is the block form of Ruby's Tempfile.create
for the targets with no stdlib to bind to — the ipaddr/zlib
arrangement, and the CRuby and JRuby trees take Ruby's own. The name is
the temp dir, a prefix, the pid and 64 bits from SecureRandom.
What differs. Ruby's create opens with O_EXCL and retries on a
collision, so it cannot be made to clobber a file an attacker
pre-created in the temp directory. This opens the computed path with
File.open(path, "wb+"), so a pre-created file at that exact path
would be written to rather than refused.
What it costs today: nothing, and why that is not the same as safe.
Every caller in the corpus is a TEST writing probe bytes to its own
TMPDIR, and the name carries 64 random bits, so guessing it is not a
practical attack. The difference matters the day a RUNTIME caller
handles untrusted input through a temp file — at which point the
exclusive open is the fix, not a wider random component.
ActionView::ViewHelpers.strip_tags parses the HTML and serializes the
text, matching Rails::HTML5::FullSanitizer on 24 of 25 measured
probes — including the ones a regex gets wrong ("a < b" →
"a < b", a > inside a quoted attribute value, an unterminated
tag swallowing the rest, the CONTENT of a <script> surviving).
Why. The 25th is decoding: Rails turns é into é, which
needs HTML5's 2231-entry named-entity table. A well-formed reference
(&name;, {, «) passes through unchanged here instead,
and a bare & still escapes to &.
What it costs. Nothing that renders: the two agree byte for byte on
every reference that round-trips (&, <, ) and a
browser draws é and é the same. They part company only on
malformed input, where HTML5's legacy no-semicolon matching applies —
¬anentity; is ¬anentity; to Rails and unchanged here.
sanitize is a separate matter and is NOT a divergence: the safe-list
sanitizer is unimplemented and raises on input containing markup,
serving only the tagless case that sanitize(strip_tags(x)) produces.
That is a gap, and it names itself when reached.
ActionView::ViewHelpers.strip_tags("<b>Hi</b> & <script>bad() </script>there") is "Hi & bad()there" on the CRuby tree and
"Hi & there" on the JRuby one. Same for <style>.
Why. Both trees serve strip_tags from the real
rails-html-sanitizer, and the gem's best_supported_vendor answers
Rails::HTML5::Sanitizer only where Loofah.html5_support? — which
needs an HTML5 parser in Nokogiri, and JRuby has none. So JRuby gets
Rails::HTML4::Sanitizer, whose full sanitizer removes the CONTENT of
script and style where the HTML5 one removes only the tags.
Where it is visible. This one probe and its <style> twin. Every
other sanitize / strip_tags / auto_link behaviour in the corpus was
checked against both vendors side by side and agrees, so the divergence
is exactly "the text inside a script or style element". It is Rails'
own difference, not ours: a Rails app on JRuby answers the same way.
Where it is pinned. tests/overlay_sanitize_autolink.rb asserts
BOTH values, branching on the vendor, so a runner whose nokogiri lacks
HTML5 reads as the other correct answer rather than as a regression.
ActionView::ViewHelpers.auto_link now runs the safe-list pass before
linking, through the shared sanitize engine, with sanitize: false
skipping it as the gem's flag does. The entry below is kept as the
record of what the skip cost while it stood.
ActionView::ViewHelpers.auto_link on every target except the CRuby
overlay's finds and wraps the links exactly as rails_autolink does,
and hands back the body around them AS GIVEN. Rails runs the body
through the safe-list sanitizer first.
input a < b > c
Rails a < b > c
here a < b > c
input addr <foo@bar.com> ok
Rails addr ok (the unknown tag is dropped)
here addr <<a href="mailto:foo@bar.com">foo@bar.com</a>> ok
input <a href='x'>t</a>
Rails <a href="x">t</a> (attributes renormalised)
here <a href='x'>t</a>
Why. The safe-list pass is HTML5 tree construction, not filtering —
the argument is in the header of
ruby_overlay/runtime/action_view_sanitize.rb and is the same reason
the shared sanitize REFUSES markup instead of approximating it. That
refusal is the honest answer where the caller can be told; auto_link
is on campfire's read path, under a rescue Exception that returns
"", so raising there is a blank message body rather than an error.
Linking without the pass is the only remaining option, and it is stated
here rather than discovered.
The size of it, measured. Against rails_autolink 1.1.8 on
actionview 8.1.3, over 36 probes:
- 36 / 36 byte-identical to
auto_link(..., :sanitize => false)— the gem minus this pass. Every linking decision is the gem's: the scheme list, where a URL ends, which trailing punctuation is the sentence's, the bracket rule, the e-mail local part, and both clauses ofauto_linked?. - 30 / 36 identical to the gem's default. All six differences are the three shapes above — escaped angle brackets, a dropped tag, renormalised quotes. Not one is a different link.
What it costs, and what it does not. The links this helper CREATES
are still safe by the gem's own rule table: the scheme list has no
javascript: in it and the www. branch is prefixed http://, so
auto_link cannot manufacture a scripting URL out of text. What is
lost is Rails' SECOND layer over markup that was ALREADY in the body.
campfire's is ActionText content that arrived through h, so the first
layer is the one doing the work — but an app that feeds auto_link raw
user HTML and leans on this pass to clean it gets no cleaning here.
One consequence follows from the same skip: Rails' body pass turns a
bare & into & before the URL regex ever runs, so the gem's href
never carries one. Here it does — https://x.co/a?b=1&c=2 reaches the
attribute as written. campfire's body arrives through h, so its &
is already an entity.
A second, unrelated to sanitizing. The gem strips trailing
\p{Word} — Unicode letters, marks, numbers and connector punctuation.
The port spells ASCII out and TAKES everything above it as a word
character. A URL ending in a non-ASCII letter agrees; one ending in
non-ASCII PUNCTUATION (», 。) keeps the character here and drops it
there.
Where it is pinned. tests/shared_autolink.rb, which asserts the
30 agreements AND the three divergent shapes, so a future change that
starts sanitizing says so in that file rather than in the campfire
suite. The CRuby overlay is unaffected: it redefines auto_link on the
real gem chain and is gated separately by
tests/overlay_sanitize_autolink.rb.
ActionView::ViewHelpers.link_to("t", "/u", target: "_blank") renders
<a href="/u" target="_blank">. Rails renders
<a target="_blank" href="/u"> — measured against ActionView 8.2 for
both helpers, and mail_to and link_to_raw share the shape.
Why. Every one of these builds its attributes as
{ href: href }.merge(opts.to_h), so the default lands ahead of the
caller's; Rails merges the other way round.
Where it is visible. ATTRIBUTE ORDER only — never in which
attributes, their values, or the element. scripts/compare is a DOM
comparison (see the note on its own output), so it cannot see this, and
the campfire tag tallies cannot either. It surfaced from the other
direction: the auto_link port in
ruby_overlay/runtime/action_view_sanitize.rb agrees with the real
rails_autolink gem on 13 of 14 probes, and the fourteenth is an email
address, where the anchor goes through mail_to and comes back with its
attributes transposed.
Why it is still here, and what it would cost. The fix is one merge
reversed in three helpers. The blast radius is MEASURED, not assumed:
20 call sites total — 6 in the campfire emit, 14 in lobsters, and
ZERO in the blog, because the view walker inlines most anchors as
literal strings and only reaches these helpers when the URL or the
attributes are dynamic. Every one of the 20 passes attributes, so every
one moves.
That is small enough to do, and it was left undone only because it
arrived at the tail of a session that had already changed the escape
surface twice. Do it with the golden dumps regenerated in the same
commit, and check compare-* on every target rather than assuming a
DOM comparison cannot see it.
campfire's WebPush::Connections (upstream since #351) opens pooled push
connections as class HTTP < Net::HTTP; include Stages; end, where
Stages overrides two private methods of CRuby's net/http to learn how
far a request got before it failed:
def begin_transport(...); @stage = :checking; super.tap { @stage = :sent }; end
def connect(...); @stage = :connecting if @stage == :checking; super; endOn the ruby family this runs as written, against Ruby's own net/http
(19 of campfire's 20 push tests pass against the TLS server its own
test helper starts; the twentieth mixes a module into one connection
with extend, the per-object mixin no compiled target has).
For spinel, the forwarding and the hooks are done:
- The forwarding.
lower::known_super_forwardinggives a(...)override that only reachessuperthe destination's own parameters when the destination is a stdlib method of known signature. For these two that is CRuby's:begin_transport(req)andconnect(). The strict spinel emit has no errors left. - The hooks.
runtime/spinel/net_http.rbroutes its transport throughconnectandbegin_transport(req)in CRuby's order and holdskeep_alive_timeoutandproxy?, so an override of either hook runs. It works against the net package before and after matz/spinel#8361, which adds the same methods upstream.tests/emit_and_run.rsruns the pattern natively (forwarding_into_net_http_transport_hooks_runs_on_spinel).
Three gaps remain, all on spinel's side:
- The class itself (matz/spinel#8369). A class nested in a compact
class A::Bbody is undefined at run time when it is named like its superclass. campfire'sclass WebPush::Connections+ nestedclass HTTP < Net::HTTPis exactly that, and the emit writes nested modules in the compact form too. Until it is fixed, a pooled push delivery on the spinel binary raises NameError where it reachesWebPush::Connections::HTTP. - The tests' TLS server. campfire's push-service test helper serves
TLS itself (
OpenSSL::SSL::SSLContext#key=, a server-side context). spinel's openssl package is a client, so those tests cannot handshake even with the class defined. - Proxies. One test constructs
Net::HTTP.new(host, port, proxy). The net package refuses a proxy at construction.
I18n.localeis per request (runtime/ruby/i18n_locale.rb): held per thread as the gem holds it, the app'sdefault_locale(Rails':enwhen it sets none) untilI18n.locale =orwith_localesets another of itsavailable_locales, which an unavailable one raisesI18n::InvalidLocalefor. Translation is folded at compile time (src/i18n), once per available locale, and a call that can run in more than one picks its branch byI18n.localewhen it runs:t/I18n.twith a literal key, attribute names, validation messages and form labels. A controller'stisAbstractController::Translation's: a lazyt(".key")reads<controller_path>.<action_name>.key, then<controller_path>.key, withaction_nameread when it runs. Its_htmlkeys stay an error: Rails returns them html-safe, and a String here carries no such mark on to the view that renders it. A key the locale lacks answers as Rails does in the running environment:Translation missing: en.key(I18n'sOptions considered were:list whendefault:was a list of keys, none of them there either), a template'stranslation_missingspan, orI18n::MissingTranslationDatawhereconfig.i18n.raise_on_missing_translationsis set (:strictand a value computed at boot stay errors). A key with run-time text after a literal prefix (t("statuses.#{v}")) folds every translation under the prefix into acaseon the normalized key, falling back to the missing answer ordefault:. Where such a key names a subtree, Rails answers a Hash; here it raisesNotImplementedError. A key with no literal prefix, or one reaching more than 400 translations, is an error rather than a guess. ARails::Enginegem'sconfig/localesload before the app's, read where Bundler installed the gem (BUNDLE_PATH,GEM_HOME,GEM_PATH), so an uninstalled gem's keys stay missing. Engines load in lockfile order, not Rails' railtie order, which differs only when two gems define one key. A gem that is not an engine adds its files from its own code; none is read unlesssrc/i18n/gems.rs::NON_ENGINE_LOCALESnames its paths.fragment_name_with_digest(name, digest_path)adds no template digest: templates carry none here (seeruntime/spinel/action_controller_fragment_caching.rb), so an omitteddigest_pathadds nothing and an explicit one is kept in front.- Token-free forms are read from one shape only: an app helper whose
token_tagbody is exactly""(campfire's header-only forgery protection) synthesizestoken_fields_omitted. Anothertoken_tagbody is not read, and the forms keep their token field. - Only Rails' default
field_error_procis modeled. A label or control whose field has errors renders inside<div class="field_with_errors">, decided per render. An app that sets its ownconfig.action_view.field_error_prockeeps its form fields as errors. A field's errors are told apart by the attribute each message was added on, aserrors[:field]'s are (see above). A form over a record that is not one of the app's models (a form object, say) is not wrapped, even when it has errors. config.after_initializeblocks do not run at boot. campfire has two:Room::MessagesCount.ensure!(its counter triggers, whichschema.rbcannot dump) and starting the WAL checkpointer outside tests. The test suite gets the first through itsload_fixturesoverride, which is read; a served tree gets neither.Rails.application.env_configholds what is set and is consulted for nothing: a forgery failure always renders the 422 thataction_dispatch.show_exceptions = :rescuableasks for.savewrites the whole row; onlytouchwrites what changed. Rails' partial writes UPDATE the changed columns; this runtime's_adapter_updatewrites every column the record loaded. A baretouch(and sobelongs_to … touch: true) UPDATEsupdated_atalone (_adapter_touch), which is what keeps campfire's trigger-maintainedrooms.messages_countcorrect when a message touches its room. Asave/updateof a record loaded before a trigger moved one of its columns still writes the stale value back.
No target can subclass Hash, so the class exists only in the
analyzer, as HashWithIndifferentAccess[V] (analyze::indifferent).
lower::indifferent_access rewrites each call on a value of that type
into the String-keyed Hash call. It converts keys and values the way
Rails does: a Symbol key becomes its String, other keys stay as they
are, and a written Hash or Array value converts all the way down, an
Array in place under []=. The value stays a Hash, so is_a?(Hash),
merging it into another hash, and JSON encoding answer as in Rails. A
method outside the modeled surface is an error, as is any use of the
constant other than new.
Where it differs from Rails:
- A value whose type is not known. A Symbol key read from one
converts only along a chain of
[]/fetch/dig/first/lastreads rooted at an indifferent hash. Once the value is held in a local or passed to a method, the indifferent type is gone andx[:key]reads the plain hash, which answersnilwhere Rails answers the value. - An indifferent hash inside an untyped or Array value is copied. Rails keeps such a hash as it is, shared, when it is nested into another. The runtime cannot tell it from a plain Hash, so it converts it into a new hash.
- The constructor drops a source hash's
default/default_proc. - A value that may not be a Hash takes a String key where Rails raises.
This covers a read on that chain, or a value typed as an indifferent
hash OR something else.
h[:name][:x]on a String value is aTypeErrorin Rails. The converted call asks"…"["x"]instead and answers a substring ornil.include?,member?anddeleteare not converted there, since an Array answers them for a Symbol too.
emit.md— the universal IR contract; the consumers of the runtime.analyze.md— RBS-paired typing ofruntime/ruby/.verification.md— toolchain tests that exercise runtime + emitted project end-to-end.
RouteHelpers.room_at_message_path(1, 5, host: "once.campfire.test")
emits /rooms/1/@5?host=once.campfire.test. Rails emits /rooms/1/@5.
Why. :host is a URL-GENERATION option, not a route segment and not
a query parameter. actionpack 8.1.1,
ActionDispatch::Routing::RouteSet:
RESERVED_OPTIONS = [:host, :protocol, :port, :subdomain, :domain,
:tld_length, :trailing_slash, :anchor, :params,
:only_path, :script_name, :original_script_name]path_for passes that list as reserved, so none of those names reaches
the query string. A _path helper drops :host entirely (full_url_for
is the only consumer); :anchor becomes #frag; :params becomes the
query. Our synthesis knows none of this and treats every leftover kwarg
as a query parameter.
What it costs. campfire's
MessagesControllerTest#test_creating_a_message_broadcasts_the_message_to_the_room
builds the expected copy-link URL with
room_at_message_path(@room.id, Message.last.id, host: "once.campfire.test")
and compares it to the rendered data-copy-to-clipboard-content-value.
Both sides are ours, so they would agree if the view rendered the same
wrong URL — the test fails because only the TEST passes host:. A page
that renders one of these is serving a URL with a bogus query on it.
The fix is a rule table, not a special case. Port RESERVED_OPTIONS
and give the four that MEAN something (anchor, params,
trailing_slash, and host/protocol/port for the _url family)
their actual behaviour, rather than dropping host alone and leaving
anchor: to become ?anchor=.
Rails defines dom_id on ActionView::RecordIdentifier and includes
that module into its helpers. This runtime defines it on
ActionView::ViewHelpers and offers RecordIdentifier.dom_id as a
delegate — but only on the ruby family, from
ruby_overlay/runtime/action_view_record_identifier.rb.
Why not runtime/ruby/, beside the function it delegates to. That
directory prices all nine targets, and the ones with no module system
flatten ActionView's modules into a single namespace. Kotlin emitted
both domIds into one ViewHelpers.kt and refused to build —
"Conflicting overloads", then "Overload resolution ambiguity" at the
call site. A delegate is exactly the shape that cannot survive
flattening: same name, same arity, same namespace.
What it costs. A strict target that meets
ActionView::RecordIdentifier gets an uninitialized constant. Nothing
does — the one caller is an app test helper, and those run on the ruby
family. A target that needs it wants the module split, not this file
copied.
Rails runs an assert_select block against the MATCHED ELEMENTS: a
nested assert_select inside searches only what the outer one selected.
Ours yields with no scoping, so a nested assertion searches the last
response body again.
What it costs, and the direction is the bad one. A nested assertion
can PASS against markup the outer selector never matched. campfire's
broadcast test is the shape: the outer assert_select is scoped to a
Nokogiri fragment built from the pubsub queue, and the assertions inside
it look at the POST response instead. Both happen to contain the
message, so the inner assertion is answering about the wrong document
and agreeing anyway.
Not fixed here. Scoping means the block's assertions run against a
node set rather than a body, which is a change to every assert_select
call site's plumbing rather than to this one method.
recv.try(:name) is Rails' respond_to?(name) && public_send(name, …)
— a DEFINEDNESS guard. It used to be grounded at ingest to
recv && recv.name, the &. desugar, which is a NILNESS guard: the two
agree whenever the receiver either is nil or does respond, and diverge on
the one case between. That cost campfire's own
MessagesControllerTest#test_creating_a_message_broadcasts_the_message_to_the_room,
where streamble.try(:to_gid_param) || streamble over [room, :messages]
raised on the Symbol instead of taking the fallback.
lower::try_guard now asks the TREE which classes answer the name and
emits a narrowing over the fewest is_a? tests that cover them:
(s.to_gid_param if s.is_a?(ApplicationRecord) ||
s.is_a?(Opengraph::Location) ||
s.is_a?(Opengraph::Metadata)) || snil.is_a?(X) is false, so the narrowing does everything the nil guard
did and answers nil — rather than raising — for the non-nil receiver
that does not respond.
WHAT IT STILL CANNOT SEE, and this is the divergence that remains. The pass reads methods DECLARED in the tree plus the short list the pipeline synthesizes on every model. It does not see:
- runtime methods —
to_param,strip,id. Atry(:strip)therefore keeps the nil guard, because folding to nil would be wrong for exactly the names the runtime supplies. - column accessors, which are synthesized from the schema after this
pass runs. lobsters' 31
trysites are all of this shape (user.try(:username)), so they keep the nil guard too — correct there, since the receiver is a nilableUserthat does define it, but correct by accident rather than by decision.
So the divergence is narrower than it was and has not gone: a non-nil receiver that does not answer a RUNTIME-supplied or COLUMN-backed name still raises where Rails answers nil. Closing it means giving the pass the schema and the runtime surface, both of which exist and neither of which is wired to it.
The earlier proposal, and why this is not it. This entry used to say: fold to nil when analysis knows the receiver's type has no such method, "leaving the untyped-receiver case as it is today". The untyped receiver IS the failing case — campfire's site is a block parameter over a mixed array — so that plan would have closed nothing. The defining set is knowable where the receiver's type is not.
lower::try_guard emits x.m if cond, and a modifier-if binds looser
than every boolean operator. Rendered bare as the left operand of ||,
x.m if cond || fallback re-parses with the fallback INSIDE the
condition — the expression answers nil and the fallback never runs. The
Ruby emitter parenthesizes an If / Case / RescueModifier operand
for that reason, the same call recv_needs_parens already made for a
receiver.
Worth knowing because it is invisible in review: the emitted line is valid Ruby either way, and only the parse changes.
url_for splits a route helper's option hash in two: the twelve names in
ActionDispatch::Routing::RouteSet::RESERVED_OPTIONS (actionpack 8.1.3,
route_set.rb:838) it consumes itself, and everything else, which it
forwards to the path generator and which ends up in the query string. We
forwarded all twelve, so room_at_message_path(1, 5, host: "x") rendered
/rooms/1/@5?host=x where Rails renders /rooms/1/@5 — and that extra
?host= was what failed campfire's own broadcast assertion.
lower::route_url_options now models the split as a table, and the table
has three filled cells and one empty one:
-
the seven host-only names —
host,protocol,port,subdomain,domain,tld_length,only_path— are dropped from a_pathcall site.path_forisurl_for(…, PATH, …), and thePATHstrategy never callsbuild_host_url, so these contribute nothing to a path. Dropping them is EXACT, not an approximation.On the
_urlspelling they are not dropped, because there they are the answer:x_url(…, host: h)becomes"http://#{h}#{RouteHelpers.x_path(…)}"— the same shape the view lowerer grounds a hostless_urlwith (Rails.application.domainin place ofh) and the same oneemit::ruby::library::rewrite_url_helpers_absolutebuilds for the explicit…routes.url_helpers.x_url(…, host:)chain.protocol:replaces thehttpand rides bare ("https", not"https://"), which is that older pass's convention; Rails'normalize_protocolaccepts both spellings and we accept only the first.x_url(…, only_path: true)is Rails asking the URL spelling for a path, and gets one.A hostless
_urlin a controller body isurl_from_path(RouteHelpers.x_path(…))(controller_to_library::rewrites::rewrite_controller_route_helpers).ActionController::Base#url_from_pathis Rails'url_optionsmerge: the request's protocol, host and optional port, each replaced by the key the controller'sdefault_url_optionsnames (a nil value removes it), thenbuild_host_urldrops the scheme's standard port. Sorender plain: articles_urlin an integration test answershttp://www.example.com/articles, as Rails does; it used to answer/articles. With no host at all it answers the path, where Rails raises "Missing host to link to!". A TEST body's_urlstill renders the path, and the harness meets Rails halfway:assert_redirected_tocompares both sides as absolute URLs, andget/follow_redirect!accept an absolute one. Targets whose controller runtime has no request (request_host_for_redirectis""there) keep the path. A jbuilder template's_url(json.url article_url(a, format: :json)) isActionView::ViewHelpers.url_for_path(RouteHelpers .article_path(…) + ".json"). The universal body answers the path, because a strict target's view has no request in scope. The ruby family reopens it (view_helpers_ext.rb) overRails.application.protocol+.domain, the grounding an ERB view's_urlgets, and so renders Rails' absolute URL. Neither sees a controller'sdefault_url_optionsyet. -
anchor:is rendered,#tag, after the query string — the orderpath_forappliesadd_paramsand thenadd_anchorin. -
format:islower::route_format_suffix's, which monomorphizes the helper rather than widening its signature. -
params:is the query itself — Rails merges its value into the generated query string, which is what an erased**splatrenders too — so the two are one shape:foo_path(…) + RouteHelpers.query_suffix(h), rendered at run time throughActionView::ViewHelpers.to_query. On the ruby family that isHash#to_queryin full (nested Hashes asa[b], Arrays asa[], pairs sorted per level,runtime/spinel/hash_to_query.rb), andCgiIo.parse_form_intoreads the brackets back; every other family renders a value as itsto_s, in insertion order. -
script_name:,original_script_name:andtrailing_slash:are NOT modeled. Each genuinely changes the path — the first two prefix it, the third appends a/— and each is still treated as an ordinary query key, sofoo_path(trailing_slash: true)renders?trailing_slash=true. Left visibly wrong rather than silently dropped: no corpus app writes one of them on an app route, and a dropped option is a URL that looks right and is not.
A second, smaller divergence in the same place: Rails escapes a fragment
with Journey::Router::Utils.escape_fragment, which leaves /, ? and
: alone. The generated helper reuses the url_encode its query keys
use, which percent-encodes them. Every anchor the corpus writes is a
slug, a tag or a dom_id, so nothing can tell the difference today.
Sentry.capture_exception is a façade in runtime/ruby/gem_facades.rb
that writes one line to stderr and returns nil. Nothing is transmitted
to an error-tracking service, because this runtime has none.
It is the one façade in that file that does not raise, and
deliberately: every other occupant stands in for a path the read side
never executes, so a loud raise is a useful alarm. capture_exception
is only ever called from a rescue — it runs on exactly the paths that
are already going wrong, and a raise there replaces the app's error
handling with a second error. campfire's MessagesHelper.message_tag
is the case: with no Sentry constant in the tree the rescue itself
raised NameError, and a rendering failure became a 500 for the whole
request, which is the opposite of what the handler was written for.
The line carries no detail. exception.message and
exception.class both fail inside the façade in the emitted campfire
tree (undefined method 'message' for an instance of NoMethodError) —
the parameter is declared untyped there and the dispatch table comes
out short, the same shape as matz/spinel#4219. Three reductions failed
to reproduce it standalone. An app's own logging is unaffected:
campfire's next line, Rails.logger.error "… #{e.class} … #{e.message}",
runs, because its e is the rescue's own local.
Rails with the gem installed and no SENTRY_DSN also transmits
nothing, so the delivery half of this is not a divergence for an
unconfigured deployment; the lost detail is.
Rails declares it mattr_accessor(:allowed_attributes) with no default
(actiontext 8.1.3, app/helpers/action_text/content_helper.rb:11), so
in an app that never configured it the reader answers nil and every
caller falls through its own ||. Ours answers the list that fallback
computes — the sanitizer's own set plus ActionText::Attachment:: ATTRIBUTES, which is exactly what Rails' sanitizer_allowed_attributes
builds from the same two pieces.
Why, and it is a type problem rather than a behaviour one. campfire's
ContentFilters::SanitizeAttributes copies Rails' expression verbatim:
ActionText::ContentHelper.allowed_attributes ||
(sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_asanitizer_class is ActionText::ContentHelper.sanitizer.class — a
CLASS OBJECT. Neither we nor spinel can dispatch statically on one: our
Ty has no singleton variant (analyze::body::send collapses
instance.class onto the instance's own Ty::Class), and spinel's
sp_Class is a dynamic receiver. So the right arm is Array[untyped]
whatever the left says, and the result was handed twelve lines later to
a sanitize this runtime declares takes Array[String]. One list,
described two contradicting ways — and on spinel the contradiction
surfaced as far from its cause as it could get:
sanitize_attributes.rb:13: error: incompatible pointer types passing
'sp_PolyArray *' to parameter of type 'sp_StrArray *'
from the C compiler, with the campfire binary failing to LINK.
Typing the LEFT arm closes it, because || now folds to a left that
cannot be falsy (Ruby's falsy set is nil and false and nothing else,
so it reads off the type) instead of unioning with the right. The values
are identical either way: campfire computes the same list from the same
two pieces. Only an app that asks whether the reader is nil can tell
the difference, and none does.
What is still not modeled: a method that returns a class object is
declared by its INSTANCE type, because that is the only type we have.
def sanitizer_class: () -> Class is what gets emitted — honest, and
what spinel's sp_Class wants — but a chain through it stays dynamic.
Closing that means a singleton variant in Ty, which every target's
exhaustive match would have to answer for.
FIXED 2026-08-31, exactly as scoped below: push_to_param_method
synthesizes @id.to_s beside dom_prefix
(model_to_library/markers.rs), skipping abstract models and any model
carrying its own to_param (it runs after push_user_methods with a
skip guard, because that pass's dedup runs the other way — a
synthesized name pushed first would SHADOW the model's override, the
opposite of what the original wording here assumed). Behind the fixed
wall stood nothing: Zlib.crc32 and the initials-SVG template both ran
first try, and the avatar answered 200 with the full SVG. The demo's
avatars are the initials arm — Active Storage only serves an avatar a
user UPLOADED, which the archive excludes anyway.
The browser then found the second half, below: 200 with
text/html, and browsers do NOT sniff SVG in an <img> — a correct
body rendered as a broken image. Both halves verified together:
image/svg+xml on the wire, naturalWidth 150 in Chromium, and the
e2e LEDGER entry in helpers.js retired so an avatar 500 FAILS the
suite now. The original entry follows.
Rails gives every ActiveRecord::Base a to_param (id&.to_s), which a
model may override — lobsters' User#to_param answers the username. The
lowered model gets no such method: model_to_library registers a
SIGNATURE for persisted? and friends (mod.rs:1274) but synthesizes no
to_param body, and the definition that exists lives in
runtime/spinel/scaffold/ruby_overlay/runtime/active_support_core_ext.rb
— the ruby overlay, which the spinel target never applies.
campfire calls it directly:
# app/models/users/avatars_helper.rb:9
AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size]so on the spinel binary every avatar request answers
500 GET /users/<sgid>/avatar -- NoMethodError: undefined method 'to_param' for an instance of User
Measured 2026-08-30 against the campfire spinel binary with assets
built: four such 500s on the signed-in room page, reported by
e2e/campfire/assets.spec.js. Nothing else on the page fails — the
module graph, the stylesheets and the cable all load.
Not caught by anything else, and that is the point. The cable walk
(scripts/campfire-cable-drive.rb) never requests an avatar, the emitted
suite does not reach this helper, and the page returns 200 with the
avatar <img> simply broken. It took a browser asking for the file.
The fix is a lowering, not a runtime file — a to_param synthesized
beside dom_prefix (model_to_library/markers.rs::push_dom_prefix_method
is the template), pushed BEFORE push_user_methods so a model's own
override still wins. It is id.to_s, and it belongs to every target, not
to the ruby overlay.
Until then e2e/campfire/helpers.js carries it as a LEDGER entry so the
asset spec reports it without failing the run.
A response body crossed the FFI as a C string — FIXED, and the "keep-alive serialization" diagnosis with it
FIXED 2026-08-31. The ~30 s second-browser stall this entry used to
attribute to a single worker parking in one connection's keep-alive loop
had a different cause entirely, and the correction matters because the
wrong version argued the demo needed -w N + Redis. It doesn't.
What was actually wrong. Tep::Server::Scheduled#write_response
wrote inline bodies with sp_net_write_str — a strlen-terminated C
string across the FFI — while announcing Content-Length from the same
string. A body with a NUL byte truncates at the NUL: GET /account/logo
(a PNG served through res.body; a PNG's 9th byte is 0x00) promised
405,666 bytes and delivered 8. The sign-in page's <img> for that
logo therefore never finished; the browser held the request open waiting
for the difference, the page's load event hung, and only the server's
30 s KEEPALIVE_TIMEOUT closing the connection released it
(net::ERR_CONTENT_LENGTH_MISMATCH, then a retry that succeeded). The
"consistently ~30 s" signature was our own keep-alive timeout, observed
from the outside. Same family, same fix: Content-Length computed from
length (characters) understated the byte count of any page carrying
multibyte UTF-8, corrupting keep-alive framing, and the request-body
readers compared raw_body.length against a byte count. All wire
lengths are bytesize now and bodies go out through write_bytes
(both server variants, the request drains, and the raw-mode fan-out).
What was wrong with the old diagnosis. It quoted
Tep::Server#worker_loop — the blocking prefork server — but the
scaffold's main.rb has booted Tep::Server::Scheduled
(fiber-per-connection) since cable landed. That server never serialized
on keep-alive; idle-holder probes against it answer in single-digit
milliseconds. The stale-comment lesson again: the quoted code was real,
just not the code that runs.
Verified: e2e/campfire two-context sign-in went 30,422 ms →
303 ms; /account/logo delivers all 405,666 bytes and file(1)
parses the result as a complete PNG; scripts/campfire-cable-walk --spinel 13/13; scripts/compare spinel 7/7.
Why no existing gate saw it. The walk and the suite never fetch a
binary body over the wire and byte-count it against the header;
assets.spec.js watches for 404s and console errors, and a truncated
200 is neither. It took a browser waiting on the missing bytes.
Still true and worth keeping from the old entry: -w N preforks
processes whose Tep::Broadcast registries are per-worker, so
cross-worker fan-out still needs the opt-in Tep::RedisFeed. That is a
scaling limit, not — as this entry used to claim — a prerequisite for
"two tabs, one room, one live message" on one worker.
FIXED 2026-08-31. The controller runtime was never the problem —
render/send_file/send_data all store @content_type — the
scaffold's dispatch glue forwarded it to the response only for the
:json and :turbo_stream request formats. Everything else fell to
tep's inline-body default (text/html): the account logo's
image/png, and — the case that made it urgent — the initials
avatar's image/svg+xml, because browsers do NOT sniff SVG in an
<img> and rendered a correct 200 as a broken image. The glue now
forwards ANY controller content type that differs from the html
default, which is the contract the CRuby overlay dispatch has always
had (ruby_overlay/main.rb passes it unconditionally). Verified:
logo image/png, avatar image/svg+xml, turbo-stream POSTs still
text/vnd.turbo-stream.html.
FIXED 2026-08-31. tag.time **attributes, datetime: …, data: { … }
rendered as
<time>{datetime: "2026-08-31T00:38:06Z", data: {local_time_target: :date}}</time>— the attribute hash stringified into the tag's BODY. ingest::expr
desugars a double splat into the merge chain it is defined to be, which
is a Send rather than a Hash; tag_builder's content/attributes split
therefore read the sole argument as CONTENT. The guard meant to catch
this required args.len() > 1, and a call whose arguments are entirely
keywords has exactly one. Now:
<time datetime="2026-08-31T00:45:43Z" data-local-time-target="date"></time>Not campfire-specific: it hit every tag.<el> call with a **splat and
no positional content.
A CORRECTION TO WHAT THIS ENTRY USED TO SAY. It claimed the
broadcast-rendered row "carries no body", on the strength of a browser
observation. Measured directly over HTTP, the server render does
contain the body — message__body markup and the posted text are both
present, before and after this fix. The two render paths do not disagree:
what looked like a direct render in the browser was campfire's own
CLIENT-side optimistic echo (app/views/messages/_template.rb, the
$messageDatetime$ template), which is why its element carried a
client-generated id. Why tab B's DOM did not display a body that is in
the HTML it received was unexplained here until 2026-08-31; the answer
is in the sanitizer entry below — the body was never in the frame for a
Trix-composed message, and every direct-over-HTTP measurement had
posted plain text, which takes the one path that works.
A near-miss worth keeping. The first version of the fix routed to the
content_tag fallback, which returns before the inline path's
qualify_view_helpers — so it emitted a bare ViewHelpers.content_tag.
The emitted tree defines only ActionView::ViewHelpers. On the SPINEL
binary that resolved and rendered correctly; on CRuby it raised
NameError inside MessagesHelper#message_tag, whose body campfire
wraps in rescue Exception, so every message became "" and vanished.
The suite reported expected "#message_13" in response body — five
tests, two files, no mention of a constant. The AOT target was the
PERMISSIVE lane here and the interpreted one caught it, which inverts
the usual assumption. Qualification now happens inside
content_tag_fallback, so every fallback path is covered.
An HTML message body renders as nothing: the safe-list sanitizer is a raising façade behind a rescue Exception — FIXED, twice over
FIXED 2026-08-31, in two acts, verified in a browser (e2e/campfire 4/4 including the two-tab live-message milestone; smoke floor raised 3 → 4 in the same commit).
Act one: the safe-list sanitizer is PORTED, not raising.
ActionView::ViewHelpers.sanitize / sanitize_allowing are now a real
scanner-based safe-list sanitizer in the shared runtime — the gem's own
rule tables (rails-html-sanitizer 1.7.1 / loofah 2.25.2: default tags
and attributes, URI attributes, the 27 allowed protocols, data-URI
mediatypes, and loofah's allowed_uri? decode-strip-downcase pipeline
step for step). MEASURED: byte-identical with the gem on 75 of 81
corpus probes (tests/shared_sanitize.rb); the six divergences are
declared policies, every one more-escaped or more-blocked than the gem,
never less:
- entities in text and in kept attribute values stay as written rather
than being decoded and re-serialized (same policy
strip_tagsledgers; identical rendering); - no HTML5 tree reconstruction on malformed nesting — source tag order is kept, still-open tags close at end of input, a close nothing opened is dropped;
- a numeric reference to a C1 control in a URL (
jav…ascript:) is decoded, stripped and therefore BLOCKED, where the gem's parser remaps it to its Windows-1252 character first and allows it.
The refusal that remains, narrowed to where it is honest: an allow-list
naming a rawtext/foreign/template container raises NotImplementedError.
No corpus caller does. The CRuby overlay keeps binding the real gem.
An allowed style attribute is DROPPED, not served. Its value wants
Loofah's CSS scrubber (scrub_css over the Crass tokenizer), which is
not ported; keeping it unscrubbed is the unsafe direction, so the port
leaves style out of the allow-list and removes it from every element,
where the gem keeps the declarations its CSS safe-list passes. This used
to raise, and nothing reached it until campfire's Lexxy merge (9a258bd):
the lexxy gem's engine adds style to Action Text's attribute list, and
the raise inside campfire's rescue Exception rendered EVERY message
body on the spinel binary as "". On CRuby (the gem) a message's inline
styles survive; on spinel they do not. Porting the CSS scrubber closes
this.
Act two: h() must not escape the chain's product. With the raise
gone, the body rendered as its own ESCAPED source: campfire's
Filter.apply wraps every filter product in
ActionText::Content.new(...), whose to_s is born html-safe in Rails
— and neither lane carries that mark at runtime, so
h(ContentFilters::TextMessagePresentationFilters.apply(...)) escaped
the sanitized markup on BOTH lanes (the overlay's gem sanitizer answers
a plain String too; invisible until now because every gate posted
plain text). The chain's type is not inferable (*splat of class
objects into a reduce), but its construction is statically visible,
so lower::html_safe now collects constants initialized
ActionText::Content::Filters.new(...) and rewrites
h(<chain>.apply(x)) → <chain>.apply(x).to_s — the escape exemption
decided at the call-site rewrite layer, where every other exemption in
this runtime is decided, and one rewrite fixes both lanes.
The original entry follows, for the mechanism and the measurements.
A message posted in one browser tab reaches a second tab's DOM and is inserted — and arrives EMPTY. The two render paths disagree about the same message, in the same run.
ROOT CAUSE FOUND 2026-08-31, and it is not the fan-out.
MessagesHelper#message_presentation runs the body through
ContentFilters::TextMessagePresentationFilters and the sanitizer;
ActionView::ViewHelpers.sanitize_allowing is a deliberately RAISING
façade on this target ("the safe-list sanitizer is not modelled … only
tagless input is served"), and campfire wraps the whole helper in
rescue Exception → Sentry.capture_exception → "". So:
- a PLAIN-TEXT body (
message[body]=hello) renders everywhere — page, frame, both tabs; - an HTML body (
message[body]=<div>hello</div>) renders as""everywhere — page AND frame.
Trix always submits HTML. Every message composed in the browser is
<div>…</div> at minimum, so every composer-posted message body
vanishes on every server-rendered path, while the DB row is correct
(action_text_rich_texts.body holds the full HTML). Tab A only ever
saw its own client-side echo. Every wire-level gate posts urlencoded
plain text (campfire-cable-drive.rb, the suite, curl probes), which
is why 13/13 walks and a green suite coexist with a chat app that
cannot display a chat message.
Two reporting gaps compounded it: Rails.logger is a no-op class in
runtime/ruby/rails.rb, so campfire's own Rails.logger.error line goes
nowhere, and the Sentry façade prints details unavailable (its e
arrives through an untyped parameter — see gem_facades.rb). Naming the
exception took splicing $stderr.puts into the emitted rescue.
The fix is modelling the safe-list sanitizer for this target (or
lowering campfire's filter chain onto the HTML infrastructure
strip_tags already uses). Until then this entry is what keeps
cable.spec.js at test.fixme.
Measured 2026-08-30, campfire spinel binary, two browser contexts,
one room. Tab A posts; both tabs are subscribed and connected:
| body visible | |
|---|---|
| tab A (direct render of its own POST) | yes, +18 ms |
tab B (turbo-stream over /cable) |
no — 30 s, never |
The frame does arrive. Tab B's #messages_room_1 gains
<div id="message_1" class="message …"> with the author, the avatar and
the "Message options" control. What it does not gain is the message text.
The same row also renders local_time's options hash as TEXT rather
than expanding it into tag attributes — the two paths, same message:
tab A: <time class="message__timestamp" datetime="2026-08-30T23:41:53.831Z"
data-local-time-target="date" title="8/30/26, 7:41 PM">August 30, 2026</time>
tab B: <time>{datetime: "2026-08-30T23:41:53Z", data: {local_time_target: :date}}</time>(An uppercased variant of that text appears in innerText because the
day separator carries text-transform; it is the same string.)
So the defect is in the partial as rendered by the BROADCAST path, not in the fan-out: a helper whose keyword options survive one path and become a stringified Hash on the other, and a body that is dropped entirely.
Why the cable walk does not see it.
scripts/campfire-cable-drive.rb asserts html.include?(BODY) against
the raw frame — it reads the wire, not the DOM, and never renders. This
is the same shape as [[project_campfire_empty_message_bodies]]'s lesson
("an emptiness test must not go through a renderer") arriving from the
opposite direction: a wire test cannot see a renderer that drops content
downstream of the bytes it checked.
Tracked by e2e/campfire/cable.spec.js — test.fixme while this was
open, an executing milestone spec (and a smoke-floor increment) since
it closed.
FIXED 2026-08-31. The other half of the <time> defect above, and
the one the tag_builder fix could not reach. campfire writes
def local_datetime_tag(datetime, style: :time, **attributes) # time_helper
def message_timestamp(message, **attributes) # messages_helper
local_datetime_tag message.created_at, **attributesingest::library_class flattens BOTH keyword forms to positionals —
style: to a positional-with-default, **attributes to a trailing
positional defaulting to {} — and ingest::expr erases the call's **
into the merge chain it is defined to be, which is likewise positional.
Each half is individually sound. Together they slid the bundle one slot
left:
def self.local_datetime_tag(datetime, style = :time, attributes = {})
TimeHelper.local_datetime_tag(message.created_at, attributes) # → styleso style bound the whole hash and attributes bound {}. Every
message permalink rendered
<time datetime="…" data-local-time-target="{class: "message__timestamp"}">— the class dropped and the data attribute carrying an inspected
Hash. Now local_datetime_tag(message.created_at, :time, attributes).
Why an argument in that slot can only be an erased **. The same
inference lower::kwsplat makes from arity, made from position instead:
style: is a KEYWORD in the source, so Ruby offers no way to fill it
positionally, and a positional argument sitting there cannot have been
written by the app's author. It is ingest's own flattening showing
through. That reasoning needs a fact the flattening destroys, so ingest
now records it — Param::from_keyword and Param::from_kwrest mark the
two flattened shapes. Both are inert in emit (the emitted parameter list
is byte-identical) and read only by lower::kwrest_forward.
The one thing that did NOT change is the emitted parameter list. The repair is entirely at the call site. Across all of campfire the pass rewrites exactly one line, and raises no residue diagnostic.
KNOWN DIVERGENCE. A bundle that actually CARRIES one of the named
keywords — message_timestamp(message, style: :date) — keeps it in the
rest hash instead of binding the parameter, so the callee sees its
default and the key renders as a stray attribute. Ruby distributes by key
at call time; nothing static knows the keys of a hash forwarded through a
method boundary. The previous behaviour was wrong for EVERY bundle, this
one for the subset that names a keyword, and campfire's two call sites
are both outside that subset. A literal keyword list is unaffected —
local_datetime_tag ts, style: :date is lower::helper_kwargs' case and
still splices by name, which is why the two passes run in that order.
scripts/campfire-http-shape sends 71 requests (86 responses with
revisits) to Rails campfire and to the emit, on the same seed, and
compares status, Content-Type, Cache-Control, ETag, Vary,
Content-Encoding, Location, Set-Cookie names and attributes, and every
other header. Rails against itself: zero differences. Rails against the
emit: 86 of 86 responses differ on the ruby lane, 90 on spinel. The
sweep is basecamp/once-campfire-rust's, vendored; see the script's
header. Grouped by cause, largest first:
- Rails' default security headers are absent (54 responses):
X-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff,Referrer-Policy,X-Permitted-Cross-Domain-Policies,X-XSS-Protection: 0—config.action_dispatch.default_headers, which every Rails response carries. WithoutX-Frame-Optionsany site can frame a signed-in campfire page. The one to close first. - The app's own
config.rumiddleware is not applied. campfire'sconfig.rusaysuse Rack::Deflater. The CRuby overlay now wrapsMain.run_rackinGzipCache(Static stays outside so/cablehijack is never compressed; CSS/JS stay identity; identical HTML is not deflated on every request). spinel tep gzips inline bodies whenAccept-Encodingincludes gzip, from a cache keyed by SHA-256 of the identity body (CRuby keys by the body itself — MRI's string hash is cheaper than SHA-256 here). Gzip runs outside the lock on both lanes. Re-runscripts/campfire-http-shapebefore treating the 65 Content-Encoding misses as current. - Rails'
Rack::ETag/Rack::ConditionalGetare absent: no weak ETag on a 200 (52), noCache-Control: max-age=0, private, must-revalidate, so a revisit is 200 where Rails answers 304 (10-14). The explicit halves are the two entries above: "Conditional GET is ALWAYS FRESH", "expires_inrecords Cache-Control but emits no header" (the five asset/avatar/QR/blob/logo Cache-Control values). - Cookie attributes.
session_tokenis set withoutexpires(campfire writes itcookies.signed.permanent, so a browser restart signs the user out) and withoutSameSite=Lax; the Rails session cookie likewise lacks both. - Formats. A format the action does not render (
.xml,.yaml,Accept: text/htmlon the web manifest) answers 200 or 500 where Rails answers 406; the Turbo-stream and SVG content types lackcharset=utf-8; a missing template is a 500, not a 406. - Redirects are relative. Rails'
redirect_towrites an absoluteLocation(http://host/rooms/1); ours writes the path. - Routes the emit does not serve:
/up(rails/health#show) is a 404 (500 on spinel);HEADof a page is a 404 on both lanes. - The account user list's next page is a 500 on both lanes:
/account/users?page=2asked for as a Turbo Stream (what the list's infinite scroll sends past 500 users) reaches a view whoseturbo_streamhelper is not lowered (NameErroron ruby,replaceon an untyped receiver on spinel). - Blob URLs were signed differently — FIXED. Rails'
blob_idpurpose signs with SHA1 over URL-safe padded Base64 (…fQ==--<40 hex>); ours signed with SHA256 over unpadded, so a blob URL minted by Rails (in a page, an email, a cache) did not verify on the emit, and vice versa. Byte-identical now; see the next entry. - A variant URL's variation key is ours. Rails' representation URL
carries the variation SIGNED under the same verifier (purpose
variation); ours carries a readablelimit-1200x800-keep. The blob half of the URL is Rails' now; a representation URL minted by Rails still does not resolve on the emit, and vice versa. - Smaller:
Last-Modifiedabsent on the paginated messages (5),X-Total-Countabsent on the autocompleter JSON (2),Content-Dispositionabsent on avatar and logo images (2).
Each group is a fix in runtime/ruby/ or an entry here; the sweep
becomes a gate the way campfire-compare's room page did, once the list
is ledgered.
tests/rails_compat_vectors.rb runs the runtime's own signing and
verifying code — the cookie jar's verifier, ActiveRecord::SignedId,
ActionText::SignedGlobalId, Active Storage's blob verifier,
GlobalID.param, tep's cookie codec — against
tests/rails_compat/rails_compat.json: 500-odd cases minted by Rails
inside campfire in production mode, with a fixed secret and a frozen
clock (once-campfire-rust's generator, vendored beside it; our oracle's
bundle regenerates every deterministic section byte for byte). The
table it prints, and the gaps in it:
| section | match | |
|---|---|---|
| key_generator | 6/6 | |
| cookie_escaping.parse | 7/7 | |
| cookie_escaping.write | 3/5 | tep writes %20 for a space and escapes *; Rails writes + and leaves *. Both decode the same. |
| signed_cookies.verify | 26/35 | see below |
| signed_cookies.generate | 1/10 | the jar writes "exp":null: cookies.signed.permanent is not modeled, so a session_token has no expiry inside the signature (nor on the Set-Cookie — the header entry above). |
| signed_cookies.generate_envelope | 10/10 | given Rails' expiry, the envelope is Rails' |
| json_string | 20/20 | the runtime's JSON string codec against the JSON inside Rails' signed cookies, both ways |
| signed_ids.generate / verify | 14/14, 29/31 | |
| global_ids, sgids.generate | 4/4, 5/5 | |
| sgids.verify | 19/24 | |
| app_verifiers.blob_id / envelope / data | 8/8, 3/3, 7/7 | fixed with this entry: the blob verifier signed with signed-id's envelope |
| encrypted_cookies.verify / generate | 20/20, 8/8 | cookies.encrypted; see "cookies.encrypted is Rails' encrypted cookie jar" below |
| csrf | 0/208 | not implemented |
| passwords | 0/45 | not held |
What Rails still accepts that the runtime rejects, or reads
differently. Signed cookies: a signed empty value reads as absent (the ""
sentinel); pre-5.2 cookies with no metadata, and metadata with no
purpose, are rejected where Rails accepts them; an Integer or object
value reads back as its JSON text (the jar is String-typed); a
Marshal-serialized or unparseable value reads as its raw text where
Rails answers nil. Signed ids: the legacy SHA1 fallback verifier
(use_legacy_signed_id_verifier) is not consulted, and a String id
reads back as an Integer. Signed GlobalIDs: the Marshal-era (Rails 7.0),
JSON-legacy and globalid < 1.0 envelopes are rejected — campfire's own
lib/rails_ext reads the first unverified for mentions, which is the
path the runtime follows (SignedGlobalId unverified read), so a
mention renders; any other attachable signed by an older install does
not.
Not implemented. The session cookie is SIGNED where Rails'
CookieStore ENCRYPTS it (AES-256-GCM under
"authenticated encrypted cookie"), so a Rails _campfire_session is
not read after a migration — the flash and the CSRF secret start over,
while the user stays signed in (the session_token cookie is signed and
verifies). CSRF tokens are the session's own, unmasked (see
runtime/spinel/request_forgery_protection.rb's header), so a form
rendered by Rails does not post to the emit. Passwords: the ruby family
runs the bcrypt gem; spinel's package is not yet held to these vectors.
The JSON string codec — FIXED. MessageVerifier.json_string was
quote-wrapping and json_value quote-stripping, so a value Rails signed
with a <, a quote or a newline in it read back as \u003c / \" /
\n, and one the runtime signed carried a bare quote into the
envelope; extract_raw ended a data value at its first , or },
cutting Active Storage's object payloads short. Both directions are
ActiveSupport's JSON now (<, >, & escaped; control characters as
\n or \u00XX; \uXXXX and surrogate pairs decoded), byte-wise so
CRuby and spinel agree, and extract_raw reads a whole JSON value. The
json_string and app_verifiers.data sections hold it to Rails' bytes
(16/20 and 4/7 before).
On the spinel binary (tests/rails_compat_vectors_spinel.rs): the
same driver, compiled by spinel over sp_crypto and run under CRuby over
OpenSSL, must print identical tables; the 155 clock-stable cases do.
Running them there found a defect none of Rails' vectors could show —
FIXED: the binary derived its signing keys through
sp_crypto_b64url_decode, which returns a C string with no length, so a
derived key holding a zero byte was cut at the zero. About one
SECRET_KEY_BASE in five derives such a key (429 of 2,000 sampled); on
those deployments every signed cookie, signed id, sgid and blob URL the
binary made was signed with a key a few bytes long — forgeable, and
matching nothing Rails signs. The key is decoded in Ruby now
(runtime/spinel/message_digest.rb), and nul_key cases — secrets
whose keys hold a zero, answered by OpenSSL — pin it. sp_crypto's HMAC
itself is binary-safe (it reads the key's length off the String), so
web push's HKDF was never affected.
The other direction — Rails verifying what the runtime signs — needs no separate harness where generation is byte-identical to Rails', which is every deterministic section above; it matters where randomness is involved (encryption IVs, CSRF masks), which is exactly what is not implemented.
scripts/campfire-richtext-corpus renders once-campfire-rust's corpus of
stored message bodies — 247 handwritten (fixtures, Lexxy and Trix
mentions, figures, galleries, remote images and video, content
attachments, opengraph embeds, sgids good / tampered / expired /
cross-purpose / Rails-7 / deleted, autolinks, hostile markup), 400
seeded fuzz, 400 seeded mutations — through campfire's own
message_presentation and body.to_plain_text, served by Rails and by
the emit from the same database, and compares case by case. Their
generator, vendored and run by our oracle, reproduces their recorded
answers byte for byte on all 647 cases the two corpora share, and the
Rails-served page matches the generator on every case it serves (49
bodies make Rails itself raise; the emit must raise on them too).
| presentation, bytes / DOM | handwritten (247) | fuzz (400) | mutation (400) |
|---|---|---|---|
| ruby (the real sanitizer gems) | 152 / 209 | 147 / 216 | 242 / 277 |
| spinel (the ported sanitizer) | 128 / 196 | 98 / 191 | 181 / 228 |
DOM is their normalization (HTML5 fragment, whitespace-only text
dropped, whitespace collapsed, attributes sorted). Plain text: ruby
208 / 85 / 246, spinel 206 / 85 / 244.
Where the gap is. At the DOM level 344 presentation failures are shared by both lanes and 88 are spinel's alone. The 88 are the ported sanitizer — what swapping in spinel-loofah under rails-html-sanitizer is for. The 344 are roundhouse's compilation of campfire's pipeline, whatever sanitizer runs under it; by cluster:
- A bare
<truncated the message — FIXED 2026-09-30. `1 < 2 && 32
`).rendered1: Action Text's scanners (next_element,element_end,to_plain_text,scan_tags) took any<for a tag, and SanitizeTags removed< 2 && 3 >and everything after it. They now askActionView::ViewHelpers.tag_open_at?, HTML5's tag-open rule the sanitizer engine already used — corrected on the way for</+ non-letter, which is a bogus comment the tokenizer drops, not text. Presentation DOM after: ruby 212 / 251 / 304, spinel 199 / 212 / 253; plain text ruby 210 / 108 / 274. Two fuzz cases (278, 349) that passed by accident — the misread<swallowed what Rails' tree builder drops — fail now, exposing the real gap: the fragment scanner is not an HTML5 tree builder (an unclosed<textarea>is RCDATA to the end; table content foster-parents out of - Trix figures (
<figure data-trix-attachment=…>, what older installs stored) are not converted: a mention inside one renders as its name, an image not at all. - Remote image and video attachments, content attachments
(
text/htmlwithcontent=) and galleries render empty. - A mention's plain text keeps the HTML in the user's name, where Rails strips it.
- Byte-level only, DOM-equivalent: a newline after each mention's
</span>and a blank line after each opengraph embed (a partial's trailing newline Rails trims),href/targetorder and'versus'in autolinks.
A report, not yet a gate: the floors above become a ratchet once the spinel lane runs the real sanitizer packages.
scripts/campfire-db-differential runs once-campfire-rust's model
scenario (30-odd operations: messages and boosts created and
destroyed, an STI becomes!, closed and direct rooms, a membership's
connection counter, ban/unban, deactivation, search history, a bot and
its webhook, account settings) from campfire's fixtures, on Rails
(rails runner) and through the transpiled models (the same statements
in an overlay controller, one GET), then diffs every table with random
and clock values reduced to their shape. Then Rails boots on the
database the emit wrote and reads, authenticates, searches, edits and
deletes through Active Record.
Both lanes: 13 of 13 tables match, rollback 17/17. memberships
passes under one printed forgiveness — the insert_all entry above
(ids assigned in another order, microsecond timestamps where SQLite
stamps milliseconds). Everything else is Rails' rows.
It got there by finding seven defects, each fixed and each pinned in
tests/model_scenario_lowerings.rs or tests/spinel_db_lease.rs:
Room.find(id).messages.create!(…)stayed on the plain reader's Array (create!for an instance of Array): the association constructor rewrite now takes any owner expression, which it names once.- A scope-free app skipped the scope pass entirely, so its association constructors never rewrote at all.
- A local assigned inside
begin … rescueread as unresolved after it, and the rewrites keyed on its type silently declined. pluck(:id)on a parameter holding an Array (Rooms::Direct.find_for) had no Arraypluck.- The mocha slot guard prepended to a stubbed app method named
MochaStub, which only the test helper loaded: destroying a membership 500'd on both lanes in production. has_rich_text'sdependent: :destroywas not expanded: a destroyed message left its body inaction_text_rich_texts.- An attribute-hash
update!(status: …)wrotecreated_atback EMPTY: its temporal normalize sat behind a.to_s.nil?that is never true.
Db.with_connection (runtime/spinel/db.rb) released its lease only on
the happy path — its comment called that "acceptable on the happy path;
revisit if the dispatch path starts raising". Every 500 leaked one
connection for good; after a pool shard's worth (4) the next lease on
that shard parked forever, and every request served by a thread
assigned to that shard hung. Any signed-in user who could reach a 500
four times could take a shard down. Found by campfire-http-shape,
whose sweep alternates routes and so kept landing the one that raised
on the same shard: the binary stopped answering it after four. The
release is in an ensure now (capture_sql in the same file has used
one on this lane all along); tests/spinel_db_lease.rs raises more
times than the pool holds and checks the pool is whole again.
Tep::Url.parse_query stored a form body into a String→String hash, so
repeated user_ids[]=2&user_ids[]=3 keys kept the last value, and
Main.nest_params then read user_ids[] as a sub-hash with the key
"": the controller saw {"" => "3"} where Rails sees ["2", "3"].
campfire's Rooms::DirectsController#create does
User.where(id: params.fetch(:user_ids, []).including(Current.user.id)),
so on the binary every new direct room was created with its creator
alone, and the request answered 302 as if it had worked. The cable
walk's direct-room probes checked status codes only and stayed green.
nest_params also knew one level of one resource per request, and let
the body win over the query string, where Rails lets the query win.
runtime/spinel/param_builder.rb is now a port of Rails' own
QueryParser.each_pair + ParamBuilder#store_nested_param, and
Main.request_params merges body, query and path captures in Rails'
order, answering 400 where Rails does. It is held to Rails' answers
for 2,755 query strings (tests/params_vectors/, borrowed from
once-campfire-rust and regenerated byte-identical with our oracle's
bundle), under CRuby and compiled by spinel
(tests/spinel_param_builder.rs); the walk's group-room probe checks
the members are there.
Still open, smaller: a MULTIPART body's text fields reach the
builder by name from its parser (Tep::Request#body_fields), so a
repeated field in a multipart form still keeps its last value. The
other targets keep their own nesting (the Python overlay's
_nest_params, Elixir's nest_params), not held to these vectors.
Found 2026-08-31, and it blocks the cable walk and the browser
milestone on the spinel lane. Introduced with the in-process job queue
(3aed469c), not by anything since.
Before any job runs, the binary is healthy:
$ curl -o /dev/null -w '%{http_code} %{time_total}s' http://127.0.0.1:59444/
302 0.000974s # and 0.0% CPU at idle
After one queued job raises, it serves nothing at all:
[tep 0.8.1-vendored] listening on http://0.0.0.0:59273 (workers=1)
[job] a queued job raised: uninitialized constant ThreadPoolExecutor
← no further output, ever
sample(1) puts 100% of the main thread in sp_Scheduled_s_run_worker
— Tep::Server::Scheduled.run_worker's while alive_count > 0; tick(1000); end — and curl against the bound port times out. Observed
twice, independently: the cable walk's binary (spun 53 minutes, 13 past
its own timeout 2400, until reaped by hand) and the archive's binary
under scripts/smoke campfire, whose log has ZERO [WebServer] lines
after the raise.
A THEORY, not a diagnosis. Scheduler.tick marks the fiber it is
about to resume with sched_wake_at[best] = -1, and its readiness test
is sched_wake_at[i] <= now. Since -1 <= now is always true, a fiber
that yields WITHOUT re-arming its wake time is permanently ready:
any_time_ready then forces poll_round(0), which never blocks, and the
loop spins while starving the accept fiber. The -1 hazard is known and
already guarded at both ordinary yield points — pause re-arms, and
io_wait says so explicitly ("-1 would mean 'ready now' to the tick
picker, so use a far-future wake_at as the sentinel"). So the suspect is
a third path that reaches Fiber.yield without re-arming, on or near the
Db.with_connection { ActiveJob.drain } line in the scaffold's
job_loop. ActiveJob.drain itself is not obviously at fault: it
rescues StandardError, NameError is one, and the log line proves the
rescue ran.
Why no existing gate sees it. The ruby lane holds jobs
(enqueue_without_running) and runs under Puma, so neither
campfire-suite nor scripts/campfire-e2e ever drains a raising job on
this server. scripts/smoke campfire DOES hit it, and stays green,
because the only spec that posts a message is test.fixme.
ThreadPoolExecutor is the trigger, not the bug. It is
concurrent-ruby (campfire's suite also wants Concurrent::CyclicBarrier).
Any raising job wedges the server the same way; a missing constant is
merely the one this app reaches first.
The trigger is gone (2026-09-14), the entry stays. Concurrent:: ThreadPoolExecutor, FixedThreadPool and CyclicBarrier are ported
over spinel's own threads (runtime/spinel/concurrent.rb; the ruby
family runs the gem), Net::HTTP::Persistent has a client on spinel
(runtime/spinel/net_http.rb — a connection per request, since the
package keeps none alive), and the config value that holds the pool is
built once per process rather than per read (config.x.<key> readers
memoize; see ingest::app). So Room::PushMessageJob runs, posts its
deliveries, and each one fails INSIDE the pool's own rescue — the
WebPush.payload_send façade raises, and WebPush::Pool#deliver_later
logs it — rather than out of the job. What a raising job does to the
scheduled server is untested since; the threaded server drains jobs on
a thread of its own.
Since 2026-09-22 the façade does not raise: delivery is the gem's on
the ruby family and a port of it on spinel (runtime/spinel/web_push.rb,
web_push_crypto.rb — byte-identical crypto, tests/spinel_web_push_crypto.rs).
The service worker and manifest it depends on are served since c89e3b22.
A delivery to a real push service is not yet confirmed.
The threaded binary dies on more than one OS worker — CLOSED (runtime fixed upstream; the declaration is lifted)
The green-thread server (runtime/spinel/tep/server_threaded.rb) was
validated on ONE OS worker first. With spinel's autodetected worker
count, a browser's parallel load of the signed-in room page took the
binary down in 4 of 7 runs on a 16-core Linux box (gdb: SIGSEGV in
sp_StrArray_scan under sp_gc_mark_drain — a stop-the-world mark
reaching an Array[String] whose element was already freed), and in
one more run a cable subscribe never confirmed with the server alive.
SPINEL_WORKERS=1: 9 of 9 green. Diagnosed the same day with
SPINEL_GC_VERIFY=1 SPINEL_GC_STRESS=1 and a TSan build:
matz/spinel#4272 — the boxed proc channel is per-worker TLS the barrier
never published, so a stale slot on an idle worker named an object
another worker's collection had freed — plus the per-class pool's
plain push under the parallel sweep. Both fixes merged upstream on
2026-09-02 (matz/spinel PRs #4273 and #4274). Meanwhile the program
declared one worker for itself on main.rb's first line (the runtime
reads SPINEL_WORKERS at the first Thread.new, so the operator's
environment still won; matz/spinel#4266).
Lifted. On spinel master with both merges (cdf83a4a), the same
16-core loop with the autodetected worker count and no declaration:
10 of 10 runs alive and passing — no fault, no verifier report — and
the process observed at 9 OS threads under the browser's load (3 at
idle), so the workers were really there. The one symptom that remained
on that runtime was not the collector's: a cable subscribe that never
confirmed, and a browser reporting an invalid HTTP response on an
asset or on the cable handshake. That was ours — the next entry — and
with it fixed the loop is 10 of 10 green with no handshake error, and
10 of 10 again launched plain (no gdb, so address-space randomisation
on), server alive after each. One plain launch before those, made
before the exit status was being captured, reset the browser's
connections right after sign-in — not reproduced in the ten that
followed, and recorded here rather than explained away.
SPINEL_WORKERS=N in the environment still sets the count; the README
of each archive says so.
The campfire binary on 12 OS workers died inside SQLite within a second
of sixteen parallel room-page requests, about one run in two:
sqlite3_clear_bindings from Db.finalize, with six other workers
inside the library on their own connections at that moment (filed as
matz/spinel#4312 with what the bisection ruled out — a 4 GB collection
budget still crashed, so not the collector; a pool of one or two
connections was clean, so it needed parallel connections; the lease, the
pool's Mutex/ConditionVariable and Thread.current held up in a
64-thread standalone on 12 workers; the empty room page was clean). The
cable sweep then hit the same wall on FOUR workers at 300 sockets: the
connect storm's presence writes.
Mechanism. DbConn#prepare_cached prepared through SQL.stmt_out,
an ffi_buffer — ONE 8-byte block of static C storage for the whole
process — and read the statement pointer back out of it. Two workers
preparing at the same moment wrote in turn and one read the other's
pointer: a statement then belonged to two connections, one connection's
cache held a handle it never prepared, and the next reset, clear or
finalize on it from either side was undefined behaviour inside SQLite.
Every row of the bisection follows: the seeded room page prepares
constantly (with the bind gate off every value is a new SQL string), the
empty page barely prepares at all, one connection serialises the
prepares behind the lease, and no collector is involved.
Fix. The prepare and the read of its out-buffer are one critical
section under Db.prepare_lock (a module-level Mutex); no raise
inside it, since synchronize carries no ensure on this lane. A
per-thread or per-connection out-buffer would remove the lock; the FFI
declares buffers once per module today, and a prepare is short.
N cable sockets on one binary: the connect storm, the idle cost, and fan-out per subscriber — MEASURED (2026-09-03)
scripts/campfire-cable-sweep drives the campfire binary with N held-open
Action Cable sockets (scripts/campfire-cable-sweep.rb: a stdlib RFC 6455
client on one IO.select loop, each socket signed in as a seeded user and
subscribed to the stream its room page names), pinned to a CPU set so a
tier of once.com/campfire's table runs on its own core count. Four cores,
four workers, the 1,000-user seed, roundhouse 74222ab8 on spinel 7182e9b6:
| sockets | rooms | connect storm | idle CPU | idle RSS | frames delivered | fan-out p50 / p99 | last frame p99 |
|---|---|---|---|---|---|---|---|
| 10 | 1 | 6 ms | 0.003 cores | 30 MB | 300 / 300 | 45 / 58 ms | 59 ms |
| 30 | 1 | 14 ms | 0.005 cores | 33 MB | 900 / 900 | 46 / 69 ms | 70 ms |
| 100 | 2 | 40 ms | 0.010 cores | 43 MB | 1500 / 1500 | 39 / 70 ms | 72 ms |
| 300 | 6 | 0.13 s | 0.028 cores | 89 MB | 1500 / 1500 | 32 / 55 ms | 61 ms |
| 300 | 1 | 0.13 s | 0.029 cores | 77 MB | 9000 / 9000 | 46 / 69 ms | 77 ms |
| 1000 | 20 | 0.53 s | 0.129 cores | 184 MB | 1500 / 1500 | 34 / 65 ms | 67 ms |
| 1000 | 1 | 0.57 s | 0.134 cores | 143 MB | 30000 / 30000 | 121 / 178 ms | 199 ms |
| 3000 | 20 | 4.3 s | 0.58 cores | 296 MB | 4500 / 4500 | 94 / 158 ms | 180 ms |
| 5000 | 20 | 11.6 s | 0.57 cores | 433 MB | 7500 / 7500 | 171 / 315 ms | 423 ms |
The table's 1,000-user row is 4 CPUs and 8 GB; at that size the binary idles at 3% of a core and 184 MB and delivers every frame. Rooms hold 50 subscribers from 100 sockets up (150 and 250 at 3,000 and 5,000), so the fan-out per message is the same size through 1,000; a message to a room of 1,000 costs 225 µs per delivered frame and the last subscriber gets it 50 ms after the first, which is our serial fan-out from the posting thread.
The walls, in the order the sweep met them. At 300 on four workers the
connect storm crashed the server: matz/spinel#4312, the shared prepare
out-buffer above, ours. At 1,000 every connection past about 450 was lost
silently: matz/spinel#4314, IO#wait_readable refusing a descriptor at
or past 1024 in front of a park that polls; fixed upstream the same night
(7182e9b6), which is what makes the 1,000 row and beyond measurable. From
1,000 to 5,000 the cost per connect quintuples (0.53 to 2.1 ms) and the
fan-out latency triples at a constant room size: matz/spinel#4317, every
wake in the monitor costs O(parked threads) — a standalone puts it at
8.5 µs per wake with nothing parked and 86 µs with 5,000 parked, linear.
Readings that did not survive and are withdrawn: the single-writer SQLite
theory (this storm subscribes only the message stream and writes
nothing), the listen backlog (1024, overflow counters unmoved), and "the
monitor pinned at a full core" (it was, under 568 dead threads; with the
descriptor fix the busiest thread in a 1,000-connect storm is 15–20% of a
core).
What that table is, and is not. Every row above was taken with ONE
subscription per socket and no presence, which is the shape the driver
had; it is a floor, and the rows are kept as measured rather than
restated. The driver has since grown the second subscription a real
campfire tab opens — {"channel":"PresenceChannel","room_id":N} on the
same consumer — so a connect storm now costs a memberships UPDATE per
socket and a disconnect storm another. Re-measuring the tier with
presence on is a re-run of the same script, and the two shapes are
distinguished in every result ("presence": true|false).
Still not covered: room-page load under concurrency, which is the other half of the workload and where the thread server is weakest (the interleaving cost on matz/spinel#4306); and payloads lighter than production's (the attachment gaps are a ledgered bias).
scripts/campfire-cable-sweep --lane rails boots campfire itself out of
the scripts/campfire-oracle tree — clustered Puma at WEB_CONCURRENCY,
Redis for both the cable adapter and the fragment cache, production env,
pinned to the same CPU set — and runs the SAME driver against it. A
number from either lane produced by a different script is not a
comparison, so there is one script.
Three things had to be true first, and each was found by trying.
-
Sign-in does not scale, on either lane. campfire rate-limits
SessionsController#createto 10 per 3 minutes per IP (measured: the 8th sign-in answers 429) and each one is a bcrypt. The driver signs in through the real form with theauthenticity_tokennow — Rails refuses a POST without it, and a driver that skipped the token would only ever have run against the lane that forgives it — but above a few dozen sockets it takes--cookies, one pre-minted signedsession_tokenper line.scripts/campfire-oracle cookiesmints them through Rails' own cookie jar against the seed's deterministic token shape. ONCE answers the same problem the same way: their harness ships 10,000 pre-forged cookies. -
One cookie file has to work on both lanes, which is the PBKDF2 iteration fix below.
-
CPU and RSS have to be read off the process TREE. The binary is one process; clustered Puma is a master and N forked workers, and sampling the master alone would have charged Rails almost nothing for almost everything it did. The driver rediscovers the tree on every sample (Puma replaces a reaped worker) and reports PSS beside RSS, because a forked worker's copy of a shared page is counted once per worker in the first and split between them in the second — RSS alone would invent a penalty the clustered lane does not pay, PSS alone would break comparison with the single-process rows already measured.
Was: ActionController::CookieJar had signed and permanent
views and no encrypted, so cookies.encrypted[...] was a
NoMethodError, and the runtime had no AES-GCM.
Now (measured against Rails 8.1.4, load_defaults 8.1):
cookies.encrypted writes and reads
b64(ciphertext)--b64(iv)--b64(tag), AES-256-GCM with a 12-byte IV and
no associated data, the key PBKDF2-HMAC-SHA256(secret_key_base,
"authenticated encrypted cookie", 1000, 32), the plaintext the _rails
envelope (purpose cookie.<name>) around the value as JSON, so a
read answers the value with its type (a Hash with String keys, an
Integer). ActionController::MessageEncryptor holds the format, over
MessageDigest.aes_256_gcm_encrypt / aes_256_gcm_decrypt (OpenSSL on
CRuby, spinel's packages/openssl on spinel).
tests/rails_compat_vectors.rb holds the jar to campfire's 28 vectors
(tampered parts, wrong name, rotated secret, Marshal value, the
pre-5.2 cookie without metadata), CRuby and the spinel binary alike.
Left as they are, each stated where it lives:
- Not read: key rotation (
config.action_dispatch.cookies_rotations), the AES-256-CBC jar (use_authenticated_cookie_encryption = false), overriddenauthenticated_encrypted_cookie_salt/encrypted_cookie_cipher/cookies_serializer(:marshal,:hybrid), and the{"_rails":{"data":…}}envelope no cookie jar writes. An app that sets them gets the defaults here. expires:on a write is dropped:cookies.encrypted[:t] = { value: x, expires: 1.hour }sends noExpiresand puts noexpin the envelope, so the cookie reads for as long as the browser keeps it. Rails records the expiry inside the encrypted envelope, so a copied short-lived cookie stops reading server-side. Only.permanentsets one. The signed jar has the same gap.- The session cookie is still the signed one (see "Not
implemented" in the vectors entry above):
cookies.encrypted[<session key>]reads nil in an emitted app until the session store writes through this jar. - Link cost (spinel): AES-GCM comes from spinel's
packages/openssl, whichruntime/spinel/message_digest.rbrequires, so every spinel web app now links libssl/libcrypto and a build host needs the OpenSSL headers.
ActionController::MessageVerifier derived its key with 65_536 PBKDF2
iterations, and every test we run derived it the same way on both sides,
so nothing noticed that no Rails app uses that number.
ActiveSupport::KeyGenerator.new(secret) defaults to 2**16;
Rails::Application#key_generator — which is what every signed cookie
and every signed id in a Rails app actually goes through — passes
iterations: 1000.
Measured, not read. A session_token cookie minted by campfire under
Rails 8.2 through the app's own ActionDispatch::Cookies::CookieJar
reproduces bit for bit at PBKDF2-HMAC-SHA256(secret, "signed cookie",
1_000, 64) then HMAC-SHA1, and at no other point in the
{1_000, 65_536} x {SHA1, SHA256}^2 grid. Before the change the emitted
binary answered 302 to a cookie Rails had just issued for a session row
its own database held; after it, 200. ONCE's own load harness had the
number all along — test/performance/create_dummy_cookies.rb forges its
10,000 cookies with KeyGenerator.new("dummy", iterations: 1000), which
had been read here as a bug in their script.
This is what lets the cable sweep hand ONE cookie file to both lanes, and it is the precondition the unsigned-stream-name entry above was waiting on.
A signed id's envelope was wrong too, and is fixed with it. Rails 8.2
mints record.signed_id(purpose: :avatar) as
{"_rails":{"data":1,"pur":"user/avatar"}}, URL-safe base64 with no
padding — the id embedded as JSON, and NO exp key at all when there is
no expiry — where this file built the cookie jar's
{"_rails":{"message":"<base64>","exp":null,"pur":…}} in strict base64.
Both are ActiveSupport::Messages::Metadata; which one it writes depends
on whether the verifier's serializer can carry the metadata itself, and
the cookie jar's cannot (it signs an already-serialized String) while the
signed-id verifier's can. Same secret, same salt, same digest — three
different bytes. MessageVerifier.data_envelope /
verified_data_json are the second face, and ActiveRecord::SignedId
reads them.
Why nothing caught either. The unit vectors were real ActiveSupport
output from a HAND-ASSEMBLED verifier — KeyGenerator.new(SECRET, hash_digest_class: SHA256) and MessageVerifier.new(key, serializer: JSON) — which is a configuration no Rails application has: the app's
generator passes 1_000 iterations and the app's verifier picks the other
envelope. Two wrong answers, both correctly signed, pinned as ground
truth. They come from campfire itself now, minted through signed_id and
CookieJar.build (the runner is in the test's header) — a vector is the
APP's output or it is not an oracle. scripts/campfire-compare could not
have caught it either: it masks "signed blobs (avatar sgids, stream
signatures)" as volatile, which is exactly the bytes that were wrong.
The unit harness also loaded CRuby's stdlib Base64 where an emitted
ruby tree ships the one in runtime/spinel/base64.rb, emitted to
runtime/base64.rb in the tree — which is why write_bundled_requires
skips the stdlib require for it. The harness loads the one that ships
now, and that is what made the missing urlsafe_encode64_nopad visible.
on_subscribe :present, unless: :subscription_rejected? landed in
unknown_calls and was dropped with a lower_residue warning, because
it is an ActiveSupport::Callbacks chain and there is no such thing in
an emitted tree. campfire's PresenceChannel does ALL of its work in
those two hooks: present is the memberships UPDATE that records
somebody as being in a room, absent the one that takes them out. So the
binary subscribed correctly, delivered every frame, and wrote nothing —
invisible to the cable walk (which asserts frames) and invisible to the
suite (Rails' Channel::TestCase builds the channel itself). The cable
sweep found it by running the two lanes side by side: eight sockets, and
Rails had eight connected_at rows where the binary had none.
ingest::channel_callbacks consumes the declarations and inlines the
chain — guards and all, ancestors first — into one generated method per
hook:
def after_subscribe
present if !(subscription_rejected?)
nil
endA method rather than a table, for the reason every lowering here prefers
one: a chain walked at run time is a list of Symbols a static target
cannot dispatch. Inlined rather than super, because the parent chain is
a compile-time fact and a virtual call is not free on the strict targets.
A block form (on_subscribe { … }) or a lambda guard is left in
unknown_calls with its warning rather than half-modelled.
Both lanes call both hooks. The overlay's Cable::Dispatch already kept
the channel object for the life of the subscription; the spinel lane now
keeps confirmed channels on the per-connection Cable::WsMessage handler
and a new Cable::WsClose walks them under a database lease when the
socket closes. Verified against Rails on the same seed: eight rows during
the idle phase on both lanes, zero after teardown on both.
Correction, 2026-09-04 (later the same day). The idle-CPU doubling this
entry attributes to the second registered stream per socket is not the
streams. The idle cost is the monitor's turn rate times a poll over
every parked socket, and the turn rate is set by how spread the
per-connection ping phases are: a second subscription only made the
connect storm ten times longer (a presence write per connect through
SQLite's sleeping busy handler), which spread the phases. Same binary,
1,000 sockets, 4 workers on 4 cores: one subscription after a 0.55 s
storm idles at 0.132 cores (monitor blocking 65 turns/s); two
subscriptions after a 6.2 s storm at 0.234 (182/s); ONE subscription with
the connects paced 6 ms apart, a 6.6 s storm, at 0.396 (337/s). Real
connections arrive over minutes, so the paced row is the honest idle
number for a per-connection heartbeat: one monitor turn per beat, ~1.1 ms
of poll over 1,000 TCP sockets per turn. Reproduced in matz's standalone
by staggering its heartbeat starts (0.021 cores started together, 0.375
staggered, 0.010 with one shared heartbeat; matz/spinel#4317). The idle
axis is therefore mostly ours: a shared heartbeat, Action Cable's shape,
is the next change; the population term, a poll over everyone parked
on every event, stays the runtime's and is what #4317 asks for.
once.com/campfire's table puts 1,000 concurrent users on 4 CPUs and
8 GB. Both lanes on taskset -c 0-3, the same 1,000-user / 20-room seed,
the same pre-minted cookie file, and the same driver: the binary on four
OS workers, campfire on WEB_CONCURRENCY=3 (its own
(processor_count * 0.666).ceil at four cores) x 5 threads, production,
Redis for the cable adapter and the fragment cache. Every socket holds
the two subscriptions a real tab holds, so every connect is a
memberships UPDATE and every close another. roundhouse 883db7a5 on
spinel 7182e9b6, Rails 8.2.0.alpha @ 1a02651a, campfire @ 94a48aac.
| N | lane | connect storm | storm CPU | idle CPU | idle RSS / PSS | frames | fan-out p50 / p99 | last frame p99 | chat CPU | teardown CPU |
|---|---|---|---|---|---|---|---|---|---|---|
| 100 | binary | 0.87 s | 0.24 s | 0.013 cores | 56 / 44 MB | 500 / 500 | 51 / 79 ms | 80 ms | 0.036 cores | 0.14 s |
| 100 | rails | 0.62 s | 1.52 s | 0.009 cores | 741 / 514 MB | 500 / 500 | 38 / 204 ms | 207 ms | 0.105 cores | 0.39 s |
| 300 | binary | 2.48 s | 0.80 s | 0.040 cores | 73 / 62 MB | 1500 / 1500 | 40 / 74 ms | 76 ms | 0.083 cores | 0.37 s |
| 300 | rails | 1.10 s | 2.86 s | 0.005 cores | 794 / 586 MB | 1500 / 1500 | 32 / 299 ms | 303 ms | 0.182 cores | 0.63 s |
| 1000 | binary | 6.70 s | 3.99 s | 0.243 cores | 143 / 133 MB | 1500 / 1500 | 38 / 76 ms | 81 ms | 0.285 cores | 1.46 s |
| 1000 | rails | 2.35 s | 5.86 s | 0.013 cores | 902 / 697 MB | 1500 / 1500 | 33 / 201 ms | 207 ms | 0.170 cores | 1.57 s |
Both lanes deliver every frame at every size. Nothing here is published; the plan defers that until the binary is competitive, and on one axis it is not.
Memory: the binary, by 5x. 133 MB of PSS against 697 MB at 1,000 sockets — 2% of the tier's 8 GB against 9%. RSS reads higher for Rails still (902 MB) because three forked workers each carry their own copy of a page; PSS is the fair number and the binary wins on either.
Idle CPU: Rails, by 19x, and this is the thing to fix. 0.013 cores against 0.243 to hold a thousand silent sockets. Action Cable runs ONE shared heartbeat off its reactor's select timeout; this lane runs a ping thread per connection and every wake of the monitor costs O(parked threads) — matz/spinel#4317, already filed with a standalone that puts it at 15 ns per parked thread per wake.
And the second subscription is most of it, which #4317 does not explain. The same 1,000 sockets holding ONE subscription idle at 0.136 cores (and storm in 0.57 s for 1.15 CPU-s, teardown 0.22 s) — the same number of connections, the same number of ping threads, the same parked count. Holding 2,000 subscriptions instead of 1,000 costs ~1.5x the idle CPU on a workload where neither subscription receives anything. Not a drain: over a 90-second idle it settles at 0.207 cores rather than falling back. Something in the per-connection write path is O(registered streams) and it is the next probe.
Fan-out: the binary's tail, Rails' median. 38 vs 33 ms at the 50th percentile, 76 vs 201 ms at the 99th. Net of the idle tax the binary also does the work more cheaply: subtracting each lane's own idle draw from its chat window leaves ~0.041 cores against ~0.157 for the same 1,500 delivered frames. The binary is ~3.8x cheaper at delivering, and pays a quarter of a core to hold the sockets it delivers to.
Connect storm: Rails, on wall time; the binary, on CPU. 2.35 s against 6.70 s to subscribe 1,000 sockets and write 1,000 presence rows, for 5.86 CPU-seconds against 3.99. Three processes x five threads have more hands than four workers do, and the binary's presence writes serialize harder behind one SQLite writer. Teardown is a tie (1.46 vs 1.57 CPU-s), which is the first time the disconnect half has been measured on either lane.
The 1,000-user tier above loses idle CPU to Rails by 19x. Two changes land on that axis the same day, one upstream and one here, and each was measured with the other held still.
Theirs. matz/spinel#4317 asked for persistent readiness registration
in the scheduler's monitor: a wake handed the whole parked population to
poll(2), and a server's parked threads are its idle connections. It was
answered in a day — 692e50b2 puts the readiness set in an epoll set,
1c34d424 carries the same contract to kqueue, 1c269a3c moves the set
into each worker so a wake needs no hand-off (the half #4306 left open).
Ours. A green thread per connection sleeping three seconds is the
best case only while the connections' beat phases coincide. Real
connections arrive over minutes, the phases spread, and the monitor turns
once per beat. Cable now keeps a registry of open drivers on Tep::APP
and one thread beats all of them — Action Cable's shape, and the CRuby
overlay's Reactor#beat already.
SPINEL_SCHED_POLL=1 keeps the old path, so these columns are the same
binary on the same cores in the same hour with a ping thread per
connection in both. Four cores pinned, four workers, the 1,000-user /
20-room seed, two subscriptions per socket. scripts/campfire-cable-sweep --env is what holds the binary still.
| N | storm CPU | idle cores | fan-out p99 | |||
|---|---|---|---|---|---|---|
| poll | epoll | poll | epoll | poll | epoll | |
| 300 | 0.77 s | 0.74 s | 0.046 | 0.016 | 63 ms | 60 ms |
| 1000 | 3.88 s | 2.64 s | 0.251 | 0.052 | 95 ms | 75 ms |
| 3000 | 15.22 s | 8.99 s | 0.470 | 0.154 | 169 ms | 173 ms |
| 5000 | 32.90 s | 18.28 s | 0.465 | 0.211 | 377 ms | 334 ms |
Every cell delivers every frame on both backends. Storm WALL time barely moves — the storm is not CPU-bound, it is N presence INSERTs through one SQLite writer — but the CPU spent getting there nearly halves at the top, and idle falls by 2.2x to 4.9x with nothing else changed.
One shared beat took 1,000 sockets from 0.052 to 0.009 idle cores — and put every connection's frame in the same instant. That burst is measurable, and the sweep's driver is the first thing it hits: one Ruby thread reading every socket has to drain N pings before it can stamp the next message. The driver now reports what it cost itself, which is what separates the two.
| N=5000 | ping per connection | one beat | ten slices |
|---|---|---|---|
| idle cores | 0.203 | 0.047 | 0.046 |
| fan-out p50 / p99 | 199 / 367 ms | 228 / 1402 ms | 208 / 543 ms |
| driver's longest reading pass | 101 ms | 791 ms | 146 ms |
| server CPU through the chat window | 0.404 cores | 0.281 | 0.312 |
About two thirds of the p99 the unsliced beat added is the driver's own
backlog. beat_cycle therefore walks the registry in BEAT_SLICES
pieces spread across the interval: every connection is still pinged once
per PING_INTERVAL, ten slices divide the burst by ten, and the monitor
pays ten wakes an interval instead of the 333 a second a thread per
connection costs at 1,000 sockets. At 3,000 the sliced beat is at or
better than the per-connection thread on every column (p99 166 vs 176 ms,
idle 0.027 vs 0.144); at 5,000 its p99 is still the worse of the two (543
vs 367) and 44 ms of that gap is the driver again. 5,000 is a tier past
the one campfire's table asks for and the idle number there is 4.4x
better, so this is recorded rather than chased.
The honest cell is the PACED one — connects 6 ms apart, so the beat
phases are spread the way a deployment spreads them, which is what
--pace exists for. Same seed, same cores, two subscriptions per socket.
| 1,000 sockets, paced | idle cores | fan-out p50 / p99 | chat CPU |
|---|---|---|---|
| poll + thread per connection (2026-09-03) | 0.396 | — | — |
| epoll + thread per connection | 0.046 | 35 / 76 ms | 0.120 cores |
| epoll + one beat | 0.010 | 34 / 129 ms | 0.084 cores |
| epoll + sliced beat | 0.011 | 33 / 66 ms | 0.085 cores |
| Rails as ONCE deploys it (2026-09-04) | 0.013 | 33 / 201 ms | 0.170 cores |
The axis that was 19x against us is now level: 0.011 cores against Rails' 0.013 to hold a thousand silent sockets, with the fan-out tail three times better and the delivery CPU half. Memory was already 5x ours and is unchanged. Nothing here is published; the plan still defers that, but the reason it deferred — one axis we lost badly — is gone.
1c34d424 went to CI without a Mac under load, and the shape to suspect
was a park that only ever ends on its timeout. 300 sockets in one room on
a 16-core Mac, 30 s idle then 20 posts: 300/300 subscribed in 0.16 s,
pings exactly on cadence, 6,000/6,000 frames at p50 19 ms / p99 32 ms,
clean teardown. No stall in any run; the beat count is where a
timeout-only park would have shown first.
- Editing a running bash script is editing what it is about to execute:
the sweep died on
line 136: syntax error near unexpected token 'done'in a cell whose file had been correct at launch. Copy it, or wait. - A ping count read by the client cannot see a registry leak — a closed
connection's driver refuses the write, so the leak is invisible at
every socket and shows up only as work.
tests/spinel_cable_heartbeat.rbreads the registry instead. - The box's clock is CEST; a run that looks stalled against an EDT timestamp usually is not.
The fiber server is back beside the threaded one, as a measurement lane — OPEN (matz/spinel#4306)
Tep::Server::Scheduled and Tep::Scheduler (the fiber-per-connection
server on one poll loop, retired on 2026-09-01) were restored on
2026-09-03, unchanged apart from a refused WebSocket upgrade, and every
binary now carries BOTH servers: TEP_SERVER=fiber selects the fiber
one in scaffold/main.rb, anything else the threaded one. The point is
a comparison with nothing else varying — same compiler, same emitted
tree, same database and pool, same process — because the blog bench had
shown the cost of the threaded design and the compiler was moving under
it at the same time: on 2026-09-03 the nightly's spinel lane read 7.9k
req/s where the night before it read 22.1k on /articles/1.json, and
separating the runtime's per-park cost (matz/spinel#4306), its
broadcast wake (#4305, fixed the same day) and a same-day compiler
regression (#4304, fixed) took a hand-rebuilt archive each time. The
bench harness carries the pair as spinel (threads, autodetected OS
workers) and spinel-fiber (the same binary, TEP_SERVER=fiber SPINEL_WORKERS=1), and the report says what the pair measures.
What the fiber lane is not. An HTTP GET lane. A WebSocket upgrade
answers 501: the driver's recv loop wants a parked green thread, and on
this server it would run on the main thread and hold every other
connection. Spinel's Thread#[] is per green thread, not per fiber, so
the per-request state thread_state.rb keeps in Thread.current is
shared by every fiber on the main thread — correct while a request runs
from start to finish without yielding, which a GET does, and the reason
the campfire compare and cable gates stay on the threaded server. With
one worker the scaffold's job-loop thread never runs while the poll
loop holds the main thread. None of that is fixable without the fiber
scheduler learning what the runtime now does for threads, which is the
question #4306 asks the other way round.
First observation, same binary, spinel 790db4cb, 16-core macOS,
wrk -t2 -c64 -d3s /articles/1.json: fiber 32.6k req/s (p99 2.4 ms);
threads on the autodetected 16 workers 28.8k (p50 0.15 ms, p99 205 ms).
Before that day's runtime fixes the same pair on the same machine read
28.7k against 7.0k. The bench box's numbers are the bench page's.
Found while lifting the one-worker declaration above, on the runtime
with both collector fixes: 2 of 5 browser runs stalled on tab A's
subscribe with the server alive and every OS worker idle, and a
passing run logged WebSocket connection to 'ws://…/cable' failed: Error during WebSocket handshake: net::ERR_INVALID_HTTP_RESPONSE.
The traced server (a warn at welcome, EOF and every ping) made it
plain in one run: the socket on fd 40 read EOF at trace line 138, a
ping thread wrote to fd 40 at line 184 and the write SUCCEEDED, and
the next welcome on fd 40 came at line 185 — the browser's error that
run was ERR_INVALID_HTTP_RESPONSE on an asset fetch, the HTTP
connection that held the number in between. Line 426 was the same
for fd 31, whose socket had closed at 183. Over the ten control runs
the census is 5 such writes in 4 runs; one of the four reached the
browser as an error, the rest landed where nothing noticed.
Mechanism. Cable.spawn_ping gave each connection a green thread
that slept three seconds and wrote a ping to ws.fd, exiting when a
write FAILED. Closing the socket does not fail the next write: the
kernel hands a closed fd's number to the next accept, so the write
succeeds into a stranger — a WebSocket frame ahead of an HTTP
response (the asset error), ahead of a 101 (the handshake error), or
interleaved with another thread's frame on a live cable socket (a
frame the client cannot parse; it closes and reconnects, and if the
stray thread is still alive the reconnect meets it again — the
stall). Tep::Broadcast.publish had the narrower form of the same
race: it copied (topic, fd) pairs out under the registry lock and
wrote to the numbers outside it. Both are ours; neither is the
scheduler. One worker hid it only by odds.
Fix. The connection's Tep::WebSocket::Driver owns a write lock
and a closed flag: every frame goes through write_frame, which
takes the lock and refuses once retire has flipped the flag; the
server retires the driver after the recv loop returns and BEFORE it
closes the fd. A subscription now holds the driver, not the number,
and publish writes through it. The ping thread exits on the refused
write, within one interval of the close, never having touched the
number's next owner; the lock also serialises a broadcast against the
connection's own frames, so two threads no longer interleave bytes on
one socket. Every exit of the recv loop (idle timeout and protocol
error included) now dispatches close, so the registry drops the fd's
entries while the number is still uniquely the connection's. Rule:
a thread may hold an fd NUMBER only through the object that owns
the fd, and that object decides when writes stop.
Narrowed, not solved. Two browser tabs, one room; tab A posts. Tab B's
socket receives <turbo-stream action="append" target="messages_room_1">,
the row is inserted (<div id="message_1" class="message …"> with the
author and the message-options control), and the body text is not
displayed.
What was ruled out on 2026-08-31. The server render is not at fault:
requested directly over HTTP with Accept: text/vnd.turbo-stream.html,
the same Views::Messages.message(message) returns message__body
markup containing the posted text. Both the broadcast
(Message::Broadcasts#broadcast_create) and the controller response
(app/views/messages/create.rb) call that one function with one
argument, so there is no second render path to disagree with the first.
What an earlier version of this entry got wrong. It read tab A as a
server render that worked and tab B as a broadcast render that dropped
the body. Tab A's row carried a CLIENT-generated id
(message_lk96p3l27rr) because it was campfire's own optimistic echo
from app/views/messages/_template.rb — the $messageDatetime$
template — not a server render at all. The comparison was between a
server render and a JavaScript one.
Re-measured 2026-08-31, after both <time> defects closed. On the
RUBY lane the milestone now PASSES outright — cable.spec.js,
un-fixmed, is green in 1.2s with the body displayed in both tabs. On
the SPINEL lane it still fails, with the row present and the body
absent. The two lanes emit BYTE-IDENTICAL helper source
(TimeHelper.local_datetime_tag(message.created_at, :time, attributes)
in both), so whatever remains is downstream of the shared lowering — in
the spinel target's emit, its runtime, or Tep — and NOT the tag-builder
family.
That measurement is not yet conclusive, because the spinel run also wedges (see the entry above): the job raises moments after the post, so "tab B never received it" and "tab B received it without a body" are not yet distinguishable on that lane. The row IS present, which argues the broadcast arrived before the wedge — but the walk, which would settle it by reading the frame, cannot complete on the spinel lane until the wedge is fixed. Fix the wedge first; this entry is blocked behind it.
Why the cable walk does not see it.
scripts/campfire-cable-drive.rb asserts html.include?(BODY) against
the raw frame — it reads the wire, not the DOM, and never renders. Same
shape as [[project_campfire_empty_message_bodies]]'s lesson ("an
emptiness test must not go through a renderer") from the opposite
direction: a wire test cannot see a client that fails to display bytes
it received intact.
Tracked by e2e/campfire/cable.spec.js, which is test.fixme until this
and the keep-alive serialization entry are closed.
FIXED 2026-08-31. The base model's synthesized dom_prefix is a
type-column dispatch now: lower::sti_scope — which already derives
the subclass→base map for relation scoping and becomes! — stamps the
subclass set onto the base Model (sti_subclass_names, the
self-describing-IR move), and push_dom_prefix_method emits
case @type; when "Rooms::Open" then "rooms_open"; …; else "room".
Every lane answers the subclass's dom class for an STI row, exactly as
Rails' dom_class does, hydration class notwithstanding. The
comparator's mask is retired — a divergence here fails CI again — and
the browser spec's list locator went lane-agnostic
([id^="messages_"]) so no spec re-couples to either spelling. The
original entry follows.
dom_id(record) derives its prefix from the record's CLASS, and for an
STI row that is the subclass: room 1 is a Rooms::Open, so Rails names
the room page's message list messages_rooms_open_1. The emit's
dom_prefix is synthesized per MODEL at lowering time and association
reads hydrate the base class, so the same list is messages_room_1.
Each lane is SELF-consistent — its broadcast frames target the element
its own pages render, which is why every behavioral gate passes on both
— but the DOMs differ, found by scripts/campfire-compare on its first
run (2026-08-31). The fix wants STI-aware hydration or a type-column
dispatch in dom_prefix; until then the comparator forgives exactly
this rewrite, by name.
FIXED 2026-08-31. The mechanism was Message#to_key — campfire
overrides it to [client_message_id], and Rails' dom_id derives its
identity half from to_key.join("_"), everywhere: page rows, broadcast
rows, and every edit_/boosting_/presentation_ derivative. The
runtime's dom_id read record.id directly. Now the lowerer
synthesizes a per-model dom_record_key — @id.to_s by default, the
model's own to_key.join("_") when it defines one (one String per
model, so the strict targets never union Array[Integer] with
Array[String?] across a poly slot) — and dom_id consumes it. The
comparator's mask is retired with the fix: every per-message id now
matches Rails byte for byte, and a divergence here fails CI again.
The user-visible half — Turbo's append replacing the sender's
optimistic echo instead of standing a duplicate row beside it — is
pinned by the browser milestone spec. The original entry follows.
campfire's broadcast render keys every per-message dom id off
client_message_id (message_cable-walk-1, and with it
edit_message_…, boosting_…, boosts_…, presentation_…), so the
sender's optimistic client-side echo — which minted that id — is
RECONCILED when the frame arrives. The emit keys the same ids off the
database id (message_505). Consequence beyond bytes: on the emit the
sender's tab may hold both its echo and the appended row, since nothing
shares an id to replace. Found by scripts/campfire-compare
(2026-08-31); the browser milestone passes because toContainText is
satisfied by either row. Fix belongs wherever the broadcast render
resolves dom_id for an unsaved-id context; forgiven by name in the
comparator until then.
FIXED 2026-08-31. The renders the broadcast lowerings SYNTHESIZE
(the broadcasts_to expansions, partial: forms, and the
receiver-convention default) now ride inside the bracket pair
ViewHelpers.broadcast_render(ViewHelpers.begin_broadcast_render,
Views::Messages.message(self))and csrf_token_hidden_input — the one choke point every form's token
input flows through, button_to's included now — answers "" while
the flag is up. That is Rails' semantics arrived at honestly: its
broadcast renderer has no session, ours runs inside the triggering
request and had one at hand. An app-SUPPLIED html: expression passes
through unwrapped, which is also Rails: campfire's closeds controller
hands in a render_to_string rendered inside the request — session,
token and all — and Rails broadcasts those bytes untouched.
The bracket is TWO PLAIN CALLS riding left-to-right argument evaluation — the first argument raises the flag before the render (the second) runs. Three richer shapes were tried first, and each was backed out by a lane that could not spell it:
- a BLOCK (
broadcast_render { … }) dissolves on the AOT lane — on the real tree the discriminator is an ensure-guardedx = yieldinlined into a heap poly proc (matz/spinel#4245, rediagnosed with matz; small mirrors pass). - a
^() -> Stringproc ARGUMENT (blk.call, theActiveJob.enqueuecontract) compiles on spinel but needs a.call+ function-type arm in all nine target emitters the runtime file lowers through — CI showed kotlin (Unresolved reference 'call'), rust (unstablefn_traits) and five compare lanes red at once. The detour still paid: the RBS reader grewProcType, the analyzer.call-on-Ty::Fn, andactive_job.rbsnow declares its queueArray[^() -> nil]instead of the documenteduntypedwart. - begin/
ensureis a construct half the targets have no statement arm for, so the runtime body carries none. The flag self-heals instead:reset_slots!— every lane's per-request dispatch entry — clears it, so a render that raises can leave at most the REMAINDER of its own request token-less.
The chase also closed real typing gaps: transaction { } answers its
block, tap its receiver, and controller private-helper params take
the analyzer's call-site-unified types (broadcast_create_room(Room room) where (untyped room) had poisoned every chain under it).
The comparator's last frame mask retires with this; the frames now compare against Rails with NO forgivenesses on either lane. The original entry follows.
A broadcast render has no session, and Rails' form_with omits the
authenticity_token hidden input there (campfire's JS supplies
X-CSRF-Token from the page meta on submit, so the forms still work).
The emit renders the token input unconditionally. Functionally
identical at submit time; a byte divergence in every boost form of
every broadcast frame. Found by scripts/campfire-compare
(2026-08-31); forgiven by name in the comparator.
lower::view_to_library::walker lowers a view's fragment-cache block to
a read, a render into the site's own accumulator on a miss, and a write:
__cache_hit_352 = Rails.cache.read_str("views/#{ViewHelpers.cache_scope}messages/_message/#{message.cache_key_with_version}/presentation-v2")
if __cache_hit_352.nil?
__cache_io_352 = String.new
… body …
io << Rails.cache.write_str(<same key>, __cache_io_352, 0)
else
io << __cache_hit_352
end
What it closed. /rooms/1/messages cost 127 sqlite round trips
against Rails-as-deployed's 7 — and the 127 was Rails' own uncached
renderer, because campfire's messages index preloads only
with_creator, so boosts, rich text and attachments are N+1 in Rails
too. Rails' 7 was its Redis fragment cache. Because the cache block
wraps the WHOLE partial body, a hit runs none of it. Measured with
scripts/campfire-queries (2026-09-06): 7 vs 7, and every other
route at or below Rails. That was the last query-parity gap.
No template digest, unlike Rails. Rails hashes the template and its
render tree into the key because Redis outlives the deploy that changed
the template. This store lives in the process: a template change means a
recompile, a new binary and an empty store, so the digest would be a
constant that changes exactly when the thing it guards is already gone.
An app's own manual version string still works — campfire's
"presentation-v2" is a literal and folds into the key's constant text
at compile time.
The key builder DECLINES rather than guesses. Only two element
shapes are claimed: a literal, and a name that is both one of this
view's locals and a model's snake singular. Anything else renders
transparently, which is what every site did before the store existed. A
key that misses costs a render; a key that collides, or that fails to
move when the row does, serves the WRONG BYTES — and nothing in this
repo would catch that, because campfire-compare renders each lane once
from cold. model_singulars alone would claim a helper named message;
locals alone would claim notice; the conjunction claims exactly the
Rails convention these partials are written to.
Not by TYPE, which was the first cut and declined every site in the
corpus: a view body reaches the walker as compiled ERB whose locals are
still bare Send { recv: None } barewords with ty: None, because
models type before views lower.
Invalidation is belongs_to … touch:, which is why that had to land
first (see its section above). Nothing deletes a fragment; the key moves
when updated_at does, and a boost's touch cascades through its Message
to its Room.
Two stores answer Rails.cache — the shared runtime's Rails::Cache
and the CRuby overlay's Rails::MemoryStore — and every method the
compiler emits a call to has to exist on both. read_str/write_str
landed on the shared one first and every campfire page 500'd on the
CRuby lane with undefined method 'read_str'; the suite went 256 -> 229
and campfire-compare's emit walk died on the room page. Same shape as
the Db shims' parity rule.
The lock is in the lane that needs it. runtime/ruby is transpiled
to nine targets and cannot spell Mutex; the single-threaded lanes have
nothing to guard, rust and go already lock a class-level slot per
access, the CRuby overlay's store has held a Mutex since it was written
(Puma is threaded), and the spinel binary — a green thread per
connection, no GVL — gets runtime/spinel/fragment_cache.rb, a reopen
of the three state-touching methods that wins by load order the way
csrf_token.rb does. A read-then-write PAIR is deliberately not atomic
on any of them: holding a lock across the render would serialize every
request on the page's first fragment, and two threads that miss the same
key both render with one write winning — a duplicate render, not a wrong
answer.
Known residue. The shared store caps entries (5,000, FIFO — LRU
needs a touch on every READ, and the read path is what a fragment cache
exists to make cheap); the CRuby overlay's MemoryStore has no cap and
never had one, which mattered less before there were fragments in it.
The spinel lock compiles and serves but has not been load-tested under
green-thread contention.
Rails fragment-caches each message row (cache [ message, "presentation-v2" ]), and the cache keeps whatever render FIRST warmed
it: a page GET renders with a session, so the row's eight quick-boost
forms carry the authenticity_token hidden input; a broadcast renders
without one, so they don't. Token presence in the room page's boost
forms therefore encodes each row's RENDER HISTORY — after a cable post,
Rails' own page serves both spellings side by side (36 seeded rows with
tokens, the 4 cable-posted rows without, on the compare walk). The emit's
forms always carry the token, and since 2026-09-06 that is a DECISION
rather than an absence: it fragment-caches the same rows now, but the
key carries a ViewHelpers.cache_scope namespace, so a broadcast render
(which omits the token) and a request render (which does not) can never
be served for each other. Our entries are therefore always
session-rendered and always token-carrying. Rails puts both in one
namespace, which is exactly what makes its answer history-dependent.
Neither spelling is wrong to the app:
the JS submits with X-CSRF-Token from the page meta, which is why
Rails can ship the token-less cached copy at all.
Matching Rails here would mean reproducing not its renderer but its
cache's history-dependence — and now that we have a cache of our own,
matching it would mean deliberately DROPPING the namespace that keeps
the two renders apart, i.e. adopting the bug. So the comparator still
forgives exactly this, by name, and ONLY on the room page: the mask is scoped so a token reappearing in a broadcast FRAME
still fails the run (that ratchet is the "Broadcast forms and CSRF"
fix above). Found by scripts/campfire-compare when the room page was
triaged for gating (2026-09-01).
An advisory String? erases nil on the binary — matz/spinel#4250 — FIXED upstream
FIXED 2026-09-01 (spinel 2d59b3cd, "Keep the nil arm an advisory
nilable seed promises"), the day it was filed; the fix landed with its
own regression test (test/rbs-seed/nilable_return.rb, our repro's
shape). Verified by the gate that found it:
scripts/campfire-compare --spinel is fully green — room page and
both frames equivalent to Rails on the compiled binary, with only the
fragment-cache forgiveness above still applied (a Rails-side
history-dependence, not a spinel gap). No mask to retire, because none
was worn. The original entry follows.
Under --rbs sig, a method sidecar-declared () -> String? stops
returning nil for its valueless-if else path on the compiled binary:
.nil? answers false and compact keeps the slot. campfire's
body_classes joins three such guard-if helpers, so every page the
binary serves says <body class="sidebar admin "> — trailing space —
where Rails (and the CRuby lane, and the binary WITHOUT the sidecars)
says sidebar admin. Found by the room-page gate the day it was
promoted (2026-09-01); no behavioral test can see it, because a
trailing space in class changes no selector. Filed with a 12-line
repro. --spinel is expected RED on room.html until #4250 closes —
deliberately NOT masked, the same posture #4240 held: an open defect
is not a written-down decision.
(The gate's other binary-only find the same day was ours, and is
fixed: runtime/spinel's parse_db_time read whole seconds where the
CRuby overlay's twin reads micros, so updated_at.to_fs(:epoch)
answered …000 for Rails' …418.)
SanitizeTags is inert on the spinel binary — matz/spinel#4240 — FIXED upstream
FIXED 2026-08-31, hours after filing (c178fc15): the block was not
even the discriminator — the no-block form took the same wrong path.
Two mechanisms agreed on the wrong answer: container-read alias
promotion bound the local to a string HANDLE on the evidence that the
mutator-name table matched replace and the container held a string
SOMEWHERE, and the poly replace arm took the builtin without asking
whether a user class owns the name (the question its pack sibling
always asked). Verified by RUNNING the repro (both columns answer
CRuby now) and then by the gate that found it:
scripts/campfire-compare --spinel is fully green — both frames
equivalent to Rails on the compiled binary, <script> content pruned
exactly as Rails prunes it. The comparator's spinel lane is a real
gate from this day. The original entry follows.
ContentFilters::SanitizeTags#apply is fragment.replace(selector) { nil }, and on the compiled binary that call NEVER REACHES
ActionText::Fragment#replace: replace is a builtin-owned name, the
fragment arrives through the filter chain's untyped slot, and spinel
routes the call to the builtin's semantics on the wrong class
(matz/spinel#4240, filed 2026-08-31 with a 20-line repro; the family of
#4205/#4218). The scanner itself is fine — the same selector and input
pass under the spinel test harness, where the receiver is typed
(test_replace_with_campfires_own_allow_list).
Every probe instrumented on the way to this: the selector was
correct (427 chars), parse_selector called DIRECTLY answered kind=not
with 44 exclusions, and replace entered for no caller — the
before/after lengths were equal on every message since the day the tree
first compiled. Plain-text bodies have nothing to strip, which is how a
dead security filter coexisted with every green gate until
scripts/campfire-compare diffed an HTML-bodied frame against Rails.
Security posture, stated precisely: no unsafe markup shipped. The
downstream safe-list sanitizer (sanitize_allowing, via
SanitizeAttributes) enforces the same tag allow-list with strip
semantics, so a <script> still cannot render — the divergence is that
Rails' pipeline REMOVES a disallowed element's content (replace { nil } prunes children) where the binary keeps it as escaped text:
alert(1) visible as text where Rails shows nothing. Defense-in-depth
held; fidelity did not.
scripts/campfire-compare --spinel is expected RED on
frame_html.html until #4240 closes — deliberately not masked in the
comparator: an open defect is not a written-down decision. The ruby
lane gates green.
Rails' form builder flips a form to enctype="multipart/form-data"
the moment a file_field renders inside it. The emitted <form>
carries no enctype, so the browser posts application/x-www-form- urlencoded — and a file input in a urlencoded form submits its
FILENAME as a plain string, never its bytes. campfire's join page is
the corpus case: the signup form carries an avatar picker, so a new
user who chooses an avatar posts user[avatar]=<name>.png and no
attachment is created. An empty picker posts an empty string, which is
why signup itself works.
Closing this needs two halves in one commit: the form lowering
emitting the enctype when a file field is in the tree, and the request
side parsing a multipart body into the same Hash[String, ParamValue]
shape the urlencoded parser fills — shipping the enctype alone would
turn a silently-wrong filename into a request the server cannot read
at all. Attachment upload is already outside the archive's scope (the
Active Storage tail), so this rides with that work, written down here
so the form's spelling is a decision rather than a surprise.
Found 2026-09-01 by the join-form probe that closed the view-params
symbol-key defect (the form's action was the visible half; the
enctype was sitting beside it).
An app that registers SQLite functions in an initializer —
raw_connection.create_function("regexp", 2) do |fn, …| … end,
create_aggregate("stddev", 1) do step … finalize … end, the shape
lobsters' config/initializers/sqlite_functions.rb patches into the
adapter — has them read at ingest into App::sql_functions. Each block
body is an ordinary SqlFunctions class method taking the SQLite
context as its first parameter under the app's own name for it (fn),
so fn.result = … and an aggregate's fn[:n] state read as written;
a next that left the block is a return in the method.
Both lanes that install them generate a sql_functions.rb beside
db.rb, and Db calls its install on every pooled connection, as
Rails' adapter patch does:
- CRuby registers each through the sqlite3 gem; the context object handed in is the gem's own function proxy — the object the app's blocks were written against.
- Spinel binds SQLite through FFI (
project::spinel_sql_functions_file): oneffi_callbacktrampoline per function reads thesqlite3_valuearguments into the Ruby values the gem would hand the block, and a smallSqlFnContextclass plays the gem's proxy —result=, and[]/[]=over per-GROUP state keyed by the addresssqlite3_aggregate_contextreturns, behind a Mutex because the OS workers share the process. One spinel shape is designed around, not hidden: anffi_callbackargument cannot beniland a trampoline is typed overconst void *, so registration goes through a six-lineffi_sourceadapter. The app's bodies ship as written —fn[:n] ||= 0/fn[:n] += 1on the context class compile since spinel 606acc03 (matz/spinel#5054, filed from this).
JRuby does not install them yet: it reaches SQLite over JDBC, whose
user-function API is org.sqlite.Function subclasses — a different
registration shape. Raw SQL that calls one fails with SQLite's "no such
function" there.
Lobsters reaches stddev from FlaggedCommenters, which renders on any
page where you view your own threads, profile or inbox (the 2023
ruby-bench snapshot had that warning switched off for this reason, and
registers no functions — so on spinel the snapshot keeps FlaggedCommenters'
façade, which stands aside only when the app registers both stddev and
if: Facade::lifted_by_sql_functions).
Found 2026-09-25 bringing current lobsters' benchmark routes up on the ruby lane; the spinel install landed the same day.
request.format.json? raised NoMethodError — the predicate stays on request_format, not a Mime-typed Request#format — FIXED
protect_from_forgery unless: -> { request.format.json? } — Rails' own
guide idiom for an API controller that still inherits
ActionController::Base — raised NoMethodError on every dispatched
action: private method 'format' called for nil outside a real
dispatch (request is nil until a dispatcher parks one, as in a
unit-style controller.process_action call), or undefined method 'json?' for an instance of String behind a real one (the shared
ActionDispatch::Request#format is a plain attr_reader over a name
String; the CRuby overlay's twin carries no format at all).
Why the fix is not a typed Mime::Type on Request#format. The
per-request format is already modeled elsewhere, correctly: every
controller carries request_format, a plain Symbol attribute the
dispatcher sets from the path's .json suffix / a (.:format) route
segment / a route-forced format (main.rb, both ruby-family lanes),
and action bodies already compare against it directly (if request_format == :json, the Jbuilder-lowerer's respond_to
flatten). A request.format.html?-shaped call in an ACTION body was
already rewritten to self.request_format == :html at the AST level
(lower::controller_to_library::rewrites::rewrite_request_format) —
precisely because the Request object's own format cannot answer the
predicate on either lane. Giving Request#format a real Mime-typed
value instead would duplicate that state in two places that could
disagree (a request_format and a request.format seeded from
different negotiations), and would still leave the CRuby overlay's
Request with nothing to return. runtime/ruby/mime.rb's ported
Mime::Type — used elsewhere, for the Mime registry's own callers —
deliberately omits json?/html? for a related reason (its own
comment: "a call beyond [lookup/lookup_by_extension/[]] stays an
honest gap"); request.format is answered from request_format
instead of reopening that gap.
Where the gap was. rewrite_request_format ran over action bodies
only (lower_action_body). Expressions spliced into the synthesized
process_action — filter if:/unless: lambdas (protect_from_forgery
and any before_action …, if:/unless:), block-form filter bodies
(before_action -> { … }), and rescue_from handlers — bypass that
pipeline. The guide idiom
protect_from_forgery unless: -> { request.format.json? } was the
first shape that surfaced; structurally every dispatcher-embedded expr
had the same hole.
Fix. synthesize_process_action runs rewrite_request_format once
over the finished dispatcher body (after rescue wrapping). map_expr
walks guards, block-form filter bodies, and rescue handlers in one
pass — the same helper action bodies already get, applied at the
dispatcher boundary rather than bolted into cond_from_guards alone.
What was verified.
tests/protect_from_forgery_unless_request_format.rs's
protect_from_forgery_unless_request_format_json_does_not_raise: a
:json-formatted dispatch skips the forgery check and runs the action
(previously NoMethodError); an html POST with no token still hits
the check and is blocked (422) — proving the rewritten guard still
protects a non-json request, not just stopped raising.
Found 2026-10-07 probing request.format on a from-scratch Rails API
app under --target spinel; reproduces identically under --target ruby, since both lanes consume the same controller_to_library
universal IR.
TEMPORARY, pending roundhouse-rb#58: content_security_policy is
not modelled on any target — no directive builder exists, and no
emitter writes the header. On the ordinary ruby-family and strict
targets that is simply a dropped class-body macro, surveyed like any
other. On --target futamura's whole-route replacement
(src/emit/futamura/routes.rs) it used to be more than that: every
ingest gap blocks the route it is attributed to, and a
content_security_policy do |p| … end block — however ordinary —
earned the same "controller class-body block not recognized" gap as
a construct nobody has read yet, which blocks every route on every
controller that inherits or includes it. On an app that sets its CSP
once, high in the controller hierarchy, that is most of the app.
Until (1) on #58 (modelling the directive builders) lands, a block
this ingest can read well enough to know it does nothing but build a
policy object — every statement a direct call on the block's own
parameter, naming a known CSP directive, with symbol/string/simple-
literal arguments, in a controller's own class body or a concern's
included do — is recorded as a named divergence instead
(ingest::app::csp_block_is_recognized,
ingest::app::CSP_DIVERGENCE_MESSAGE): it still shows in --survey
and check --continue, but project::futamura_whole_route excludes
it from the gaps routes::plan blocks on. The lowered app sends no
Content-Security-Policy header from these routes, same as it always
has — this only stops treating that as a reason to keep the route on
Rails. A block this ingest cannot read as cleanly — a dynamic
directive, a conditional, any other code — keeps the blocking gap,
unchanged, wherever it appears.