sync: fix "send on closed channel" panic in responsesWorker at teardown - #49
Open
GrapeBaBa wants to merge 1 commit into
Open
Conversation
…r panic)
responsesWorker reads the handler channel under handlersMu, releases the lock,
then sends `ch <- res` unlocked. makeRequest's ctx-cancellation goroutine, at
teardown, took handlersMu and close()d that same channel — so a late response
made responsesWorker send on a closed channel and panic, crashing the process
(hit reliably by the testground daemon at multi-instance run teardown).
A select{ case ch<-res: case <-done: } would NOT fix it: a send case on a closed
channel is still selectable and panics. The only race-free fix is to never close
ch from the cancel goroutine.
Refactor:
- Each handler is a pendingRequest{ ch, done }. The cancel goroutine deletes the
handler and close(done); it never closes ch (responsesWorker is ch's only sender).
- responsesWorker does select{ case h.ch<-res: case <-h.done: } — never panics (ch
is never closed) and never blocks forever (done fires at teardown).
- Since ch is no longer closed, one-shot receivers (publish, SignalEntry, Barrier)
unblock via context instead of channel-close, via a new awaitResponse helper that
selects on the response, the request ctx, and the client ctx.
- subscribe already selects on its contexts; unchanged.
go build / go vet / gofmt clean.
GrapeBaBa
force-pushed
the
fix/responsesworker-send-on-closed-channel
branch
from
June 9, 2026 08:15
3571c9d to
49e34ed
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
(*DefaultClient).responsesWorkerpanics withsend on closed channeland crashes the process at teardown:I hit this reliably while running the testground daemon for a multi-instance test: at run teardown (right after
all containers are complete/deleting containers) the daemon's sync client panics, the daemon process dies, and every subsequenttestground runthen fails withdial tcp [::1]:8042: connect: connection refused— the run reportsoutcome ... canceled.Root cause — a close-vs-send race on the handler channel
In
sync/client_conn.go:responsesWorker()looks up the per-request handler channel underhandlersMu, releases the lock, then sendsch <- resunlocked.makeRequest()'s ctx-cancellation goroutine, on teardown, takeshandlersMu, thenclose(c.handlers[req.ID]),delete(...), unlocks.If a response arrives for a request whose handler is closed in the window between
responsesWorkerreleasing the lock and performing the send,responsesWorkersends on a now-closed channel → panic. It's probabilistic (more in-flight requests / teardown timing → more likely); low-traffic runs often dodge it, busy ones hit it every time.A third goroutine closing the channel that
responsesWorkeris the sole sender of is the underlying problem — in Go the sender should own the close, and there must be no send after close.Fix — never close the handler channel from the cancel goroutine
Make
responsesWorkerthe channel's only writer and never closechfrom a third goroutine; use a per-requestdonechannel for teardown signalling, and unblock one-shot receivers via the context instead of via channel-close.Each handler is now a
pendingRequest{ ch, done }. The cancel goroutinedeletes the handler andclose(done)— it never closesch.responsesWorkerdelivers withselect { case h.ch <- res: case <-h.done: }: it can never panic on send-to-closed (chis never closed), and never blocks forever (doneis closed when the request/client context fires — every caller wraps the request in a cancellable context withdefer cancel()).Because
chis no longer closed, the one-shot receivers (publish,SignalEntry, and theBarriergoroutine) unblock via context through a small helper:subscribealready selects onc.ctx.Done()/ctx.Done()/resCh, so it still terminates via its contexts (theresChclose branch simply becomes unreachable); streaming delivery still works through the send-vs-doneselect.Verification
go build ./...andgo vet ./sync/pass;gofmtclean on all changed files.grep close(confirmschis never closed anywhere (onlydone); (2)doneis always eventually closed for every registered handler, soresponsesWorkernever blocks; (3) every one-shot receiver unblocks on both the request ctx and the client ctx (no hang); (4)subscribestill terminates and streams.recover()around the racy send) was additionally runtime-confirmed: with it, a 3-instance multi-impl testground run that previously crashed the daemon at teardown completed cleanly (outcome = success) across repeated runs. This PR is the cleaner, structural form of that same fix.Alternative
If you'd prefer the minimal change, a one-line
recover()around the send also stops the crash without the refactor — happy to switch to that if that's more in line with how you'd like to fix it.