fix(serve): ignore SIGHUP until the reload handler is installed (REPL-29403)

Merged
PI
3mo ago
+128-2

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): when argv[2] === "serve", registers an ignore-handler at module-evaluation time. It is index.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 the sandbox.opencode Loki 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-serve commands 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):

  1. Mid-boot storm: SIGHUP every 50ms from guard-armed until listening; asserts ≥1 HUP was swallowed pre-listen and /global/health returns 200. Written first and failed against the old code (process died before listening).
  2. Post-listen handover: SIGHUP after the guard releases produces the harness reload log 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.ts that ignores SIGHUP for serve until 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.ts to cover module graph evaluation.
    • Added subprocess tests for mid-boot HUP storms and post-listen reload; exported isolatedEnv for reuse.