fix(scripts): make the tsgo watchdog opt-in and stop the harness leaking

ClawSweeper review on 6ba02c9d0a raised two findings.

[P1] The 45-minute default applied an unproven deadline to every tsgo
invocation. No supported duration contract covers every host and project, and
CI already bounds its own tsgo jobs at 15-20 minutes, so the default could only
ever fire outside CI where it was least validated. Drop it: an unset
OPENCLAW_TSGO_TIMEOUT_MS keeps the pre-existing unbounded wait, so no existing
run changes behavior, and operators opt in per host. Documented in
docs/help/testing.md beside the sibling Vitest watchdog.

[P2] The regression harness could leak its wedged child. The fake compiler
ignores SIGTERM by design, so a pre-fix or otherwise failing run left the tree
running after spawnSync gave up. Bound the fixture's loop as a backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jesse Merhi
2026-08-17 16:14:03 +10:00
parent 6ba02c9d0a
commit c7a699ee82
3 changed files with 33 additions and 13 deletions
+5
View File
@@ -694,6 +694,11 @@ Native dependency policy:
after 5 minutes with no stdout or stderr output. Set
`OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=0` to disable the watchdog for
an intentionally silent investigation.
- `scripts/run-tsgo.mjs` waits indefinitely by default. Set
`OPENCLAW_TSGO_TIMEOUT_MS` to bound a run on hosts where a wedged
compiler would otherwise block its caller forever; on expiry the whole
tsgo process tree is killed and the run fails. Values above Node's
timer ceiling saturate instead of collapsing to a 1ms deadline.
</Accordion>
+8 -6
View File
@@ -16,8 +16,6 @@ import {
shouldSkipSparseTsgoGuardError,
} from "./lib/tsgo-sparse-guard.mts";
/** Watchdog bound for one tsgo run; sized well above a healthy whole-program check. */
const DEFAULT_TSGO_TIMEOUT_MS = 45 * 60 * 1000;
/** Node's timer ceiling: a longer delay silently becomes 1ms, so a raised override must saturate. */
const MAX_TSGO_TIMEOUT_MS = 2_147_483_647;
@@ -51,10 +49,14 @@ async function main(): Promise<void> {
}
ensureRepoToolNodeModulesLink(tsgoPath);
const timeoutMs = Math.min(
readPositiveEnvInt("OPENCLAW_TSGO_TIMEOUT_MS", env, DEFAULT_TSGO_TIMEOUT_MS),
MAX_TSGO_TIMEOUT_MS,
);
// Opt-in deadline: no supported duration contract covers every host and project,
// so an unset value keeps the pre-existing unbounded wait rather than guessing one.
const timeoutMs = env.OPENCLAW_TSGO_TIMEOUT_MS?.trim()
? Math.min(
readPositiveEnvInt("OPENCLAW_TSGO_TIMEOUT_MS", env, MAX_TSGO_TIMEOUT_MS),
MAX_TSGO_TIMEOUT_MS,
)
: undefined;
try {
// Managed run owns the whole tsgo process tree: on timeout it SIGKILLs the
// process group, because a wedged checker ignores SIGTERM and would otherwise
+20 -7
View File
@@ -242,17 +242,16 @@ describe.skipIf(process.platform === "win32")("run-tsgo watchdog", () => {
fs.chmodSync(fakeTsgo, 0o755);
}
function runFakeTsgo(cwd: string, timeoutMs: string) {
function runFakeTsgo(cwd: string, timeoutMs: string | undefined) {
const { OPENCLAW_TSGO_TIMEOUT_MS: _unset, ...baseEnv } = process.env;
return spawnSync(
process.execPath,
[path.resolve("scripts/run-tsgo.mjs"), "-p", "tsconfig.extensions.json"],
{
cwd,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_TSGO_TIMEOUT_MS: timeoutMs,
},
env:
timeoutMs === undefined ? baseEnv : { ...baseEnv, OPENCLAW_TSGO_TIMEOUT_MS: timeoutMs },
// spawnSync blocks this thread, so vitest's own per-test budget can never
// fire; a regression here would hang the worker instead of failing.
timeout: 25_000,
@@ -264,8 +263,12 @@ describe.skipIf(process.platform === "win32")("run-tsgo watchdog", () => {
it("kills a wedged tsgo that ignores SIGTERM instead of blocking its caller forever", () => {
const cwd = createTempDir("openclaw-run-tsgo-watchdog-");
// Mirrors the observed wedge: the checker refuses SIGTERM and never reports,
// so only a process-group SIGKILL frees the caller.
writeFakeTsgo(cwd, "#!/bin/sh\ntrap '' TERM\nwhile true; do sleep 1; done\n");
// so only a process-group SIGKILL frees the caller. The bounded loop is the
// harness backstop: a pre-fix or failing run must not leak this tree.
writeFakeTsgo(
cwd,
"#!/bin/sh\ntrap '' TERM\ni=0\nwhile [ $i -lt 60 ]; do sleep 1; i=$((i+1)); done\n",
);
const result = runFakeTsgo(cwd, "2000");
@@ -274,6 +277,16 @@ describe.skipIf(process.platform === "win32")("run-tsgo watchdog", () => {
expect(result.stderr.trim().split("\n").at(-1)).toBe("[tsgo] FAILED (exit 1)");
}, 30_000);
it("arms no watchdog until an operator opts in", () => {
const cwd = createTempDir("openclaw-run-tsgo-watchdog-");
writeFakeTsgo(cwd, "#!/bin/sh\nsleep 2\nexit 0\n");
const result = runFakeTsgo(cwd, undefined);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("killed the tsgo process tree");
}, 30_000);
it("leaves a tsgo that finishes inside the watchdog bound alone", () => {
const cwd = createTempDir("openclaw-run-tsgo-watchdog-");
writeFakeTsgo(cwd, "#!/bin/sh\nexit 0\n");