Shutdown is broken: os.Exit races fx OnStop hooks, losing buffered reports on every restart #22
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?
Problem
Three defects in the shutdown path. The first causes silent data loss on every single restart. Verified on
mainatfbfe1df. These are correctness bugs, not style issues.1.
os.Exitinrun()pre-empts fx'sOnStophooks — buffered telemetry is lostbackend/internal/server/server.go:91-94:On SIGTERM, the server's own signal goroutine (
server.go:103-115) cancels the context,serve()returns, andrun()callsos.Exitimmediately. That races fx's own signal handling and can terminate the process before fx runs theOnStophooks of the other components.The critical casualty is
backend/internal/reportbuf/reportbuf.go:78-83—reportbuf'sOnStopis the only code path that flushes buffered reports to disk on shutdown. Whenos.Exitwins the race, everything accepted since the last periodic flush is discarded.Worst case that is bounded by the existing config: up to a full flush window of accepted telemetry is thrown away on each restart, and the process still exits
0, so nothing anywhere reports a problem.2. Unsynchronized cross-goroutine access to
s.httpServer— nil deref on early shutdowns.httpServeris assigned inside theserveUntilShutdowngoroutine atinternal/server/http.go:19. It is read and dereferenced from a different goroutine incleanShutdownatinternal/server/server.go:138(s.httpServer.Shutdown(...)).There is no synchronization between the spawn at
server.go:117-119and the<-ctx.Done()->cleanShutdownatserver.go:121-122. A signal arriving in the window beforehttp.go:19executes produces a nil-pointer panic. Independently of the nil case, it is an unsynchronized read/write of the same field — a genuine data race.This is invisible today because
make testruns without-race(see #21).3.
close(b.done)will panic ifOnStopruns twiceinternal/reportbuf/reportbuf.go:78-83closes the channel with nosync.Onceguard.CODE_STYLEGUIDE_GO.md: "Always handle the case where a channel might be closed. This prevents panic and ensures graceful shutdowns."Related, same area
exitCodecan never be non-zero.server.go:39is only ever assigned0(server.go:130). A listen failure (http.go:36-42) logs, cancels, and the process still exits0. Failures are unobservable to any supervisor.WriteTimeout(10s) contradictsmiddleware.Timeout(60s).http.go:11-12vsroutes.go:10,21. The 60s per-handler budget is unreachable — the server kills the write at 10s, so the chi timeout is dead configuration. Pick one coherent budget.startupTimeis dead. Set atserver.go:63, never read. Real uptime comes frominternal/healthcheck/healthcheck.go.Definition of done
os.Exitis removed from the shutdown path. The server requests shutdown through fx (injectfx.Shutdownerand callShutdown()) so fx runs every component'sOnStopin dependency order.s.httpServeris no longer written and read from different goroutines without synchronization. Construct it before spawning the serving goroutine, or guard it. Shutdown must be safe when it arrives before the listener is up — no nil deref.reportbuf'sOnStopis idempotent; a second invocation does not panic.exitCodereflects reality — a listen failure results in a non-zero process exit.WriteTimeoutand the chi request timeout are made mutually coherent, with a comment stating the intended budget.startupTimefield is removed, or wired up and used.-race. Note that #21 adds-racetomake test; if #21 has not landed, run the race detector manually for this work and say so in the PR.cd backend && make checkpasses; rootmake checkpasses.TODO.mdupdated in the same commit.(closes #N).Implementation requirements
GO_HTTP_SERVER_CONVENTIONS.mdfor lifecycle and graceful shutdown, but do not copy itsos.Exit-adjacent shape where it conflicts with correct fx teardown — fx owns the process lifetime here.maketargets only; never rawgoinvocations.