v0.8.4 — the multiplayer transport: server and browser

The second release from the audit. Every fault here was invisible in solitaire, and four of
the five server faults were in the one file no test had ever stood up; `http.ts` now has an
end-to-end suite on a real port. CHANGELOG has the reasoning.

SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and
move for whoever took the seat next — revoked now, in memory and on disk. The browser
numbered intents from 1 per page load while the server remembered the seat's last number,
so the first move after a reload was swallowed as a resend — the connect push carries the
count and the client continues from it. Nothing serialised moves within a game and every
write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200),
and the boot's bare `JSON.parse` then took every game down — per-path write queues, a
per-game move queue, and a boot that skips one bad file. An error after the SSE head was
sent crashed the process. Bodies were unbounded before any secret check.

BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed
submit is `false`, not an unhandled rejection. The documentation renderer flattened nested
bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel
promised cars the engine refuses; it asks `acceptsCar` now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
Jesse.Markowitz
2026-09-29 17:02:32 -04:00
co-authored by Claude Fable 5.1
parent 4d222a7eba
commit e47cd3d400
22 changed files with 813 additions and 101 deletions
+68
View File
@@ -19,6 +19,74 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.8.4 — 2026-09-29
The second release from the audit: the multiplayer transport, server and browser. Every fault here
was invisible in solitaire, and four of the five server faults were in the one file no test had
ever stood up — `http.ts` now has an end-to-end suite that binds a real port.
### A player who left could play the seat the next arrival took
Leaving a lobby freed the chair and kept the token: it stayed in memory and on disk, and the next
player to join was given the vacated chair. So once the game began, the leaver's browser still held
a token for that seat — `/api/stream` served it the newcomer's hand and menu, `/api/intent` let it
move for them, and its stream connection displaced theirs. `lobby-and-sessions.md` had said all
along that Leave "drops the token"; the code did not. It does now, for a player's own leave and for
the host's remove alike, in memory and in `sessions.json`, before the chair is offered to anyone.
### The first move after a reload could be silently swallowed
The browser numbered its intents from 1 on every page load; the server remembers a seat's last
accepted number for the life of the game and answers a repeat with "already applied". A seat that
had made one move, reloaded, and clicked again sent `seq: 1` twice — ok, nothing happened, nothing
pushed, the click looked dead. The connect push now carries the server's count (`lastSeq`) and the
client continues from it, never backwards.
### Two moves at once could tear the save, and a torn save took every game down
Nothing serialised moves within a game: the handler awaits the disk write between applying and
answering, and two moves arriving together interleaved across it — both applied in memory, both
writing the same `game.json.tmp`. Measured at 200 rounds of two concurrent writes: every round lost
one to `rename` ENOENT, six left the file as invalid JSON. And the boot did a bare `JSON.parse` on
each save at the top level, so one such file was a crash loop with every game on the server
unreachable. Three fixes, each pinned: every write to a path queues behind the one before it with a
unique temp name; each game's moves run one at a time through apply, persist, answer, broadcast; and
an unreadable save is logged and skipped rather than fatal, as is one whose replay throws.
### One error after the SSE head was sent was a whole-server crash
The handler's one `catch` answered every error with a JSON 500 — and on a response whose head was
already written (both streams, static files) `writeHead` throws inside the catch, with nothing above
it. Node exits on an unhandled rejection. `sendJson` now ends such a response instead; the lobby's
host-reassignment write and the static file stream have their own error paths; and the 500 no longer
echoes the error's message, which for a disk error carried the data directory's absolute path.
### Anyone could exhaust the process's memory with one POST
Request bodies were buffered whole, with no cap, before any secret was checked. A 64 KiB limit —
the largest body any route has a use for is a few hundred bytes — answers 413; a body that is not a
JSON object answers 400 rather than surfacing as a 500.
### In the browser
**A double-click did the thing twice.** The page redraws the same menu the instant a submit is sent,
so a second click before the round trip posted a second, fresh `seq` for the same option, and the
server applied it again: two cards drawn, two Moves spent, two cars coupled. One submit in flight at
a time now; a click that lands during one is dropped, and the push is milliseconds away. A network
failure or a non-JSON answer is `false` from `submit` rather than an unhandled rejection.
**The documentation renderer flattened nested bullets.** The comment said nesting was rendered by
recursion; the code appended the nested bullet to its parent as text, and the published home-deck
page read "…knows. - ABS Signals is the exception…" with a literal dash mid-sentence. Real nesting
now, and the test renders the real document to prove the dash is gone.
**"Still needs 2 boxcar" behind a caboose.** The make-up panel counted by category and promised cars
the engine would refuse: nothing couples behind a caboose (§A.3), so it printed a need while no yard
chip lit and the only offer was to send the train out as it stands. It asks `acceptsCar` per
category now, the way the engine's own make-up report does, and says why when nothing more couples.
---
## 0.8.3 — 2026-09-29
The first of three releases from a code audit (engine, server, client, tests and hygiene, each read