fix(e2e): clean telegram credential timeouts

This commit is contained in:
Vincent Koc
2026-06-01 10:06:06 +02:00
parent 2ea7c518a5
commit e680604577
2 changed files with 162 additions and 4 deletions
+89 -3
View File
@@ -21,6 +21,33 @@ type RunCommandOptions = {
const DEFAULT_OUTPUT_LIMIT = 128 * 1024;
const DEFAULT_FETCH_BODY_LIMIT = 1024 * 1024;
const KILL_GRACE_MS = 5_000;
const SIGNAL_EXIT_CODES = {
SIGHUP: 129,
SIGINT: 130,
SIGTERM: 143,
};
const ACTIVE_CHILD_TREE_KILLERS = new Set<(signal: NodeJS.Signals) => void>();
let forwardedSignalExitCode: number | undefined;
let forwardedSignalForceKillTimer: NodeJS.Timeout | undefined;
for (const signal of Object.keys(SIGNAL_EXIT_CODES) as Array<keyof typeof SIGNAL_EXIT_CODES>) {
process.on(signal, () => {
forwardedSignalExitCode ??= SIGNAL_EXIT_CODES[signal];
if (ACTIVE_CHILD_TREE_KILLERS.size === 0) {
process.exit(forwardedSignalExitCode);
}
const activeKillers = Array.from(ACTIVE_CHILD_TREE_KILLERS);
for (const killChildTree of activeKillers) {
killChildTree(signal);
}
forwardedSignalForceKillTimer ??= setTimeout(() => {
for (const killChildTree of activeKillers) {
killChildTree("SIGKILL");
}
process.exit(forwardedSignalExitCode);
}, KILL_GRACE_MS);
});
}
function timeoutError(message: string) {
return Object.assign(new Error(message), { code: "ETIMEDOUT" });
@@ -70,15 +97,19 @@ export function runCommand(
options: RunCommandOptions,
) {
return new Promise<void>((resolve, reject) => {
const useProcessGroup = process.platform !== "win32";
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
detached: useProcessGroup,
});
const activeChildTree = registerActiveChildProcessTree(child);
const outputLimit = options.outputLimit ?? DEFAULT_OUTPUT_LIMIT;
let stdout = "";
let stderr = "";
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
let pendingTimeoutReject: (() => void) | undefined;
let timedOutError: Error | undefined;
const timeoutMs = Math.max(1, options.timeoutMs);
const timeoutKillGraceMs = Math.max(0, options.timeoutKillGraceMs ?? KILL_GRACE_MS);
@@ -94,6 +125,7 @@ export function runCommand(
}
settled = true;
clearTimers();
activeChildTree.unregister();
reject(error);
};
const timeout: NodeJS.Timeout = setTimeout(() => {
@@ -103,11 +135,12 @@ export function runCommand(
timedOutError = timeoutError(
`${command} ${args.join(" ")} timed out after ${timeoutMs}ms\n${stdout}${stderr}`,
);
child.kill("SIGTERM");
activeChildTree.killChildTree("SIGTERM");
killTimer = setTimeout(() => {
child.kill("SIGKILL");
killTimer = undefined;
activeChildTree.killChildTree("SIGKILL");
pendingTimeoutReject?.();
}, timeoutKillGraceMs);
killTimer.unref?.();
}, timeoutMs);
timeout.unref?.();
@@ -122,8 +155,26 @@ export function runCommand(
if (settled) {
return;
}
if (forwardedSignalExitCode !== undefined) {
activeChildTree.unregister();
return;
}
if (timedOutError && killTimer && childProcessTreeMayStillExist(child)) {
const error = timedOutError;
pendingTimeoutReject = () => {
if (settled) {
return;
}
settled = true;
clearTimers();
activeChildTree.unregister();
reject(error);
};
return;
}
settled = true;
clearTimers();
activeChildTree.unregister();
if (timedOutError) {
reject(timedOutError);
return;
@@ -138,6 +189,41 @@ export function runCommand(
});
}
function signalChildProcessTree(child: ReturnType<typeof spawn>, signal: NodeJS.Signals) {
if (process.platform !== "win32" && child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// The process group can disappear between timeout and cleanup.
}
}
child.kill(signal);
}
function childProcessTreeMayStillExist(child: ReturnType<typeof spawn>) {
if (process.platform === "win32" || !child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch {
return false;
}
}
function registerActiveChildProcessTree(child: ReturnType<typeof spawn>) {
const killChildTree = (signal: NodeJS.Signals) => signalChildProcessTree(child, signal);
ACTIVE_CHILD_TREE_KILLERS.add(killChildTree);
return {
killChildTree,
unregister: () => {
ACTIVE_CHILD_TREE_KILLERS.delete(killChildTree);
},
};
}
export async function fetchJsonWithTimeout(params: FetchJsonParams) {
const timeoutMs = Math.max(1, params.timeoutMs);
const maxBodyBytes = resolveFetchBodyLimit(params.maxBodyBytes);
+73 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path, { win32 } from "node:path";
@@ -18,6 +18,41 @@ function makeTempDir(prefix: string) {
return dir;
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForFile(filePath: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (existsSync(filePath)) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
throw new Error(`timeout waiting for ${filePath}`);
}
async function waitForDead(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
throw new Error(`process still alive: ${pid}`);
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
@@ -112,6 +147,43 @@ setInterval(() => {}, 1000);
},
);
it.runIf(process.platform !== "win32")(
"kills timed-out child process groups",
async () => {
const dir = makeTempDir("openclaw-telegram-credential-tree-timeout-");
const childPidPath = path.join(dir, "child.pid");
let childPid: number | undefined;
try {
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
const parentScript = [
"const { spawn } = require('node:child_process');",
"const fs = require('node:fs');",
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
`fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`,
"setInterval(() => {}, 1000);",
].join("");
const runPromise = runCommand(process.execPath, ["-e", parentScript], dir, {
timeoutKillGraceMs: 25,
timeoutMs: 100,
});
await waitForFile(childPidPath, 2_000);
childPid = Number.parseInt(readFileSync(childPidPath, "utf8"), 10);
await expect(runPromise).rejects.toMatchObject({
code: "ETIMEDOUT",
message: expect.stringContaining("timed out after 100ms"),
});
await waitForDead(childPid, 2_000);
} finally {
if (childPid !== undefined && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
}
},
);
it("aborts broker fetches that never return", async () => {
let signal: AbortSignal | undefined;
await expect(