mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 03:24:42 -07:00
feat(core): improve shell execution service reliability (#10607)
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user