Skip to content

Prevent orphaned Harper processes after test runner crashes - #30

Open
kriszyp wants to merge 14 commits into
mainfrom
fix/reap-harper-on-runner-death
Open

Prevent orphaned Harper processes after test runner crashes#30
kriszyp wants to merge 14 commits into
mainfrom
fix/reap-harper-on-runner-death

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 21, 2026

Copy link
Copy Markdown
Member

A test runner that dies without running teardown — SIGKILL, a hard crash, a cancelled CI job — cannot reap the Harper instances it started, because those are deliberately detached into their own process groups so whole-tree teardown works. Left alone they hold their loopback address's fixed ports until reboot.

This adds a single shared monitor process that closes that gap. startHarper() publishes explicit metadata for each instance to an on-disk registry and makes sure exactly one monitor is running for that registry (per user on the machine by default). The monitor scans on an interval and terminates — SIGTERM, then SIGKILL after a grace period — the process group of any instance whose owning runner is gone, or which has outlived its lifetime budget, then shuts itself down once the registry has been empty for a while.

Because the registry is the only thing standing between an abrupt death and a stranded port, everything that touches it has to survive an abrupt death itself. That is what the review rounds on this PR converged on, and where the recent commits are:

  • A record's lifetime is its process group's, not its leader's. The cleanup unit is the group; Harper exiting is not the end of it, because a child that ignored the SIGTERM stays in that group still holding the ports. Two writers used to end the record at the leader's exit — the monitor pruned any record whose leader was gone, and the runner deregistered on the exit event — and either one alone cancelled the pending SIGKILL escalation and every later chance to reap that group. Removal now belongs to the monitor alone, the half that can see the group, and it happens when the group has no members left rather than when a reap is due — a leader that exits on its own under a live runner is the case that makes those different. The runner only untracks its own child.
  • A leaderless group is only reaped on the evidence that it is still ours. groupOutlivedLeader refuses a leader PID that ps still describes with a different start time: a group id is reserved only for the lifetime of the group that held it, so a reused PID's group is somebody else's. See For the human reviewer feat: output Harper logs on test failure #6 for what this does not establish.
  • The registry is published by rename. writeRegistryFile writes a complete file beside the registry and renames it into place. An in-place write truncates first, so a writer killed in that window left torn JSON — and readRegistryFile read torn JSON as an empty registry, which the next writer persisted, discarding every reap target on the machine including other runners'. Exactly the event this PR exists to survive was the one that defeated it.
  • A failed read never reports "empty". Only ENOENT is an empty registry; a permission error, unparseable content, or an instances that is not an array now propagates rather than being written back as emptiness.
  • A lock holder releases only its own lock. withRegistryLock compares the lock's token before unlinking. A section that overran the 10s stale timeout used to delete the lock of the process that had superseded it, admitting a third process alongside it. Acquisition is also bounded at 30s, so a pathological holder fails a registration instead of hanging startHarper forever.
  • Startup ends at readiness, not at resolution. Waiting for registration before resolving left settled false past readiness, and every startup guard keyed on it — so a post-readiness log line, which a real Harper emits constantly, re-armed the idle watchdog and let a registry-lock stall SIGKILL a healthy instance, while startupOutput kept accumulating past the snapshot its own comment promises. startupFinished() is now the boundary those guards always meant.
  • An identity is a property of the process, and a reap target is never a broadcast. ps -o lstart= renders in the caller's timezone and locale, so two differently configured runners recorded different strings for one process and read each other's live records as PID reuse; the lookup now pins TZ=UTC and LC_ALL=C. And because the registry is on-disk state a corrupt or planted record can reach, signalProcessGroup refuses a group id of 1 or belowkill(-1) is a broadcast to every process the monitor may signal, and no instance we register is ever that.
  • Instance ids are unique per module copy, not per PID. Worker threads share process.pid and each holds its own copy of the registry module, so both workers' first starts claimed <pid>-1; registration replaces same-id records, so one of two live instances was left with nothing to reap it. The id now carries a CSPRNG suffix.
  • The registry directory is per-user, and nothing in it follows a symlink. Cross-user reaping could never work — signalling another user's process group returns EPERM, which reads as "still alive", so a foreign record pinned the monitor forever. The default is now ${TMPDIR}/harper-integration-test-monitor-${uid} at mode 0700, the pending file is created wx with a CSPRNG suffix, and monitor.log opens O_NOFOLLOW.

