fix(test): add opt-in vitest no-output watchdog

This commit is contained in:
Vincent Koc
2026-04-12 04:30:21 +01:00
parent 9c7c360fed
commit f466435529
2 changed files with 168 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
import path from "node:path";
import { spawnPnpmRunner } from "./pnpm-runner.mjs";
import {
forwardSignalToVitestProcessGroup,
installVitestProcessGroupCleanup,
shouldUseDetachedVitestProcessGroup,
} from "./vitest-process-group.mjs";
@@ -14,6 +15,11 @@ function isTruthyEnvValue(value) {
return TRUTHY_ENV_VALUES.has(value?.trim().toLowerCase() ?? "");
}
function parsePositiveInt(value) {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
export function resolveVitestNodeArgs(env = process.env) {
if (isTruthyEnvValue(env.OPENCLAW_VITEST_ENABLE_MAGLEV)) {
return [];
@@ -27,6 +33,10 @@ export function resolveVitestCliEntry() {
return path.join(path.dirname(vitestPackageJson), "vitest.mjs");
}
export function resolveVitestNoOutputTimeoutMs(env = process.env) {
return parsePositiveInt(env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS);
}
export function resolveVitestSpawnParams(env = process.env, platform = process.platform) {
return {
env,
@@ -39,6 +49,91 @@ export function shouldSuppressVitestStderrLine(line) {
return SUPPRESSED_VITEST_STDERR_PATTERNS.some((pattern) => line.includes(pattern));
}
export function installVitestNoOutputWatchdog(params) {
const timeoutMs = params.timeoutMs;
if (!timeoutMs || timeoutMs <= 0) {
return () => {};
}
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
const forceKillAfterMs = params.forceKillAfterMs ?? 5_000;
const streams = params.streams?.filter(Boolean) ?? [];
let active = true;
let silenceTimer = null;
let forceKillTimer = null;
const clearForceKillTimer = () => {
if (forceKillTimer !== null) {
clearTimeoutFn(forceKillTimer);
forceKillTimer = null;
}
};
const clearSilenceTimer = () => {
if (silenceTimer !== null) {
clearTimeoutFn(silenceTimer);
silenceTimer = null;
}
};
const resetSilenceTimer = () => {
if (!active) {
return;
}
clearSilenceTimer();
silenceTimer = setTimeoutFn(() => {
if (!active) {
return;
}
params.log?.(
`[vitest] no output for ${timeoutMs}ms; terminating stalled Vitest process group.`,
);
params.onTimeout?.();
if (forceKillAfterMs > 0) {
clearForceKillTimer();
forceKillTimer = setTimeoutFn(() => {
if (!active) {
return;
}
params.log?.(
`[vitest] process group still alive after ${forceKillAfterMs}ms; sending SIGKILL.`,
);
params.onForceKill?.();
}, forceKillAfterMs);
}
}, timeoutMs);
};
const handleActivity = () => {
clearForceKillTimer();
resetSilenceTimer();
};
const listeners = streams.map((stream) => {
const handler = () => {
handleActivity();
};
stream.on("data", handler);
return { stream, handler };
});
resetSilenceTimer();
return () => {
if (!active) {
return;
}
active = false;
clearSilenceTimer();
clearForceKillTimer();
for (const { stream, handler } of listeners) {
stream.off("data", handler);
}
};
}
function forwardVitestOutput(stream, target, shouldSuppressLine = () => false) {
if (!stream) {
return;
@@ -79,11 +174,25 @@ function main(argv = process.argv.slice(2), env = process.env) {
...spawnParams,
});
const teardownChildCleanup = installVitestProcessGroupCleanup({ child });
const teardownNoOutputWatchdog = installVitestNoOutputWatchdog({
streams: [child.stdout, child.stderr],
timeoutMs: resolveVitestNoOutputTimeoutMs(env),
log: (message) => {
console.error(message);
},
onTimeout: () => {
forwardSignalToVitestProcessGroup({ child, signal: "SIGTERM" });
},
onForceKill: () => {
forwardSignalToVitestProcessGroup({ child, signal: "SIGKILL" });
},
});
forwardVitestOutput(child.stdout, process.stdout);
forwardVitestOutput(child.stderr, process.stderr, shouldSuppressVitestStderrLine);
child.on("exit", (code, signal) => {
teardownChildCleanup();
teardownNoOutputWatchdog();
if (signal) {
process.kill(process.pid, signal);
return;
@@ -93,6 +202,7 @@ function main(argv = process.argv.slice(2), env = process.env) {
child.on("error", (error) => {
teardownChildCleanup();
teardownNoOutputWatchdog();
console.error(error);
process.exit(1);
});