Parent: #9487
Summary
Three small self-host correctness gaps found during live inspection of edge-nl-01 on 2026-07-27. Each one turns a transient event into a durable wrong state, and each is a few lines.
1. An empty secret file silently degrades to "unset"
src/selfhost/load-file-secrets.ts:26-45. A missing or unreadable <NAME>_FILE correctly throws (#6284), but a zero-byte or whitespace-only file sets env[NAME] = "" — which every downstream nonBlank() treats as unconfigured, and preflight.ts:173 deliberately skips absent values.
So a truncated GITHUB_WEBHOOK_SECRET file boots an instance that rejects every webhook, healthily, indefinitely. This is directly adjacent to the known secret-rotation footgun on this host, where the file is rewritten in place: the window in which it is momentarily empty is exactly when a container restart reads it.
2. The crash handler can stall exit behind a telemetry flush
src/selfhost/process-lifecycle.ts:81-92 awaits flush() with no deadline before exit(1), wired to flushPostHog (src/server.ts:334). A wedged PostHog egress delays — or entirely prevents — the very restart the module exists to guarantee, leaving a process that has already logged a fatal alive and serving from a state it declared unsound.
3. Half-written blobs are served forever
src/selfhost/blob-store.ts:48-52 writes with writeFile directly. Keys are input-hash-addressed (loopover/shots/<hash>.png), so a truncated PNG from a mid-write kill is never retried or overwritten — the next lookup for that same input finds a file and serves it as a permanently "valid" cache hit. The same codebase already does tmp+rename correctly at src/selfhost/private-config.ts:466-469.
Tests
Expected outcome
None of the three can leave a durable wrong state behind: no silently-unconfigured secret, no un-restarted process, no permanently-cached truncated object.
Parent: #9487
Summary
Three small self-host correctness gaps found during live inspection of
edge-nl-01on 2026-07-27. Each one turns a transient event into a durable wrong state, and each is a few lines.1. An empty secret file silently degrades to "unset"
src/selfhost/load-file-secrets.ts:26-45. A missing or unreadable<NAME>_FILEcorrectly throws (#6284), but a zero-byte or whitespace-only file setsenv[NAME] = ""— which every downstreamnonBlank()treats as unconfigured, andpreflight.ts:173deliberately skips absent values.So a truncated
GITHUB_WEBHOOK_SECRETfile boots an instance that rejects every webhook, healthily, indefinitely. This is directly adjacent to the known secret-rotation footgun on this host, where the file is rewritten in place: the window in which it is momentarily empty is exactly when a container restart reads it.2. The crash handler can stall exit behind a telemetry flush
src/selfhost/process-lifecycle.ts:81-92awaitsflush()with no deadline beforeexit(1), wired toflushPostHog(src/server.ts:334). A wedged PostHog egress delays — or entirely prevents — the very restart the module exists to guarantee, leaving a process that has already logged a fatal alive and serving from a state it declared unsound.Promise.race([flush(), sleep(N)]). Losing a few telemetry events is unambiguously the cheaper failure.3. Half-written blobs are served forever
src/selfhost/blob-store.ts:48-52writes withwriteFiledirectly. Keys are input-hash-addressed (loopover/shots/<hash>.png), so a truncated PNG from a mid-write kill is never retried or overwritten — the next lookup for that same input finds a file and serves it as a permanently "valid" cache hit. The same codebase already does tmp+rename correctly atsrc/selfhost/private-config.ts:466-469.${target}.tmp-<uuid>thenrename. Unique temp names, so two concurrent puts of one key cannot publish each other's partial file.Tests
"", the value that reads as unconfigured downstream.flush()never settles; a flush that settles first is still awaited.Expected outcome
None of the three can leave a durable wrong state behind: no silently-unconfigured secret, no un-restarted process, no permanently-cached truncated object.