Campfire Passes the Browser Gate
Last week the fanout benchmark post ended with the load generator satisfied: delivered == posted on every cell, Orisha ahead in eight of eight. What the post didn’t say is that the app delivering those frames was not yet Campfire. The loadgen understands posts and subscriptions; it has never asked a page to render, never checked a password digest, never waited on a Turbo Frame swap, never listened to the browser console. A benchmark can prove the fast path is fast. It cannot prove the app is the app.
So we built a second instrument of a different kind: a 471-line Playwright harness that drives real Chromium — two tabs, two browser contexts, real WebSockets — through the contract Campfire’s own frontend enforces. Setup, two-tab live messaging, duplicate suppression, stored-markup safety, permalinks, editing, search, profile and account updates, QR codes, live room creation and renaming, bots, custom styles, session transfers, join-by-invite, autocomplete-started direct pings. Every pageerror, every console.error, every failed request is fatal — the bar is not “renders” but “renders and stays silent.”
This week that gate went green end to end on a fresh database. This post is about what it cost — because the invoice is the interesting part. Fifteen-odd fixes, and not one of them was a benchmark-shaped problem.
What the app actually looks like
Before the invoice, the material it’s written in. A route is a ! [METHOD /path/:param] branch into a flow; a flow is a chain of |> steps where : name binds a step’s output and -> expr produces the arm’s value. This is the refresh endpoint the frontend’s heartbeat calls, route through flow:
! [GET /rooms/:id/refresh] p |> orisha:cookie(req, name: "session_token"): tok |> current-email(token: tok): ue
|> orisha:query-param(req, name: "since"): sn
|> orisha:header(req, name: "Host"): rhst
|> room-refresh(rid: p.id, email: ue, since: sn, host: rhst): res -> res Inside room-refresh, each message created since the client’s watermark renders and wraps as a Turbo stream append — one tor, typed params, optional ?string for things a request may not carry, std/io:free discharging the allocation the renderer handed back:
pub tor refresh-msg { mid: i64, cmid: string, cat: string, uat: string,
anm: string, uid: i64, auat: string, body: string,
abio: string, rid: string, action: string, tgt: string,
host: ?string }
refresh-msg =
views:user-title(name: anm, bio: abio): atitle
|> views:ts-ver(ts: auat): uver
|> views:ts-iso(ts: cat): iso
|> views:ts-ms(ts: cat): ms
|> views:ts-ms(ts: uat): ums
|> views:room-name(): rname
|> views:boost-rows(mid): brow
|> signing:user-sgid(id: uid): usg
|> views:message-render(cmid, mid, uid, rid, ms, ums, iso, author: anm,
atitle, uver, usgid: usg, roomname: rname,
body, boosts: brow, host): mhtml
|> views:stream-wrap(action, target: tgt, html: mhtml): shtml
|> pages:frag-write(text: shtml)
|> std/io:free(content: mhtml) And the response is a record literal — orisha:Response is fields, not a framework object:
| done d5 |> koru/sqlite3:close.pooled(conn: d5)
|> pages:frag-take(): sbody -> { status: 200, body: sbody,
content_type: "text/vnd.turbo-stream.html" }
| err e5 |> koru/sqlite3:close.pooled(e5.conn) -> { status: 500,
body: "refresh failed",
content_type: "text/plain" } One move the earlier listings underplayed, so refresh-msg’s tail now runs on it: the thread is automatic. A |> step’s result can travel to the next step without being spelled — and it lands by type. A producing stage’s value fills the one unfilled parameter whose type it matches, written arguments narrowing the field first; exactly one candidate binds, zero is KORU092, several is KORU093 and the error names them. A bound head threads the same way by name — seed(): n |> bump(n) |> bump(n) re-reads the running n at each step, not the seed. In the live code: stream-wrap(action, target: tgt): shtml leaves html unfilled and the mhtml thread binds it — the emitted call reads .{ .action = action, .target = tgt, .html = mhtml } — and pages:frag-write() picks up shtml the same way. free(content: mhtml) stays explicit because the running value has already moved on.
The extreme case is the compiler’s own analysis pipeline, verbatim from koru_std/compiler.kz — a CompilerContext threads through eight passes and appears in zero of them:
~analysis = check-release-gate
|> check-structure
|> check-phantom-signatures
|> check-phantom-args
|> pass-auto-discharge
|> check-flow
|> check-phantom-semantic
|> check-purity
| failed f => failed { f.ctx, f.message } And the thread carries qualifications, not just base types — a string<allocated!> arrives as string<allocated!> and a step that can’t accept it refuses at compile time. That is not a hypothetical: it is the string<*> finding two sections down, where stream-wrap’s html: string rejected the renderer’s allocated output until the declaration asked for any qualification. The chain threads automatically — and it fails when the types say no. The honest boundary, found while adopting it in this app: the refusal guards the thread, not the intent. Push stream-wrap to fully implicit (stream-wrap()) and the leftover parameters don’t error — they fill from the nearest compatible in-scope binding, which in this flow is the user’s sgid. That compiles, and the emitted call reads .{ .action = usg, .target = usg, .html = mhtml } — legal, silent, and wrong in intent.
Probing harder found worse. A compatible binding didn’t just fill leftovers — it took the thread’s own slot first. other(): o |> produce() |> consume(b: "B") emitted .a = o and the produce was computed and discarded; other(): o |> produce() |> consume() compiled .a = o, .b = o where KORU093 should have named the ambiguity. The election was running on a field the scope fill had already raided — the refusal was a property of empty scopes, not of the language. That is fixed at the compiler now: every slot the thread can fill is reserved for the election before the fill runs (pins 210_320, 210_321), and an auto-filled argument no longer narrows it — write x: o to say which slot the scope value takes. The produce is the nearest binding of its type, because “the value that just arrived” is what |> means.
One door was still open. produce() |> consume(a: "A", b: "B") — every slot already written — compiled, emitted const result_1 = produce(), and dropped the value where nothing could ever read it. The checker’s own comment said “Complete is never an error”: a call with no unfilled parameters simply never looked at the thread. That is no longer true — a pass after desugaring audits every produce that stayed unnamed, and a value with no landing place is refused: KORU092: the string produced by 'produce' has nowhere to land — every parameter downstream that could take it is already written (pin 210322). The exits that stay legal are the honest ones — bind it and write it (produce(): s |> consume(a: s, b: "B"), pin 210_323), or say `:` and drop it on purpose. The conveyor either delivers its cargo or names the spill.
Everything in the rest of this post happens inside that shape — | db Source blocks with {{x}} parameters, ! row iterating results, | done/| err arming outcomes, and zig ~procs in .kz files where the host boundary lives. Now the invoice.
The compiler bug a chat app found
The headline finding is in the compiler, which is where it belongs.
Koru flows produce values with ->. A branch arm can produce inline (| done -> x) or run a chain of |> steps and produce at the end — this is the tail of the profile page, verbatim:
| done m2 |> koru/sqlite3:close.pooled(conn: m2)
|> pages:frag-take(): membs
|> app-head(title: unm): hd
|> pages:page-write(text: hd)
|> views:profile-render(uid, name: unm, email: em, bio: bo,
turl, qrpath: qrp, memberships: membs): pg
|> pages:page-write(text: pg)
|> pages:page-write(text: "</html>")
|> pages:page-take(): pageout -> pageout The parser had two code paths for “a |> line” — one for continuation lines that start a branch arm’s tail, one for continuation lines inside an arm’s body — and only the first stripped the trailing ->. That last line parsed as an invocation whose produce was silently eaten. The binding stayed bound, the produce never happened, and the checker then false-flagged pageout as an unused binding (KORU100).
A chat app found this because a chat app is the first thing we’ve built that nests produce-bearing chains inside enough branch arms to hit the gap — ten sites, all on the same page-render pattern. The fix is one missing indexOfTopLevelArrow call in parsePipelineContinuationBase, mirroring the two sibling parsers that already had it. The app kept its natural shape; the compiler learned its own rule. That is the whole point of exercising the toolchain against real programs: the suite can’t contain the test a real app happens to be.
The library contracts the buffer discipline demanded
Three findings live in the libraries, and all three are the same lesson: a slice into a shared buffer is a view, not a value.
A mid-render take is a dangling view. pages:page-take() returns buf.items — a live slice of the request’s shared page buffer. The profile page folded membership rows into a fragment, then kept writing to the page; the buffer grew, reallocated, and the taken slice pointed at freed memory. Segfault on /users/me/profile. The fixed shape, verbatim — rows fold into a fragment buffer while the page buffer stays a pure sink:
! row mr |> koru/sqlite3:col.int(stmt: mr, index: 0): mrid
|> koru/sqlite3:col.text(stmt: mr, index: 1): mrn
|> views:itoa(n: mrid): mris
|> views:memb-link(rid: mris, name: mrn): ml
|> pages:frag-write(text: ml)
| done m2 |> koru/sqlite3:close.pooled(conn: m2)
|> pages:frag-take(): membs frag-take is two lines of zig behind the .kz host boundary — the ~proc line is the Koru signature, the body is the host implementation, and the whole contract is visible in them:
~proc frag-take|zig {
return frag_buf.items;
} A slice, not a copy — the caller gets a view that dies on the next frag-write. page-begin clears both buffers per request, so no call-site had to learn a new verb.
A borrowed string can’t be a response body. The QR endpoint was three allocator bugs wearing a trench coat. svg-respond declared svg: string<*> — a borrow — so the caller’s allocated! obligation auto-discharged (freed svg) while body_allocated: true told orisha to free it again on write. Under that, errOut() returned a static literal that the same flag tried to free. Under that, the module allocated with page_allocator while std/io:free frees through koru_allocator(). Double-free, free-of-static, allocator mismatch — all invisible to curl until the right request walked through. The fix is the consume spelling:
pub tor svg-respond { svg: string<std/io:!allocated> } -> orisha:Response <!allocated> in parameter position is a consume: the caller’s obligation discharges into the response, where body_allocated: true settles it exactly once on write. The sibling declaration that started the week is the same grammar used differently — <*> accepts a value of any qualification as a borrow, which is what let the renderer’s string<allocated!> flow into the Turbo wrapper without a copy:
pub tor stream-wrap { action: string, target: string, html: string<*> } -> string A thread-local buffer can’t be its own input. The /rooms/:id/refresh endpoint — the last wall before green — panicked with @memcpy arguments alias. This is the entire culprit, verbatim:
threadlocal var origin_buf: [320]u8 = undefined;
fn originFor(h: ?[]const u8) []const u8 {
const host = h orelse return "";
const proto: []const u8 = if (std.posix.getenv("DISABLE_SSL") != null) "http" else "https";
return std.fmt.bufPrint(&origin_buf, "{s}://{s}", .{ proto, host }) catch "";
} views:host-or calls this to resolve an optional Host header; the refresh flow fed its return — a slice inside origin_buf — into message-render, which calls originFor again. bufPrint wrote into origin_buf while reading from it. A Debug-build check caught what a release build would have silently corrupted: the fix is passing the raw ?string down so originFor runs exactly once at the render boundary.
The lies a load generator can’t see
The rest are app-contract bugs — but they’re the reason the instrument is a browser and not a loadgen. Each produces output that looks correct until a real client asks the real question:
IN ({{ids}})binds the whole list. The sqlite3 Source block parameterizes every{{x}}as?— there is no raw splice.u.id IN ('3,1')is one string, matches nothing, and the direct-room grant inserted zero rows. The browser followed a 302 into a room that 404’d. The fix keeps the list parameterized —{{allids}}now carries a JSON array, andjson_eachexpands it inside the query, so the ids stay data and never become SQL text:| db d3 |> koru/sqlite3:default(conn: d3) { INSERT INTO memberships (room_id, user_id, involvement, created_at, updated_at) SELECT {{rid2}}, u.id, 'everything', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM users u WHERE u.id IN (SELECT CAST(value AS INTEGER) FROM json_each({{allids}})) }COALESCE('', digest)is notCOALESCE(NULL, digest). Blank password fields on profile save wiped every digest — empty string wins coalesce.NULLIFrestored the Rails semantics; digests verified 60 chars after the fix.sessions.last_active_atisNOT NULL. Two insert paths (session transfer, join-by-invite) omitted it and both 500’dsession failed. The column wasn’t in the insert; now it is.The FTS index is write-once. Messages reindexed on create, never on update — search returned the pre-edit text while the page showed the edit. The update path now upserts
message_search_indexin the same transaction.A DOM id is a contract.
room-linkemittedlist_rooms_rooms_open_2— thelist_rooms_prefix doubled onto a key already carryingrooms_open— while broadcasts targetedlist_rooms_open_2. The rename broadcast fired and landed on nothing.rooms.nameisNULLfor directs. The heading is “Ping with the other members’ names” — a join across memberships, not aCOALESCEon the room row./rooms/:id/refreshdidn’t exist. The heartbeat controller’s refresh request 404’d on every page; the reference contract (append new messages, replace updated,text/vnd.turbo-stream.html) is now implemented.Turbo Frame requests expose buffer state. A
directs-pageflow missingpage-beginaccumulated into the shared buffer — the “same” page eventually returned three<body>elements and two copies of the frame.curlshowed HTML that looked right; Turbo silently refused the swap.page-beginopens the flow now.The sidebar is not ambient. Our layout rendered the rooms frame on every page; the reference yields it only on room-adjacent pages. The reference’s own
rooms-listcontroller then crashed readingCurrent.room.idwhere no room meta exists — and the zero-console-error gate counted it. This is the finding only a browser could produce: a bug in the reference’s JavaScript that our over-eager layout woke up.
What the gate actually measured
The speed question was already answered on dedicated hardware — the fanout table from the sibling post, reproduced here because it is the same binary that sat the gate:
| clients | Orisha delivered/s | Rust delivered/s |
|---|---|---|
| 50 | 2,894 | 1,694 |
| 100 | 2,076 | 1,271 |
| 200 | 1,049 | 703 |
| 300 | 672–716 | 540–559 |
| 500 | 416–428 | 353–361 |
One s-8vcpu-16gb-amd droplet, server pinned to cores 0–3, loadgen to 4–7, same 169-message seed, interleaved cells, complete == posted on every quoted run. Ranges are ranges — the chart draws them as intervals, not invented midpoints.
The PASS line, verbatim:
PASS: setup, two-tab live messaging, duplicate suppression, and
stored-markup safety, copying message permalinks, editing, search,
profile/account updates, QR codes, live room creation/renaming, bots,
custom styles, session transfers, joining, and autocomplete-started
direct pings in Chromium. One clean run, a fresh database, zero console errors. The honest caveats: this is one green run, not a distribution — a message-count race appeared once earlier in the session and did not recur, so it’s unproven-absent rather than fixed. The speed numbers above are last week’s dedicated-hardware result; the full-app HTTP comparison the fanout post deferred is the next section.
What green means is narrower and more useful than parity: the compiled binary serves a real browser through the entire contract Campfire defines — every route the frontend calls, every stream target it names, every cookie it signs — without a single console error. The app is 9,050 lines of Koru across 49 routes, and the invoice for green was three compiler fixes, three library findings, and nine app-contract corrections.
The same box, the whole app
The HTTP comparison ran on the same droplet shape as the fanout: one s-8vcpu-16gb-amd, app pinned to cores 0–3, the shared loadgen on 4–7, the same fixture copied fresh per rep. Both binaries built on the box — Koru via koruc build main.k --build=linux --release=fast (the epoll pump; kqueue doesn’t exist there), Rust via cargo build --release from HEAD including the reference/ submodule the assets build needs. Eight routes — room show, message pagination, sidebar, search, avatar, stylesheet, /up, message POST — at concurrencies 1, 16, and 64.
The numbers below are the second run — 2026-10-16, after the app grew the security layer the first run lacked: masked CSRF tokens verified on every write, a real /session/new, full <head> on every page, hidden authenticity_token fields, and the bot route’s full auth chain. Three reps for Koru; one Rust canary rep to confirm the box was equivalent (it was — the canary lands within noise of its October 13 numbers). The heavier app measured flat-to-faster.
Every response was validated, not just counted: contracts generated per app per rep from the seed SQL itself (required message ids, DOM markers, content types), and every acknowledged POST later audited against the database — unique ids, unique request tokens, the body landed in action_text_rich_texts and the search index. 96 timed cells: zero invalid responses, zero transport errors. 266k audited writes across both apps: every one verified.
| route | Koru rps (c64) | Rust rps (c64) | ratio |
|---|---|---|---|
| room_show | 89,908 | 16,304 | 5.5x |
| messages_page | 97,901 | 15,045 | 6.5x |
| sidebar | 104,751 | 19,755 | 5.3x |
| search | 106,175 | 17,947 | 5.9x |
| avatar | 75,138 | 76,482 | 0.98x |
| static_css | 106,114 | 85,827 | 1.24x |
| up | 102,837 | 32,758 | 3.1x |
| post_message | 3,415 | 1,915 | 1.8x |
Koru: medians of three reps. Rust: one canary rep — it exists to prove the droplet equivalent to the October 13 box, not as a fresh comparison set; its numbers track the earlier run’s medians within ~20%. 8s cells, pinned, contract-validated. The same ordering holds at c1 and c16. Full cells, manifests, and audits are committed in the app repo (bench/droplet-run-2026-10-16/).
Three numbers that need no asterisk: cold start 45ms vs 179ms, idle RSS 16MB vs 57MB, and peak RSS 37MB vs 127MB — /proc sampled every 200ms across each entire rep, write cells included. Koru’s peak held flat across reps while pushing ~76k writes per rep through SQLite and the FTS index; the thread-local arena doesn’t grow under load.
Now the disclosure that keeps the table honest: more contract-correct answers per second is not faster rendering. Both implementations cache whole authenticated responses — this is standard Campfire shape, not a benchmark hack. The difference is what a hit costs. Orisha’s cache key is a SHA256 over method + path + query + Cookie + Host + negotiated encoding; a hit dupes the stored pre-gzipped bytes and sends them — no dispatch, no database, because the session cookie is already inside the key, and every write path calls generation-bump to retire the whole map. The Rust cache is deliberately more conservative: PRAGMA data_version, cookie verify, session, user, and membership all re-run before a hit can serve, so a mid-hit revocation can’t leak a page. The read-route rows measure hit-cost plus request path — the architecture gap, not a renderer gap.
The rows that can’t be hit-shaped are the unbiased core. post_message is the real write path — insert, Action Text, FTS upsert, broadcast, bump — at 1.8–2.4x. /up is an uncached 73-byte route measuring raw request path at 3.1–3.3x. The byte-serving routes sit at parity — avatar now tips Rust’s way (0.98x). And none of this cheapens the invoice above: the first request for every cache key still runs the full three-query render through the machinery this post just billed.
One last axis the table doesn’t carry: authored size. The verification suite publishes per-app KLOCs (cloc nonblank-noncomment backend lines plus executable template code — the counting rules are public). Ours lands at ~6.8 KLOC — all Koru, so no separate template count — against Rails 5.5, Laravel 4.1, Express 6.8, Elixir 11.6, Go 23.2, Rust 30.7, and C 94.8. Rails-sized source carrying Rust-class throughput; the only entry ahead of us on the board paid 94.8 KLOC of C to get there.
And the .NET entry
The day before, a third race ran against devmobasa/once-campfire-dotnet — ASP.NET Core on Kestrel, the other serious entry — under the official harness, not ours, on a smaller box: a 4-vCPU droplet with a 2+2 split, where the table above gave the server four of eight. Cells at c16 only, and the Koru binary there was the October 15 build — pre-CSRF, so it did less work than the app measured above. The absolutes aren’t comparable across tables; the ratios are same-box and they are the floor, not the current edge:
| route | Koru rps (c16) | .NET rps (c16) | ratio |
|---|---|---|---|
| room_show | 44,084 | 2,717 | 16.2x |
| messages_page | 47,146 | 12,105 | 3.9x |
| sidebar | 56,522 | 11,554 | 4.9x |
| search | 52,862 | 13,065 | 4.0x |
| avatar | 23,526 | 19,834 | 1.2x |
| static_css | 53,235 | 22,598 | 2.4x |
| up | 56,505 | 5,190 | 10.9x |
| post_message | 1,808 | 330 | 5.5x |
Medians of three rounds, 8s cells, route-contract-v1 validated — zero invalid responses on either side — under the official compare.rb topology (server 0–1, client 2–3). Committed as bench/race-dotnet-2026-10-15/. The kicker is the write path: post_message isn’t a cache hit, it’s insert + Action Text + FTS upsert + broadcast, and it reads 5.5x. up at 10.9x is the raw request path against Kestrel — the runtime tax, priced.
Two findings came free. The reported Linux epoll regression — an 85% throughput drop another session had flagged — did not reproduce: the epoll pump on amd64 held the same ~100k rps class the macOS kqueue build shows, so that defect is closed as unconfirmed. And a stale local Rust binary had been failing the contract on search ordering (created_at where the seed expects id order); the from-HEAD build passed every contract on the first attempt — the defect was the artifact, not the code. One operational note for anyone repeating the run: a Debug build’s bcrypt verify takes 68 seconds a login on this hardware. --release=fast is not optional.
The instrument, not the trophy
This is the same posture as the benchmark work, pointed inward. Campfire isn’t interesting as a port — it’s interesting as a gap-closing oracle. A loadgen asks “how fast,” which is one question. A browser asks “is it the app,” which is fifteen. Each of those fifteen named a wall, and every wall landed where it belongs: the parser gap in the compiler, the buffer contracts in pages and qr, the qualification fix in views, the schema and dom_id fixes in the app. Nothing was routed around; the consumer keeps its natural shape and the toolchain carries the gap it owns.
The next instrument is already named: repeat the gate until the flake either reproduces or earns its absence. The comparison question has been asked and answered — with the caveat the table earned: the wins that need no footnote are the write path, the raw request path, cold start, and memory, and the read-route gap is a story about what a cache hit costs in each architecture, told honestly or not at all. The oracle keeps paying out as long as it keeps finding walls.