intertwingly

It’s just data

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:

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

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.