diff --git a/tests/boundary/process/processSettlementDeadline.test.ts b/tests/boundary/process/processSettlementDeadline.test.ts index 83c7483663..eed7975c36 100644 --- a/tests/boundary/process/processSettlementDeadline.test.ts +++ b/tests/boundary/process/processSettlementDeadline.test.ts @@ -1,5 +1,10 @@ -import { spawn } from "node:child_process"; import { describe, expect, it } from "vitest"; + +import { + spawnOwnedProviderProcess, + type ProviderProcessHandle, +} from "../../../src/process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { observeSettlement } from "../../../src/process/capture/ProcessCaptureLifecycle.js"; import { observeOwnedProcessGroups } from "../../../src/process/ProcessOwnershipObservation.js"; @@ -9,65 +14,121 @@ const ABSENT_GROUP_ID = 4_294_967_295; const observeGroup = async (groupId: number) => observeOwnedProcessGroups("settlement-deadline-probe", [groupId]); -describe("settlement deadline classification", () => { - it("does not report a single empty observation as alive_at_deadline", async () => { - expect( - (await observeGroup(ABSENT_GROUP_ID)).get(ABSENT_GROUP_ID)?.state, - ).toBe("empty"); - - const settlement = await observeSettlement( - "settlement-single-empty", - [ABSENT_GROUP_ID], - 0, - () => undefined, - "linux", - ); - expect(settlement.state).toBe("unverifiable"); +const waitForOwnedExit = ( + child: ProviderProcessHandle, + timeoutMs: number, +): Promise => + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(true); + return; + } + const onExit = (): void => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + child.off("exit", onExit); + resolve(false); + }, timeoutMs); + child.once("exit", onExit); }); - it("still reports observed liveness as alive_at_deadline", async () => { - const runId = "settlement-live-group"; - // A detached child leads its own process group and carries the run token, - // so the group observes as a genuinely owned live member. - const child = spawn("sleep", ["30"], { - detached: true, - stdio: "ignore", - env: { ...process.env, REA_PROCESS_RUN_ID: runId }, - }); - child.unref(); - try { - // Wait until the child is actually listed before asserting on it. - const started = Date.now(); - while ( - (await observeOwnedProcessGroups(runId, [child.pid!])).get(child.pid!) - ?.state !== "alive" - ) { - if (Date.now() - started > 5_000) - throw new Error("live fixture group never observed as alive"); - await new Promise((resolve) => setTimeout(resolve, 50)); - } +const stopOwnedFixture = async ( + child: ProviderProcessHandle, + ownership: Parameters[0], +): Promise => { + let cleanupFailure: string | undefined; + try { + const result = await cleanupOwnedProcessGroup(ownership); + if (!result.cleaned) cleanupFailure = result.reason; + } catch (cause: unknown) { + cleanupFailure = cause instanceof Error ? cause.message : String(cause); + } + + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + if (!(await waitForOwnedExit(child, 5_000))) + throw new Error("owned settlement fixture did not exit after cleanup"); + if (cleanupFailure !== undefined) + throw new Error( + `owned settlement fixture cleanup failed: ${cleanupFailure}`, + ); +}; + +// Windows Job Object ownership has a separate settlement path from POSIX PGIDs. +describe.skipIf(process.platform === "win32")( + "settlement deadline classification", + () => { + it("does not report a single empty observation as alive_at_deadline", async () => { + expect( + (await observeGroup(ABSENT_GROUP_ID)).get(ABSENT_GROUP_ID)?.state, + ).toBe("empty"); const settlement = await observeSettlement( - runId, - [child.pid!], + "settlement-single-empty", + [ABSENT_GROUP_ID], 0, () => undefined, "linux", ); - expect(settlement.state).toBe("alive_at_deadline"); - } finally { - child.kill("SIGKILL"); - } - }); + expect(settlement.state).toBe("unverifiable"); + }); - it("still reports two consecutive empty observations as quiesced", async () => { - const settlement = await observeSettlement( - "settlement-two-empty", - [ABSENT_GROUP_ID], - 1_000, - () => undefined, - "linux", - ); - expect(settlement.state).toBe("quiesced"); - }); -}); + it("still reports observed owned liveness as alive_at_deadline", async () => { + const runId = "settlement-live-group"; + const launched = await spawnOwnedProviderProcess({ + command: process.execPath, + arguments: ["-e", "setInterval(() => undefined, 1000)"], + runId, + }); + try { + const started = Date.now(); + let observation = ( + await observeOwnedProcessGroups(runId, [ + launched.ownership.processGroupId, + ]) + ).get(launched.ownership.processGroupId); + while ( + observation?.state === "empty" && + Date.now() - started <= 5_000 + ) { + await new Promise((resolve) => setTimeout(resolve, 50)); + observation = ( + await observeOwnedProcessGroups(runId, [ + launched.ownership.processGroupId, + ]) + ).get(launched.ownership.processGroupId); + } + expect( + observation?.state, + observation?.state === "unverifiable" + ? observation.reason + : undefined, + ).toBe("alive"); + + const settlement = await observeSettlement( + runId, + [launched.ownership.processGroupId], + 0, + () => undefined, + process.platform, + ); + expect(settlement.state).toBe("alive_at_deadline"); + } finally { + await stopOwnedFixture(launched.process, launched.ownership); + } + }); + + it("still reports two consecutive empty observations as quiesced", async () => { + const settlement = await observeSettlement( + "settlement-two-empty", + [ABSENT_GROUP_ID], + 1_000, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("quiesced"); + }); + }, +);