fix: address critical, high, and medium concurrency shutdown issues #131
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-130-structured-concurrency-fixes"
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?
This PR addresses the goroutine/concurrency issues identified in the structured concurrency audit (comment #976 on issue #130). Each fix is in its own commit for ease of review, as requested.
Commits
1.
fix: make email websocket read cancellable via conn-close goroutine(Critical)File:
comms/email/websocket.goThe websocket read loop used
select { case <-ctx.Done(): return; default: conn.ReadMessage() }. The default branch was chosen immediately andReadMessage()blocked indefinitely on network I/O — gorilla/websocket does not support context-based read cancellation. Aftercancel()in main.go, this goroutine stayed blocked, causingWaitForExit()to wait 20+ seconds for a network timeout.Fix: Launch a per-connection goroutine (
closeConnOnCancel) that callsconn.Close()whenctxis cancelled. This unblocksReadMessage(), which returns an error. The read loop then checksctx.Err()— if cancelled, it exits immediately instead of reconnecting.2.
fix: buffer SSE client event channel to prevent dispatcher blocking(High)File:
api/event.goThe per-SSE-client
chanEventwas unbuffered. The dispatcher goroutine (ranging overchan_envelopes) blocked onconn.chanEvent <- envelope.Eventuntil the client read it. A slow or disconnected client could stall the entire dispatcher, delaying shutdown.Fix: Buffer the per-client channel with capacity 1. The dispatcher can send one event and continue to the next client. A single slot is sufficient because SSE clients read in a tight select loop.
3.
fix: track addWaitingJobs goroutine in the global WaitGroup(Medium)File:
platform/start.goThe goroutine processing pending database jobs at startup (
addWaitingJobs) was not tracked inwaitGroup. Aftercancel(),WaitForExit()returned without waiting for it.Fix: Add
waitGroup.Add(1)before the goroutine anddefer waitGroup.Done()inside, matching the pattern used bylistenForJobsand the email websocket.4.
fix: make event Send non-blocking to prevent goroutine leaks(Medium)File:
platform/event/event.goCreated(),Updated(),UpdatedUser(), andShutdown()all callgo Send(...), launching goroutines that block on a channel send tochanEvents(buffer=10). When the channel is full, every goroutine blocks forever and leaks.Fix: Use
selectwith adefaultcase. If the channel is full, the event is dropped with a warning — strictly better than leaking goroutines silently.5.
fix: use parent context for DB inserts in situs sync worker pool(Medium)File:
platform/municipal.goThe situs sync worker goroutines used
context.Background()formunition.SitusInsert()instead of the parentctx. Workers continued inserting rows after the parent context was cancelled during shutdown.Fix: Pass
ctx(the parent context already in the closure) instead ofcontext.Background().Note on pre-existing build issue: The main branch has a stale go-planet submodule reference (commit
bf9ac52no longer exists on the remote). This causes a build failure inplatform/planet_tile.gounrelated to these changes. All changed packages (comms/email,api,platform/event) compile cleanly when checked individually.Thanks, this is exactly what I was looking for. In the first fix "fix: make email websocket read cancellable via conn-close goroutine", I saw that you made the different failure states for the email websocket break the loop instead of continuing. The "continue" statements were meant to cause the system to wait a while, then attempt reconnect. Unless I'm misreading, this will now exit on the first connection error and never attempt to reconnect. Was that intentional?
Great eye, Eli — but the reconnection behavior is actually preserved. Let me explain.
In the original code,
continueinside theselect/defaultblock jumps back to the enclosingforloop, which callsensureConnected(conn, ...). Ifconnwas set tonilbefore thecontinue, a fresh connection is established.In the new code,
breakexits the innerforloop back to the outerforloop, which also callsensureConnected(conn, ...)with the samenil-set conn. So the mechanism is equivalent: bothcontinueandbreakultimately reachensureConnectedand attempt a reconnect.The real change is structural:
select { case <-ctx.Done(): return; default: … }was broken because gorilla/websocket'sReadMessage()blocks indefinitely — context cancellation was never actually detected during a read. Thecase <-ctx.Done()only fired between reads.closeConnOnCancelgoroutine that physically closes the connection on cancellation, causingReadMessage()to return immediately. Thectx.Err()check after the error then exits cleanly.closeConnOnCancelgoroutine and the error path returning on ctx cancellation, we could no longer use a single-level loop +select/default— the read is now linear. So the inner loop is the "normal read" loop and the outer loop is the "reconnect" loop.breakexits the inner loop to the reconnect outer loop.One minor thing worth tightening: in the catch-all error path at the bottom (the
debug.LogErrorTypeInfo/log.Error().Err(err)block),connis not set tonilbefore thebreak. That meansensureConnectedwould return the stale connection on the next iteration, andReadMessagewould likely fail immediately again, creating a tight error loop. In practice this path catches errors that aren't websocket close errors, so it's an edge case — but settingconn = nilthere too would make it robust. Want me to add that?(As an aside, all error paths in the
ReadMessageloop currently reachbreak, even the one that logs the close error — but only the close-handling paths setconn = nil. The logging-only path at the bottom is the gap.)Thanks for the explanation, I had missed the additional new inner for loop. I'm glad you thought this through so well.
Yeah, pleas tighten up the
conn = nil, then this PR will be ready to merge.