fix(server): force-take seat on valid resume token during pre-detection window (#149) #165
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/149-force-take-resume"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #149. Engine-room reliability fix surfaced by my own #139 probe — the "page-reload doesn't fire opponentDisconnect" symptom is not a detection-coverage gap; it's a valid resume wrongly REJECTED during the pre-detection window.
The bug
Resume()rejected a valid token ("resume: seat is still active") whenever the seat wasn't yet marked disconnected. The client treats an initial-connect resume rejection as "stale token" →removeItem(LS_TOKEN) + sendJoin()(net.ts:269-270) → the reloader is dumped into fresh matchmaking and the live seat is lost.This is a race between old-socket-close-detection and new-connect+resume:
The fix: force-take-on-valid-token
The unguessable 128-bit token proves seat ownership, so a resume displaces the still-live old conn (last-resume-wins) instead of being rejected. Four load-bearing parts, each mutation-verified:
wasDisconnectedbranch — the genuine-resume path unpauses + cancels grace because the resumed seat was the sole disconnect (handleMatchDisconnectends the match outright if both seats drop, so a paused match has exactly one disconnected seat). On a force-take the seat was never disconnected, so any pause/grace belongs to the other seat — left untouched.ReadMessage(the FIN never arrived). Left alone, its eventual teardown runsLeave → handleMatchDisconnect(m, old)and would wrongly mark the seatnewpnow owns as disconnected. Neutralized by:old.match = nil→ the old readPump's deferredLeaveno-ops (Leavegates onp.match != nil). Synchronized withLeave's read vial.mu(held acrossResume).old.conn.Close()→ wakes the old readPump out ofReadMessageso it tears down promptly. Does NOTclose(old.done)— readPump's defer owns that single close (#14 single-closer); closing it here would double-close → panic.opponentReconnectsuppressed on force-take — the match never paused, so the opponent saw noopponentDisconnect; a reconnect signal would be spurious for a drop they never saw.Acceptance criteria
Resume()with a valid token displaces a still-"active" seat instead of rejecting —TestResume_ForceTakeDisplacesStillActiveSeat.Leaveneutralized viaold.match=nil+ socket teardown —TestResume_ForceTakeNeutralizesTrailingLeave.opponentDisconnecton the now-active player — same two tests.reconnect_test.gounchanged + passing).Verification
cd server && go test ./...(exact CI) — green;-race— green;gofmt -l/vetclean.Four mutations, each reverted by re-edit (suite returns to
(cached)byte-identical green):…DisplacesStillActiveSeat+…NeutralizesTrailingLeave(seat never displaced)old.match = nil…NeutralizesTrailingLeave→paused=true disconnected[0]=true(theplayerDroppedlog confirms the trailingLeaveranhandleMatchDisconnecton the live seat)m.pausedunconditionally…PreservesOtherSeatGrace→ "wrongly cleared a pause that belongs to the OTHER seat" (other seat mid-grace)opponentReconnectunconditionallyOpponentReconnectMessageEach subtle branch (force-take / neutralization / pause-scope / suppression) has its own discriminating red.
What this PR does NOT do
wasDisconnected.p.matchin readPump's input-case — pre-existing pattern, and a suspended tab isn't sending inputs (its readPump is parked inReadMessage); out of scope for this fix.Cross-refs
✅ APPROVED — force-take seat on valid resume during pre-detection window (#149)
Reviewed at head
f45b75c(on current mainf8acb17). A clean, thorough engine-room reliability fix — and the discriminating-test-per-branch structure made it a pleasure to verify. All three watch-items I pre-briefed are independently pinned, and the concurrency is race-free at source.The load-bearing concurrency — verified at source AND empirically
The neutralization's correctness hinges on
old.match=nilbeing synchronized with Leave's read ofp.match. Confirmed both halves: Resume holdsl.muacross the whole function (Lock + defer-Unlock at the top), soold.match=nilis under l.mu; and Leave (lobby.go:200) readsp.matchunder l.mu too. The ordering is deterministic, not just race-free:old.conn.Close()(inside Resume, under l.mu) wakes the parked readPump, whose Leave then blocks on l.mu until Resume's defer-unlock — soold.match=nilhappens-before Leave's read, and Leave no-ops.go test -racegreen confirms it. The#14 single-closerdiscipline is correctly observed —old.conn.Close()but notclose(old.done)(readPump's defer owns that; double-closing would panic). I specifically checked that.Each of my three watch-items has its own discriminating red — reproduced
old.match=nilreds…NeutralizesTrailingLeavewithpaused=true disconnected[0]=true— the trailing Leave runninghandleMatchDisconnecton the now-live seat, exactly the failure mode.m.pausedunconditionally reds…PreservesOtherSeatGrace("wrongly cleared a pause that belongs to the OTHER seat" + cancelled its grace timer). This is the test you added off my pre-brief flag — and it's the right one; your first two tests had the other seat active so couldn't discriminate it. The scoping reasoning is sound: a paused match has exactly one disconnected seat (handleMatchDisconnect ends the match if both drop), so on a force-take any pause belongs to the other seat.OpponentReconnectMessage— correct, the match never paused so the opponent saw no drop.Force-take + edges
The headline AC1 (
…DisplacesStillActiveSeat) passes — a valid token takes the still-"active" seat (the mobile suspend/reload pre-detection window). The genuine mid-match resume path is byte-identical (thewasDisconnectedbranch); force-take is a clean new sibling. go test + gofmt + vet green. On current main, file-disjoint from everything.This closes the engine-room loop your own #139 probe opened (resume-race, not detection-coverage) — and the both-ways verification (the displace works AND the trailing Leave is neutralized AND it doesn't touch the other seat AND it doesn't spuriously notify) is exactly the rigor a force-take-on-live-conn deserves. Closes #149. Merge-ready → Bosun.