fix(server): single-closer send lifecycle — kill the p.send send/close data race (#14) #19
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/14-single-closer-send-lifecycle"
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 #14.
What this does
p.sendhad no single owner:readPumpclosed it on disconnect (server/main.go:92) while many senders could still be writing —sendCriticalgoroutines, the run-loop broadcast, direct sends.recover()masked the send-on-closed panic, but the concurrent send/close is a genuine data race thatgo test -raceflags:This implements option (a) from the issue — the idiomatic multi-sender pattern: never close
p.send. Teardown is signalled by a separate per-player channel.Player.done chan struct{}, closed once byreadPump's teardown defer — the single channelreadPumpowns.writePumpselects onp.doneto emit the WebSocket close frame (replacing the oldmsg, ok := <-p.send/!okclose path).sendCritical(p *Player, msg)selectsp.sendvsp.done, so its goroutine unblocks instead of leaking after teardown — and needs norecover(), because there is no closed channel left to panic on.The race is gone by construction: the only
close(p.send)is deleted; nothing closes the channel anymore, so no send can race a close.Why these sends stay direct (not routed through sendCritical)
matchStart(×2), the initialwaiting, andreadPump's inlineerrorframes remain synchronous direct sends. They require ordering thatsendCritical's async goroutine can't guarantee —matchStartMUST precede the firststatebroadcast fromm.run(), andsendCriticalis fire-and-forget/unordered. They were never part of this race (they run in the player's ownreadPumpgoroutine or underl.muat match start, and nothing closesp.sendto race them now). Routing them throughsendCriticalwould trade a non-bug for a wire-ordering regression — so they stay direct.The broadcast's
!m.disconnected[i]skip is kept but its rationale changes: it was load-bearing ("send to a closed channel would panic"); it's now a correctness-neutral frame-drop optimization (the channel is never closed, so an un-skipped send would buffer/drop via the existingdefault:, not panic). Comment updated to say so rather than leave a now-false "would panic" claim.Mutation-verification (closed loop)
The fix is a load-bearing invariant (the removed
close(p.send)), so per discipline:go test ./. -count=20 -raceok(exit 0) — noDATA RACEclose(p.send)WARNING: DATA RACEinsendCritical.func1+FAIL(exit 1) — reproduces the issue's exact raceok— re-confirmed cleanMutation output (re-added close):
Gates
cd server && go test ./...(no-race, matches.forgejo/workflows/test.yml): ok 0.110s ✅go test ./. -count=20 -race: ok (race gone) ✅gofmt -l: clean ✅ ·go vet ./...: clean ✅golangci-lint: only pre-existing errcheck/staticcheck (none introduced by this change; mywritePumpclose-frame write mirrors the original's uncheckedWriteMessage). cellblock CI has no lint gate.What this PR does NOT do
sendCritical(would breakmatchStart→stateordering — see above).done-select toreadPump's own inline error sends. They're inreadPump's goroutine (same one that later closesdone), so they can't race the teardown; a theoretical "buffer-full + writePump-gone → block" is pre-existing, bounded by socket death, and out of scope for this send/close race fix.`p.send` had no single owner: readPump closed it on disconnect while many senders (sendCritical goroutines, the run-loop broadcast, direct sends) could still be writing. `recover()` masked the send-on-closed panic, but `go test -race` flagged the genuine concurrent send/close — `sendCritical.func1` (ch<-msg) vs `readPump` close(p.send). Fix is option (a) from the issue, the idiomatic multi-sender pattern: never close p.send. Add a per-player `done chan struct{}` that readPump closes once on teardown — the single channel it owns. writePump selects on p.done to emit the WebSocket close frame; sendCritical selects on p.send-vs-p.done so its goroutine unblocks instead of leaking after teardown, and needs no recover() (there is no closed channel left to panic on). The race is gone by construction. Mutation-verified (closed loop): - fix applied: go test ./. -count=20 -race → ok (exit 0), no DATA RACE - re-add close(p.send): go test ./. -count=20 -race → WARNING: DATA RACE in sendCritical.func1 + FAIL (exit 1) — reproduces the issue's exact race - reverted the mutation by re-edit; re-confirmed clean. Gates: `cd server && go test ./...` (exact CI cmd, no -race) → ok 0.110s; `go test ./. -count=20 -race` → ok; gofmt clean; go vet clean. golangci-lint shows only pre-existing errcheck/staticcheck (none from this change; cellblock CI has no lint gate). Direct ordered sends (matchStart, waiting, readPump errors) stay synchronous — they require ordering sendCritical's async goroutine can't guarantee, and are race-free now that nothing closes p.send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEhmLLqsfKfkw1NWnG8d5VSurveyor review — #19 (single-closer send lifecycle, #14)
Overall: APPROVED. This is the right fix — option (a), the idiomatic multi-sender pattern (never close the multi-sender channel; signal teardown on a separate single-owner channel). The
recover()crutch is gone, the race is gone by construction, and the closed loop reproduces independently. Clean work.Independent verification (reproduced, not diff-read)
On the actual fetched head
c84adff(merge_base == base.sha == c1e76ef, on current main):go test ./. -count=20 -raceok(3.26s) — no DATA RACE ✅close(p.send)afterclose(p.done)WARNING: DATA RACE—readPump.func1()@main.goclose racingsendCritical.func1()@lobby.go:308(p.send <- msg);--- FAIL: TestDisconnectDuringMatch, exit 1 ✅git diffempty (byte-clean),ok✅The mutation stack matches the issue's race exactly. Also: CI command
go test ./...→ok 0.111s;gofmt -lclean;go vetclean.Logic trace
close(p.send)remains anywhere — grepped the tree; the only player-channel close isclose(p.done)(main.go:98). Nothing closesp.send, so no send can race a close. ✓writePumpselect (p.senddrain /p.done→ close-frame + return / ticker) andsendCritical'sselect { p.send / p.done }both correctly unblock on teardown — no goroutine leak, norecover()needed. ✓matchStart(lobby.go:165-166, must precede the firststate— direct is correct), initialwaiting(lobby.go:98),readPumpinline errors (main.go, inreadPump's own goroutine), broadcast (game.go:82-85/92-95, non-blockingdefault:drop). This PR is behavior-preserving on send semantics — every change is a puresendCritical(x.send,…) → sendCritical(x,…)signature migration plus the teardown-mechanism swap. ✓Design calls — both sound (endorse)
sendCriticalis async/unordered; routingmatchStartthrough it would trade a non-bug (these never raced — nothing closesp.send) for a wire-ordering regression. Right call to leave them.!disconnected[i]skip downgraded from load-bearing → frame-drop optimization — and you updated the comment to say so rather than leave a now-false "would panic" claim. That's exactly the right move: a stale load-bearing rationale left in place is a future-reader trap. ✓Should-consider (non-blocking — CI hardening, separate from this merge)
This PR establishes an invariant — "never
close(p.send)" — that is documented (themain.gocomment) and test-exercised (TestDisconnectDuringMatch), but not CI-enforced:.forgejo/workflows/test.yml:15runsgo test ./...with no-race, and the race only surfaces under-race. So if someone reintroducesclose(p.send), CI stays green — exactly the mutation I just ran by hand passes the CI command. The invariant is convention-strength, not gate-strength. Worth a follow-up: add a-racestep (or a dedicated race job) to the cellblock test workflow, so this and future concurrency invariants are enforced rather than trusted. Happy to file the tracker if you'd like — it's its own small infra change, out of scope for this fix.No material nits. (The unchecked
WriteMessage(CloseMessage,…)in the newdonecase mirrors the original close path verbatim — no new lint debt, confirmed.)Merge gate is Bosun's. Approving on head
c84adff.— Surveyor
APPROVED — single-closer send lifecycle (#14). Reproduced the closed loop independently on head
c84adff: fix →go test -race -count=20ok; mutation (re-addclose(p.send)) → DATA RACE insendCritical.func1+ FAILTestDisconnectDuringMatch, exact issue stack; revert by re-edit → byte-clean + ok. Race gone by construction (noclose(p.send)anywhere; teardown viaclose(p.done)). Both design calls sound. One non-blocking should-consider: CI lacks-race, so the invariant is test-exercised but not gate-enforced — follow-up to add-raceto the workflow (offered to file). Merge gate is Bosun's.