Files
noitu/plans/reports/code-reviewer-260921-1529-server-architecture-review.md

26 KiB
Raw Permalink Blame History

Server architecture review — noitu Go server

Branch dev @ 5178a97 · 2026-09-21 · read-only · scope server/, proto/, Makefile, Dockerfile, .github/

Verdict

The server is in good shape and the design is sound. go vet, golangci-lint (defaults) and go test ./... -race -count=1 are all clean; coverage is 88–100% everywhere except cmd/noitu-server at 0.0%. No data race, no goroutine leak (TestGoroutinesReturnToBaseline), no auth bypass, no injection surface, no PII in logs.

The one-goroutine-per-room actor with a single inputs chan any is the right shape and should stay. room.go's 1858 lines are a file problem, not an architecture problem: every method is on *room, called only from run(), so the engine-ownership invariant holds by construction and a pure file split preserves it at zero risk. Do not convert it to typed handlers or a state machine, and do not split lobby from game — see §1 for why each would cost more than it buys.

Real defects are small and few. The most valuable work is not defect-fixing: it is (a) the per-IP join limiter, which is simultaneously the CI flake and a CGNAT availability bug, (b) removing State.History from the hot broadcast path, (c) splitting the four oversized files, (d) closing the 0% coverage hole.

Top 10 ranked actions

# Action Kind Size Risk Why now
1 Per-IP join limiter is shared by every client behind one NAT egress (1/s, burst 5) — raise it, key it better, or make it configurable fix S low Explains today's CI flake and breaks a whole café/CGNAT of real players (§4 C2, §5)
2 Add a per-IP concurrent-connection cap (F6) fix S low One host can hold all 2000 sockets; the only remaining unbounded-per-peer resource
3 Split room.go into 7 files along existing seams refactor M ~nil 1858 lines is past the point where a reviewer can hold it; pure moves, invariants preserved by construction (§1)
4 Drop History from game.State; add LastMove() + UsedWords() refactor S low O(chain) copy on every TurnUpdate and every bot move, for two call sites (§2)
5 Refuse StartGame / quick-match auto-start while draining (F7) fix S low beginGame can raise liveGames after the drain decision; drain then waits its full timeout (§4 C3)
6 Guard attach against a session already torn down fix S low Quick match can auto-start a game against a dead socket, burning a 30 s turn clock in front of a real player (§4 C1)
7 cmd/noitu-server at 0% — add main_test.go for the env parsers + drain loop fix S nil Only real coverage hole; all pure functions; config parsing is where a prod misconfig hides
8 Split wsapi_test.go (2534 lines, 75 tests) by topic; delete the 3-line hub_test.go refactor M nil Mirrors #3; a test file nobody can navigate stops being extended (§5)
9 Split session.go into socket half / protocol half refactor S nil Two unrelated concerns in one 746-line file (§1)
10 Dockerfile: golang:1.25-alpine → golang:1-alpine, alpine:3.22 → alpine:3 fix S low House rule is moving-major; dependabot is currently generating churn the rule exists to avoid (§6)

Deliberately not in the list, with reasons in-section: typed handler dispatch (§1), lobby/game actor split (§1), Engine API consolidation (§2), bot Board changes (§2), committing word data (§3).


1. Structural assessment

wsapi/room.go — keep the actor, split the file

The actor is doing real work that a redesign would have to re-earn:

  • room.go:387 run() is the only place the engine is touched; every handler is a *room method reachable only from the type switch at room.go:474-511.
  • room.go:540-543 — one RoomState per input, driven by the lobbyChanged flag, from the single place that knows the input finished. No handler has to remember to broadcast.
  • room.go:498-511 — timers are recomputed after every input rather than per arm, so a new input type cannot silently forget one.
  • room.go:362 send() never blocks a session goroutine, and checks ctx.Done() on its own before the select so a dead room cannot report a false success.

Proposal (recommended): 7 files, pure moves, no signature changes.

