Prevent orphaned Harper processes after test runner crashes - #30
Prevent orphaned Harper processes after test runner crashes#30kriszyp wants to merge 14 commits into
Conversation
heskew
left a comment
There was a problem hiding this comment.
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
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>
be8fcaa to
76aecd0
Compare
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>
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, thenSIGKILLafter 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:
SIGTERMstays 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 theexitevent — and either one alone cancelled the pendingSIGKILLescalation 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.groupOutlivedLeaderrefuses a leader PID thatpsstill 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.writeRegistryFilewrites 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 — andreadRegistryFileread 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.ENOENTis an empty registry; a permission error, unparseable content, or aninstancesthat is not an array now propagates rather than being written back as emptiness.withRegistryLockcompares 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 hangingstartHarperforever.settledfalse 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 stallSIGKILLa healthy instance, whilestartupOutputkept accumulating past the snapshot its own comment promises.startupFinished()is now the boundary those guards always meant.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 pinsTZ=UTCandLC_ALL=C. And because the registry is on-disk state a corrupt or planted record can reach,signalProcessGrouprefuses a group id of 1 or below —kill(-1)is a broadcast to every process the monitor may signal, and no instance we register is ever that.process.pidand 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.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 mode0700, the pending file is createdwxwith a CSPRNG suffix, andmonitor.logopensO_NOFOLLOW.For the human reviewer
ctx.harper.processis the Harper process itself andHarperContextis identical tomain. Everything new is internal (harperInstanceRegistry.ts,harperMonitor.ts) and not re-exported fromindex.ts.deregisterHarperInstanceis 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'sexit, and the registry can briefly hold records for instances that are already over.Framing-Verdict: chosen-approach-soundwas not what the planning round returned — it returnedbetter-alternative-exists, preferring exactly this monitor-owned removal over the two-writer version I had planned, and I adopted it.startHarperstill blocks on registration. Resolution waits for the registry write so a runner killed the instantstartHarperreturns 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: betweenspawnand 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.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>/statstarttime plus boot identity, with a portable fallback) replaces the identity mechanism rather than fixing this one.mkdirdoes not repair the mode of a directory that already exists, so a directory pre-created by another account keeps its permissions. With the pending fileO_EXCL, the logO_NOFOLLOW, and now a signalling floor that refuseskill(-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.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.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, apswedged in uninterruptible sleep is unbounded by construction and a stuck filesystem operation cannot be cancelled — Node has no async cancellation for either.killHarperresolves on the leader'sexit, so aSIGTERM-ignoring child can still be running when teardown returns; that predates this PR and is independent of the retention invariant. On WindowssignalWindowsProcessTreeshells out totaskkillasynchronously from aprocess.once('exit')handler where the loop is already stopped, and theSIGINT/SIGTERMhandlers re-raise into listeners that are still attached. Windows keeps the runner-side handlers as its only protection, same asmain— the comment documenting the first is restored.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.
the monitor finishes reaping a group whose leader dies before its children— a TERM-default leader with a TERM-ignoring descendant holding a listener; the runner isSIGKILLed, so only the monitor can act. Without the fix the descendant survives with its port held.a reap in flight is finished even though its runner is alive to see the leader exit— the same descendant under an expired lifetime budget with the runner alive, which is the route where the runner's own deregistration used to cancel the escalation.a group that outlives its leader stays reapable until its runner dies— nothing is due to be reaped: the leader is killed alone, and the record has to survive several scans so that killing the runner later still reaps the descendant.a record whose leader PID has been reused is dropped, not signalled— a seeded record naming an unrelated detached group leader; its group must not receive a signal.instances started from two worker threads of one runner are registered and reaped separately— two real workers in one process, each starting a fake Harper tree. Without the CSPRNG id one registration replaces the other and one tree survives the runner's death.a process identity does not depend on the environment that read itanda reap cannot be widened into a broadcast by a bad record.a registry write survives the abrupt death of its writer,a superseded lock holder does not release the lock that replaced it,an unreadable registry is reported rather than silently read as empty,neither startup watchdog can time out a Harper that already reported ready, runnerSIGKILL/SIGHUPreaping with port release, the shared-monitor singleton, the lifetime backstop, idle shutdown, gracefulkillHarperordering, startup watchdog, tree kill.npm run check,npm run build,npm test— 44/44 passed.better-alternative-existsverdict is adopted in docs: add CONTRIBUTING.md and rewrite AGENTS.md #3 above.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