Skip to content

Commit 68790eb

Browse files
committed
fix(scripts): preserve secret proof timeout grace
1 parent 9a92c3d commit 68790eb

2 files changed

Lines changed: 104 additions & 10 deletions

File tree

scripts/e2e/secret-provider-integrations.mjs

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const COMMAND_TIMEOUT_MS = readPositiveInt(
2525
120000,
2626
"OPENCLAW_SECRET_PROOF_COMMAND_MS",
2727
);
28+
const COMMAND_TIMEOUT_KILL_GRACE_MS = 1000;
2829
const READY_TIMEOUT_MS = readPositiveInt(
2930
process.env.OPENCLAW_SECRET_PROOF_READY_MS,
3031
120000,
@@ -297,20 +298,27 @@ function runCommand(command, args, options = {}) {
297298
let timedOut = false;
298299
let aborted = false;
299300
let killTimer;
301+
let forceKillAt;
302+
const armForceKill = () => {
303+
forceKillAt ??= Date.now() + COMMAND_TIMEOUT_KILL_GRACE_MS;
304+
killTimer ??= setTimeout(
305+
() => terminateProcessTree(child, "SIGKILL"),
306+
COMMAND_TIMEOUT_KILL_GRACE_MS,
307+
);
308+
killTimer.unref();
309+
};
300310
const abort = () => {
301311
if (usesProcessGroup ? !processTreeIsAlive(child) : childHasExited(child)) {
302312
return;
303313
}
304314
aborted = true;
305315
terminateProcessTree(child, "SIGTERM");
306-
killTimer ??= setTimeout(() => terminateProcessTree(child, "SIGKILL"), 1000);
307-
killTimer.unref();
316+
armForceKill();
308317
};
309318
const timer = setTimeout(() => {
310319
timedOut = true;
311320
terminateProcessTree(child, "SIGTERM");
312-
killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 1000);
313-
killTimer.unref();
321+
armForceKill();
314322
}, timeoutMs);
315323
const abortSignal = options.signal;
316324
if (abortSignal?.aborted) {
@@ -347,27 +355,42 @@ function runCommand(command, args, options = {}) {
347355
if (killTimer) {
348356
clearTimeout(killTimer);
349357
}
358+
forceKillAt = undefined;
350359
abortSignal?.removeEventListener("abort", abort);
351360
removeParentSignalHandlers();
352361
reject(error instanceof Error ? error : new Error(formatErrorMessage(error)));
353362
});
354363
child.on("close", (code, signal) => {
355364
clearTimeout(timer);
356-
if (killTimer) {
357-
clearTimeout(killTimer);
358-
}
359365
abortSignal?.removeEventListener("abort", abort);
360366
removeParentSignalHandlers();
361367
const result = { code, signal, stdout: stdout.text(), stderr: stderr.text() };
368+
const finishTerminatedTree = async () => {
369+
await finishTimedOutCommandProcessTree(child, {
370+
forceKillAt,
371+
timeoutKillGraceMs: COMMAND_TIMEOUT_KILL_GRACE_MS,
372+
});
373+
if (killTimer) {
374+
clearTimeout(killTimer);
375+
}
376+
forceKillAt = undefined;
377+
};
362378
if (aborted) {
363-
reject(new Error(scrub(`command aborted: ${command} ${args.join(" ")}`)));
379+
void finishTerminatedTree().finally(() =>
380+
reject(new Error(scrub(`command aborted: ${command} ${args.join(" ")}`))),
381+
);
364382
return;
365383
}
366384
if (timedOut) {
367-
terminateProcessTree(child, "SIGKILL");
368-
reject(new Error(scrub(`command timed out: ${command} ${args.join(" ")}`)));
385+
void finishTerminatedTree().finally(() =>
386+
reject(new Error(scrub(`command timed out: ${command} ${args.join(" ")}`))),
387+
);
369388
return;
370389
}
390+
if (killTimer) {
391+
clearTimeout(killTimer);
392+
}
393+
forceKillAt = undefined;
371394
if (result.signal && options.allowFailure !== true) {
372395
reject(
373396
new Error(
@@ -880,6 +903,21 @@ async function stopGateway(child) {
880903
await waitForProcessTreeExit(child, 1000);
881904
}
882905

906+
async function finishTimedOutCommandProcessTree(child, { forceKillAt, timeoutKillGraceMs }) {
907+
if (!processTreeIsAlive(child)) {
908+
return;
909+
}
910+
const graceRemainingMs =
911+
forceKillAt === undefined ? timeoutKillGraceMs : Math.max(0, forceKillAt - Date.now());
912+
if (graceRemainingMs > 0) {
913+
await waitForProcessTreeExit(child, graceRemainingMs);
914+
}
915+
if (processTreeIsAlive(child)) {
916+
terminateProcessTree(child, "SIGKILL");
917+
}
918+
await waitForProcessTreeExit(child, timeoutKillGraceMs);
919+
}
920+
883921
function childHasExited(child) {
884922
return child.exitCode !== null || child.signalCode !== null;
885923
}

test/scripts/secret-provider-integrations.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,62 @@ describe("secret provider integration proof harness", () => {
514514
expect(sizeAfterWait).toBe(sizeAfterReturn);
515515
});
516516

517+
it.runIf(process.platform !== "win32")(
518+
"preserves timeout kill grace for descendants after the leader exits",
519+
async () => {
520+
const root = makeTempDir();
521+
const cleanupPath = path.join(root, "command-descendant-cleanup.txt");
522+
const descendantPidPath = path.join(root, "command-descendant.pid");
523+
const scriptPath = path.join(root, "spawn-cleaning-descendant.mjs");
524+
const descendantScript = [
525+
"import fs from 'node:fs';",
526+
`fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(process.pid));`,
527+
"process.on('SIGTERM', () => {",
528+
` setTimeout(() => { fs.writeFileSync(${JSON.stringify(
529+
cleanupPath,
530+
)}, "clean"); process.exit(0); }, 75);`,
531+
"});",
532+
"setInterval(() => {}, 1000);",
533+
].join("\n");
534+
fs.writeFileSync(
535+
scriptPath,
536+
[
537+
"import childProcess from 'node:child_process';",
538+
"import { setTimeout as delay } from 'node:timers/promises';",
539+
`childProcess.spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify(
540+
descendantScript,
541+
)}], { stdio: "ignore" });`,
542+
"process.on('SIGTERM', () => process.exit(0));",
543+
"await delay(60_000);",
544+
"",
545+
].join("\n"),
546+
);
547+
const proof = await import(
548+
`${pathToFileURL(proofScriptPath).href}?case=timeout-grace-${Date.now()}`
549+
);
550+
let descendantPid = 0;
551+
552+
try {
553+
const command = proof.runCommand(process.execPath, [scriptPath], {
554+
timeoutMs: 150,
555+
});
556+
557+
await waitFor(() => fs.existsSync(descendantPidPath));
558+
descendantPid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
559+
expect(Number.isInteger(descendantPid)).toBe(true);
560+
expect(isProcessAlive(descendantPid)).toBe(true);
561+
562+
await expect(command).rejects.toThrow(/command timed out/u);
563+
expect(fs.readFileSync(cleanupPath, "utf8")).toBe("clean");
564+
expect(isProcessAlive(descendantPid)).toBe(false);
565+
} finally {
566+
if (descendantPid && isProcessAlive(descendantPid)) {
567+
process.kill(descendantPid, "SIGKILL");
568+
}
569+
}
570+
},
571+
);
572+
517573
it.runIf(process.platform !== "win32")(
518574
"aborts command process groups after the leader exits before stdio closes",
519575
async () => {

0 commit comments

Comments
 (0)