























@@ -5,6 +5,9 @@
55import { spawn } from "node:child_process";
66import { waitForChildProcess } from "../utils/child-process.js";
778+const DEFAULT_OUTPUT_LIMIT_CHARS = 16 * 1024 * 1024;
9+const FORCE_KILL_GRACE_MS = 5000;
10+811/**
912 * Options for executing shell commands.
1013 */
@@ -15,6 +18,8 @@ export interface ExecOptions {
1518timeout?: number;
1619/** Working directory */
1720cwd?: string;
21+/** Optional maximum retained stdout/stderr characters per stream. */
22+maxOutputChars?: number;
1823}
19242025/**
@@ -23,10 +28,46 @@ export interface ExecOptions {
2328export interface ExecResult {
2429stdout: string;
2530stderr: string;
31+stdoutTruncatedChars?: number;
32+stderrTruncatedChars?: number;
33+outputLimitExceeded?: "stdout" | "stderr";
2634code: number;
2735killed: boolean;
2836}
293738+type OutputCapture = {
39+text: string;
40+truncatedChars: number;
41+};
42+43+function clampMaxOutputChars(value: number | undefined): number {
44+if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
45+return DEFAULT_OUTPUT_LIMIT_CHARS;
46+}
47+return Math.max(1, Math.floor(value));
48+}
49+50+function appendCapturedOutput(
51+current: OutputCapture,
52+chunk: Buffer | string,
53+maxOutputChars: number,
54+truncateTail: boolean,
55+): OutputCapture {
56+const text = String(chunk);
57+const combined = `${current.text}${text}`;
58+const overflowChars = Math.max(0, combined.length - maxOutputChars);
59+if (overflowChars === 0) {
60+return {
61+text: combined,
62+truncatedChars: current.truncatedChars,
63+};
64+}
65+return {
66+text: truncateTail ? combined.slice(overflowChars) : combined.slice(0, maxOutputChars),
67+truncatedChars: current.truncatedChars + overflowChars,
68+};
69+}
70+3071/**
3172 * Execute a shell command and return stdout/stderr/code.
3273 * Supports timeout and abort signal.
@@ -44,21 +85,64 @@ export async function execCommand(
4485stdio: ["ignore", "pipe", "pipe"],
4586});
468747-let stdout = "";
48-let stderr = "";
88+let stdout: OutputCapture = { text: "", truncatedChars: 0 };
89+let stderr: OutputCapture = { text: "", truncatedChars: 0 };
4990let killed = false;
5091let timeoutId: NodeJS.Timeout | undefined;
92+let forceKillTimer: NodeJS.Timeout | undefined;
93+let settled = false;
94+const maxOutputChars = clampMaxOutputChars(options?.maxOutputChars);
95+const truncateOutput = options?.maxOutputChars !== undefined;
96+let outputLimitExceeded: "stdout" | "stderr" | undefined;
97+const markOutputLimitExceeded = (stream: "stdout" | "stderr") => {
98+if (!truncateOutput && !outputLimitExceeded) {
99+outputLimitExceeded = stream;
100+killProcess();
101+}
102+};
103+const finish = (code: number) => {
104+if (settled) {
105+return;
106+}
107+settled = true;
108+if (timeoutId) {
109+clearTimeout(timeoutId);
110+}
111+if (forceKillTimer) {
112+clearTimeout(forceKillTimer);
113+}
114+if (options?.signal) {
115+options.signal.removeEventListener("abort", killProcess);
116+}
117+if (outputLimitExceeded) {
118+stderr = appendCapturedOutput(
119+stderr,
120+`${stderr.text ? "\n" : ""}exec ${outputLimitExceeded} exceeded output limit ${maxOutputChars} chars`,
121+maxOutputChars,
122+true,
123+);
124+}
125+resolve({
126+stdout: stdout.text,
127+stderr: stderr.text,
128+stdoutTruncatedChars: stdout.truncatedChars || undefined,
129+stderrTruncatedChars: stderr.truncatedChars || undefined,
130+ outputLimitExceeded,
131+code: outputLimitExceeded ? 1 : code,
132+ killed,
133+});
134+};
5113552136const killProcess = () => {
53137if (!killed) {
54138killed = true;
55139proc.kill("SIGTERM");
56-// Force kill after 5 seconds if SIGTERM doesn't work
57-setTimeout(() => {
58-if (!proc.killed) {
140+forceKillTimer = setTimeout(() => {
141+if (!settled) {
59142proc.kill("SIGKILL");
60143}
61-}, 5000);
144+}, FORCE_KILL_GRACE_MS);
145+forceKillTimer.unref?.();
62146}
63147};
64148@@ -79,33 +163,29 @@ export async function execCommand(
79163}
8016481165proc.stdout?.on("data", (data) => {
82-stdout += data.toString();
166+const before = stdout.truncatedChars;
167+stdout = appendCapturedOutput(stdout, data, maxOutputChars, truncateOutput);
168+if (stdout.truncatedChars > before) {
169+markOutputLimitExceeded("stdout");
170+}
83171});
8417285173proc.stderr?.on("data", (data) => {
86-stderr += data.toString();
174+const before = stderr.truncatedChars;
175+stderr = appendCapturedOutput(stderr, data, maxOutputChars, truncateOutput);
176+if (stderr.truncatedChars > before) {
177+markOutputLimitExceeded("stderr");
178+}
87179});
8818089181// Wait for process termination without hanging on inherited stdio handles
90182// held open by detached descendants.
91183waitForChildProcess(proc)
92184.then((code) => {
93-if (timeoutId) {
94-clearTimeout(timeoutId);
95-}
96-if (options?.signal) {
97-options.signal.removeEventListener("abort", killProcess);
98-}
99-resolve({ stdout, stderr, code: code ?? 0, killed });
185+finish(code ?? 0);
100186})
101187.catch(() => {
102-if (timeoutId) {
103-clearTimeout(timeoutId);
104-}
105-if (options?.signal) {
106-options.signal.removeEventListener("abort", killProcess);
107-}
108-resolve({ stdout, stderr, code: 1, killed });
188+finish(1);
109189});
110190});
111191}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。