fix(crestodian): bound local command probes

This commit is contained in:
Vincent Koc
2026-05-28 15:37:05 +02:00
parent 76ebc14956
commit 8e3be0a705
2 changed files with 72 additions and 4 deletions

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { probeLocalCommand } from "./probes.js";
describe("crestodian probes", () => {
it("bounds noisy local command probe output", async () => {
const result = await probeLocalCommand(
process.execPath,
["-e", "process.stdout.write('x'.repeat(4096));"],
{ outputLimit: 64, timeoutMs: 1_000 },
);
expect(result.found).toBe(true);
expect(result.version).toHaveLength(64);
});
it.runIf(process.platform !== "win32")(
"force-kills timed-out local command probes that ignore SIGTERM",
async () => {
const startedAt = Date.now();
const result = await probeLocalCommand(
process.execPath,
["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"],
{ timeoutKillGraceMs: 25, timeoutMs: 25 },
);
expect(result).toMatchObject({
command: process.execPath,
error: "timed out after 25ms",
found: true,
});
expect(Date.now() - startedAt).toBeLessThan(2_000);
},
);
});

View File

@@ -7,38 +7,68 @@ export type LocalCommandProbe = {
error?: string;
};
const LOCAL_COMMAND_PROBE_OUTPUT_MAX_CHARS = 16 * 1024;
const LOCAL_COMMAND_PROBE_KILL_GRACE_MS = 500;
function appendBounded(previous: string, chunk: string, limit: number): string {
const next = previous + chunk;
return next.length > limit ? next.slice(-limit) : next;
}
export async function probeLocalCommand(
command: string,
args: string[] = ["--version"],
opts: { timeoutMs?: number } = {},
opts: { outputLimit?: number; timeoutKillGraceMs?: number; timeoutMs?: number } = {},
): Promise<LocalCommandProbe> {
const timeoutMs = opts.timeoutMs ?? 1_500;
const outputLimit = opts.outputLimit ?? LOCAL_COMMAND_PROBE_OUTPUT_MAX_CHARS;
const timeoutKillGraceMs = opts.timeoutKillGraceMs ?? LOCAL_COMMAND_PROBE_KILL_GRACE_MS;
return await new Promise((resolve) => {
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
let killTimer: NodeJS.Timeout | undefined;
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
});
const timeoutResult = (): LocalCommandProbe => ({
command,
found: true,
error: `timed out after ${timeoutMs}ms`,
});
const finish = (result: LocalCommandProbe) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (killTimer) {
clearTimeout(killTimer);
}
resolve(result);
};
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
finish({ command, found: true, error: `timed out after ${timeoutMs}ms` });
killTimer = setTimeout(
() => {
child.kill("SIGKILL");
child.stdout.destroy();
child.stderr.destroy();
finish(timeoutResult());
},
Math.max(0, timeoutKillGraceMs),
);
killTimer.unref?.();
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
stdout = appendBounded(stdout, String(chunk), outputLimit);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
stderr = appendBounded(stderr, String(chunk), outputLimit);
});
child.on("error", (err: NodeJS.ErrnoException) => {
finish({
@@ -48,6 +78,10 @@ export async function probeLocalCommand(
});
});
child.on("close", (code) => {
if (timedOut) {
finish(timeoutResult());
return;
}
const text = `${stdout}\n${stderr}`.trim().split(/\r?\n/)[0]?.trim();
finish({
command,