From 5cd1fa19a43cd9529395901c0fa9fc4d7207d078 Mon Sep 17 00:00:00 2001 From: Adam Love Date: Tue, 21 Jul 2026 21:33:21 -0500 Subject: [PATCH] fix(lifecycle): detect parent death on Windows via PID existence probe (#982) startLifecycleGuard already runs everywhere, but makeDefaultIsParentAlive only detected death via the ppid changing (POSIX reparent-to-init) or the grandparent going to PID 1 (POSIX ps probe, NaN on Windows). On Windows an orphaned child keeps its original ppid and is never reparented, so isParentAlive() returned true forever and the orphaned server never shut down. Add a cross-platform positive existence probe (process.kill(ppid, 0)) as an additional death signal; it is injectable for testing and strictly additive to the existing POSIX paths. --- src/lifecycle.ts | 34 ++++++++++++++++++++++++++++++++++ tests/lifecycle.test.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 67d53128f..bbc54d07c 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -61,6 +61,32 @@ export interface IsParentAliveDeps { getPpid?: () => number; /** Read the grandparent ppid. Default: ps-based POSIX probe, NaN on Windows. */ readGrandparentPpid?: () => number; + /** + * Positive existence probe for a PID. Default: {@link defaultIsPidAlive} + * (signal-0 via `process.kill`). This is the only parent-death signal that + * works on Windows, where an orphaned child keeps its original ppid (#982). + */ + isPidAlive?: (pid: number) => boolean; +} + +/** + * Cross-platform "does this PID still exist?" probe. Node maps signal 0 to an + * existence/permission check without delivering a signal: it throws `ESRCH` + * when the process is gone and `EPERM` when it exists but can't be signalled + * (treated as alive). Any other error is treated as alive to avoid a spurious + * reap. Unlike the ppid-equality and grandparent checks, this fires on Windows, + * where a parent's death does NOT reparent the child or change its ppid — so + * without this probe the guard's `isParentAlive()` returned `true` forever and + * the orphaned server never shut down (#982). + */ +export function defaultIsPidAlive(pid: number): boolean { + if (!pid || pid <= 1) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EPERM"; + } } /** @@ -81,6 +107,7 @@ export interface IsParentAliveDeps { export function makeDefaultIsParentAlive(deps: IsParentAliveDeps = {}): () => boolean { const getPpid = deps.getPpid ?? (() => process.ppid); const readGp = deps.readGrandparentPpid ?? readGrandparentPpidImpl; + const isPidAlive = deps.isPidAlive ?? defaultIsPidAlive; const originalPpid = getPpid(); const originalGrandparentPpid = readGp(); @@ -89,6 +116,13 @@ export function makeDefaultIsParentAlive(deps: IsParentAliveDeps = {}): () => bo if (ppid !== originalPpid) return false; if (ppid === 0 || ppid === 1) return false; + // Positive existence probe (#982): on Windows an orphaned child keeps its + // original ppid (no reparent-to-init) and the POSIX grandparent probe is + // unavailable, so both checks above/below stay green after the parent dies. + // Directly test whether the parent PID still exists — the one death signal + // that works cross-platform. + if (!isPidAlive(originalPpid)) return false; + // Grandparent orphan check (#311): npm-exec wrappers stay alive past the // session owner. If our grandparent is now PID 1 but wasn't at startup, // the wrapping chain is orphaned and we should shut down. diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 18b0aa21d..531c310a3 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -294,6 +294,7 @@ describe("makeDefaultIsParentAlive — grandparent orphan detection (#311)", () const isAlive = makeDefaultIsParentAlive({ getPpid: () => 100, readGrandparentPpid: () => currentGrandparent, + isPidAlive: () => true, // parent PID present; isolate the grandparent path }); assert.equal(isAlive(), true, "alive at startup when grandparent is a normal process"); @@ -310,6 +311,7 @@ describe("makeDefaultIsParentAlive — grandparent orphan detection (#311)", () const isAlive = makeDefaultIsParentAlive({ getPpid: () => 100, readGrandparentPpid: () => 1, + isPidAlive: () => true, }); // Multiple polls — never flip to false while ppid is stable. @@ -318,17 +320,36 @@ describe("makeDefaultIsParentAlive — grandparent orphan detection (#311)", () assert.equal(isAlive(), true); }); - test("tolerates NaN grandparent (Windows / ps failure)", () => { - // On Windows readGrandparentPpidImpl returns NaN; the check must fall - // back to the original ppid-only path and stay green while ppid is stable. + test("tolerates NaN grandparent (Windows / ps failure) while parent PID is alive", () => { + // On Windows readGrandparentPpidImpl returns NaN; with the parent PID still + // present the check must stay green while ppid is stable. const isAlive = makeDefaultIsParentAlive({ getPpid: () => 100, readGrandparentPpid: () => NaN, + isPidAlive: () => true, }); assert.equal(isAlive(), true); }); + test("detects parent death on Windows via PID existence probe (#982)", () => { + // The Windows failure mode: an orphaned child keeps its original ppid (no + // reparent-to-init) and the ps-based grandparent probe returns NaN, so + // BOTH the ppid-equality and grandparent checks stay green forever. The + // PID existence probe is the only signal that fires — without it the guard + // never shut the orphan down. + let parentAlive = true; + const isAlive = makeDefaultIsParentAlive({ + getPpid: () => 100, // stable ppid, exactly as Windows keeps it + readGrandparentPpid: () => NaN, // no ps on Windows + isPidAlive: () => parentAlive, + }); + + assert.equal(isAlive(), true, "alive while the parent PID still exists"); + parentAlive = false; // claude.exe exits; child is NOT reparented on Windows + assert.equal(isAlive(), false, "must reap once the parent PID is gone (#982)"); + }); + test("direct ppid death still takes precedence over grandparent check", () => { // If our own parent dies (ppid flips to init), shut down immediately — // don't wait for a grandparent poll to confirm. @@ -336,6 +357,7 @@ describe("makeDefaultIsParentAlive — grandparent orphan detection (#311)", () const isAlive = makeDefaultIsParentAlive({ getPpid: () => ppid, readGrandparentPpid: () => 7, // grandparent alive the whole time + isPidAlive: () => true, }); assert.equal(isAlive(), true); @@ -350,6 +372,7 @@ describe("makeDefaultIsParentAlive — grandparent orphan detection (#311)", () const aliveCheck = makeDefaultIsParentAlive({ getPpid: () => 100, readGrandparentPpid: () => currentGrandparent, + isPidAlive: () => true, }); let shutdownCalled = false;