SIGTERM during startup panics on a nil httpServer, and cleanShutdown reads two fields with no happens-before edge #226

Closed
opened 2026-08-20 06:42:47 +02:00 by clawbot · 2 comments
Collaborator

Found during the review of #218. Pre-existing on next; NOT introduced or widened by that PR, which is why it was not blocking there.

1. Nil dereference on an early SIGTERM. s.httpServer is assigned inside the serveUntilShutdown goroutine (internal/server/http.go:29), but cleanShutdown calls s.httpServer.Shutdown(ctxShutdown) unguarded (internal/server/server.go:236). A SIGTERM arriving between the OnStart hook returning and that assignment panics on a nil *http.Server.

The window is not theoretical: it spans configure() and enableSentry(), and the latter performs a network sentry.Init when SENTRY_DSN is set. A container killed early in a crash-loop or a fast redeploy lands in it.

2. No happens-before edge on s.httpServer and s.sentryEnabled. On the SIGTERM path, and on a direct app.Stop(), those fields are written by the serve goroutine and read by cleanShutdown with no synchronisation. The race detector has never flagged it because the existing internal/server tests never call serve(). The path added by #218 IS race-free — the Shutdowner channel supplies the edge — which is precisely why -race stayed silent there.

Not milestoned to 1.0: the impact is a non-graceful exit of a process that was already terminating, and steady-state operation is unaffected. Raising it here rather than folding it into the lifecycle work so it is not lost.

Definition of done:

  • cleanShutdown reads httpServer and sentryEnabled under a mutex, or behind a "server ready" handshake
  • a test calls app.Stop() immediately after app.Start() and passes under -race
  • no change to the clean-shutdown drain budget: the stopTimeout / ShutdownTimeout / TailHookReserve arithmetic pinned by #134 and #102 stays intact

Separately, and cheaply: nothing in the repo tests that the process exit STATUS is non-zero on listen failure — the test added by #218 asserts fx's ShutdownSignal.ExitCode, not the process's. Rewriting main as Start/Done/Stop would regress #200 silently with that test still green. Worth an exec-based assertion on the built binary.

Found during the review of https://git.eeqj.de/sneak/webhooker/pulls/218. Pre-existing on `next`; NOT introduced or widened by that PR, which is why it was not blocking there. **1. Nil dereference on an early SIGTERM.** `s.httpServer` is assigned inside the `serveUntilShutdown` goroutine (`internal/server/http.go:29`), but `cleanShutdown` calls `s.httpServer.Shutdown(ctxShutdown)` unguarded (`internal/server/server.go:236`). A SIGTERM arriving between the `OnStart` hook returning and that assignment panics on a nil `*http.Server`. The window is not theoretical: it spans `configure()` and `enableSentry()`, and the latter performs a network `sentry.Init` when `SENTRY_DSN` is set. A container killed early in a crash-loop or a fast redeploy lands in it. **2. No happens-before edge on `s.httpServer` and `s.sentryEnabled`.** On the SIGTERM path, and on a direct `app.Stop()`, those fields are written by the serve goroutine and read by `cleanShutdown` with no synchronisation. The race detector has never flagged it because the existing `internal/server` tests never call `serve()`. The path added by https://git.eeqj.de/sneak/webhooker/pulls/218 IS race-free — the Shutdowner channel supplies the edge — which is precisely why `-race` stayed silent there. Not milestoned to 1.0: the impact is a non-graceful exit of a process that was already terminating, and steady-state operation is unaffected. Raising it here rather than folding it into the lifecycle work so it is not lost. Definition of done: - `cleanShutdown` reads `httpServer` and `sentryEnabled` under a mutex, or behind a "server ready" handshake - a test calls `app.Stop()` immediately after `app.Start()` and passes under `-race` - no change to the clean-shutdown drain budget: the `stopTimeout` / `ShutdownTimeout` / `TailHookReserve` arithmetic pinned by https://git.eeqj.de/sneak/webhooker/issues/134 and https://git.eeqj.de/sneak/webhooker/issues/102 stays intact Separately, and cheaply: nothing in the repo tests that the process exit STATUS is non-zero on listen failure — the test added by https://git.eeqj.de/sneak/webhooker/pulls/218 asserts fx's `ShutdownSignal.ExitCode`, not the process's. Rewriting `main` as `Start`/`Done`/`Stop` would regress https://git.eeqj.de/sneak/webhooker/issues/200 silently with that test still green. Worth an exec-based assertion on the built binary.
Author
Collaborator

Reopening. I closed this prematurely and the defect is still in next.

The fix is real and was independently verified — both halves, by reverting each change separately and reproducing the exact predicted failure 3/3 — but that verification ran against the branch of #277, which has NOT merged. On next today, internal/server/server.go:236 still calls s.httpServer.Shutdown(ctxShutdown) unguarded while internal/server/http.go:29 assigns s.httpServer inside the serveUntilShutdown goroutine, and sentryEnabled is read at server.go:245 with the same absence of an edge.

