Skip to content

Commit 1fa4713

Browse files
committed
fix(cron): kill command process trees on timeout
1 parent 0cd7996 commit 1fa4713

3 files changed

Lines changed: 49 additions & 1 deletion

File tree

src/cron/command-runner.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { setTimeout as delay } from "node:timers/promises";
15
import { describe, expect, it } from "vitest";
26
import { runCronCommandJob } from "./command-runner.js";
37
import type { CronJob } from "./types.js";
@@ -90,6 +94,31 @@ describe("runCronCommandJob", () => {
9094
});
9195
});
9296

97+
it.skipIf(process.platform === "win32")("kills shell process groups on timeout", async () => {
98+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-command-"));
99+
const markerPath = path.join(tempDir, "survived");
100+
const childScript = [
101+
`setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "alive"), 350)`,
102+
"setInterval(() => {}, 1000)",
103+
].join(";");
104+
const shellCommand = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(childScript)}`;
105+
106+
const result = await runCronCommandJob({
107+
job: makeCommandJob({
108+
kind: "command",
109+
argv: ["sh", "-lc", shellCommand],
110+
timeoutSeconds: 0.05,
111+
}),
112+
});
113+
114+
expect(result.status).toBe("error");
115+
expect(result.error).toBe("command timed out");
116+
117+
await delay(700);
118+
await expect(fs.access(markerPath)).rejects.toThrow();
119+
await fs.rm(tempDir, { recursive: true, force: true });
120+
});
121+
93122
it("marks no-output timeouts as cron errors", async () => {
94123
const result = await runCronCommandJob({
95124
job: makeCommandJob({

src/cron/command-runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export async function runCronCommandJob(params: {
116116
...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}),
117117
...(payload.outputMaxBytes !== undefined ? { maxOutputBytes: payload.outputMaxBytes } : {}),
118118
...(params.abortSignal ? { signal: params.abortSignal } : {}),
119+
killProcessTree: true,
119120
});
120121
const ok =
121122
result.code === 0 &&

src/process/exec.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export type CommandOptions = {
256256
noOutputTimeoutMs?: number;
257257
signal?: AbortSignal;
258258
maxOutputBytes?: number;
259+
killProcessTree?: boolean;
259260
};
260261

261262
const WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS = 250;
@@ -368,7 +369,8 @@ export async function runCommandWithTimeout(
368369
): Promise<SpawnResult> {
369370
const options: CommandOptions =
370371
typeof optionsOrTimeout === "number" ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout;
371-
const { timeoutMs, cwd, input, baseEnv, env, noOutputTimeoutMs, signal } = options;
372+
const { timeoutMs, cwd, input, baseEnv, env, noOutputTimeoutMs, signal, killProcessTree } =
373+
options;
372374
const hasInput = input !== undefined;
373375
const resolvedEnv = resolveCommandEnv({ argv, baseEnv, env });
374376
const stdio = resolveCommandStdio({ hasInput, preferInherit: true });
@@ -393,6 +395,9 @@ export async function runCommandWithTimeout(
393395
stdio,
394396
cwd,
395397
env: resolvedEnv,
398+
// Cron shell wrappers need their own process group so timeout/abort kills
399+
// reach foreground children instead of leaving duplicate scheduled work.
400+
...(killProcessTree && process.platform !== "win32" ? { detached: true } : {}),
396401
windowsHide: invocation.windowsHide,
397402
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
398403
...(shouldSpawnWithShell({ resolvedCommand: invocation.command, platform: process.platform })
@@ -455,6 +460,19 @@ export async function runCommandWithTimeout(
455460
// Fall through to Node's direct child kill as a last resort.
456461
}
457462
}
463+
if (
464+
killProcessTree &&
465+
process.platform !== "win32" &&
466+
typeof child.pid === "number" &&
467+
child.pid > 0
468+
) {
469+
try {
470+
process.kill(-child.pid, "SIGKILL");
471+
return;
472+
} catch {
473+
// Fall through to Node's direct child kill as a last resort.
474+
}
475+
}
458476
child.kill("SIGKILL");
459477
};
460478

0 commit comments

Comments
 (0)