26 KiB
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:387run()is the only place the engine is touched; every handler is a*roommethod reachable only from the type switch atroom.go:474-511.room.go:540-543— oneRoomStateper input, driven by thelobbyChangedflag, 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:362send()never blocks a session goroutine, and checksctx.Done()on its own before theselectso 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)withWithOverlay(path). The overlay is a second database built by the samebuild-dictionary --wordspath, 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)
outDegreeandopenersmust be recomputed after merge, not merged — merging them is what would break thevalidate()out-degree invariant; (c) a suppression list needs its own table, because removing a word can orphan anopenerand lower an out-degree belowminOpeningOutDegree. - Licensing: the overlay is a separate artifact with its own LICENSE and
ATTRIBUTION, copied as its own layer exactly the way
Dockerfile:74-77already does fordata/. 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.rundefersleaveRoom()(session.go:280), which sendsdisconnectInputonly ifcurrentRoom()is non-nil. A session that dies betweenhub.createRoom/quickMatchhanding 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 thenhandleCreate/handleJoincallsattachon it (room.go:578,room.go:715), leaving a seat with a non-nil deadsess. No grace window opens (that needsdisconnectInput), soallConnected()reports true. Worst case is quick match:handleJoin's auto-start (room.go:726) gates onallConnected(), so a real player is auto-started into a game against a dead socket and waits out the 30 s turn clock. Fix: checkm.sess.ctx.Err() != nilat the top ofhandleCreate/handleJoinand refuse (for join) orr.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 onclientIP(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 withtoo_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 at31^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 noisDraining()check, sohub.gameStarted()(room.go:907) can raiseliveGamesafterstartDraining().newRegisteredRoomrefuses new rooms (hub.go:261) but an existing lobby is untouched. Consequence is bounded byNOITU_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: inhandleLobby'slobbyStartarm and inhandleJoin's auto-start, refuse withserver_restartingwhenr.hub.isDraining(). - C4 · low · player-driven log volume.
recordRejection(room.go:1021) emitsslog.Info("word_rejected", …)for every rejected submission, up to 5/s per connection. The corpus feedback loop it serves is real, butnoitu_words_rejectedalready 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
defaultindispatch(session.go:441-575). A future or malformedClientMessagecosts 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.IdleForis a dead knob in production.server.go:27is documented as operator-facing butcmd/noitu-servernever sets it (main.go:88-98) and noNOITU_*variable exists —docs/andREADMEcorrectly do not claim one. Rooms are fixed atdefaultIdleWindow(10 min,room.go:83). Either wireNOITU_IDLE_TIMEOUTand document it, or delete the field and let the constant be the only source. - C7 · low ·
/debug/varspublishescmdlineandmemstats.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 — butdocs/deployment.mdshould say explicitly: never bindNOITU_DEBUG_ADDRto a public interface. - C8 · informational · resume token has no liveness check on the prior
connection.
handleResume(room.go:1448) accepts the takeover even whens.sess != nil, then closes the old connection (room.go:1471). With the token inlocalStorage, 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 fromcrypto/rand(session.go:742) and is registered/expired correctly (hub.go:122), so it is not guessable.
Plausible (not traced to a failure)
- C9 ·
reserveCodebefore the ceiling check.newRegisteredRoom(hub.go:268) draws a code — up to 10crypto/randrounds and 10 lock acquisitions (hub.go:332-341) — before checkingmaxRoomsathub.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:
- The join was refused and the test never noticed.
playingPair(pvp-game.spec.js:74-78) usesjoinRoom, notjoinRoomSeated— it clicks "Vào phòng" and goes straight toreadyAndStartwith no assertion that the seat exists. The suite runsworkers: 1against one server process with every page on127.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 getstoo_many_attempts, the guest never reaches the lobby, thereadyclick is a no-op, and the failure surfaces 10 s later on the owner'sstart-game— exactly the observed symptom athelpers.js:130. Lobby.svelte:211also gates onoffline; a socket blip keeps the button disabled independently ofcan_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:19golang:1.25-alpineandDockerfile:37alpine:3.22are exact-minor pins; the house rule prefers a moving major, andnode:24-alpine(:4) anddistroless/static-debian12:nonrootalready follow it. As written, the dependabotdockerecosystem generates exactly the churn the rule exists to avoid. Change togolang:1-alpineandalpine:3, or record in the README why these two are pinned harder.go.modsaysgo 1.25.0, so the Go pin is currently consistent — just needlessly tight. make test-godoes not mirror CI. It runsgo vet && go test -race(Makefile), while CI additionally enforcesgofmt -landgolangci-lint(ci.yml). A contributor can passmake testand fail CI. Add alint-gotarget running both, and call it fromtest-go.- Duplicated vet.
golangci-lintrunsgovetby default, so the separatego vetstep inci.ymlis 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. Theproto.ymlbreaking-change gate with--against '.git#ref=origin/main'and the generated-tree sync check (with--intent-to-addfor newly emitted files) are both correct.dependabot.ymlcovers all four manifests with sensible grouping. Thebuild-dictionarybinary is built once in thebuildstage and copied intodict— no duplicated toolchain. Nothing stale found.
Unresolved questions
- 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.
- 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 inhandleResume. - C6
IdleFor. Wire it asNOITU_IDLE_TIMEOUT, or delete the field? - Coverage floor. Is 85% the number you want, and should
cmd/be held to it or exempted with a documented reason? - 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
openersandoutDegreeand therefore the only part that is not additive.