Skip to content

Commit a1d278b

Browse files
committed
fix(crabbox): preserve telegram proof kill grace
1 parent 0fd5dae commit a1d278b

2 files changed

Lines changed: 99 additions & 2 deletions

File tree

scripts/e2e/telegram-user-crabbox-proof.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export const COMMAND_STDERR_TAIL_CHARS = 256 * 1024;
148148
export const COMMAND_FAILURE_STDOUT_TAIL_CHARS = 64 * 1024;
149149
export const COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
150150
export const COMMAND_TIMEOUT_KILL_GRACE_MS = 5_000;
151+
const COMMAND_PROCESS_TREE_EXIT_POLL_MS = 25;
151152
export const REMOTE_SETUP_COMMAND_TIMEOUT_MS = 90 * 60 * 1000;
152153
const REMOTE_ROOT = "/tmp/openclaw-telegram-user-crabbox";
153154
const CREDENTIAL_SCRIPT = fileURLToPath(new URL("./telegram-user-credential.ts", import.meta.url));
@@ -608,6 +609,44 @@ function commandProcessTreeAlive(child: ChildProcess) {
608609
}
609610
}
610611

612+
async function waitForCommandProcessTreeExit(child: ChildProcess, timeoutMs: number) {
613+
const deadlineAt = Date.now() + timeoutMs;
614+
while (Date.now() < deadlineAt) {
615+
if (!commandProcessTreeAlive(child)) {
616+
return true;
617+
}
618+
await new Promise((resolvePoll) => {
619+
setTimeout(resolvePoll, COMMAND_PROCESS_TREE_EXIT_POLL_MS);
620+
});
621+
}
622+
return !commandProcessTreeAlive(child);
623+
}
624+
625+
async function finishTimedOutCommandProcessTree(
626+
child: ChildProcess,
627+
options: {
628+
forceKillAt: number | undefined;
629+
timeoutKillGraceMs: number;
630+
},
631+
) {
632+
if (!commandProcessTreeAlive(child)) {
633+
activeCommandChildren.delete(child);
634+
return;
635+
}
636+
const graceRemainingMs =
637+
options.forceKillAt === undefined
638+
? options.timeoutKillGraceMs
639+
: Math.max(0, options.forceKillAt - Date.now());
640+
if (graceRemainingMs > 0) {
641+
await waitForCommandProcessTreeExit(child, graceRemainingMs);
642+
}
643+
if (commandProcessTreeAlive(child)) {
644+
signalCommandTree(child, "SIGKILL");
645+
await waitForCommandProcessTreeExit(child, options.timeoutKillGraceMs);
646+
}
647+
activeCommandChildren.delete(child);
648+
}
649+
611650
function untrackCommandChild(child: ChildProcess) {
612651
if (!commandProcessTreeAlive(child)) {
613652
activeCommandChildren.delete(child);
@@ -664,6 +703,7 @@ export function runCommand(params: {
664703
let settled = false;
665704
let stdoutLimitError: string | null = null;
666705
let timeoutError: Error | null = null;
706+
let forceKillAt: number | undefined;
667707
let killTimer: NodeJS.Timeout | undefined;
668708
const timeoutMs = params.timeoutMs ?? COMMAND_TIMEOUT_MS;
669709
const timeoutKillGraceMs = params.timeoutKillGraceMs ?? COMMAND_TIMEOUT_KILL_GRACE_MS;
@@ -684,6 +724,7 @@ export function runCommand(params: {
684724
)}`,
685725
);
686726
signalCommandTree(child, "SIGTERM");
727+
forceKillAt = Date.now() + timeoutKillGraceMs;
687728
killTimer = setTimeout(() => {
688729
signalCommandTree(child, "SIGKILL");
689730
}, timeoutKillGraceMs);
@@ -736,9 +777,16 @@ export function runCommand(params: {
736777
settled = true;
737778
untrackCommandChild(child);
738779
if (timeoutError) {
739-
signalCommandTree(child, "SIGKILL");
780+
const error = timeoutError;
740781
clearTimers();
741-
reject(timeoutError);
782+
void finishTimedOutCommandProcessTree(child, {
783+
forceKillAt,
784+
timeoutKillGraceMs,
785+
}).then(
786+
() => reject(error),
787+
(cleanupError: unknown) =>
788+
reject(cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError))),
789+
);
742790
return;
743791
}
744792
clearTimers();

test/scripts/telegram-user-crabbox-proof.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,55 @@ setInterval(() => {}, 1000);
311311
}
312312
});
313313

314+
posixIt("lets timed-out command descendants exit during kill grace", async () => {
315+
const root = makeTempDir();
316+
const scriptPath = path.join(root, "trap-term-grace.mjs");
317+
const readyPath = path.join(root, "descendant.ready");
318+
const donePath = path.join(root, "descendant.done");
319+
320+
fs.writeFileSync(
321+
scriptPath,
322+
`
323+
import { spawn } from "node:child_process";
324+
325+
const descendant = spawn(process.execPath, [
326+
"--input-type=module",
327+
"--eval",
328+
${JSON.stringify(
329+
`import { writeFileSync } from "node:fs";
330+
writeFileSync(${JSON.stringify(readyPath)}, "ready");
331+
process.on("SIGTERM", () => {
332+
setTimeout(() => {
333+
writeFileSync(${JSON.stringify(donePath)}, "done");
334+
process.exit(0);
335+
}, 75);
336+
});
337+
setInterval(() => {}, 1000);`,
338+
)},
339+
], { stdio: "ignore" });
340+
descendant.unref();
341+
process.on("SIGTERM", () => process.exit(0));
342+
setInterval(() => {}, 1000);
343+
`,
344+
"utf8",
345+
);
346+
347+
const runPromise = runCommand({
348+
args: [scriptPath],
349+
command: process.execPath,
350+
cwd: root,
351+
timeoutKillGraceMs: 500,
352+
timeoutMs: 500,
353+
});
354+
355+
await waitFor(() => fs.existsSync(readyPath));
356+
await expect(runPromise).rejects.toMatchObject({
357+
code: "ETIMEDOUT",
358+
message: expect.stringContaining("timed out after 500ms"),
359+
});
360+
expect(fs.readFileSync(donePath, "utf8")).toBe("done");
361+
});
362+
314363
posixIt("keeps closed command groups tracked for parent cleanup", async () => {
315364
const root = makeTempDir();
316365
const commandPath = path.join(root, "closed-command.mjs");

0 commit comments

Comments
 (0)