fix(server): drain on SIGTERM instead of waiting out podman's SIGKILL #109
Inga granskare
Etiketter
Inga etiketter
area/backups
area/ci
area/control-panel
area/identity
area/infra
area/observability
area/payments
area/security
area/storage
area/web
blocked
needs-info
needs-triage
ready-for-implementation
type
bug
type
chore
type
docs
type
epic
type
feature
type
task
wontfix
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Inget förfallodatum satt.
Beroenden
Inga beroenden satta
Referens
bitborg/bitborg-web!109
Läser in…
Hänvisa till i nytt ärende
Ingen beskrivning angiven.
Ta bort grenen "fix/sigterm-graceful-shutdown"
Borttagning av en gren är permanent. Även om den borttagna grenen kan fortsätta existera en kort tid innan den faktiskt tas bort, kan det INTE ångras i de flesta fall. Vill du fortsätta?
Closes #107. Every deploy spent a fixed ~10 s waiting for a graceful stop that never came, then got
SIGKILLed — dropping in-flight requests rather than draining them. Measured on production: 10.07 sof ~12.7 s total deploy unavailability.
The mechanism is narrower than the issue says
#107 (and my own comment on it) said the server "does not handle SIGTERM" and that Node's default for an
unhandled signal is to do nothing. That is not true in general — the first attempt to reproduce this
locally showed the old entry exiting in ~1 s.
It only fails as pid 1: the kernel does not apply default signal dispositions to pid 1, so a signal
whose handler was never explicitly installed is discarded (
signal(7)). The Containerfileexecs theserver, making it pid 1, and
@astrojs/node11.0.3 installs no signal handling at all.Proven in a container as pid 1 with
--stop-timeout 5:podman stopSo installing any handler is what makes the signal deliverable; draining gracefully is then what we
choose to do with it. Worth recording, because "add a SIGTERM handler" and "make SIGTERM reach the
process at all" are different problems with the same fix.
Why a wrapper
dist/server/entry.mjsstarts listening as an import side effect and returns no handle on theserver it started, so there is nothing to attach a handler to. It does expose the two hooks needed:
ASTRO_NODE_AUTOSTART=disabledsuppresses the auto-start, andstartServeris exported.scripts/server.mjssets the env var and then uses a dynamic import — a staticimportis hoistedabove the assignment, so the server would autostart regardless.
scripts/shutdown.mjsholds the logic with the server injected as three callbacks(
close/closeIdleConnections/destroy), so the deadline path is testable without binding a portor hanging a real socket — the same reason
src/lib/health.tstakes aprobeDbcallback.Behaviour
close()to stop accepting, so in-flight requests finish.closeIdleConnections()as well. Without itclose()waits for the client to disconnect, so abrowser holding a keep-alive socket open would burn the entire deadline and force the destroy path
with nothing actually in flight.
StopTimeout, then force-close and exit — astuck request must not reintroduce the very
SIGKILLthis fixes. A test asserts the default staysunder 10 s.
crash; non-zero would make systemd log every deploy as a failure.
SIGTERMmid-drain is logged and ignored rather than racing a secondclose()and resetting the deadline.Verification
End to end against the real image, as pid 1, with production's default 10 s
StopTimeout:Also confirmed the wrapper starts from a foreign cwd (ESM imports are module-relative), since the
container runs it from
/app.11 new tests, 92 total — the graceful path, the deadline path (the acceptance criterion: a hung
request still exits cleanly), that it does not force-close one tick early, a server missing both optional
callbacks, and re-entrancy.
pnpm check0 errors,pnpm lintclean.vitest.config.tsnow also collectsscripts/**/*.test.mjs, since the runtime entry points there runoutside the Astro bundle.
scripts/shutdown.mjsis added to coverage individually rather than globbingscripts/, whose other files (the migrator, the e2e harness) are covered by being run rather thanunit-tested — globbing would add permanent 0% noise, which the existing comment in that file argues
against.
Effect
Deploy unavailability ~12.7 s → ~2.8 s, on top of the ~109 s already removed by bitborg-infra #254.
The acceptance criterion "no
resorting to SIGKILLwarning in the journal on a deploy" is verifiable onthe very next deploy — which is the one this PR triggers.
Follow-up, deliberately not in scope
At ~2.8 s a modest Caddy
lb_try_durationgenuinely bridges a deploy, which reverses bitborg-infra#250's conclusion that no sane retry window could. Filing separately.
Also not in scope: making
/healthzreport 503 while draining. It needs no change with a singlecontainer, but would matter if active upstream health checks are ever enabled.