For the human reviewer

  1. One monitor, not one sidecar per instance. An earlier revision of this PR paired every Harper instance with its own supervisor process watching a liveness pipe. That reaped faster (pipe EOF vs. one scan interval) but cost a process per instance and could only ever see the instances it was born with. The shared monitor costs one process per user per machine, is reused by every concurrent runner, and also cleans up stale instances left behind by earlier runs. Reversing this would mean going back to the spawn topology in the earlier revision.
  2. No public API change. Harper is spawned directly, so ctx.harper.process is the Harper process itself and HarperContext is identical to main. Everything new is internal (harperInstanceRegistry.ts, harperMonitor.ts) and not re-exported from index.ts.
  3. The runner no longer deregisters — the newest behaviour change here. deregisterHarperInstance is gone rather than fixed: the runner watches one process and the record covers a group, so it cannot tell when the group is finished. A record for a cleanly finished instance now lives until the monitor's next scan (one interval, 2s by default) instead of disappearing on the child's exit, and the registry can briefly hold records for instances that are already over. Framing-Verdict: chosen-approach-sound was not what the planning round returned — it returned better-alternative-exists, preferring exactly this monitor-owned removal over the two-writer version I had planned, and I adopted it.
  4. startHarper still blocks on registration. Resolution waits for the registry write so a runner killed the instant startHarper returns still leaves a record. It is one line to flip to fire-and-forget if the sub-second window is not worth it. Two windows remain open by design and were re-raised this round: between spawn and the registry rename there is no record at all (milliseconds, up to 30s under lock contention), and the clean-exit branch resolves without that wait. Closing them properly means a monitor-owned launch or a child-side handoff protocol, which is a different PR.
  5. Declined: fencing the stale-lock reclaim. Reclaim is mtime-only, so a holder stalled past 10s (swap, a paused container) can still complete its read-modify-write after someone else reclaimed the lock and wrote — the token check prevents the wrong unlink, not the wrong write. Raised in five rounds and left alone deliberately: real fencing means threading a token through every critical section and re-validating before each write, which is a design change rather than a fix to a review comment. The lost update drops one instance record, and the 4h backstop still reaps what it described.
  6. Declined: replacing wall-clock start times as the identity. ps -o lstart= is derived from boot time on Linux, so an NTP step shifts the string for every process on the host, and the next scan reads live records as PID reuse. That is fail-safe — a mismatched record is dropped, never signalled — so the cost is instances that stop being monitored, not a wrong kill, and the same is true of the unobserved-gap case in bullet 2 above: a group of ours that ended while nothing was watching, whose id was reused by another detached group that then lost its own leader, is indistinguishable. Reaping is best-effort against PID reuse throughout, and the alternative (/proc/<pid>/stat starttime plus boot identity, with a portable fallback) replaces the identity mechanism rather than fixing this one.
  7. Declined: validating a pre-existing registry directory. mkdir does not repair the mode of a directory that already exists, so a directory pre-created by another account keeps its permissions. With the pending file O_EXCL, the log O_NOFOLLOW, and now a signalling floor that refuses kill(-1), what remains there is denial of monitoring rather than an arbitrary write or a wide signal, and refusing to run on a directory we do not own is a behaviour change I would not make under a review comment.
  8. Declined: a recovery path for an unreadable registry.json. Fail-closed is the decision above, so a corrupt registry stops monitoring for that user until the file is deleted (which the thrown message says), and a running monitor logs a scan failure every interval. Self-healing means deciding that unknown cleanup state is the same as no instances — the exact conflation that erased other runners' reap targets.
  9. Declined: spawnSync('ps') on the registration path. Two ~10ms synchronous calls per Harper start, against a multi-second Harper boot. Raised as a blocker in one round and dismissed on the numbers in another. Relatedly, a ps wedged in uninterruptible sleep is unbounded by construction and a stuck filesystem operation cannot be cancelled — Node has no async cancellation for either.
  10. Declined: moving the monitor liveness check outside the lock. It is inside the critical section on purpose: the check and the insert have to be atomic, or the monitor's idle exit races a new registration.
  11. Pre-existing, filed rather than fixed. killHarper resolves on the leader's exit, so a SIGTERM-ignoring child can still be running when teardown returns; that predates this PR and is independent of the retention invariant. On Windows signalWindowsProcessTree shells out to taskkill asynchronously from a process.once('exit') handler where the loop is already stopped, and the SIGINT/SIGTERM handlers re-raise into listeners that are still attached. Windows keeps the runner-side handlers as its only protection, same as mainthe comment documenting the first is restored.
  12. A monitor already running keeps its old code. Registration reuses any live recorded monitor, so on a machine that has run an older build of this branch the fix reaches the monitor only after that process exits (idle timeout, or kill) — worth knowing when testing this locally rather than on a fresh box.

Verification

Every new test below was run against the code without its fix and observed failing, then against the fix and observed passing.

