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;