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:
co-authored by
Claude Fable 5.1
parent
4d222a7eba
commit
e47cd3d400
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user