I closed it on a reviewer's confirmation without checking the fix had landed. Caught by the TODO.md refresh unit, which declined to record it as fixed and escalated instead of writing something false.

This closes for real when #277 merges. That PR fixes it as a side effect of needing the race gone to write its own tests: it constructs the server in New — stronger than the mutex this issue's definition of done proposed, since after New returns there is no mutable shared state to guard — and makes sentryEnabled an atomic.Bool. It also adds TestEarlyShutdown_NoPanicAndNoRace, which calls app.Stop() immediately after app.Start() with no sleep across 25 cycles, and which fails in each singly-reverted state.

Not dispatching this separately; doing so would duplicate work already written and reviewed.

Reopening. I closed this prematurely and the defect is still in `next`. The fix is real and was independently verified — both halves, by reverting each change separately and reproducing the exact predicted failure 3/3 — but that verification ran against the branch of https://git.eeqj.de/sneak/webhooker/pulls/277, which has NOT merged. On `next` today, `internal/server/server.go:236` still calls `s.httpServer.Shutdown(ctxShutdown)` unguarded while `internal/server/http.go:29` assigns `s.httpServer` inside the `serveUntilShutdown` goroutine, and `sentryEnabled` is read at `server.go:245` with the same absence of an edge. I closed it on a reviewer's confirmation without checking the fix had landed. Caught by the `TODO.md` refresh unit, which declined to record it as fixed and escalated instead of writing something false. This closes for real when https://git.eeqj.de/sneak/webhooker/pulls/277 merges. That PR fixes it as a side effect of needing the race gone to write its own tests: it constructs the server in `New` — stronger than the mutex this issue's definition of done proposed, since after `New` returns there is no mutable shared state to guard — and makes `sentryEnabled` an `atomic.Bool`. It also adds `TestEarlyShutdown_NoPanicAndNoRace`, which calls `app.Stop()` immediately after `app.Start()` with no sleep across 25 cycles, and which fails in each singly-reverted state. Not dispatching this separately; doing so would duplicate work already written and reviewed.
clawbot reopened this issue 2026-08-24 03:35:44 +02:00
Author
Collaborator

Now genuinely fixed and landed. #277 merged as 62576f6 on next.

Verified against next itself rather than a branch, which is the step I skipped when I closed this prematurely earlier:

  • internal/server/server.go:118s.httpServer = s.newHTTPServer() in the constructor. internal/server/http.go:46 carries the note that this used to be assigned from the serving goroutine. After New returns there is no mutable shared state to guard, which is stronger than the mutex this issue's definition of done proposed.
  • internal/server/server.go:100sentryEnabled atomic.Bool, with Store at :154/:175 and Load at :254.
  • internal/server/early_shutdown_test.go present. It calls app.Stop() immediately after app.Start() with no sleep, across 25 cycles so the window is actually observed, and an independent review confirmed it fails in each singly-reverted state — nil-pointer panic plus 7 races with the constructor change reverted; the exact predicted write/read pair with only sentryEnabled reverted.
  • make check on next runs it with the race detector, 0 SKIP, 0 DATA RACE.

The drain-budget arithmetic from #134 and #102 is untouched, as this issue required.

Not covered, and deliberately left open elsewhere: the exec-based assertion on the built binary's process exit status, raised in this issue under "Separately, and cheaply". That sits outside this definition of done and is not a reason to hold this open.

Closing.

Now genuinely fixed and landed. https://git.eeqj.de/sneak/webhooker/pulls/277 merged as `62576f6` on `next`. Verified against `next` itself rather than a branch, which is the step I skipped when I closed this prematurely earlier: - `internal/server/server.go:118` — `s.httpServer = s.newHTTPServer()` in the constructor. `internal/server/http.go:46` carries the note that this used to be assigned from the serving goroutine. After `New` returns there is no mutable shared state to guard, which is stronger than the mutex this issue's definition of done proposed. - `internal/server/server.go:100` — `sentryEnabled atomic.Bool`, with `Store` at `:154`/`:175` and `Load` at `:254`. - `internal/server/early_shutdown_test.go` present. It calls `app.Stop()` immediately after `app.Start()` with no sleep, across 25 cycles so the window is actually observed, and an independent review confirmed it fails in each singly-reverted state — nil-pointer panic plus 7 races with the constructor change reverted; the exact predicted write/read pair with only `sentryEnabled` reverted. - `make check` on `next` runs it with the race detector, 0 `SKIP`, 0 `DATA RACE`. The drain-budget arithmetic from https://git.eeqj.de/sneak/webhooker/issues/134 and https://git.eeqj.de/sneak/webhooker/issues/102 is untouched, as this issue required. Not covered, and deliberately left open elsewhere: the exec-based assertion on the built binary's process exit status, raised in this issue under "Separately, and cheaply". That sits outside this definition of done and is not a reason to hold this open. Closing.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#226