From f466435529479ebcee9bc0d1d9b749c9bab6978d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 12 Apr 2026 04:30:21 +0100 Subject: [PATCH] fix(test): add opt-in vitest no-output watchdog --- scripts/run-vitest.mjs | 110 ++++++++++++++++++++++++++++++++ test/scripts/run-vitest.test.ts | 59 ++++++++++++++++- 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index 36914beb03a..d0c30341556 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -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); }); diff --git a/test/scripts/run-vitest.test.ts b/test/scripts/run-vitest.test.ts index 85dbed06a1e..fcbcf26fb82 100644 --- a/test/scripts/run-vitest.test.ts +++ b/test/scripts/run-vitest.test.ts @@ -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(); + } + }); });