Skip to content

Commit 01124cf

Browse files
committed
fix(e2e): clean secret proof timeouts
1 parent e8f3bce commit 01124cf

2 files changed

Lines changed: 82 additions & 5 deletions

File tree

scripts/e2e/secret-provider-integrations.mjs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,31 +223,66 @@ function runCommand(command, args, options = {}) {
223223
return new Promise((resolve, reject) => {
224224
const child = childProcess.spawn(command, args, {
225225
cwd: options.cwd ?? process.cwd(),
226+
detached: options.detached ?? process.platform !== "win32",
226227
env: options.env ?? process.env,
227228
shell: options.shell,
228229
stdio: options.stdio ?? ["pipe", "pipe", "pipe"],
229230
windowsVerbatimArguments: options.windowsVerbatimArguments,
230231
});
231232
const stdout = createOutputCapture("stdout");
232233
const stderr = createOutputCapture("stderr");
234+
let timedOut = false;
235+
let killTimer;
233236
const timer = setTimeout(() => {
234-
child.kill("SIGTERM");
235-
setTimeout(() => child.kill("SIGKILL"), 1000).unref();
236-
reject(new Error(scrub(`command timed out: ${command} ${args.join(" ")}`)));
237+
timedOut = true;
238+
terminateProcessTree(child, "SIGTERM");
239+
killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 1000);
240+
killTimer.unref();
237241
}, timeoutMs);
238-
child.stdout.on("data", (chunk) => {
242+
child.stdout?.on("data", (chunk) => {
239243
stdout.append(chunk);
240244
});
241-
child.stderr.on("data", (chunk) => {
245+
child.stderr?.on("data", (chunk) => {
242246
stderr.append(chunk);
243247
});
248+
const parentSignalHandlers = new Map();
249+
const removeParentSignalHandlers = () => {
250+
for (const [signal, handler] of parentSignalHandlers) {
251+
process.off(signal, handler);
252+
}
253+
parentSignalHandlers.clear();
254+
};
255+
if (process.platform !== "win32" && child.pid) {
256+
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
257+
const handler = () => {
258+
terminateProcessTree(child, signal);
259+
removeParentSignalHandlers();
260+
process.kill(process.pid, signal);
261+
};
262+
parentSignalHandlers.set(signal, handler);
263+
process.once(signal, handler);
264+
}
265+
}
244266
child.on("error", (error) => {
245267
clearTimeout(timer);
268+
if (killTimer) {
269+
clearTimeout(killTimer);
270+
}
271+
removeParentSignalHandlers();
246272
reject(error instanceof Error ? error : new Error(formatErrorMessage(error)));
247273
});
248274
child.on("close", (code, signal) => {
249275
clearTimeout(timer);
276+
if (killTimer) {
277+
clearTimeout(killTimer);
278+
}
279+
removeParentSignalHandlers();
250280
const result = { code: code ?? 0, signal, stdout: stdout.text(), stderr: stderr.text() };
281+
if (timedOut) {
282+
terminateProcessTree(child, "SIGKILL");
283+
reject(new Error(scrub(`command timed out: ${command} ${args.join(" ")}`)));
284+
return;
285+
}
251286
if (result.code !== 0 && options.allowFailure !== true) {
252287
reject(
253288
new Error(

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,48 @@ describe("secret provider integration proof harness", () => {
199199
}
200200
});
201201

202+
it.runIf(process.platform !== "win32")(
203+
"kills timed-out command process groups",
204+
async () => {
205+
const root = makeTempDir();
206+
const markerPath = path.join(root, "command-descendant-marker.txt");
207+
const scriptPath = path.join(root, "spawn-descendant.mjs");
208+
const descendantScript = [
209+
"import fs from 'node:fs';",
210+
`fs.appendFileSync(${JSON.stringify(markerPath)}, "x");`,
211+
"process.on('SIGTERM', () => {});",
212+
`setInterval(() => fs.appendFileSync(${JSON.stringify(markerPath)}, "x"), 20);`,
213+
].join("\n");
214+
fs.writeFileSync(
215+
scriptPath,
216+
[
217+
"import childProcess from 'node:child_process';",
218+
"import { setTimeout as delay } from 'node:timers/promises';",
219+
`childProcess.spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify(
220+
descendantScript,
221+
)}], { stdio: "ignore" });`,
222+
"process.on('SIGTERM', () => process.exit(0));",
223+
"await delay(60_000);",
224+
"",
225+
].join("\n"),
226+
);
227+
const proof = await import(`${pathToFileURL(proofScriptPath).href}?case=timeout-${Date.now()}`);
228+
229+
await expect(
230+
proof.runCommand(process.execPath, [scriptPath], {
231+
timeoutMs: 150,
232+
}),
233+
).rejects.toThrow(/command timed out/u);
234+
235+
const sizeAfterReturn = fs.existsSync(markerPath) ? fs.statSync(markerPath).size : 0;
236+
await new Promise((resolve) => {
237+
setTimeout(resolve, 250);
238+
});
239+
const sizeAfterWait = fs.existsSync(markerPath) ? fs.statSync(markerPath).size : 0;
240+
expect(sizeAfterWait).toBe(sizeAfterReturn);
241+
},
242+
);
243+
202244
it("detects startup secret leaks after the retained output cap", () => {
203245
const root = makeTempDir();
204246
const fakeOpenClaw = writeLeakingStartupOpenClaw(root);

0 commit comments

Comments
 (0)