From 8e3be0a7058d25fa68fe6f15ced985cc5a95fb02 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 28 May 2026 15:37:05 +0200 Subject: [PATCH] fix(crestodian): bound local command probes --- src/crestodian/probes.test.ts | 34 ++++++++++++++++++++++++++++ src/crestodian/probes.ts | 42 +++++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 src/crestodian/probes.test.ts diff --git a/src/crestodian/probes.test.ts b/src/crestodian/probes.test.ts new file mode 100644 index 00000000000..e92228add8f --- /dev/null +++ b/src/crestodian/probes.test.ts @@ -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); + }, + ); +}); diff --git a/src/crestodian/probes.ts b/src/crestodian/probes.ts index 703c9f6f713..87e927d2a0a 100644 --- a/src/crestodian/probes.ts +++ b/src/crestodian/probes.ts @@ -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 { 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,