// Package lifecycle holds helpers shared by the components that // register fx start and stop hooks. package lifecycle import ( "context" "fmt" "log/slog" "sync" ) // WaitForShutdown waits for wg to drain, bounded by ctx. // // fx hands OnStop a context carrying the application's stop // timeout. A bare wg.Wait() discards that deadline, so a single // goroutine that never observes cancellation — a delivery target // that never returns, a SQLite operation blocked on a lock — // hangs the process forever instead of letting it exit when the // timeout expires, which is exactly when a clean shutdown matters // most. // // On timeout it logs at error naming component and returns an // error: the goroutines are still running, and reporting success // would hide an unclean shutdown from the operator. The waiting // goroutine outlives this call and exits when (if) wg drains; it // holds nothing but the channel it closes. func WaitForShutdown( ctx context.Context, log *slog.Logger, component string, wg *sync.WaitGroup, ) error { done := make(chan struct{}) go func() { defer close(done) wg.Wait() }() select { case <-done: return nil case <-ctx.Done(): log.Error( "shutdown timed out, goroutines still running", "component", component, "error", ctx.Err(), ) return fmt.Errorf( "%s: shutdown timed out, "+ "goroutines still running: %w", component, ctx.Err(), ) } }