test(FN-5189): complete Step 4 — add supervision regression test
Fusion-Task-Id: FN-5189 Fusion-Task-Lineage: 4caa3f0a-af81-4c60-88e8-de229ed72e08
This commit is contained in:
committed by
gsxdsm
parent
818db4a714
commit
dc8dd33f80
@@ -0,0 +1,136 @@
|
||||
import { once } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = resolve(process.cwd(), "..", "..");
|
||||
const fixturePath = join(repoRoot, "packages", "core", "src", "__tests__", "fixtures", "process-supervisor-child.mjs");
|
||||
|
||||
type Scenario = "clean-exit" | "sigterm" | "uncaught-exception";
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDead(pid: number, timeoutMs = 10_000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (isAlive(pid)) {
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error(`Timed out waiting for supervised child ${pid} to exit`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
function buildParentScript(scenario: Scenario): string {
|
||||
return `
|
||||
import { superviseSpawn } from "./packages/core/src/index.ts";
|
||||
const child = superviseSpawn(process.execPath, [${JSON.stringify(fixturePath)}, "keepalive"], {
|
||||
stdio: "ignore",
|
||||
killGraceMs: 50,
|
||||
maxLifetimeMs: 5_000,
|
||||
});
|
||||
console.log(String(child.pid));
|
||||
if (${JSON.stringify(scenario)} === "clean-exit") {
|
||||
process.exit(0);
|
||||
}
|
||||
if (${JSON.stringify(scenario)} === "sigterm") {
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
setInterval(() => {}, 1_000);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
throw new Error("FN-5189 uncaught parent failure");
|
||||
}, 10);
|
||||
setInterval(() => {}, 1_000);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function spawnParent(scenario: Scenario): Promise<{ parent: ReturnType<typeof spawn>; childPid: number }> {
|
||||
const parent = spawn(process.execPath, ["--import", "tsx/esm", "--input-type=module", "--eval", buildParentScript(scenario)], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
parent.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
const childPid = await new Promise<number>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timed out waiting for supervised child pid from scenario ${scenario}`));
|
||||
}, 5_000);
|
||||
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
const pid = Number.parseInt(stdout.trim().split(/\s+/)[0] ?? "", 10);
|
||||
if (!Number.isFinite(pid)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
parent.stdout?.off("data", onData);
|
||||
resolve(pid);
|
||||
};
|
||||
|
||||
parent.stdout?.on("data", onData);
|
||||
parent.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
parent.once("exit", (code, signal) => {
|
||||
if (!Number.isFinite(Number.parseInt(stdout.trim().split(/\s+/)[0] ?? "", 10))) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Parent exited before printing child pid (code=${code} signal=${signal})`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { parent, childPid };
|
||||
}
|
||||
|
||||
describe("reliability interactions: FN-5189 verification spawn supervision", () => {
|
||||
const spawnedParents = new Set<ReturnType<typeof spawn>>();
|
||||
|
||||
afterEach(() => {
|
||||
for (const parent of spawnedParents) {
|
||||
if (parent.exitCode === null && parent.signalCode === null) {
|
||||
try {
|
||||
parent.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
}
|
||||
spawnedParents.clear();
|
||||
});
|
||||
|
||||
const caseIt = process.platform === "win32" ? it.skip : it;
|
||||
|
||||
caseIt.each([
|
||||
["clean-exit"],
|
||||
["sigterm"],
|
||||
["uncaught-exception"],
|
||||
] satisfies [Scenario][]) ("reaps supervised child after parent %s", async (scenario) => {
|
||||
const { parent, childPid } = await spawnParent(scenario);
|
||||
spawnedParents.add(parent);
|
||||
|
||||
if (scenario === "sigterm") {
|
||||
parent.kill("SIGTERM");
|
||||
}
|
||||
|
||||
await once(parent, "exit");
|
||||
await waitForDead(childPid);
|
||||
|
||||
expect(isAlive(childPid)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user