diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 7e29d3539cf..2cdca967e73 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -2139,7 +2139,10 @@ retirement. Launcher cancellation first signals its outer group, so Playwright and the wrapper own graceful server shutdown. It allows 45 seconds for that chain before bounded 5-second forced cleanup of revalidated descendants. Normal launcher exit uses the -same retained descendant inventory. Zombies count as stopped; an unavailable or +same retained descendant inventory. Zombies count as stopped, including when +macOS answers a group signal with EPERM because every remaining member is an +unreaped zombie; EPERM stays fatal while a fresh inspection shows a running +member. An unavailable or uncertain identity inspection triggers bounded direct-child-only retirement and still fails the tree audit. Failed cleanup preserves the temporary state. Graceful owners are selected from the same validated table used for delivery; ESRCH allows diff --git a/tests/runner-e2e/process-tree-owner.test.ts b/tests/runner-e2e/process-tree-owner.test.ts index f53b9d4340f..4f32f088ca7 100644 --- a/tests/runner-e2e/process-tree-owner.test.ts +++ b/tests/runner-e2e/process-tree-owner.test.ts @@ -174,3 +174,38 @@ it.skipIf(process.platform === 'win32')('bounds fallback even when the known dir expect(signals).toEqual(['SIGTERM', 'SIGKILL']); } finally { owner.stopObserving(); } }); + +function epermFixture(afterSignal: ProcessObservation[]) { + let table = [row(process.pid, 1, 10), row(100, process.pid, 100), row(200, 100, 200)]; + const root = { pid: 100, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null }; + const attempts: Array<[number, NodeJS.Signals]> = []; + const owner = createProcessTreeOwner(root as ChildProcess, { + readTable: async () => table, + signalGroup: (pid, signal) => { + attempts.push([pid, signal]); + if (pid !== 200) return; + // Darwin killpg reports EPERM, not ESRCH, once every group member exited + // but is not reaped yet. The group exits between inspection and signal. + table = [row(process.pid, 1, 10), row(100, process.pid, 100), ...afterSignal]; + throw Object.assign(new Error("kill EPERM"), { code: "EPERM" }); + }, + }); + return { owner, attempts }; +} + +it.skipIf(process.platform === "win32")("treats EPERM from a group that is now only zombies as already exited", async () => { + const f = epermFixture([row(200, 100, 200, "start-200", "Z")]); + try { + await f.owner.observe(); + await expect(f.owner.signal("SIGKILL")).resolves.toEqual([100]); + expect(f.attempts).toEqual([[200, "SIGKILL"], [100, "SIGKILL"]]); + } finally { f.owner.stopObserving(); } +}); + +it.skipIf(process.platform === "win32")("keeps EPERM fatal while the group still has a running member", async () => { + const f = epermFixture([row(200, 100, 200, "start-200", "Z"), row(201, 200, 200)]); + try { + await f.owner.observe(); + await expect(f.owner.signal("SIGKILL")).rejects.toThrow("kill EPERM"); + } finally { f.owner.stopObserving(); } +}); diff --git a/tests/runner-e2e/process-tree-owner.ts b/tests/runner-e2e/process-tree-owner.ts index 3dcae17791f..ed12bf10d72 100644 --- a/tests/runner-e2e/process-tree-owner.ts +++ b/tests/runner-e2e/process-tree-owner.ts @@ -138,7 +138,14 @@ export function createProcessTreeOwner(root: ChildProcess, options: { const ids = new Set(preserved.map(group => group.processGroupId)); return new Set(liveGroups().filter(group => !ids.has(group.processGroupId)).map(group => group.processGroupId)); } - function signalSnapshot(signal: NodeJS.Signals, selected?: ReadonlySet) { + /** Darwin killpg reports EPERM, not ESRCH, when every remaining group member + * has exited but is not yet reaped. A zombie-only group needs no signal; any + * running member keeps EPERM a real failure. */ + async function exitedSinceSnapshot(pid: number) { + const fresh = await readTable(); + return !!fresh && !fresh.some(row => row.processGroupId === pid && running(row)); + } + async function signalSnapshot(signal: NodeJS.Signals, selected?: ReadonlySet) { const currentProcessGroupId = table.find(row => row.pid === process.pid)?.processGroupId ?? null; if (currentProcessGroupId === null) throw new Error("Cleanup caller group identity is unavailable"); const live = liveGroups(); @@ -147,14 +154,18 @@ export function createProcessTreeOwner(root: ChildProcess, options: { for (const pid of ordered) { if (selected && !selected.has(pid)) continue; try { signalGroup(pid, signal); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; else continue; } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH" || (code === "EPERM" && await exitedSinceSnapshot(pid))) continue; + throw error; + } signaled.push(pid); } return signaled; } async function signal(signal: NodeJS.Signals, selected?: ReadonlySet) { await observe(); - return signalSnapshot(signal, selected); + return await signalSnapshot(signal, selected); } function gracefulRoots() { const live = liveGroups(); @@ -183,7 +194,7 @@ export function createProcessTreeOwner(root: ChildProcess, options: { // Choose and deliver from one validated table. ESRCH grants no ownership // coverage: the next grace poll can select that branch's surviving owner. const selected = new Set([...gracefulRoots()].filter(pid => !covered.has(pid))); - const delivered = signalSnapshot(signal, selected); + const delivered = await signalSnapshot(signal, selected); for (const pid of delivered) { if (pid === root.pid) directGraceDelivered = true; covered.add(pid);