File Contents ~LOC
room.go struct, newRoom, send, run + timer closures, inLobby/occupied/freeSeat/seatedCount/allConnected/seatOf/sendTo/broadcastError 350
room_inputs.go every *Input struct + lobbyAction consts (room.go:94-204) 120
room_lobby.go handleCreate, handleJoin, handleLobby, canStart, guestsReady, takenNicknames, vacate, promote, playerSlots, broadcastRoomState, seatIDs 380
room_game.go beginGame, handleSubmit, handleResign, handleClaimDeadEnd, handleBotMove, maybeScheduleBot, mark/inputMark, applyEliminations, broadcastTurn/sendTurnUpdate/sendGameStarted/broadcastElimination/broadcastGameOver, scoreRows, wireEndReason, nearMissFor, recordRejection, handleReportWord 420
room_presence.go handleDisconnect, nextGraceExpiry, handleGraceExpiry, eliminateAbsent, handleResume, detachAll 200
room_chat.go chatEntry, handleChat, sendChatHistory, chatMessageFor 150
bot_board.go frozenBoard, freezeBoard (room.go:1824-1858) 50

Invariants: all preserved, by construction. Same package, same receiver, same call graph — the engine stays on the room goroutine because nothing else can reach a *room method; the one-broadcast-per-input site does not move; the per-recipient rendering loops are untouched. Risk is limited to a bad copy-paste, which go build catches.

Rejected — typed handlers (interface{ apply(*room) } or a handler map). The type switch is 30 lines and is the only dispatch in the package. Moving it onto the input types scatters the two exceptions that currently read at a glance (chatInput and reportWordInput set idleActivity = false, room.go:489, room.go:497) into the types themselves, and buys no exhaustiveness — Go gives none for either shape. Revisit only past ~20 arms.

Rejected — a phase state machine. The lobby/playing/over phase is already derived in exactly one place (room.go:1595 inLobby()), and every handler's guard asks a different question — seat ownership (occupies), turn ownership, owner role, readiness. A phase enum answers none of them and adds a field that can disagree with r.engine.

Rejected — separate lobby and game actors. The state that spans both is exactly the state that makes it hard: seats (with wins, chatFrom, graceUntil), chat, chatSeq, owner, turnSeq. Two actors would need a handoff protocol for all of it and would reintroduce the race the single goroutine removes. seat.wins (room.go:222-226) and the chat log explicitly outlive games; handleGraceExpiry (room.go:1402) must work identically in and out of a game.

wsapi/hub.go (402) — one split worth doing

Size is fine and the mutex discipline is right (registries only, never engine state — hub.go:46-51). One concern has its own policy, its own metrics and the most obvious growth path: extract quickmatch.go (~90 lines: waiting, quickMatch, cancelQuickMatch, errAlreadyQueued). While there, decide whether the queue needs hub.mu at all — it is never mutated atomically with rooms or sessions, so its own mutex would document that. Optional second split: roomcode.go for roomCodeAlphabet/randomCode/reserveCode (~60).

wsapi/session.go (746) — split by concern, keep the switch

Two unrelated halves: the socket (run, readLoop, writeLoop, write, drain, keepalive, close, send, trySend, attach/release — ~330) and the protocol (dispatch and its handlers, handleHello, resumeFrom, handleSubmit, handleReportWord, toRoom, roomCreateError — ~350). Split into session.go and dispatch.go.

The 16-arm switch at session.go:441 is fine as a switch — it is the wire contract read top to bottom, and each arm's error code is deliberately different. The duplication worth removing is smaller: Resign (session.go:513), ClaimDeadEnd (session.go:524) and SendChat (session.go:560) each re-implement toRoom's currentRoom-then-send shape with their own codes. One helper taking (limiter, input, missingCode, droppedCode) collapses ~30 lines and turns "which limiter pays for which action" into a table. Do it with #9, not before.

