Skip to content

Orphan recovery

Applies to: all platforms, by two different mechanisms.

Platform Behaviour Consequence
POSIX Reparents an orphan to init process.ppid changes, so comparing against the startup value is enough
win32 Freezes the ppid at spawn and never updates it The comparison can never fire; only a signal-0 probe of the original pid can answer

ParentProcess runs both checks regardless of platform. Each is cheap, and running both means no branch for a difference that is really about which signal arrives first.

Only ESRCH counts as death. EPERM means the process exists but is not ours to signal — still alive.

One residual false negative remains on win32: the OS may reuse a dead parent’s pid, and the probe would then find the new process alive. This is accepted, because the cost is only a watchdog that stays armed slightly too long.

ParentWatchdog polls on that pair of checks, because no cooperation is required from a parent that may have died in a way that ran no shutdown code at all. Its timer is unref’d, so an armed watchdog never by itself keeps the process alive.

What survives a hard kill: nothing in-process

Section titled “What survives a hard kill: nothing in-process”

Every mechanism above needs some of our code to run. A SIGKILL of the host runs none of it.

Mechanism What it covers How it fails
close() on the Command The ordinary case: the host decided to stop Never runs if the host is killed
ParentWatchdog The host died without shutting down cleanly Needs our polling timer to still be running — in our process
Job Object (win32) Descendants that detached from the process tree Kernel-enforced, but only for what was spawned inside the job
CliRegistry (on disk) Everything above, after the fact Nothing in memory is required — a later host reads the directory

That last row is the gap CliRegistry closes, and why it is on disk rather than in memory. A later host reads the directory and finds what an earlier one left behind:

const registry = new CliRegistry(stateDirectory);
for (const orphan of registry.orphans()) {
// Its CLI still runs; the host that spawned it does not.
console.warn('orphan:', orphan.pid, orphan.sessionId, orphan.workingDirectory);
}

One file per pid rather than one shared index, because hosts legitimately run concurrently — an IDE opens one per project — and per-entry files have no read-modify-write race between them. The directory is the caller’s to choose: where a host keeps its state is its decision, not this library’s.

The registry also answers a second question: whether some other host is already driving a session. Two CLIs appending to one conversation branch its history, so resuming needs to know.

Identity, not liveness, is what makes this safe on every platform. A recorded pid may be alive as an unrelated process after reuse, so ProcessIdentity.isStill() requires that the session id still appear in the process’s command line — read via Get-CimInstance Win32_Process on win32 and ps -o args= elsewhere.

Win32_Process.CommandLine is used rather than tasklist because the latter yields only the image name and cannot tell two runs apart. CIM rather than wmic, because wmic is being removed from Windows.

A command line that cannot be read means unknown, not dead — a query can fail on a live process. A caller deciding whether to kill should treat unknown as “do not”.