feat(core): improve shell execution service reliability (#10607)

This commit is contained in:
Gal Zahavi
2025-10-10 15:14:37 -07:00
committed by GitHub
parent 37678acb1a
commit 265d39f337
4 changed files with 292 additions and 125 deletions
@@ -21,6 +21,7 @@ import {
const { Terminal } = pkg;
const SIGKILL_TIMEOUT_MS = 200;
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
/** A structured result from a shell command execution. */
export interface ShellExecutionResult {
@@ -150,6 +151,36 @@ export class ShellExecutionService {
);
}
private static appendAndTruncate(
currentBuffer: string,
chunk: string,
maxSize: number,
): { newBuffer: string; truncated: boolean } {
const chunkLength = chunk.length;
const currentLength = currentBuffer.length;
const newTotalLength = currentLength + chunkLength;
if (newTotalLength <= maxSize) {
return { newBuffer: currentBuffer + chunk, truncated: false };
}
// Truncation is needed.
if (chunkLength >= maxSize) {
// The new chunk is larger than or equal to the max buffer size.
// The new buffer will be the tail of the new chunk.
return {
newBuffer: chunk.substring(chunkLength - maxSize),
truncated: true,
};
}
// The combined buffer exceeds the max size, but the new chunk is smaller than it.
// We need to truncate the current buffer from the beginning to make space.
const charsToTrim = newTotalLength - maxSize;
const truncatedBuffer = currentBuffer.substring(charsToTrim);
return { newBuffer: truncatedBuffer + chunk, truncated: true };
}
private static childProcessFallback(
commandToExecute: string,
cwd: string,
@@ -179,6 +210,8 @@ export class ShellExecutionService {
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
const outputChunks: Buffer[] = [];
let error: Error | null = null;
let exited = false;
@@ -215,9 +248,25 @@ export class ShellExecutionService {
const decodedChunk = decoder.decode(data, { stream: true });
if (stream === 'stdout') {
stdout += decodedChunk;
const { newBuffer, truncated } = this.appendAndTruncate(
stdout,
decodedChunk,
MAX_CHILD_PROCESS_BUFFER_SIZE,
);
stdout = newBuffer;
if (truncated) {
stdoutTruncated = true;
}
} else {
stderr += decodedChunk;
const { newBuffer, truncated } = this.appendAndTruncate(
stderr,
decodedChunk,
MAX_CHILD_PROCESS_BUFFER_SIZE,
);
stderr = newBuffer;
if (truncated) {
stderrTruncated = true;
}
}
}
};
@@ -229,9 +278,16 @@ export class ShellExecutionService {
const { finalBuffer } = cleanup();
// Ensure we don't add an extra newline if stdout already ends with one.
const separator = stdout.endsWith('\n') ? '' : '\n';
const combinedOutput =
let combinedOutput =
stdout + (stderr ? (stdout ? separator : '') + stderr : '');
if (stdoutTruncated || stderrTruncated) {
const truncationMessage = `\n[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to ${
MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024)
}MB.]`;
combinedOutput += truncationMessage;
}
const finalStrippedOutput = stripAnsi(combinedOutput).trim();
if (isStreamingRawContent) {
@@ -388,86 +444,99 @@ export class ShellExecutionService {
let hasStartedOutput = false;
let renderTimeout: NodeJS.Timeout | null = null;
const render = (finalRender = false) => {
if (renderTimeout) {
clearTimeout(renderTimeout);
const renderFn = () => {
renderTimeout = null;
if (!isStreamingRawContent) {
return;
}
const renderFn = () => {
if (!isStreamingRawContent) {
return;
}
if (!shellExecutionConfig.disableDynamicLineTrimming) {
if (!hasStartedOutput) {
const bufferText = getFullBufferText(headlessTerminal);
if (bufferText.trim().length === 0) {
return;
}
hasStartedOutput = true;
if (!shellExecutionConfig.disableDynamicLineTrimming) {
if (!hasStartedOutput) {
const bufferText = getFullBufferText(headlessTerminal);
if (bufferText.trim().length === 0) {
return;
}
hasStartedOutput = true;
}
}
let newOutput: AnsiOutput;
if (shellExecutionConfig.showColor) {
newOutput = serializeTerminalToObject(headlessTerminal);
} else {
const buffer = headlessTerminal.buffer.active;
const lines: AnsiOutput = [];
for (let y = 0; y < headlessTerminal.rows; y++) {
const line = buffer.getLine(buffer.viewportY + y);
const lineContent = line ? line.translateToString(true) : '';
lines.push([
{
text: lineContent,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '',
bg: '',
},
]);
}
newOutput = lines;
}
let lastNonEmptyLine = -1;
for (let i = newOutput.length - 1; i >= 0; i--) {
const line = newOutput[i];
if (
line
.map((segment) => segment.text)
.join('')
.trim().length > 0
) {
lastNonEmptyLine = i;
break;
}
}
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
? newOutput
: trimmedOutput;
// Using stringify for a quick deep comparison.
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
output = finalOutput;
onOutputEvent({
type: 'data',
chunk: finalOutput,
});
}
};
if (finalRender) {
renderFn();
const buffer = headlessTerminal.buffer.active;
let newOutput: AnsiOutput;
if (shellExecutionConfig.showColor) {
newOutput = serializeTerminalToObject(headlessTerminal);
} else {
renderTimeout = setTimeout(renderFn, 17);
const lines: AnsiOutput = [];
for (let y = 0; y < headlessTerminal.rows; y++) {
const line = buffer.getLine(buffer.viewportY + y);
const lineContent = line ? line.translateToString(true) : '';
lines.push([
{
text: lineContent,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '',
bg: '',
},
]);
}
newOutput = lines;
}
let lastNonEmptyLine = -1;
for (let i = newOutput.length - 1; i >= 0; i--) {
const line = newOutput[i];
if (
line
.map((segment) => segment.text)
.join('')
.trim().length > 0
) {
lastNonEmptyLine = i;
break;
}
}
if (buffer.cursorY > lastNonEmptyLine) {
lastNonEmptyLine = buffer.cursorY;
}
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
? newOutput
: trimmedOutput;
// Using stringify for a quick deep comparison.
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
output = finalOutput;
onOutputEvent({
type: 'data',
chunk: finalOutput,
});
}
};
const render = (finalRender = false) => {
if (finalRender) {
if (renderTimeout) {
clearTimeout(renderTimeout);
}
renderFn();
return;
}
if (renderTimeout) {
return;
}
renderTimeout = setTimeout(() => {
renderFn();
renderTimeout = null;
}, 68);
};
headlessTerminal.onScroll(() => {
@@ -503,6 +572,10 @@ export class ShellExecutionService {
if (isStreamingRawContent) {
const decodedChunk = decoder.decode(data, { stream: true });
if (decodedChunk.length === 0) {
resolve();
return;
}
isWriting = true;
headlessTerminal.write(decodedChunk, () => {
render();
@@ -535,7 +608,7 @@ export class ShellExecutionService {
abortSignal.removeEventListener('abort', abortHandler);
this.activePtys.delete(ptyProcess.pid);
processingChain.then(() => {
const finalize = () => {
render(true);
const finalBuffer = Buffer.concat(outputChunks);
@@ -551,6 +624,26 @@ export class ShellExecutionService {
(ptyInfo?.name as 'node-pty' | 'lydell-node-pty') ??
'node-pty',
});
};
if (abortSignal.aborted) {
finalize();
return;
}
const processingComplete = processingChain.then(() => 'processed');
const abortFired = new Promise<'aborted'>((res) => {
if (abortSignal.aborted) {
res('aborted');
return;
}
abortSignal.addEventListener('abort', () => res('aborted'), {
once: true,
});
});
Promise.race([processingComplete, abortFired]).then(() => {
finalize();
});
},
);
@@ -562,10 +655,18 @@ export class ShellExecutionService {
} else {
try {
// Kill the entire process group
process.kill(-ptyProcess.pid, 'SIGINT');
process.kill(-ptyProcess.pid, 'SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!exited) {
process.kill(-ptyProcess.pid, 'SIGKILL');
}
} catch (_e) {
// Fallback to killing just the process if the group kill fails
ptyProcess.kill('SIGINT');
ptyProcess.kill('SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!exited) {
ptyProcess.kill('SIGKILL');
}
}
}
}