Missing arm: dispatch has no default. A ClientMessage with an unset or future payload is silently accepted and ignored. Add default: s.send(errorMsg("unknown_message")) plus a counter — see §4 C5.


2. Engine and bot

State.History is copied on every broadcast for two readers. engine.go:576 does append([]Move{}, e.history...) inside Snapshot(), and Snapshot() is called at room.go:908 (beginGame), room.go:1142 (broadcastTurn — every turn), room.go:1207 (applyEliminations) and room.go:1831 (freezeBoard — every bot move). History is read at exactly two of those: room.go:1487-1488 wants only the last move, and room.go:1836 wants only the played-word set.

Fix: drop History from State; add Engine.LastMove() (Move, bool) and Engine.UsedWords() iter.Seq[string] (or a copied map). This removes an O(chain) allocation from the hot path entirely. State is internal and wsapi is its only non-test consumer, so the blast radius is engine_test.go + wsapi_test.go assertions. Size S.

Standings() runs on every Snapshot() (engine.go:587) even mid-game, where its own doc says it is meaningless (engine.go:539-540). Guard it: if e.over. Trivial, same commit as above.

Engine API surface: leave it. Submit/Timeout/NoMove/Resign are four genuinely different rules, not four spellings of one, and the caller-side pairing with mark()/applyEliminations is already centralized in exactly one function (room.go:1203). pointsFor returning (int, []PointPart) with capParts trimming from the end (engine.go:298-322) is correct and well-reasoned — a client with no wordlist cannot re-derive the breakdown, so it has to travel. No change.

Rule duplication engine↔room: essentially none. One near-miss: room.go:1053-1056 (nearMissFor) re-applies the link and used-word checks that Submit would apply. That is deliberate and commented — offering a suggestion that would immediately be refused is worse than offering none. Leave. handleClaimDeadEnd (room.go:641) calls HasLegalMove() then NoMove(), which re-walks; a wasted scan on a rare input. Leave.

Bot Board (4 methods, bot/bot.go:45-51) is minimal and correctly narrow — a strategy can read the position and cannot play a move. freezeBoard (room.go:1830) is the right boundary: frozen on the room goroutine before the worker exists, so Used cannot race a concurrent resign. Cost is O(chain) per bot move → O(chain²) per bot game; chains are tens of words, so this is not worth restructuring. It gets cheaper for free once UsedWords() lands. The worker's leak guard (room.go:1123-1129, select on r.ctx.Done() around the thinking delay) is correct. No change.


3. Dictionary

Store is load-once, read-only, never hands out a mutable reference (store.go:60-64), so unsynchronized concurrent use is genuinely safe. The validate() pass (store.go:289) checks declared-vs-loaded counts, orphan meanings, out-degree consistency and dangling aliases — that is the right set, and it is what makes a truncated file fail at boot rather than mid-game.

NearMiss (store.go:421) and its stripped index (store.go:376) are a clean addition: built once at Open, ambiguity refused rather than guessed, and stripDiacritics correctly folds đ/Đ by hand since they have no canonical decomposition. Cost: one extra map plus one string per word. On a 30 k-word corpus the count != 1 rule will suppress most hits (common stems have many candidates) — worth a metric on suggestion hit rate before tuning it.

Builder version drift risk: dictionary/store.go:41 requiredBuilderVersion = "5" and cmd/build-dictionary/main.go:40 builderVer = "5" are two constants in two packages that must agree, with no compile-time link and (unlike the dump URL, per the Makefile comment) I found no test asserting they do. Either have the builder import the store's constant or add a one-line test. Size S.

