Shutdown hooks ignore their context and wg.Wait() unbounded #102
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Companion defect to #97, surfaced by the independent review of PR #100. Pre-existing on
main; #100 fixed the startup half of the lifecycle and this is the shutdown half.Problem
OnStopin bothinternal/delivery/engine.goandinternal/database/retention.gotakes_ context.Contextand then callswg.Wait()with no bound. fx passes a stop context carryingStopTimeout, and both components discard it.If a worker goroutine wedges — a delivery target that never returns, a SQLite operation blocked on a lock —
wg.Wait()blocks forever and the process hangs on shutdown instead of exiting. fx's own stop timeout cannot rescue it, because nothing is watching the context it provides.This is the exact mirror of #97: there, a long-lived goroutine wrongly inherited the start context; here, shutdown wrongly ignores the stop context. Both come from treating the hook context as decoration.
Why it did not block PR #100
It is pre-existing behaviour, unchanged by that PR, and not reachable in normal operation —
Engine.stopcancels the context first, so healthy workers return promptly. It only bites when a goroutine is already stuck, which is precisely when a clean shutdown matters most.Definition of done
OnStopin both components honours its context: wait for theWaitGroup, but return when the stop context expires rather than blocking indefinitely. The usual shape is a goroutine that closes a channel whenwg.Wait()returns, selected againstctx.Done().OnStopreturns within the stop timeout instead of hanging. Mutation-verify it: without the fix the test must hang or fail, not pass.OnStophook in the tree has the same pattern (there are seven lifecycle hooks total).Two adjacent nits, worth folding in
Engine.stoplacks thecancel != nilguard thatRetentionReaper.stophas. Currently unreachable —stoponly runs afterstart— but the asymmetry between two components that otherwise mirror each other is a trap for the next reader. Make them consistent.recordingLifecycleis duplicated acrossinternal/delivery/engine_lifecycle_test.goandinternal/database/retention_lifecycle_test.go. Legal, since they are different packages, but if a third component needs it the duplication should move to a shared test helper.