v2/secret: RotatingFileProvider.Shutdown can panic with "send on closed channel"
Note
The issue was identified with my two human eyes. I've reviewed the description thoroughly.
Summary
RotatingFileProvider.Shutdown closes the notify channel from the caller's goroutine while the poll goroutine, which is the channel's only sender, may still be running. A send on a closed channel panics, and nothing recovers it, so the process crashes during graceful shutdown.
Where
Why it happens
Shutdown waits for the goroutine to exit or for its context to expire, whichever comes first, then closes notify on both paths:
select {
case <-stopped: // goroutine exited: safe
case <-ctx.Done():
err = ctx.Err() // goroutine still running
}
close(p.notify) // :102On the ctx.Done() path the goroutine still owns the channel. The guard at :129 (if ctx.Err() != nil { return }) narrows the window but cannot close it, since it is a check-then-act on state another goroutine mutates:
- goroutine checks
ctx.Err(), seesnil, falls through - goroutine is descheduled
Shutdowntakes thectx.Done()branch and closesnotify- goroutine resumes, sends at
:154, panics
Note the sends use select { case p.notify <- event: default: }. The default guards a full buffer, not a closed channel, so it offers no protection here.
How it is reached
Shutdown's context must expire before the goroutine exits, which is the normal app.App graceful-shutdown path under a deadline. FileProvider.Get reads via os.ReadFile (v2/secret/file.go:63) and ignores its context, so a slow or hung mount parks the goroutine inside Get for as long as the kernel takes. The deadline fires, Shutdown closes notify, and the send panics when the read final recover in this path. A service shutting down cleanly under a tight deadline crashes instead, which is likely to be misattributed to anything but secret rotation.
Proposed fix
Return the channel to its sender. poll closes it as it exits:
func (p *RotatingFileProvider) poll(ctx context.Context, stopped chan struct{}) {
// poll is the only sender on notify, so it closes the channel as it
// exits; closing it from Shutdown could race a send in progress here.
defer close(stopped)
defer close(p.notify)Defers run LIFO, so notify closes before stopped, meaning once Shutdown observes stopped closed, notify is closed too. Shutdown then drops its close(p.notify), and stopOnce loses its only purpose and can go with it, leaving Shutdown naturally idempotent.
Acceptance criteria (If Proposed Fix is accepted)
-
pollclosesnotify;Shutdowndoes not -
stopOnceremoved,Shutdownstill idempotent - Regression test: after
Shutdownreturnsctx.Err(),notifyis still open. Deterministic via a non-blocking receive, whereok == falsemeans it was wrongly closed - Existing
v2/secrettests pass under-race - Doc comment updated:
OnRotate's channel is closed by the poll goroutine on exit, not byShutdown
Behaviour change to call out in the MR
On the deadline path notify now closes later, when the goroutine actually finishes, rather than immediately. A consumer using for event := range p.OnRotate() (the pattern in v2/secret/README.md:79) blocks marginally longer in that case. Preferable to crashing.
Related
Found while investigating a flaky test in the same function: !707 (merged)