Community allowlist overlay — design only, no data. Plug point is store.go, between loadAliases and buildStrippedIndex: a mergeOverlay() step, after which buildStrippedIndex() and validate() run over the merged result unchanged. Shape:

  • Open(path string, opts ...Option) with WithOverlay(path). The overlay is a second database built by the same build-dictionary --words path, so there is one schema and one validator.
  • Merge rules to settle before writing code: (a) an overlay word colliding with a canonical word is a no-op, not an error; (b) outDegree and openers must be recomputed after merge, not merged — merging them is what would break the validate() out-degree invariant; (c) a suppression list needs its own table, because removing a word can orphan an opener and lower an out-degree below minOpeningOutDegree.
  • Licensing: the overlay is a separate artifact with its own LICENSE and ATTRIBUTION, copied as its own layer exactly the way Dockerfile:74-77 already does for data/. The licence question is open and out of scope here; no word data should be committed to this repo either way.

4. Correctness and robustness sweep, ranked

Confirmed (path traced)

  • C1 · medium · ghost seat from an attach-after-teardown race. session.run defers leaveRoom() (session.go:280), which sends disconnectInput only if currentRoom() is non-nil. A session that dies between hub.createRoom/quickMatch handing the room an input and the room goroutine draining it is never in a room at that moment, so no disconnect is ever sent — and then handleCreate/handleJoin calls attach on it (room.go:578, room.go:715), leaving a seat with a non-nil dead sess. No grace window opens (that needs disconnectInput), so allConnected() reports true. Worst case is quick match: handleJoin's auto-start (room.go:726) gates on allConnected(), so a real player is auto-started into a game against a dead socket and waits out the 30 s turn clock. Fix: check m.sess.ctx.Err() != nil at the top of handleCreate/handleJoin and refuse (for join) or r.cancel() (for create). Bounded elsewhere: a PvP lobby closes on the 10-min idle timer, a bot room on the turn clock.
  • C2 · medium · per-IP join limiter is far too tight for shared egress. joinsPerSecond = 1, joinBurst = 5 (session.go:50-51), keyed on clientIP (session.go:475). Every player behind one NAT/CGNAT egress — a café, a school, a Vietnamese mobile carrier — shares five room joins and then one per second, refused with too_many_attempts. This is also the CI flake (§5). The limiter is right in kind (it protects the 6-char room-code secret from brute force at 31^6 ≈ 8.9e8); the rate is wrong. At 5/s it still takes centuries to walk the space. Raise the burst and rate substantially, and make them configurable so the e2e suite and a CGNAT deployment can tune them.
  • C3 · low-medium · F7, games can start after the drain decision. beginGame (room.go:855) has no isDraining() check, so hub.gameStarted() (room.go:907) can raise liveGames after startDraining(). newRegisteredRoom refuses new rooms (hub.go:261) but an existing lobby is untouched. Consequence is bounded by NOITU_DRAIN_TIMEOUT — the drain waits its full timeout instead of exiting early — and the game is then killed mid-play anyway, which is the outcome the drain existed to avoid. Fix: in handleLobby's lobbyStart arm and in handleJoin's auto-start, refuse with server_restarting when r.hub.isDraining().
  • C4 · low · player-driven log volume. recordRejection (room.go:1021) emits slog.Info("word_rejected", …) for every rejected submission, up to 5/s per connection. The corpus feedback loop it serves is real, but noitu_words_rejected already carries the reason breakdown. Sample it, or drop the line to Debug and keep the counter. The word is normalized and capped before logging and no player identity is attached — that part is right.
  • C5 · low · silent unknown-payload handling. No default in dispatch (session.go:441-575). A future or malformed ClientMessage costs a decode and gets no answer. Add the arm plus a counter; the frame limiter (session.go:328) already bounds the flood case.
  • C6 · low · Config.IdleFor is a dead knob in production. server.go:27 is documented as operator-facing but cmd/noitu-server never sets it (main.go:88-98) and no NOITU_* variable exists — docs/ and README correctly do not claim one. Rooms are fixed at defaultIdleWindow (10 min, room.go:83). Either wire NOITU_IDLE_TIMEOUT and document it, or delete the field and let the constant be the only source.
  • C7 · low · /debug/vars publishes cmdline and memstats. expvar.Handler() (main.go:166) always exposes the full argv and heap stats. It is opt-in and on a separate listener (main.go:158-162), which is the right design — but docs/deployment.md should say explicitly: never bind NOITU_DEBUG_ADDR to a public interface.
  • C8 · informational · resume token has no liveness check on the prior connection. handleResume (room.go:1448) accepts the takeover even when s.sess != nil, then closes the old connection (room.go:1471). With the token in localStorage, a second tab evicts the first. That reads as a deliberate "one seat, latest tab wins" — record it as a decision rather than fix it. The token itself is 128 bits from crypto/rand (session.go:742) and is registered/expired correctly (hub.go:122), so it is not guessable.

