Skip to content

fix: reap the headroom proxy and read zombie pids as dead - #32

Merged
Mearman merged 2 commits into
mainfrom
fix/headroom-zombie-reap
Sep 27, 2026
Merged

Mearman merged 2 commits into
mainfrom
fix/headroom-zombie-reap

Conversation

@Mearman

@Mearman Mearman commented Sep 27, 2026

Copy link
Copy Markdown
Member

Summary

Three compounding defects in the headroom supervisor's crash handling, found by SIGKILLing the daemon during a live run:

  1. the proxy was spawned detached and unref'd with no exit listener, so nothing ever reaped it and a killed daemon lingered as a zombie;
  2. crash detection polled kill(pid, 0), which a zombie answers as alive, so the restart branch never fired and state.json kept advertising a healthy port nobody was listening on;
  3. the supervisor loop slept synchronously (Atomics.wait), blocking the event loop so exit events could never have been delivered between ticks anyway.

The supervisor now keeps every spawned ChildProcess handle, consumes its exit event (which is also the reap), awaits its sleeps on real timers, and reads liveness through a zombie-aware predicate (signal-0 alive AND ps -o stat= not defunct, Windows falling back to signal-0). Stopping escalates SIGTERM to SIGKILL on a bounded grace, because this proxy ignores SIGTERM outright. A crash clears the daemon fields from state.json immediately. En route, two more of the same class: the supervisor now handles SIGTERM/SIGINT (Node skips exit handlers on unhandled signals, so killing it orphaned the daemon), a successor supervisor stops any orphan a dead predecessor left, and the identity resync lock judges its holder with the same zombie-aware predicate, since a zombie launcher could pin the lock and fail every other launch with lock-busy.

Testing

  • pnpm run typecheck
  • pnpm run lint
  • pnpm run test (910 tests)
  • pnpm schema regenerated, zero diff
  • Live against the real headroom: kill -9 of the proxy restarts it within ~4s, the old pid fully reaped; drift restart stops the old daemon cleanly; SIGTERM of the supervisor kills its daemon

New fakes model existence, zombie and exit-event as distinct facts, so the regression tests cover the exact gap that let this escape: a zombie the signal-0 table still lists, exit-event-driven restart, state clearing on crash, SIGTERM/SIGKILL escalation, orphan takeover, and zombie launcher pruning.

Checklist

  • Tests updated or not needed
  • Docs updated if behavior changed

The crash-restart path never fired after a kill -9 of the daemon, for
three compounding reasons, all fixed at their roots.

The supervisor spawned headroom detached and unref'd with no exit
listener, so nothing ever waited on the child: it lingered as a zombie
with the supervisor as its parent. It now keeps every ChildProcess
handle and consumes the exit event, which is both the reaping and the
crash signal; unref is gone, because a handle taken off the event loop
is a child nobody reaps.

Even with a listener, the loop slept with Atomics.wait, which blocks the
event loop and stops exit events being delivered at all. The
SupervisorPorts sleep is now a real timer the loop awaits, so the loop
turns between ticks.

Crash detection polled liveness with kill(pid, 0), and a zombie answers
that as alive. Every liveness check in the coordination layer (the
supervisor, the ensure step, session pruning, status, doctor) now uses a
zombie-aware predicate: realIsProcessRunning reads ps's state column and
treats Z as dead, documented against the signal-0 check a defunct
process defeats. The test fakes model existence, zombification, and the
exit event as distinct facts, which the old always-alive fakes could
not.

Also: the deliberate stop path escalates SIGTERM to SIGKILL through a
bounded poll, as a pure stopSupervisedProcess with its own tests (this
proxy ignores SIGTERM outright); a detected crash clears the daemon
fields from state immediately, so status never claims a dead port; the
supervisor handles SIGTERM and SIGINT and routes them through an
orderly exit, because Node skips exit handlers on unhandled signal
death, which orphaned the daemon; and a successor supervisor stops an
orphan daemon its predecessor left running before starting its own.

Verified live against the real daemon: kill -9 of the proxy restarts it
within one tick with the old pid fully reaped, a drift restart serves
the new allowlist, and SIGTERM of the supervisor takes the daemon with
it.
The per-identity resync lock judged its holder with kill(pid, 0), which
an unreaped launcher still answers as alive. A launcher that died
mid-resync with nobody to reap it could therefore hold the lock as a
zombie and turn every other session's launch into a lock-busy failure
until the zombie was collected. The lock's predicate is now the same
zombie-aware realIsProcessRunning the headroom coordination layer uses,
renamed from isProcessAlive across the lock, farm, and FarmRuntime
wiring to say what it means.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-09-27T01:32:32.059028Z cfc1d31 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit 72ff6f2 into main Sep 27, 2026
31 checks passed
@Mearman
Mearman deleted the fix/headroom-zombie-reap branch September 27, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant