Campfire Chats
Four days ago Campfire compiled, linked, and booted, and I called it a floor. Today it chats, and you can have it. The archive the CI rebuilds on every push is public:
curl -O https://rubys.github.io/roundhouse/campfire/spinel.tgz
tar xzf spinel.tgz && cd campfire
spin build && ./build/bin/campfire
Thirteen megabytes down, a 5 MB native binary about half a minute later on an M-series Mac, and you need only Spinel, a C compiler, and Node — the asset pipeline ran at archive-build time so you don't have to. Open two browsers, create the account in one, invite the second from the settings page, and talk to yourself with live delivery: the message you post in one browser arrives in the other over a WebSocket, rendered by the binary.
The rest of this post is what it took to get from "boots" to that tarball.
From boots to chats
When I wrote the boot post, items two through six of the gate — the sockets, the part I actually care about — were barely started.
They are walked. On the compiled binary: sign in through the real form,
open two WebSocket connections to /cable, subscribe both through the
application's own channel, post a message over HTTP, and both sockets
receive the rendered turbo-stream frame. The connection knows who you
are — a missing, tampered, or unsigned session token each answers 401
before the socket is even upgraded. And the authorization module that
Campfire prepends onto Turbo's stock channel is in the dispatch path
and refuses a stream name you can spell but don't belong to.
That prepend deserves a sentence, because it is where this kind of work
gets interesting. Campfire installs it from an initializer —
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized — and for a
while the transpiler ingested the module and silently dropped the one
line that installs it. Both of the initializer prepends in Campfire
turn out to be security controls. A construct that rewrites method
dispatch is exactly where controls go missing without a trace, which is
why "the tests pass" was never going to be enough here: Rails' own
channel test asserts that subscribing called stream_for. It never
opens a socket. A green channel test is fully compatible with having no
authorization at all.
Still no changes to Campfire's source. Still no sig/ directory, not
one .rbs file, no annotations. The test suite now stands at 256 of
288 (the suite grew since the first post
— more of it runs now, so the denominator moved too), and the remaining
failures are still content, not topology: the Active Storage tail, web
push, and four honest wrong answers I can enumerate.
The oracle
The piece I trust most is not the walk. It is the comparator that runs after it.
campfire-compare boots real Rails and the compiled binary from one
seeded database, drives both through the same walk — sign in, read the
room, subscribe two sockets, post — and diffs what each lane's wire
actually carried: the room page, and both broadcast frames as they
arrived over the WebSocket. The comparison is DOM-shaped (attribute
order, self-closing slashes, and render-collapsed whitespace don't
count) and bytes that two runs of the same lane would disagree on —
timestamps, tokens, signatures, asset digests — are masked. Everything
else either matches Rails or fails the build.
The room page under comparison carries one hundred seeded rich-text messages — Action Text bodies, boost forms, avatars, attachments' placeholders — which makes it a decent slab of rendering surface to hold byte-honest against the framework that invented it.
One divergence is forgiven, by name, and I like it too much to hide it in a footnote: Rails fragment-caches each message row, and a fragment cache serves whatever render first warmed it. A row first rendered inside a request has a CSRF token in its boost forms; a row first rendered by a broadcast doesn't; and after our walk, Rails' own page serves both spellings side by side — thirty-six rows one way, four the other. Token presence on that page encodes each row's render history. The binary has no fragment cache and re-renders per request, so its forms always carry the token. Matching Rails there would mean reproducing not its renderer but its cache's memory of the past, which no per-request renderer can do. So the comparator forgives exactly that, on exactly that page — a token appearing in a broadcast frame still fails the run.
Every oracle caught what the previous one certified
This is the actual lesson of the week, and it is a little humbling laid end to end:
- The unit suite was green while every message body rendered
empty — on the page and in the frames. A
newwritten inside a class-side method was binding to the wrong class, so every rich-text body constructed blank, and no assertion anywhere looked at what the page said. The socket walk caught it, because a walk reads the page a user gets. - The walk was green while the HTML sanitizer was inert on the binary — a user-defined method named after a builtin, reached through an untyped slot, ran the builtin's semantics on the wrong class. No unsafe markup shipped, because a second allow-list sits downstream, but the filter everyone would point to was dead. The comparator caught it, because Rails prunes what a sanitizer removes and our page didn't. (Filed upstream; fixed the same day.)
- The comparator went red over a single trailing space in
<body class="sidebar admin ">— an advisoryString?signature was erasing thenila valuelessifreturns, so a joined class list kept an empty slot. No selector changes, no test notices, no human eye catches it; only byte-level DOM comparison can. (Also filed, also fixed the same day.) - The comparator was green while the account-settings page — which the oracle's walk never visits — answered 500. A route helper was generated to take an integer id, and one call site in Campfire hands it the model, which under Rails is legal everywhere. I found it the way users find things: I clicked on it.
- And the settings page rendering is how I found the last one: the
signup form on the invite page rendered
action="/join/"— no join code — because a view'sparams[:join_code]read a string-keyed store with a symbol key and gotnil. My command-line probes had been posting to the correct URL directly, so they could never notice a wrongactionattribute. Only a browser reads the form before submitting it.
Every one of these was invisible to everything above it on the list, and every one is now pinned by a regression test. If there is a moral, it is that "the tests pass" is a statement about the tests, and each new kind of witness — a walk, a diff, a browser, a person clicking around — pays for itself embarrassingly fast.
What is not in the box
The README says it plainly: attachments and uploaded avatars are out of scope — including the signup form's avatar picker, which posts a filename rather than a file until the form learns multipart — and web push delivery fails its background job and logs one line saying so, per message, on purpose.
And what is not in this post: a fan-out benchmark. The concurrent-subscriber number waits on a write path that can push back per connection instead of blocking the worker, and on framing honest enough to publish beside it — our fan-out is in-process where deployed Campfire's crosses Redis, and our frames are lighter than production's while attachments are missing. A number computed today would flatter us twice by construction. The request-path numbers from the first post stand; the sockets get measured when the measurement can't quietly lie.
For anyone who wants more
- The gate, walked, in the issue that defined it — including the item-by-item state and what stays open (the benchmark track).
- The conformance page — every failing test, clustered by cause, rebuilt each push.
- Campfire's inferred types and the playground, for the no-annotations claim.
A week ago this was an argument. Now it is a tarball. If you download it and something answers wrongly, that is exactly the kind of witness the ladder above says I still need — file it.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.