Plausible (not traced to a failure)

  • C9 · reserveCode before the ceiling check. newRegisteredRoom (hub.go:268) draws a code — up to 10 crypto/rand rounds and 10 lock acquisitions (hub.go:332-341) — before checking maxRooms at hub.go:277. At the ceiling every refused create pays that. Cheap reorder: an advisory count check first, keeping the authoritative one under the registration lock where it correctly is today.

Checked and clean

Trust boundaries: sanitizeText is applied to nicknames, chat and the echoed typed word (room.go:966, nickname.go:52), with NFC first so the rune cap sees what the browser renders — correct order. clientIP (server.go:242) walks XFF from the right and only when the peer is a configured proxy; malformed hops fall back to the socket address rather than keying a limiter on garbage. Static file serving is root-confined (server.go:225 underRoot). Seat authority is always the seat, never the claimed id (room.go:849 occupies, used by every handler that acts on a game). liveGames accounting is exactly-once via CompareAndSwap on both the normal path (room.go:1289) and the cancelled-mid-game path (room.go:392). Limiter maps are swept (ratelimit.go:81, server.go:316). errorMsg carries UI keys, never prose or internal errors — asserted by TestErrorMessagesAreUIKeysNotProse.


5. Tests

Structure. 13 files, 143 tests. wsapi_test.go alone is 2534 lines / 75 tests spanning ~9 topics; hub_test.go is 3 lines of comment and should be deleted (fold the note into hub.go). Split wsapi_test.go along the §1 seams — lobby_test.go, chat_test.go, resume_test.go, deadend_test.go, report_test.go, ratelimit_test.go, nickname_test.go (already exists) — keeping wsapi_test.go as the harness only (newTestServer, client helpers, fixture dictionaries, ~350 lines).

The browser-suite join flake — answer: no, there is no server-side ordering that makes can_start late. Traced: SetReady → toRoom (session.go:603) → handleLobby sets mine.ready (room.go:764-766) → the run loop broadcasts exactly one RoomState in the same iteration (room.go:540-543), with can_start computed in that same frame (room.go:1755, room.go:1671). One RTT, no timer, no second input. The client derives both isReady and canStart straight from that frame (game.svelte.js:537, Lobby.svelte:211), so there is no local optimism to drift either.

What can make the frame missing rather than late, in likelihood order:

  1. The join was refused and the test never noticed. playingPair (pvp-game.spec.js:74-78) uses joinRoom, not joinRoomSeated — it clicks "Vào phòng" and goes straight to readyAndStart with no assertion that the seat exists. The suite runs workers: 1 against one server process with every page on 127.0.0.1, so all pages share one join bucket (C2: 1/s, burst 5). A test that joins twice in quick succession after a previous test drained the bucket gets too_many_attempts, the guest never reaches the lobby, the ready click is a no-op, and the failure surfaces 10 s later on the owner's start-game — exactly the observed symptom at helpers.js:130.
  2. Lobby.svelte:211 also gates on offline; a socket blip keeps the button disabled independently of can_start.

Fixes, in order: raise/expose the join limiter (C2, which is a production fix first and a test fix second); use joinRoomSeated in playingPair; and have readyAndStart assert each guest's own ready state landed before waiting on the owner's button, so a dropped SetReady fails at the guest with a clear message instead of timing out on a third party.

