mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
278 lines
17 KiB
Markdown
278 lines
17 KiB
Markdown
# wsapi transport review: whole package, read-only
|
|
|
|
Date: 2026-09-29 · branch `dev` @ d3eb13e · scope `server/internal/wsapi/**`, `proto/noitu/v1/game.proto` (read for contract only)
|
|
|
|
## Verdict
|
|
|
|
The actor model is sound. Every engine and seat mutation stays on the room goroutine, all
|
|
sends are non-blocking, and `-race` is clean. The findings below are lifecycle and
|
|
identity bugs at the edges of that model: what happens to a seat *id* after its seat is
|
|
freed, what happens to inputs queued behind a room's last one, and what happens when a
|
|
control notice has to share the inbox with player spam. Each one was reproduced with a
|
|
throwaway test in a scratch copy of the module, and the suggested fixes for #1, #2 and #4
|
|
were applied in that copy and pass the whole package under `-race` (the only failure was
|
|
`TestCrossLanguageFixtures`, and only because the copy has no `proto/testdata`).
|
|
|
|
Baseline on the real tree: `go vet ./internal/wsapi` clean, `go test ./internal/wsapi -race -count=1` ok (29.6s).
|
|
No project file was modified.
|
|
|
|
## Findings
|
|
|
|
| # | Sev | Where | Finding |
|
|
|---|---|---|---|
|
|
| 1 | High | room_presence.go:473, :380-385; room_lobby.go:189-191 | A kicked player's token resumes into whoever now holds the same seat id: their name, wins and chat window, and the real occupant is locked out |
|
|
| 2 | Med | room.go:262 (defer order), exits at :410/:425/:431 | Inputs queued behind a room's final input are never answered. A join or resume that loses the race waits forever |
|
|
| 3 | Med | dispatch.go:350-356, session.go:203-205, dispatch.go:296-298, room.go:236-258 | Disconnect and resume notices share the lossy 32-slot inbox. Four seats bursting unmetered `Resign` overflow it, a dropped disconnect leaves a ghost "connected" seat, and a dropped resume is reported as `game_already_over` |
|
|
| 4 | Low | room_lobby.go:66-69, :78-94 | `autoStart` stays armed after a quick-match pairing that never started, so the next code joiner starts a game nobody readied for |
|
|
| 5 | Low | room.go:330, :433-435 | The idle window is reset by *any* input, refused ones included. A refused `Resign` every few minutes holds a lobby open forever |
|
|
| 6 | Low | server.go:268-291, dispatch.go:68 | The join limiter is keyed on the full IPv6 address, so one /64 has 2^64 buckets (and F10's unmapped-v4 key split is still open) |
|
|
| 7 | Low | hub.go:192-197 | Quick match still queues a player while draining, and they wait until shutdown |
|
|
| 8 | Low | dispatch.go:28-157 | No `default` arm: an unknown or empty payload is silently dropped (prior C5, recorded "not done", never rejected) |
|
|
| 9 | Low (test) | chat_test.go:317 | `TestChatDoesNotKeepARoomAlive` passes with chat resetting the idle clock |
|
|
| 10 | Low (test) | limits_test.go:203 | `detachAll` has no test at all. The whole suite passes with `defer r.detachAll()` deleted, even though the prior review cited this test as its closure |
|
|
| 11 | Nit (test) | convert_test.go:141 | A swapped `PointKind` arm (CHAIN<->SYLLABLES) passes the whole suite |
|
|
| 12 | Nit | room_presence.go:482 | A resume does not leave the quick-match queue, unlike every `takeSeat` path |
|
|
| 13 | Nit (test) | lobby_test.go:474-488 | A hand-rolled copy of `awaitNoRooms` |
|
|
|
|
---
|
|
|
|
### 1. High: a stale token resumes into another player's seat
|
|
|
|
**What is wrong.** `handleResume` looks the seat up by id (`seatOf(m.player)`) and accepts
|
|
when `s.sess == nil`. Seat ids `p1..p4` are reused. A seat vacated while its player is in
|
|
the grace window never releases that player's prior session: `vacate` only calls `release`
|
|
when `s.sess != nil`, and `holdSeat` has already nil'd it. So the prior session still points
|
|
at the room with id `p2`, and its token stays live for the rest of *its own* grace window.
|
|
|
|
**Failure scenario (reproduced end to end).** Alice (p2) drops. The owner kicks her
|
|
offline seat, which is allowed because she is not ready. Carol joins and gets p2, chats,
|
|
then refreshes, which puts p2 in grace for Carol. Alice's tab reconnects within 30s and
|
|
`web/src/lib/ws/client.js:228` presents her stored token automatically.
|
|
Result: `BUG: kicked Alice resumed into seat p2 named "Carol"`. Alice's replayed
|
|
`ChatHistory` contained `"carol-only secret" from "Carol"`, and Carol's own resume was then
|
|
answered `session_not_resumable`. That is three README invariants broken at once: resume
|
|
into the wrong seat, take another seat's series score, and chat replay leaking. Grace
|
|
expiry followed by a rejoin hits the same root cause, but only in the microsecond gap
|
|
before the token expires.
|
|
|
|
**Fix (verified in scratch).** Remember which connection a window is being held for:
|
|
|
|
```go
|
|
// seat
|
|
heldFor *session // the connection whose drop opened graceUntil
|
|
|
|
// holdSeat
|
|
s.heldFor = s.sess
|
|
s.sess = nil
|
|
|
|
// handleResume
|
|
if s == nil || (s.sess != m.prior && (s.sess != nil || s.heldFor != m.prior)) {
|
|
m.sess.send(errorMsg(codeSessionNotResumable))
|
|
return
|
|
}
|
|
```
|
|
|
|
A refilled seat is a new `*seat`, so its `heldFor` can never be the kicked player's
|
|
session. Add the scratch reproduction as a regression test in resume_test.go.
|
|
|
|
### 2. Med: inputs queued at room exit are never answered
|
|
|
|
**What is wrong.** `run` returns as soon as the room is empty, idle, or a bot game ends,
|
|
and anything still in `r.inputs` is dropped without a reply. `r.cancel()` is also the
|
|
*first* defer registered, so it runs *last*. Until then `send` still returns true into a
|
|
room nobody reads, which is exactly the "caller waits forever" case `send`'s own comment
|
|
describes.
|
|
|
|
**Failure scenarios.** (a) The owner is alone and clicks Leave while a friend's
|
|
`JoinRoom` is behind it in the inbox. `hub.joinRoom` returned nil, so dispatch answers
|
|
nothing and the friend never gets a `room_state` or an error. Reproduced by queuing
|
|
create, leave, join and running the room: the joiner received **zero** frames.
|
|
(b) The last seat's grace timer and that player's `resumeInput` are ready on the same
|
|
`select`. If the grace arm wins, the room exits and the resume is never answered, which
|
|
leaves the client's resume latch hanging (the case handleHello:271-277 was written to prevent).
|
|
|
|
**Fix (verified in scratch).** Cancel first, then answer whatever is left:
|
|
|
|
```go
|
|
func (r *room) run() {
|
|
defer r.refusePending() // runs last: after cancel, so nothing new is accepted
|
|
... existing defers ...
|
|
defer r.cancel() // registered last, so it runs first
|
|
```
|
|
|
|
`refusePending` drains non-blockingly and answers `joinInput` with `room_not_found`,
|
|
`resumeInput` with `session_not_resumable`, submit/resign/claim with `not_in_a_game`, and
|
|
lobby/chat with `not_in_a_room`.
|
|
|
|
### 3. Med: control notices can be dropped by a full inbox
|
|
|
|
**What is wrong.** `leaveRoom`'s `disconnectInput`, `attach`'s release of the previous room,
|
|
and `resumeFrom`'s `resumeInput` all go through `room.send`, which drops on a full inbox.
|
|
`Resign` is the one room input with no per-action limiter (`dispatch.go:118`, `nil`). Each
|
|
connection may burst 40 frames, and every refused `Resign` in a lobby still takes an inbox
|
|
slot.
|
|
|
|
**Measured.** With four seats each writing 39 `Resign` frames at once, the room logged
|
|
`room inbox full, dropping message` 4, 86 and 38 times across three runs, and 357 times
|
|
over three runs at `GOMAXPROCS=1`. One connection alone never overflowed it. Constructed
|
|
consequence (scratch test): the guest's disconnect was dropped, and after the grace window
|
|
had passed the seat was **still bound to its dead session** with `graceUntil` zero.
|
|
`allConnected()` then reports true, so `canStart` goes green against a dead socket, the
|
|
seat never expires, and a ready ghost cannot be kicked (`player_is_ready`). A dropped
|
|
`resumeInput` is also reported as `game_already_over` (dispatch.go:296-298), which is a
|
|
false statement: the room is alive, only busy.
|
|
|
|
**Fix.** (a) Charge `Resign` on `s.submitLimiter`, like `ClaimDeadEnd`. (b) Deliver the
|
|
two teardown notices reliably. `leaveRoom` runs on a dying session goroutine and can block
|
|
safely: `select { case r.inputs <- m: case <-r.ctx.Done(): }`. For `attach`, which runs on
|
|
*another* room's goroutine, do the same inside `go func(){...}()` so two rooms can never
|
|
wait on each other. (c) In `resumeFrom`, answer a busy room with `busy` rather than
|
|
`game_already_over`.
|
|
|
|
### 4. Low: quick-match `autoStart` outlives its pairing
|
|
|
|
**What is wrong.** `r.autoStart` is cleared only when the auto-start actually fires. If
|
|
the pairing's joiner was already torn down (the `holdSeat` return at :66-69), or either
|
|
side is offline at join time, the flag stays set. The next `handleJoin` that fills a second
|
|
connected seat starts a game with no readiness and no `StartGame`.
|
|
**Scenario (reproduced at handler level).** The quick-match partner dies before seating
|
|
and grace expires. The waiter shares the room code (it is in their `RoomState`). The
|
|
friend who joins is dropped straight into `game_started` with `ready=false`.
|
|
**Fix (verified).** The first `handleJoin` in the room is the pairing join, so consume the
|
|
flag there: `autoStart := r.autoStart; r.autoStart = false`, and also clear it on the
|
|
`holdSeat` early return.
|
|
|
|
### 5. Low: the idle window is not "ten minutes with no game started"
|
|
|
|
`idleActivity` is true for every input except chat and report, and for the grace arm.
|
|
Refused `Resign`, `SubmitWord` or `ClaimDeadEnd` (`game_not_started`), a stranger's refused
|
|
join (`room_full`) and ready toggles all restart it. Reproduced: with `IdleFor: 300ms`, a
|
|
refused `Resign` every 150ms kept the lobby open for 5x the window. **Fix:** reset only
|
|
when the input changed room state. Capture `changed := r.lobbyChanged` before the broadcast
|
|
block and call `resetIdleTimer()` only when `changed`. `handleChat` never sets
|
|
`lobbyChanged`, so the chat special case goes away.
|
|
|
|
### 6. Low: the join limiter treats every IPv6 address as its own client
|
|
|
|
`clientIP` returns the peer or hop address as a raw string, and `hub.joinLimiter` keys on
|
|
it. A single residential IPv6 customer controls a /64, which is 2^64 distinct buckets, and
|
|
that makes the 5/s code-walk guard (session.go:53-61) meaningless on a dual-stack
|
|
deployment. The same raw string also keeps prior finding F10 open, where `::ffff:a.b.c.d`
|
|
and `a.b.c.d` count as two keys. **Fix:** derive the limiter key once:
|
|
`addr.Unmap()`, and for `Is6()` use `netip.PrefixFrom(addr, 64).Masked().String()`. Use it
|
|
for `reserveIP` too.
|
|
|
|
### 7. Low: quick match queues while draining
|
|
|
|
`newRegisteredRoom` refuses rooms when draining, but the enqueue path (`hub.go:192-197`)
|
|
does not check. A lone player is told `queued:true` and then waits until shutdown. **Fix:**
|
|
`if h.draining.Load() { return errDraining }` at the top of `quickMatch`. dispatch
|
|
already maps that error to `server_restarting`.
|
|
|
|
### 8. Low: `dispatch` has no `default` arm (still open)
|
|
|
|
This was C5 in the 2026-09-21 review. The action report recorded it as "not done", not as
|
|
rejected. A `ClientMessage` with no payload, or one from a newer client, costs a frame and
|
|
gets no reply. **Fix:** `default: s.send(errorMsg(codeUnknownMessage))`, adding the code
|
|
to errcodes.go and `vi.js` (the web error-codes test will insist).
|
|
|
|
### 9. Low (test): `TestChatDoesNotKeepARoomAlive` cannot fail
|
|
|
|
Mutation check: with `idleActivity = false` removed from the chat arm, the test still
|
|
passes 3/3. The idle close merely arrives about 300ms later, and `await` allows 5s.
|
|
**Fix:** record `start` before the chats and fail if `room_idle_closed` arrives later than
|
|
`IdleFor + 150ms`.
|
|
|
|
### 10. Low (test): `detachAll` is untested
|
|
|
|
With `defer r.detachAll()` deleted, the **whole package passes**. `TestIdleRoomReleasesItsSeats`
|
|
proves the connection can create a new room, but `CreateRoom` succeeds either way, because
|
|
`attach` just overwrites the dead room pointer. **Fix:** after the idle close, have the
|
|
host `say("x")` and require `not_in_a_room`. Without `detachAll`, `toRoom` finds the dead
|
|
room and answers `busy`.
|
|
|
|
### 11. Nit (test): enum mapping is pinned for shape, not meaning
|
|
|
|
The exhaustiveness tests in convert_test.go prove each `game.PointKind` maps to a distinct
|
|
non-UNSPECIFIED wire value, but a swapped pair is still a bijection. Mutation check: CHAIN
|
|
and SYLLABLES swapped, suite green. (Swapping two reject reasons *is* caught, by
|
|
`TestRejectionsCarryTheRightReason`.) **Fix:** one line in `TestPointKindMappingIsExhaustive`:
|
|
`if want := "POINT_KIND_" + strings.ToUpper(k.String()); got.String() != want { t.Errorf(...) }`.
|
|
This works because `game.PointKind.String()` values are single words matching the wire suffixes.
|
|
|
|
### 12. Nit: a resume does not leave the quick-match queue
|
|
|
|
Every `takeSeat` path calls `hub.cancelQuickMatch`, but `handleResume` binds with
|
|
`attach` directly. A client that sends `QuickMatch` between its Hello and the room
|
|
draining the resume ends up both seated and queued. A later pairing would then pull it out
|
|
of a running game past `refuseMidGame`. **Fix:** `r.hub.cancelQuickMatch(m.sess)` next to
|
|
`m.sess.attach` at :482.
|
|
|
|
### 13. Nit (test)
|
|
|
|
`TestOneConnectionCannotStrandRooms` (lobby_test.go:474-488) re-implements `awaitNoRooms`
|
|
with its own lock-and-poll. Replace it with the helper.
|
|
|
|
## Test gaps with no coverage today
|
|
|
|
- A kicked or expired seat id being resumed by its old token (#1).
|
|
- Any input queued behind a room's final input (#2).
|
|
- Inbox overflow dropping a lifecycle notice (#3). The scratch test builds it directly on
|
|
`newRoom` plus `offlineSession`.
|
|
- `autoStart` after a failed pairing (#4). `TestQuickMatchAutoStartSkipsAGhostSeat` stops
|
|
one step short of it.
|
|
- The idle clock under refused actions (#5).
|
|
|
|
## Checked and clean
|
|
|
|
- **Engine ownership:** every `*room` handler is reached only from `run`'s switch. The bot
|
|
worker sees a `frozenBoard` copy and exits on `r.ctx`. `strategy` is never used by two
|
|
workers at once, because only the bot's own turn schedules one.
|
|
- **Timers:** all three are recreated rather than reset, stopped in a defer, and
|
|
recomputed after every input. `graceC` is nil'd before rearming. A negative
|
|
`time.Until` fires immediately, which is correct.
|
|
- **Session teardown:** `readCtx` is cancelled only after flush or 2s, with no leak of the
|
|
three goroutines (`wg.Wait`). `close` is idempotent (`CancelFunc`). The outbox never
|
|
blocks the room.
|
|
- **Hub:** the `rooms` and `sessions` maps are touched only under `mu`. Code draw, cap
|
|
check and registration share one critical section. `evict` runs on exit. `liveGames`
|
|
is decremented exactly once via the CAS.
|
|
- **Join semantics:** a running game refuses latecomers (`game_in_progress`), a full or
|
|
empty room refuses (`room_full`), and a second join to your own room is refused.
|
|
Start needs 2+ seats, everyone connected and every guest ready, and the owner has no
|
|
ready flag. Promotion clears ready, and every game clears ready again.
|
|
- **Turn authority:** submit, resign, claim and chat all check `occupies(sess, id)` (the
|
|
seat, not the claimed id) and the turn owner. A stale `turn_seq` is refused with the
|
|
server's sequence. `turnSeq` never restarts across rematches, and it moves on an
|
|
out-of-turn forfeit only when the turn does.
|
|
- **Resume races:** concurrent presentation of one token is guarded
|
|
(`s.sess != m.prior`). A dead new connection reopens the window. A stale
|
|
`disconnectInput` from the replaced connection is ignored.
|
|
- **Quick match:** it never pairs a connection with itself (`w == s`) and skips dead
|
|
waiters. Teardown and every seating path dequeue. The status is sent before any room frame.
|
|
- **Chat:** `chatFrom` scopes replay to the seat's tenure. Vacating scrubs author and name
|
|
together and re-syncs the remaining seats. Bot rooms have no chat. Chat uses `trySend`,
|
|
history uses `send`.
|
|
- **Sanitisation:** NFC first, then Cc/Cf/non-printing dropped (which also removes
|
|
Zl/Zp/NBSP), marks capped at 2, whitespace collapsed, and a rune cap on nickname (20),
|
|
chat (200), echoed word and report (64). `distinguish` includes seats in grace.
|
|
- **Limits:** a 4 KiB read limit, 20/s frame limiter closing on flood, per-session room,
|
|
submit and chat buckets, the join bucket per IP, a 20-report cap per session, a 32-frame
|
|
outbox, and a limiter sweep.
|
|
- **Enum mapping:** `RejectReason` and `EndReason` are pinned both ways, including
|
|
semantically through e2e tests. `PointKind` is pinned for shape only (#11).
|
|
- **Contract:** `errorMsg` carries only UI keys (`TestErrorMessagesAreUIKeysNotProse`),
|
|
and the wire round-trip and oneof coverage tests are present. No proto change is needed
|
|
for any fix above (#8 adds only an error-code string).
|
|
- **Test hygiene:** the remaining sleeps are deadline-bounded polls. `settle()` is used
|
|
only before goroutine and count sampling, and not as a correctness wait, except in
|
|
`TestQuickMatchDropsADisconnectedWaiter`, whose dead-waiter skip is covered separately by
|
|
`TestQuickMatchSkipsAWaiterWhoseConnectionEnded`.
|
|
|
|
## Unresolved questions
|
|
|
|
- #1's fix answers `session_not_resumable` to a kicked player who comes back. Is that the
|
|
right message, or should a kick explicitly revoke the token (`hub.unregister`) so the
|
|
client learns it at Hello?
|
|
- #5: should a ready toggle count as activity? The proposed `lobbyChanged` rule says yes.
|