Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}

/**
Expand All @@ -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();

Expand All @@ -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.
Expand Down
29 changes: 26 additions & 3 deletions tests/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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.
Expand All @@ -318,24 +320,44 @@ 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.
let ppid = 50;
const isAlive = makeDefaultIsParentAlive({
getPpid: () => ppid,
readGrandparentPpid: () => 7, // grandparent alive the whole time
isPidAlive: () => true,
});

assert.equal(isAlive(), true);
Expand All @@ -350,6 +372,7 @@ describe("makeDefaultIsParentAlive — grandparent orphan detection (#311)", ()
const aliveCheck = makeDefaultIsParentAlive({
getPpid: () => 100,
readGrandparentPpid: () => currentGrandparent,
isPidAlive: () => true,
});

let shutdownCalled = false;
Expand Down