What to move from Playwright into Go. The server-rule assertions in pvp-game.spec.js already have named in-process equivalents — TestStartIsRefusedUntilTheGuestIsReady (wsapi_test.go:1332), TestOnlyTheOwnerStartsAndKicks (:1373), TestKickFreesAnUnreadySeatOnly (:1515), TestOwnerLeavingPromotesTheOtherPlayer (:1548), TestResumeWithinGraceRestoresGame (:607). The browser suite should keep only what a browser proves: rendering, focus, the unread badge, deep links, the bundle carrying no wordlist. Concrete task: walk pvp-game.spec.js and delete every assertion whose rule is already named by one of those tests.

Coverage floors. Current: vietnamese 100, game 95.5, bot 91.2, wsapi 91.1, dictionary 89.0, build-dictionary 88.6, cmd/noitu-server 0.0. Propose an 85% per-package floor in CI (go test -coverprofile + a small check script), excluding gen/. That floor is already met everywhere except the one package that has never been tested — loadConfig, envInt, envDuration, envNonNegDuration, envList and waitForGamesToFinish are all pure and all trivially testable, and config parsing is precisely where a production misconfiguration hides.

Fuzz. Three targets today: FuzzNormalize, FuzzSanitizeText, FuzzDecode — all at the right boundaries. The gap is the composition: add FuzzSubmit over the fixture dictionary asserting the property that any input Engine.Submit accepts resolves to a canonical word whose FirstSyllable equals the link and whose LastSyllable becomes the next link. A second cheap one: FuzzStripDiacritics for idempotence (strip(strip(x)) == strip(x)), which is what NearMiss silently relies on.


6. Build and ops

  • Dockerfile version pins. Dockerfile:19 golang:1.25-alpine and Dockerfile:37 alpine:3.22 are exact-minor pins; the house rule prefers a moving major, and node:24-alpine (:4) and distroless/static-debian12:nonroot already follow it. As written, the dependabot docker ecosystem generates exactly the churn the rule exists to avoid. Change to golang:1-alpine and alpine:3, or record in the README why these two are pinned harder. go.mod says go 1.25.0, so the Go pin is currently consistent — just needlessly tight.
  • make test-go does not mirror CI. It runs go vet && go test -race (Makefile), while CI additionally enforces gofmt -l and golangci-lint (ci.yml). A contributor can pass make test and fail CI. Add a lint-go target running both, and call it from test-go.
  • Duplicated vet. golangci-lint runs govet by default, so the separate go vet step in ci.yml is redundant. It is ~free and it fails faster with a clearer message — leave it.
  • Everything else is clean. The image job's licence assertion (ci.yml, "The licence travels with the data") is a genuinely good check, as is the negative assertion that the upstream dump never reaches the final image. The proto.yml breaking-change gate with --against '.git#ref=origin/main' and the generated-tree sync check (with --intent-to-add for newly emitted files) are both correct. dependabot.yml covers all four manifests with sensible grouping. The build-dictionary binary is built once in the build stage and copied into dict — no duplicated toolchain. Nothing stale found.

Unresolved questions

  1. C2 rate. What join rate and burst do you want? The security argument caps nothing below ~5/s; the availability argument (Vietnamese mobile CGNAT) wants much more. This is a product call, not a review call — I have not changed it.
  2. C8 two-tab takeover. Is "second tab evicts the first" the intended behaviour? If yes it should be a line in docs/; if no it needs a liveness check in handleResume.
  3. C6 IdleFor. Wire it as NOITU_IDLE_TIMEOUT, or delete the field?
  4. Coverage floor. Is 85% the number you want, and should cmd/ be held to it or exempted with a documented reason?
  5. Overlay (§3). Does the suppression case (removing a shipped word) need to exist at all in v1? It is the only part of the overlay design that touches openers and outDegree and therefore the only part that is not additive.