Pre-Serilog boot failure wedges the container (100% CPU spin, restart policy never fires) #34
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?
Symptom
Any unhandled exception thrown in the ScadaBridge Host's pre-Serilog window (the pre-host block in
Program.cs— Layer-A${secret:}expander,StartupValidator, migrators — before the Serilog host is built) does not terminate the process. After printing the crash banner to stderr, the main thread spins at ~100% CPU indefinitely; the container staysrunning(RestartCount=0), sorestart: unless-stoppednever fires and the node serves nothing.The compose comments and plan docs describe this window as "exits with a bare stderr trace and restarts". Observed twice, it does not exit. Monitoring that watches container state sees a healthy-looking
runningcontainer — so any pre-host failure becomes a silent manual-restart outage.Reproductions (both live, 2026-08-07 gate)
Recorded in
docs/plans/2026-08-07-secrets-central-shared-store-live-gate.md(defect 2), gated onmain(gate first pass @68f812ea; doc merged throughe697477c):SqlExceptionfrom the expander'sSqlServerSecretsStoreMigratorrun (the 0.5.0 virgin-DB 2714 race, since fixed at the library in 0.5.1): banner printed, then the main thread — confirmed as the busy thread — spun at ~100% CPU for ~5 minutes until a manual restart. Container stayedrunning,RestartCount=0.StartupValidatorInvalidOperationException(check 4's deliberately-short pepper): identical signature — banner printed, 100% CPU, container stillrunning.Two different exception types from two different pre-host stages, same wedge — so it is not specific to SqlClient or to the new SQL store path.
Why it matters
Fail-closed is only honest if the process actually exits. Every pre-host fail-closed guard (blank/
${secret:}connection string refusal, config validation, secret-resolution failures) currently converts a designed clean fail-fast into a wedged, invisible outage: the restart policy cannot recover it and container-state monitoring cannot see it. This is a whole outage class, not a single bug site.Scope
Pre-existing — this predates the secrets central-shared-SQL-store program and affects the old SQLite path's pre-host failure modes equally; the 2026-08-07 gate merely produced two clean reproductions. Mechanism not root-caused (out of gate scope); the observable and both reproductions are recorded in the gate doc.
Filed at closeout of the central-shared-store program (scadaproj#4).
FIXED —
main @ e9c412e5.Root cause: dotnet runs as container PID 1, and Linux ignores default-action signals sent to PID 1 — so the runtime's unhandled-exception path (print banner, then
abort()→ SIGABRT) could never terminate the process and spun instead. Proven deterministically: the identicalStartupValidatorcrash exits134under an init process and wedges (100% CPU, containerrunning) without one. Not specific to any exception type or boot stage, exactly as the two gate reproductions suggested.Fix, two layers:
Program.csregisters anAppDomain.UnhandledExceptionhandler before the first statement that can throw: prints the trace, best-effort Serilog flush,Environment.Exit(134)—exit()is a syscall PID 1 can perform, 134 preserves the 128+SIGABRT crash code, and it covers unhandled exceptions on every thread, not just the boot window. It cannot fire under WebApplicationFactory (the test host catches entry-point exceptions itself), so designed boot-refusal exceptions still propagate to tests unchanged.docker/docker-compose.ymlsetsinit: trueon all 8 nodes — for crash paths that bypass the managed event entirely (Environment.FailFast, runtime-internal aborts), plus proper signal handling generally.The CoordinatedShutdown
no-Environment.Exitsource guard gained a precise carve-out (exactly one call, only inside the handler); noteEnvironment.Exitstill fires the CLR shutdown hook Akka binds viarun-by-clr-shutdown-hook = on, so the crash path loses nothingabort()had.Live verification on the rig image: the repro that wedged now yields
Exited (134)withRestartCountclimbing underrestart: unless-stopped(8 restarts in 25 s), stack trace intact — verified with and without--init, so each layer is proven independently. Full 8-node rig redeployed healthy,docker-initas PID 1, both centrals/health/ready.Operator note for production overlays: any deployment running this image under its own compose/orchestration should also add
init: true(or the platform equivalent) — the in-process handler covers managed exceptions, but only an init process coversFailFast/runtime aborts.