Refs #29

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=9 @ e7adea2

Human-Review-Need: 4 (decisions: identity-source, monitor-only-removal, shared-singleton-monitor, resolve-after-registration, default-on, lifetime-backstop-4h, windows-uncovered) @ e7adea2

@kriszyp
kriszyp requested a review from heskew August 21, 2026 14:08
gemini-code-assist[bot]

This comment was marked as resolved.

@kriszyp
kriszyp marked this pull request as ready for review August 25, 2026 03:56
@kriszyp
kriszyp requested a review from Ethan-Arrowood August 25, 2026 03:56

@heskew heskew left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at exact head 6388e032ea6dfbe73b571f32280b551f78cf9d40 for one new blocking issue found by Codex.

writeRegistryFile rewrites the shared registry in place, so an abrupt runner death during that write can leave truncated JSON. After the stale lock is reclaimed, readRegistryFile treats the torn file as an empty registry and the monitor overwrites the last recoverable state, losing every orphan-reaping target—including records belonging to other concurrent runners. Fault injection against the real monitor at this head reproduced the failure with a live detached target: {"victimAlive":true,"instancesAfter":0}.

Please write a complete temporary file in the registry directory and atomically rename it while holding the lock, then add a fault/recovery test proving a dead writer leaves either the old or new complete registry. This is blocking because it defeats the PR's headline guarantee under the precise SIGKILL/crash event it is intended to survive.

@gemini-code-assist's four earlier inline findings applied to the superseded per-instance supervisor architecture; all four threads are now outdated/resolved and are not being re-raised. Exact-head install, check, build, all 25 tests, diff checks, and the Ubuntu/Windows Node 22/24/26 matrix pass, but none injects a crash during registry rewrite.

🤖 Posted by Codex on behalf of @heskew

Comment thread src/harperInstanceRegistry.ts Outdated
kriszyp and others added 11 commits September 3, 2026 16:35
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Replace the per-instance supervisor sidecar with a single monitor process
shared by every concurrent test runner on the machine.

Harper is spawned directly again (its own detached process group), so
`ctx.harper.process` is the Harper process itself and the public API is
unchanged from main. Each instance publishes explicit metadata — PID,
process start time, owning runner, loopback address, lifetime deadline —
to an on-disk registry, and `startHarper` ensures exactly one monitor is
running for that registry. The monitor scans on an interval and
terminates the process group of any instance whose owning runner is gone
or which has outlived its budget, then shuts down once the registry has
been empty for a while. PID plus start time identifies each process, so a
recycled PID is never mistaken for a live one.

The trade against the supervisor: reaping takes one scan interval rather
than a pipe EOF, in exchange for one process per machine instead of one
per instance, plus coverage of stale instances left by earlier runs.

POSIX only — reaping relies on process groups. On Windows registration is
skipped and the runner-side cleanup handlers are the only protection.

Refs #29

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writing registry.json in place truncated the live file first, so a runner
killed in that window left torn JSON. readRegistryFile maps torn JSON to an
empty registry, and the next writer persists that emptiness — dropping every
orphan-reaping target on the machine, including other runners', under exactly
the SIGKILL the monitor exists to survive.

Each update is now written to a unique pending file beside the registry and
renamed into place, so a reader only ever sees the previous registry or the
complete new one. The name is unique per write because a fixed one could be
truncated by a second writer that reclaimed the lock as stale.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Three ways the registry could still lose the records the monitor reaps from,
found by the pre-push review of the atomic-write fix:

- withRegistryLock unlinked the lock file unconditionally, so a critical
  section that overran the stale timeout deleted the lock of the process that
  had superseded it, admitting a third process into the section alongside it.
  It now writes a token into the lock and only releases its own.
- readRegistryFile reported every read failure as an empty registry, so a
  permission error or corrupt file made the caller write that emptiness back.
  Only ENOENT is an empty registry now; anything else propagates.
- The absolute startup deadline stayed armed while startHarper waited for
  registration, so contention on the shared lock could time out and kill a
  Harper that had already reported ready.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
The pre-push review found that waiting for registration before resolving left
`settled` false past readiness, so every guard keyed on it stayed open: a
post-readiness log line — which a real Harper emits constantly — re-armed the
idle watchdog that had just been cleared, letting a registry-lock stall SIGKILL
a healthy instance, and `startupOutput` kept accumulating past the snapshot its
own comment promises. Startup now ends at readiness, which is what those guards
always meant.

Also from that round:
- Bound lock acquisition (30s) and the `ps` lookup (5s), so a pathological lock
  holder surfaces as a failed registration instead of a startHarper that never
  settles.
