Improve dashboard vitest wrapper diagnostics so transient SIGTERM handling is easier to trace. - track the last signal and reason forwarded from the heap wrapper to the vitest process group - log when the wrapper receives SIGINT or SIGTERM and when timeout shutdown escalates - extend wrapper tests to assert process-group leadership and emitted diagnostic stderr Files changed: .../scripts/__tests__/run-vitest-with-heap.test.ts | 25 +++++++++++++++++++--- .../dashboard/scripts/run-vitest-with-heap.mjs | 19 ++++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-6184 Fusion-Task-Lineage: b276d88e-a22d-4fdf-a533-c4b0aa4e199d
153 lines
4.5 KiB
JavaScript
153 lines
4.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/* global clearInterval, clearTimeout, console, process, setInterval, setTimeout */
|
|
|
|
import { spawn } from "node:child_process";
|
|
|
|
const rawArgs = process.argv.slice(2);
|
|
const heapArg = rawArgs.find((arg) => arg.startsWith("--heap="));
|
|
const heapMb = heapArg?.slice("--heap=".length) || "6144";
|
|
const vitestArgs = rawArgs.filter((arg) => !arg.startsWith("--heap="));
|
|
|
|
if (vitestArgs.length === 0) {
|
|
console.error("Usage: node scripts/run-vitest-with-heap.mjs [--heap=6144] <vitest args...>");
|
|
process.exit(1);
|
|
}
|
|
|
|
const nodeOptions = [`--max-old-space-size=${heapMb}`, process.env.NODE_OPTIONS || ""]
|
|
.join(" ")
|
|
.trim();
|
|
const timeoutMs = Number.parseInt(process.env.FUSION_RUN_VITEST_TIMEOUT_MS || "900000", 10);
|
|
const forceKillGraceMs = Number.parseInt(process.env.FUSION_RUN_VITEST_KILL_GRACE_MS || "5000", 10);
|
|
|
|
function resolveSpawnCommand() {
|
|
const override = process.env.FUSION_RUN_VITEST_SPAWN_OVERRIDE;
|
|
if (!override) {
|
|
return { command: "pnpm", args: ["exec", "vitest", ...vitestArgs] };
|
|
}
|
|
|
|
const parsedOverride = JSON.parse(override);
|
|
if (
|
|
!parsedOverride ||
|
|
typeof parsedOverride.command !== "string" ||
|
|
parsedOverride.command.length === 0 ||
|
|
!Array.isArray(parsedOverride.args) ||
|
|
parsedOverride.args.some((arg) => typeof arg !== "string")
|
|
) {
|
|
throw new Error(
|
|
"FUSION_RUN_VITEST_SPAWN_OVERRIDE must be valid JSON with string command and string[] args",
|
|
);
|
|
}
|
|
|
|
// Test seam for process-lifecycle coverage without launching real vitest.
|
|
return { command: parsedOverride.command, args: parsedOverride.args };
|
|
}
|
|
|
|
const { command, args } = resolveSpawnCommand();
|
|
// process-supervisor-allowlist: foreground wrapper signals the entire vitest process group on death/timeout; not a background daemon
|
|
const child = spawn(command, args, {
|
|
detached: true,
|
|
stdio: "inherit",
|
|
env: { ...process.env, NODE_OPTIONS: nodeOptions },
|
|
});
|
|
|
|
const heartbeat = setInterval(() => {
|
|
console.log(`[dashboard-vitest] still running: ${vitestArgs.join(" ")}`);
|
|
}, 5_000);
|
|
let timeoutExitCode = null;
|
|
let forceKillTimer = null;
|
|
let lastForwardedSignal = null;
|
|
let lastForwardReason = null;
|
|
const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
? setTimeout(() => {
|
|
timeoutExitCode = 124;
|
|
console.error(`[dashboard-vitest] timeout after ${timeoutMs}ms: ${vitestArgs.join(" ")}`);
|
|
forwardSignal("SIGTERM", "timeout");
|
|
forceKillTimer = setTimeout(() => {
|
|
forwardSignal("SIGKILL", "timeout-grace-expired");
|
|
}, Math.max(1, forceKillGraceMs));
|
|
forceKillTimer.unref();
|
|
}, timeoutMs)
|
|
: null;
|
|
timeout?.unref();
|
|
|
|
function clearHeartbeat() {
|
|
clearInterval(heartbeat);
|
|
}
|
|
|
|
function clearTimers() {
|
|
clearHeartbeat();
|
|
if (timeout) clearTimeout(timeout);
|
|
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
}
|
|
|
|
function forwardSignal(signal, reason = "external-signal") {
|
|
clearHeartbeat();
|
|
lastForwardedSignal = signal;
|
|
lastForwardReason = reason;
|
|
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
return;
|
|
} catch (error) {
|
|
if (!(error instanceof Error) || !("code" in error)) {
|
|
throw error;
|
|
}
|
|
|
|
if (error.code !== "ESRCH" && error.code !== "EPERM") {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
try {
|
|
child.kill(signal);
|
|
} catch (error) {
|
|
if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
process.on(signal, () => {
|
|
console.error(`[dashboard-vitest] received ${signal}; forwarding to vitest process group: ${vitestArgs.join(" ")}`);
|
|
forwardSignal(signal, "wrapper-received-signal");
|
|
});
|
|
}
|
|
|
|
process.on("exit", () => {
|
|
clearTimers();
|
|
try {
|
|
process.kill(-child.pid, "SIGTERM");
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof Error) ||
|
|
!("code" in error) ||
|
|
(error.code !== "ESRCH" && error.code !== "EPERM")
|
|
) {
|
|
throw error;
|
|
}
|
|
}
|
|
});
|
|
|
|
child.on("error", (error) => {
|
|
clearTimers();
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
|
|
child.on("close", (code, signal) => {
|
|
clearTimers();
|
|
if (timeoutExitCode !== null) {
|
|
process.exit(timeoutExitCode);
|
|
}
|
|
if (signal) {
|
|
const forwardedContext = lastForwardedSignal
|
|
? ` after forwarding ${lastForwardedSignal} (${lastForwardReason ?? "unknown-reason"})`
|
|
: " without a wrapper-forwarded signal";
|
|
console.error(`[dashboard-vitest] child exited via ${signal}${forwardedContext}: ${vitestArgs.join(" ")}`);
|
|
process.kill(process.pid, signal);
|
|
return;
|
|
}
|
|
process.exit(code ?? 1);
|
|
});
|