Orphan recovery
Detecting that the host died
Section titled “Detecting that the host died”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
Section titled “Identity, not liveness”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”.
Where to go next
Section titled “Where to go next”- Cross-platform — Job Objects and the process-tree table in full.
- Troubleshooting: orphans — what to do when you find one.