fix(serve): ignore SIGHUP until the reload handler is installed (REPL-29403)
opencode serve no longer dies when a SIGHUP lands before the server is listening. After readiness, SIGHUP behaves exactly as today (in-place env + harness reload).
Why
The reload handler installs only after Server.listen; until then SIGHUP hits the runtime's default disposition and terminates the process. Under s6 that restarts the service and the readiness clock. Prod impact since the starter-skills seeding burst (andytown #22795, live Jul 10): up to ten [opencode-exit] signal:1 deaths per fresh-sandbox boot, crash-looping at ~1s intervals, lifting boot-to-ready p90 from ~2s to ~13–16s (measured from {service="opencode", replo_env="production"} Loki, Jul 2–16).
Coordinator-side gating (andytown #23172/#23204) stops the seeding storm; this closes the fork-side kill window for every SIGHUP sender (env rotation, MCP auth refresh, debug reloads), so the bug class can't recur.
How
src/cli/sighup-boot-guard.ts(new): whenargv[2] === "serve", registers an ignore-handler at module-evaluation time. It isindex.ts's first import, so coverage starts before the rest of the CLI module graph evaluates — that graph is most of the boot. Each swallowed HUP logs to stderr, so bursts are visible in thesandbox.opencodeLoki stream instead of manifesting as mystery signal-1 exits.serve.ts: post-listen,SighupBootGuard.handover(reload)installs the real handler and removes the guard on-before-off in one synchronous step — no window where neither handler is registered (this also closes the pre-existing listen→handler-install micro-race). The guard owns the swap invariant; serve.ts can't get the ordering wrong.- Dropping (not queueing) early HUPs is correct: the coordinator rotates envdirs on disk before signaling, and a booting process reads that state fresh; skill discovery is a lazy post-ready scan.
- Non-
servecommands are untouched — TUI/one-shot keep default die-on-hangup semantics.
Known sliver: nothing JS-side can cover exec→first-import (~tens of ms of runtime init). With the coordinator gate as the other layer, that's acceptable.
Testing
New subprocess tests (test/cli/serve/serve-sighup.test.ts) spawn the real CLI and sync on the guard's own stderr lines (no sleeps):
- Mid-boot storm: SIGHUP every 50ms from guard-armed until listening; asserts ≥1 HUP was swallowed pre-listen and
/global/healthreturns 200. Written first and failed against the old code (process died before listening). - Post-listen handover: SIGHUP after the guard releases produces the
harness reloadlog and the server keeps serving.
Also green: existing serve/env-reload/harness-reload suites (10/10), tsgo --noEmit, prettier. isolatedEnv in the test harness is now exported for reuse instead of duplicated.
Rollout
Fork-only change; reaches sandboxes with the next replopencode release + snapshot/upgrader bump. Verification after rollout: prod [opencode-exit] signal:1 per-boot count should return to ~0 and boot-to-ready p90 to ~2s; any residual mid-boot HUPs appear as [sighup-guard] ignoring SIGHUP lines in Loki.
🤖 Generated with Claude Code
Summary by cubic
Ignore SIGHUP during opencode serve boot until the reload handler is installed to stop mid-boot kills under s6; post-listen SIGHUP still reloads in place. Addresses REPL-29403 and restores fast, stable sandbox startup.
- Bug Fixes
- Added
sighup-boot-guard.tsthat ignores SIGHUP forserveuntil listening; logs each ignored HUP. - After listen,
SighupBootGuard.handover(reload)atomically installs the real handler (no race). - Made the guard the first import in
index.tsto cover module graph evaluation. - Added subprocess tests for mid-boot HUP storms and post-listen reload; exported
isolatedEnvfor reuse.
- Added