In-flight notification goroutines are not awaited at shutdown, so alerts are lost #106
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?
The README promises (
README.md:454-456):> Shutdown: Persist final state to disk, complete in-flight notifications, stop gracefully.
Nothing in the codebase completes in-flight notifications. On shutdown the process can exit while a Slack / Mattermost / ntfy delivery is mid-retry, and that alert is silently lost.
Current state (audited against
origin/main, commit9347a28)Each backend dispatch fires an unmanaged goroutine —
dispatchNtfy(internal/notify/notify.go:197-225),dispatchSlack(:227-255),dispatchMattermost(:257-285). Each usescontext.WithoutCancel(ctx), which is deliberate and correct as far as it goes: it stops a cancelled request context from killing a delivery already under way.But
context.WithoutCancelonly detaches the goroutine from cancellation. It does not make anything wait for it.notify.New(internal/notify/notify.go:121-172) accepts anfx.Lifecycleparameter and never callslifecycle.Append. There is noOnStophook, nosync.WaitGroup, no drain step of any kind. So fx runs itsOnStophooks,main()returns, and the process exits — while retry loops are still sleeping.The window is not small.
internal/notify/retry.go:13-25retries up toDefaultMaxRetries = 5times with backoff delays up toDefaultMaxDelay = 60s. A notification that hits a temporarily failing webhook can legitimately still be in its backoff sleep minutes after the shutdown signal, and it will simply vanish.This is the exact failure mode that matters most: the alert most likely to be lost is the one being retried because the endpoint is already having trouble.
Definition of done
internal/notifytracks its in-flight delivery goroutines — async.WaitGroupincremented at dispatch and decremented on completion is the obvious mechanism.notify.Newregisters anfx.LifecycleOnStophook that waits for in-flight deliveries to drain. Thefx.Lifecycleparameter it already accepts is currently unused; wire it up.context.Contextfx passes toOnStopand give up when that context expires, so a permanently dead webhook cannot hang shutdown forever. When the drain times out with deliveries still outstanding, log at warn level how many were abandoned — silently dropping them is what this issue is fixing, so do not do it silently.make testruns with-race; theWaitGroupAddmust happen on the dispatching goroutine before it starts the worker, never inside the worker itself.httptestservers, as the existinginternal/notifytests already do. Do not sleep for real backoff durations — the retry timings must be injectable or overridable in tests so the suite stays well inside the 20-secondmake testceiling. The existing tests ininternal/notify/retry_test.goalready keep their sleeps in the 10-100ms range; follow that.make checkis green, andTODO.mdis updated in the same commit as the work.The finishing commit's title must end with
(closes #N)referencing this issue.Related, but out of scope
The README's shutdown claim also implies the watcher persists final state on stop. State is persisted at shutdown, but via an unrelated
OnStophook ininternal/state/state.go:153-155rather than by the watcher — the watcher's ownOnStop(internal/watcher/watcher.go:93-98) only cancels, and theRunloop'sctx.Done()branch (watcher.go:148-151) just logs and returns without saving. That ordering dependency is worth a look but is not part of this issue; note it in your PR description if you touch that area, and do not expand scope.Implementation plan (branch
fix/106-notify-shutdown-drain, fromorigin/mainat9347a28):1. Track in-flight deliveries (
internal/notify/shutdown.go, new file)Service:inFlight sync.WaitGroup,drainMu sync.Mutexguarding adraining bool,outstanding atomic.Int64, and anabandon chan struct{}closed once when a drain gives up.startDelivery(endpoint string, fn func()): takesdrainMu, refuses the dispatch ifdrainingis already set (logging at warn with the endpoint), otherwise increments the counters and starts the worker viainFlight.Go(...)— the counter increment happens on the dispatching goroutine, before the worker exists, never inside it.outstandingis decremented beforeDonefires so the abandoned count read on the timeout path is accurate.2. Collapse the three dispatchers
dispatchNtfy/dispatchSlack/dispatchMattermost(notify.go:197-285) are three copies of the same body. They become nil-check + one call to a sharedsvc.dispatch(ctx, endpoint, fn)that keeps the existingcontext.WithoutCancel(ctx)semantics, routes throughstartDelivery, and logs the post-retry failure as today. Behaviour is unchanged; it just stops the tracking logic from being triplicated (and keepsduplquiet).3.
OnStophook (definition of done 2, 3, 4)notify.Newstops discarding itsfx.Lifecycleand appends a hook whoseOnStopcallssvc.drain(ctx):drainingunder the mutex first, so deliveries submitted after the drain starts are refused rather than queued — a stream of new notifications cannot extend the drain (item 4);inFlight.Wait()in a helper goroutine andselects that againstctx.Done(), so the wait is bounded by whatever fx passes toOnStop(the app sets nofx.StopTimeout, so this is fx's 15s default);abandonand logs at warn with the outstanding count andctx.Err().deliverWithRetry's existingselectgains anabandoncase, so retries still sleeping in backoff returnErrDeliveryAbandonedpromptly instead of lingering. In-flight HTTP requests keep their existing 10s client timeout.4. Tests (
internal/notify/shutdown_test.go, externalpackage notify_test)Using
httptestservers and the retry knobs that already exist (SetRetryConfig,SetSleepFuncinexport_test.go) so nothing sleeps for real backoff — all waits stay in the 10-100ms band, matchingretry_test.go:OnStopcontext deadline, and the abandoned count is logged at warn — asserted by draining with an already-short deadline and checking the drain returns well inside it;-raceclean.export_test.gogetsDrain(ctx)(and an outstanding-count accessor) shims rather than exporting new production API.5. Docs (item 7) and bookkeeping (item 8)
README.md:455-456gets reworded from the unqualified "complete in-flight notifications" to the bounded semantics actually implemented (wait, bounded by the shutdown timeout; anything still outstanding is abandoned and logged).TODO.mdupdated in the same commit as the work,make fmtrun over the markdown,make checkgreen before the PR.Out of scope and untouched: the watcher-vs-state shutdown ordering noted at the bottom of this issue, and everything in
internal/watcher/internal/resolver.