mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(test): add opt-in vitest no-output watchdog
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installVitestNoOutputWatchdog,
|
||||
resolveVitestNodeArgs,
|
||||
resolveVitestNoOutputTimeoutMs,
|
||||
resolveVitestSpawnParams,
|
||||
shouldSuppressVitestStderrLine,
|
||||
} from "../../scripts/run-vitest.mjs";
|
||||
@@ -19,6 +22,16 @@ describe("scripts/run-vitest", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses the optional no-output timeout env", () => {
|
||||
expect(resolveVitestNoOutputTimeoutMs({})).toBeNull();
|
||||
expect(resolveVitestNoOutputTimeoutMs({ OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "2500" })).toBe(
|
||||
2500,
|
||||
);
|
||||
expect(
|
||||
resolveVitestNoOutputTimeoutMs({ OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "0" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("spawns vitest in a detached process group on Unix hosts", () => {
|
||||
expect(resolveVitestSpawnParams({ PATH: "/usr/bin" }, "darwin")).toEqual({
|
||||
env: { PATH: "/usr/bin" },
|
||||
@@ -40,4 +53,48 @@ describe("scripts/run-vitest", () => {
|
||||
).toBe(true);
|
||||
expect(shouldSuppressVitestStderrLine("real failure output\n")).toBe(false);
|
||||
});
|
||||
|
||||
it("kills silent vitest runs after the configured idle timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const stdout = new EventEmitter();
|
||||
const timeoutSpy = vi.fn();
|
||||
const forceKillSpy = vi.fn();
|
||||
const logSpy = vi.fn();
|
||||
|
||||
const teardown = installVitestNoOutputWatchdog({
|
||||
streams: [stdout],
|
||||
timeoutMs: 1000,
|
||||
forceKillAfterMs: 5000,
|
||||
log: logSpy,
|
||||
onTimeout: timeoutSpy,
|
||||
onForceKill: forceKillSpy,
|
||||
setTimeoutFn: setTimeout,
|
||||
clearTimeoutFn: clearTimeout,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(900);
|
||||
expect(timeoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
stdout.emit("data", "still alive");
|
||||
vi.advanceTimersByTime(900);
|
||||
expect(timeoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(timeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
"[vitest] no output for 1000ms; terminating stalled Vitest process group.",
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(forceKillSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
"[vitest] process group still alive after 5000ms; sending SIGKILL.",
|
||||
);
|
||||
|
||||
teardown();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user