- An `instances` that parses but is not an array now throws like the torn-JSON
  case rather than being read as empty and written back.
- Default the registry directory per-user and create it 0700. Cross-user
  reaping could never work — signalling another user's group returns EPERM,
  which reads as "still alive", so a foreign record pinned the monitor forever.
- Log once when `ps` cannot report start times (busybox), where PID-reuse
  detection silently degrades to a bare PID check.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Round-3 review findings:

- The pending file was written with a plain truncating open, so a symlink
  planted at its name in a shared registry directory would have been followed.
  `wx` (O_CREAT|O_EXCL) refuses to follow one, which the unique-per-write name
  already made free.
- A failed token write leaked the lock file descriptor; close it in a finally.
- `spawnSync`'s timeout escalates to SIGKILL, so a `ps` ignoring SIGTERM cannot
  outlive it.
- Restore the caveat this PR dropped: Windows' `taskkill` shell-out may not
  complete from the 'exit' handler, and there is no monitor to fall back on.
- Say "per user on the machine" where the docs still said "per machine".

Co-Authored-By: Claude Opus <noreply@anthropic.com>
… unfollowable

Round-4 review findings:

- `O_EXCL` on the pending file turned a leftover from a killed writer plus PID
  reuse into a failed, silently unmonitored registration. The name now carries
  a random component, so it collides with nothing and cannot be pre-planted.
- The monitor's log was appended with a plain open, the one remaining path in a
  shared registry directory that would follow a symlink. It now opens with
  O_NOFOLLOW at mode 0600.
- Drop the two `off('data')`-less stdout listeners in the test helpers, and the
  last "per machine" wording the per-user default contradicts.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
The pending name's random component is what the comment leans on when it calls
the name unguessable, so take it from `randomBytes` rather than `Math.random`.
Same for the lock token, in a file that now reasons explicitly about a shared
registry directory another account can reach.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the fix/reap-harper-on-runner-death branch from be8fcaa to 76aecd0 Compare September 3, 2026 22:59

@heskew heskew left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original torn-registry-write finding is fixed at 76aecd0. Two reproduced P2 findings are attached inline. Type-check, build, all 37 tests, and all seven CI checks passed during the review.

🤖 Posted by Codex on behalf of @heskew; Codex review and runtime probes, with Claude synthesis.

Comment thread src/harperMonitor.ts Outdated
Comment thread src/harperInstanceRegistry.ts Outdated
kriszyp and others added 3 commits September 8, 2026 17:34
The monitor's SIGTERM reaches the whole group, so Harper (no handler) exits
first while a child that ignored the signal stays in that group holding the
ports. Pruning the record the moment its leader was gone dropped the escalation
deadline with it, so the SIGKILL never landed and the survivor outlived the
registry that described it.

A record now survives its leader while its process group still has members and
the instance is still orphaned; POSIX keeps the group id reserved for exactly
that long, so it remains ours to signal.

Instance ids also carry a random suffix. Worker threads share process.pid and
each holds its own copy of the registry module, so two workers' first starts
both claimed `<pid>-1` and registration — which replaces same-id records —
discarded one of two live instances, leaving it with nothing to reap it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A registry record describes a process group, so its lifetime is the group's,
not its leader's. Two writers ended it at the leader's exit instead: the
monitor pruned any record whose leader was gone, and the runner deregistered
on the exit event. Either erases the only durable description of a group whose
child ignored SIGTERM and kept the ports — and with it the SIGKILL escalation,
the lifetime backstop, and every later chance to reap.

Removal now belongs to the monitor alone, which is the half that can see the
group, and it happens when the group has no members left rather than when a
reap is due. A leader that exits on its own with a live runner is the case that
makes those two different: nothing is due yet, but the survivors still have to
be remembered.

Holding a record past its leader also has to survive PID reuse. A leader PID
that ps still describes with a different start time is a reused id, and its
group is not ours to signal. An id reused after our group ended unobserved
stays indistinguishable — the same best-effort bar as the rest of the identity
checks here, now stated in the code and the README rather than claimed away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ps -o lstart=` renders in the caller's timezone and locale, so two runners
configured differently recorded different strings for the same process and read
each other's live records as PID reuse — a monitor started beside the live one,
and scans discarding instances that were still running. The lookup now pins
TZ=UTC and LC_ALL=C, making the string a property of the process alone.

`signalProcessGroup` also refuses a group id of 1 or below. The registry is
on-disk state a corrupt or planted record can reach, and `kill(-1)` broadcasts
to every process the monitor may signal; no instance we register is ever that.
The test helper's `forceKill` refuses the same ids, where an unparsed fixture
PID of 0 would have signalled the test runner's own group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants