mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 01:27:41 -07:00
Merge branch 'main' into mb/atui/00-display-content
This commit is contained in:
@@ -1,30 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-04-08T01:21:58.770Z",
|
||||
"updatedAt": "2026-04-10T15:36:04.547Z",
|
||||
"scenarios": {
|
||||
"multi-turn-conversation": {
|
||||
"heapUsedBytes": 120082704,
|
||||
"heapTotalBytes": 177586176,
|
||||
"rssBytes": 269172736,
|
||||
"timestamp": "2026-04-08T01:21:57.127Z"
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:17.603Z"
|
||||
},
|
||||
"multi-function-call-repo-search": {
|
||||
"heapUsedBytes": 104644984,
|
||||
"heapTotalBytes": 111575040,
|
||||
"rssBytes": 204079104,
|
||||
"timestamp": "2026-04-08T01:21:58.770Z"
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:22.480Z"
|
||||
},
|
||||
"idle-session-startup": {
|
||||
"heapUsedBytes": 119813672,
|
||||
"heapTotalBytes": 177061888,
|
||||
"rssBytes": 267943936,
|
||||
"timestamp": "2026-04-08T01:21:53.855Z"
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:08.035Z"
|
||||
},
|
||||
"simple-prompt-response": {
|
||||
"heapUsedBytes": 119722064,
|
||||
"heapTotalBytes": 177324032,
|
||||
"rssBytes": 268812288,
|
||||
"timestamp": "2026-04-08T01:21:55.491Z"
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:12.770Z"
|
||||
},
|
||||
"resume-large-chat-with-messages": {
|
||||
"heapUsedBytes": 106545568,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:36:04.547Z"
|
||||
},
|
||||
"resume-large-chat": {
|
||||
"heapUsedBytes": 106513760,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:35:59.528Z"
|
||||
},
|
||||
"large-chat": {
|
||||
"heapUsedBytes": 106471568,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:35:53.180Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,15 @@ import { describe, it, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { TestRig, MemoryTestHarness } from '@google/gemini-cli-test-utils';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
createWriteStream,
|
||||
copyFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINES_PATH = join(__dirname, 'baselines.json');
|
||||
@@ -182,4 +191,312 @@ describe('Memory Usage Tests', () => {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Large Chat Scenarios', () => {
|
||||
let sharedResumeResponsesPath: string;
|
||||
let sharedActiveResponsesPath: string;
|
||||
let sharedHistoryPath: string;
|
||||
let sharedPrompts: string;
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = join(__dirname, `large-chat-tmp-${randomUUID()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const { resumeResponsesPath, activeResponsesPath, historyPath, prompts } =
|
||||
await generateSharedLargeChatData(tempDir);
|
||||
sharedActiveResponsesPath = activeResponsesPath;
|
||||
sharedResumeResponsesPath = resumeResponsesPath;
|
||||
sharedHistoryPath = historyPath;
|
||||
sharedPrompts = prompts;
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it('large-chat: memory usage within baseline', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-large-chat', {
|
||||
fakeResponsesPath: sharedActiveResponsesPath,
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'large-chat',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
stdin: sharedPrompts,
|
||||
timeout: 600000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-large-chat');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for large-chat: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('resume-large-chat: memory usage within baseline', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-resume-large-chat', {
|
||||
fakeResponsesPath: sharedResumeResponsesPath,
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'resume-large-chat',
|
||||
async (recordSnapshot) => {
|
||||
// Ensure the history file is linked
|
||||
const targetChatsDir = join(
|
||||
rig.testDir!,
|
||||
'tmp',
|
||||
'test-project-hash',
|
||||
'chats',
|
||||
);
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
const targetHistoryPath = join(
|
||||
targetChatsDir,
|
||||
'large-chat-session.json',
|
||||
);
|
||||
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
|
||||
copyFileSync(sharedHistoryPath, targetHistoryPath);
|
||||
|
||||
await rig.run({
|
||||
// add a prompt to make sure it does not hang there and exits immediately
|
||||
args: ['--resume', 'latest', '--prompt', 'hello'],
|
||||
timeout: 600000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-resume-large-chat');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for resume-large-chat: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('resume-large-chat-with-messages: memory usage within baseline', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-resume-large-chat-msgs', {
|
||||
fakeResponsesPath: sharedResumeResponsesPath,
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'resume-large-chat-with-messages',
|
||||
async (recordSnapshot) => {
|
||||
// Ensure the history file is linked
|
||||
const targetChatsDir = join(
|
||||
rig.testDir!,
|
||||
'tmp',
|
||||
'test-project-hash',
|
||||
'chats',
|
||||
);
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
const targetHistoryPath = join(
|
||||
targetChatsDir,
|
||||
'large-chat-session.json',
|
||||
);
|
||||
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
|
||||
copyFileSync(sharedHistoryPath, targetHistoryPath);
|
||||
|
||||
const stdinContent = 'new prompt 1\nnew prompt 2\n';
|
||||
|
||||
await rig.run({
|
||||
args: ['--resume', 'latest'],
|
||||
stdin: stdinContent,
|
||||
timeout: 600000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-resume-and-append');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for resume-large-chat-with-messages: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function generateSharedLargeChatData(tempDir: string) {
|
||||
const resumeResponsesPath = join(tempDir, 'large-chat-resume-chat.responses');
|
||||
const activeResponsesPath = join(tempDir, 'large-chat-active-chat.responses');
|
||||
const historyPath = join(tempDir, 'large-chat-history.json');
|
||||
const sourceSessionPath = join(__dirname, 'large-chat-session.json');
|
||||
|
||||
const session = JSON.parse(readFileSync(sourceSessionPath, 'utf8'));
|
||||
const messages = session.messages;
|
||||
|
||||
copyFileSync(sourceSessionPath, historyPath);
|
||||
|
||||
// Generate fake responses for active chat
|
||||
const promptsList: string[] = [];
|
||||
const activeResponsesStream = createWriteStream(activeResponsesPath);
|
||||
const complexityResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: '{"complexity_reasoning":"simple","complexity_score":1}',
|
||||
},
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const summaryResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ text: '{"originalSummary":"large chat summary","events":[]}' },
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg.type === 'user') {
|
||||
promptsList.push(msg.content[0].text);
|
||||
|
||||
// Start of a new turn
|
||||
activeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
|
||||
|
||||
// Find all subsequent gemini messages until the next user message
|
||||
let j = i + 1;
|
||||
while (j < messages.length && messages[j].type === 'gemini') {
|
||||
const geminiMsg = messages[j];
|
||||
const parts = [];
|
||||
if (geminiMsg.content) {
|
||||
parts.push({ text: geminiMsg.content });
|
||||
}
|
||||
if (geminiMsg.toolCalls) {
|
||||
for (const tc of geminiMsg.toolCalls) {
|
||||
parts.push({
|
||||
functionCall: {
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
activeResponsesStream.write(
|
||||
JSON.stringify({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { parts, role: 'model' },
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 100,
|
||||
totalTokenCount: 200,
|
||||
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 100 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}) + '\n',
|
||||
);
|
||||
j++;
|
||||
}
|
||||
// End of turn
|
||||
activeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
|
||||
// Skip the gemini messages we just processed
|
||||
i = j - 1;
|
||||
}
|
||||
}
|
||||
activeResponsesStream.end();
|
||||
|
||||
// Generate responses for resumed chat
|
||||
const resumeResponsesStream = createWriteStream(resumeResponsesPath);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
|
||||
resumeResponsesStream.write(
|
||||
JSON.stringify({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: `Resume response ${i}` }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 10,
|
||||
totalTokenCount: 20,
|
||||
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 10 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}) + '\n',
|
||||
);
|
||||
resumeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
|
||||
}
|
||||
resumeResponsesStream.end();
|
||||
|
||||
// Wait for streams to finish
|
||||
await Promise.all([
|
||||
new Promise((res) => activeResponsesStream.on('finish', res)),
|
||||
new Promise((res) => resumeResponsesStream.on('finish', res)),
|
||||
]);
|
||||
|
||||
return {
|
||||
resumeResponsesPath,
|
||||
activeResponsesPath,
|
||||
historyPath,
|
||||
prompts: promptsList.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('useExecutionLifecycle', () => {
|
||||
mockConfig = {
|
||||
getTargetDir: () => '/test/dir',
|
||||
getEnableInteractiveShell: () => false,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalHeight: 20,
|
||||
terminalWidth: 80,
|
||||
@@ -246,11 +247,32 @@ describe('useExecutionLifecycle', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
sessionId: 'test-session-id',
|
||||
}),
|
||||
);
|
||||
expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
|
||||
});
|
||||
|
||||
it('should pass the config sessionId into shell execution config', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleShellCommand('top', new AbortController().signal);
|
||||
});
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.objectContaining({
|
||||
sessionId: 'test-session-id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle successful execution and update history correctly', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
|
||||
@@ -409,6 +409,7 @@ export const useExecutionLifecycle = (
|
||||
const activeTheme = themeManager.getActiveTheme();
|
||||
const shellExecutionConfig = {
|
||||
...config.getShellExecutionConfig(),
|
||||
sessionId: config.getSessionId(),
|
||||
terminalWidth,
|
||||
terminalHeight,
|
||||
defaultFg: activeTheme.colors.Foreground,
|
||||
|
||||
@@ -266,6 +266,29 @@ describe('textUtils', () => {
|
||||
// 0xA0 is non-breaking space, should be preserved
|
||||
expect(stripUnsafeCharacters('hello\xA0world')).toBe('hello\xA0world');
|
||||
});
|
||||
|
||||
it('should not lose text after DCS (0x90) — regression for data loss', () => {
|
||||
// 0x90 (DCS) starts a Device Control String that stripVTControlCharacters
|
||||
// treats as an unterminated sequence, swallowing all subsequent text.
|
||||
// Stripping C1 chars before VT processing prevents this data loss.
|
||||
expect(stripUnsafeCharacters('important\x90data after DCS')).toBe(
|
||||
'importantdata after DCS',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fully strip 8-bit CSI (0x9B) sequences', () => {
|
||||
// 0x9B (CSI) is equivalent to ESC[. stripAnsi should handle the
|
||||
// whole sequence including parameters.
|
||||
expect(stripUnsafeCharacters('keep\x9B42mthis text')).toBe(
|
||||
'keepthis text',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not lose text when multiple C1 chars precede valid content', () => {
|
||||
expect(stripUnsafeCharacters('start\x90\x9B\x85middle\x80end')).toBe(
|
||||
'startmiddleend',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANSI escape sequence stripping', () => {
|
||||
|
||||
@@ -98,8 +98,16 @@ export function cpSlice(str: string, start: number, end?: number): string {
|
||||
/**
|
||||
* Strip characters that can break terminal rendering.
|
||||
*
|
||||
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
|
||||
* then filters remaining control characters that can disrupt display.
|
||||
* This is a strict sanitization function intended for general display
|
||||
* contexts. It strips all C1 control characters (0x80-0x9F) and VT
|
||||
* control sequences. For list display contexts where a more lenient
|
||||
* approach is needed (preserving C1 characters and only stripping ANSI
|
||||
* codes and newlines/tabs), use a separate function instead.
|
||||
*
|
||||
* Processing order:
|
||||
* 1. stripAnsi removes ANSI escape sequences (including 8-bit CSI 0x9B)
|
||||
* 2. Regex strips C0, C1, BiDi, and zero-width control characters
|
||||
* 3. stripVTControlCharacters removes any remaining VT sequences
|
||||
*
|
||||
* Characters stripped:
|
||||
* - ANSI escape sequences (via strip-ansi)
|
||||
@@ -119,18 +127,20 @@ export function cpSlice(str: string, start: number, end?: number): string {
|
||||
*/
|
||||
export function stripUnsafeCharacters(str: string): string {
|
||||
const strippedAnsi = stripAnsi(str);
|
||||
const strippedVT = stripVTControlCharacters(strippedAnsi);
|
||||
|
||||
// Use a regex to strip remaining unsafe control characters
|
||||
// C0: 0x00-0x1F except 0x09 (TAB), 0x0A (LF), 0x0D (CR)
|
||||
// C1: 0x80-0x9F
|
||||
// BiDi: U+200E (LRM), U+200F (RLM), U+202A-U+202E, U+2066-U+2069
|
||||
// Zero-width: U+200B (ZWSP), U+FEFF (BOM)
|
||||
return strippedVT.replace(
|
||||
// Strip C0, C1, and other unsafe characters via regex first.
|
||||
// This is more efficient than multiple replaces and crucially removes C1
|
||||
// characters (e.g., 0x90 DCS) before they can be misinterpreted by
|
||||
// stripVTControlCharacters, which could otherwise cause data loss.
|
||||
const strippedWithRegex = strippedAnsi.replace(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B\uFEFF]/g,
|
||||
'',
|
||||
);
|
||||
|
||||
// Finally, use stripVTControlCharacters for any remaining VT sequences
|
||||
// that the regex might not cover.
|
||||
return stripVTControlCharacters(strippedWithRegex);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,6 +75,7 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
resolveExecutable: mockResolveExecutable,
|
||||
spawnAsync: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
};
|
||||
});
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
@@ -695,7 +696,7 @@ describe('ShellExecutionService', () => {
|
||||
);
|
||||
|
||||
expect(sigtermCallIndex).toBe(0);
|
||||
expect(sigkillCallIndex).toBe(1);
|
||||
expect(sigkillCallIndex).toBeGreaterThan(0);
|
||||
expect(sigtermCallIndex).toBeLessThan(sigkillCallIndex);
|
||||
|
||||
expect(result.signal).toBe(9);
|
||||
@@ -1476,8 +1477,11 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
|
||||
const { result } = await simulateExecution(
|
||||
'sleep 10',
|
||||
(cp, abortController) => {
|
||||
async (cp, abortController) => {
|
||||
abortController.abort();
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
if (expectedExit.signal) {
|
||||
cp.emit('exit', null, expectedExit.signal);
|
||||
cp.emit('close', null, expectedExit.signal);
|
||||
@@ -1497,11 +1501,14 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
expectedSignal,
|
||||
);
|
||||
} else {
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
expectedCommand,
|
||||
['/pid', String(mockChildProcess.pid), '/f', '/t'],
|
||||
expect.anything(),
|
||||
);
|
||||
// Taskkill is spawned via spawnAsync which is mocked
|
||||
const { spawnAsync } = await import('../utils/shell-utils.js');
|
||||
expect(spawnAsync).toHaveBeenCalledWith(expectedCommand, [
|
||||
'/pid',
|
||||
String(mockChildProcess.pid),
|
||||
'/f',
|
||||
'/t',
|
||||
]);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -1531,6 +1538,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
);
|
||||
|
||||
abortController.abort();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Check the first kill signal
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(
|
||||
@@ -1733,10 +1741,12 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
);
|
||||
|
||||
// Simulate exit to allow promise to resolve
|
||||
if (!mockPtyProcess.onExit.mock.calls[0]) {
|
||||
const res = await handle.result;
|
||||
throw new Error(`Failed early in executeWithPty: ${res.error}`);
|
||||
}
|
||||
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
const result = await handle.result;
|
||||
|
||||
expect(mockGetPty).toHaveBeenCalled();
|
||||
expect(mockPtySpawn).toHaveBeenCalled();
|
||||
expect(mockCpSpawn).not.toHaveBeenCalled();
|
||||
expect(result.executionMethod).toBe('mock-pty');
|
||||
|
||||
@@ -112,8 +112,10 @@ export interface ShellExecutionConfig {
|
||||
*/
|
||||
export type ShellOutputEvent = ExecutionOutputEvent;
|
||||
|
||||
export type DestroyablePty = IPty & { destroy?: () => void };
|
||||
|
||||
interface ActivePty {
|
||||
ptyProcess: IPty;
|
||||
ptyProcess: DestroyablePty;
|
||||
headlessTerminal: pkg.Terminal;
|
||||
maxSerializedLines?: number;
|
||||
command: string;
|
||||
@@ -833,6 +835,42 @@ export class ShellExecutionService {
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Destroys a PTY process to release its file descriptors.
|
||||
* This is critical to prevent system-wide PTY exhaustion (see #15945).
|
||||
*/
|
||||
private static destroyPtyProcess(ptyProcess: DestroyablePty): void {
|
||||
try {
|
||||
if (typeof ptyProcess?.destroy === 'function') {
|
||||
ptyProcess.destroy();
|
||||
} else if (typeof ptyProcess?.kill === 'function') {
|
||||
// Fallback: if destroy() is unavailable, kill() may still close FDs
|
||||
ptyProcess.kill();
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during PTY cleanup — process may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all resources associated with a PTY entry:
|
||||
* the PTY process (file descriptors) and the headless terminal (memory buffers).
|
||||
*/
|
||||
private static cleanupPtyEntry(pid: number): void {
|
||||
const entry = this.activePtys.get(pid);
|
||||
if (!entry) return;
|
||||
|
||||
this.destroyPtyProcess(entry.ptyProcess);
|
||||
|
||||
try {
|
||||
entry.headlessTerminal.dispose();
|
||||
} catch {
|
||||
// Ignore errors during terminal cleanup
|
||||
}
|
||||
|
||||
this.activePtys.delete(pid);
|
||||
}
|
||||
|
||||
private static async executeWithPty(
|
||||
commandToExecute: string,
|
||||
cwd: string,
|
||||
@@ -845,7 +883,7 @@ export class ShellExecutionService {
|
||||
// This should not happen, but as a safeguard...
|
||||
throw new Error('PTY implementation not found');
|
||||
}
|
||||
let spawnedPty: IPty | undefined;
|
||||
let spawnedPty: DestroyablePty | undefined;
|
||||
let cmdCleanup: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
@@ -878,7 +916,7 @@ export class ShellExecutionService {
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
spawnedPty = ptyProcess as IPty;
|
||||
spawnedPty = ptyProcess as DestroyablePty;
|
||||
const ptyPid = Number(ptyProcess.pid);
|
||||
|
||||
const headlessTerminal = new Terminal({
|
||||
@@ -912,13 +950,6 @@ export class ShellExecutionService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
pty: ptyProcess,
|
||||
}).catch(() => {});
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(ptyProcess as IPty & { destroy?: () => void }).destroy?.();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
this.activePtys.delete(ptyPid);
|
||||
},
|
||||
isActive: () => {
|
||||
try {
|
||||
@@ -1146,13 +1177,11 @@ export class ShellExecutionService {
|
||||
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||
exited = true;
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
// Attempt to destroy the PTY to ensure FD is closed
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(ptyProcess as IPty & { destroy?: () => void }).destroy?.();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Immediately destroy the PTY to release its master FD.
|
||||
// The headless terminal is kept alive until finalize() extracts
|
||||
// its buffer contents, then disposed to free memory.
|
||||
ShellExecutionService.destroyPtyProcess(ptyProcess);
|
||||
|
||||
const finalize = () => {
|
||||
render(true);
|
||||
@@ -1176,11 +1205,6 @@ export class ShellExecutionService {
|
||||
}
|
||||
onOutputEvent(event);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
ShellExecutionService.cleanupLogStream(ptyPid).then(() => {
|
||||
ShellExecutionService.activePtys.delete(ptyPid);
|
||||
});
|
||||
|
||||
const endLine = headlessTerminal.buffer.active.length;
|
||||
const startLine = Math.max(
|
||||
0,
|
||||
@@ -1191,10 +1215,24 @@ export class ShellExecutionService {
|
||||
startLine,
|
||||
endLine,
|
||||
);
|
||||
const finalOutput = getFullBufferText(headlessTerminal);
|
||||
|
||||
// Dispose the headless terminal to free scrollback buffers.
|
||||
// This must happen after getFullBufferText() extracts the output.
|
||||
try {
|
||||
headlessTerminal.dispose();
|
||||
} catch {
|
||||
// Ignore errors during terminal cleanup
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
ShellExecutionService.cleanupLogStream(ptyPid).then(() => {
|
||||
ShellExecutionService.activePtys.delete(ptyPid);
|
||||
});
|
||||
|
||||
ExecutionLifecycleService.completeWithResult(ptyPid, {
|
||||
rawOutput: Buffer.from(''),
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
output: finalOutput,
|
||||
ansiOutput: ansiOutputSnapshot,
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
@@ -1249,14 +1287,10 @@ export class ShellExecutionService {
|
||||
cmdCleanup?.();
|
||||
|
||||
if (spawnedPty) {
|
||||
try {
|
||||
(spawnedPty as IPty & { destroy?: () => void }).destroy?.();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
ShellExecutionService.destroyPtyProcess(spawnedPty);
|
||||
}
|
||||
|
||||
if (error.message.includes('posix_spawnp failed')) {
|
||||
if (error?.message?.includes('posix_spawnp failed')) {
|
||||
onOutputEvent({
|
||||
type: 'data',
|
||||
chunk:
|
||||
@@ -1316,9 +1350,9 @@ export class ShellExecutionService {
|
||||
*/
|
||||
static async kill(pid: number): Promise<void> {
|
||||
await this.cleanupLogStream(pid);
|
||||
this.activePtys.delete(pid);
|
||||
this.activeChildProcesses.delete(pid);
|
||||
ExecutionLifecycleService.kill(pid);
|
||||
this.cleanupPtyEntry(pid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,7 +75,6 @@ describe('process-utils', () => {
|
||||
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
});
|
||||
|
||||
it('should use escalation on Unix if requested', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const exited = false;
|
||||
@@ -87,6 +86,11 @@ describe('process-utils', () => {
|
||||
isExited,
|
||||
});
|
||||
|
||||
// flush microtasks
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
// First call should be SIGTERM
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
|
||||
|
||||
@@ -110,6 +114,11 @@ describe('process-utils', () => {
|
||||
isExited,
|
||||
});
|
||||
|
||||
// flush microtasks
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
|
||||
|
||||
// Simulate process exiting
|
||||
@@ -117,10 +126,11 @@ describe('process-utils', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS);
|
||||
|
||||
// Second call should NOT be SIGKILL because it exited
|
||||
expect(mockProcessKill).not.toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
|
||||
await killPromise;
|
||||
});
|
||||
|
||||
it('should fallback to specific process kill if group kill fails', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
mockProcessKill.mockImplementationOnce(() => {
|
||||
|
||||
@@ -32,7 +32,8 @@ export interface KillOptions {
|
||||
* or the PTY's built-in kill method.
|
||||
*
|
||||
* On Unix, it attempts to kill the process group (using -pid) with escalation
|
||||
* from SIGTERM to SIGKILL if requested.
|
||||
* from SIGTERM to SIGKILL if requested. It also walks the process tree using pgrep
|
||||
* to ensure all descendants are killed.
|
||||
*/
|
||||
export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
const { pid, escalate = false, isExited = () => false, pty } = options;
|
||||
@@ -55,12 +56,59 @@ export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unix logic
|
||||
// Unix logic: Walk process tree to find all descendants
|
||||
const getAllDescendants = async (parentPid: number): Promise<number[]> => {
|
||||
let children: number[] = [];
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pgrep', [
|
||||
'-P',
|
||||
parentPid.toString(),
|
||||
]);
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((p: string) => parseInt(p, 10))
|
||||
.filter((p: number) => !isNaN(p));
|
||||
for (const p of pids) {
|
||||
children.push(p);
|
||||
const grandchildren = await getAllDescendants(p);
|
||||
children = children.concat(grandchildren);
|
||||
}
|
||||
} catch {
|
||||
// pgrep exits with 1 if no children are found
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
const descendants = await getAllDescendants(pid);
|
||||
const allPidsToKill = [...descendants.reverse(), pid];
|
||||
|
||||
try {
|
||||
const initialSignal = options.signal || (escalate ? 'SIGTERM' : 'SIGKILL');
|
||||
|
||||
// Try killing the process group first (-pid)
|
||||
process.kill(-pid, initialSignal);
|
||||
try {
|
||||
process.kill(-pid, initialSignal);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Kill individual processes in the tree to ensure detached descendants are caught
|
||||
for (const targetPid of allPidsToKill) {
|
||||
try {
|
||||
process.kill(targetPid, initialSignal);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (pty) {
|
||||
try {
|
||||
pty.kill(typeof initialSignal === 'string' ? initialSignal : undefined);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (escalate && !isExited()) {
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
@@ -70,43 +118,30 @@ export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback to specific process kill if group kill fails or on error
|
||||
if (!isExited()) {
|
||||
if (pty) {
|
||||
if (escalate) {
|
||||
|
||||
for (const targetPid of allPidsToKill) {
|
||||
try {
|
||||
// Attempt the group kill BEFORE the pty session leader dies
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
pty.kill('SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!isExited()) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
pty.kill('SIGKILL');
|
||||
}
|
||||
process.kill(targetPid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (pty) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL'); // Group kill first
|
||||
pty.kill('SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ultimate fallback if something unexpected throws
|
||||
if (!isExited()) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface MemoryBaseline {
|
||||
heapUsedBytes: number;
|
||||
heapTotalBytes: number;
|
||||
rssBytes: number;
|
||||
externalBytes: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ export function updateBaseline(
|
||||
heapUsedBytes: number;
|
||||
heapTotalBytes: number;
|
||||
rssBytes: number;
|
||||
externalBytes: number;
|
||||
},
|
||||
): void {
|
||||
const baselines = loadBaselines(path);
|
||||
@@ -70,6 +72,7 @@ export function updateBaseline(
|
||||
heapUsedBytes: measured.heapUsedBytes,
|
||||
heapTotalBytes: measured.heapTotalBytes,
|
||||
rssBytes: measured.rssBytes,
|
||||
externalBytes: measured.externalBytes,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
saveBaselines(path, baselines);
|
||||
|
||||
@@ -41,8 +41,10 @@ export interface MemoryTestResult {
|
||||
snapshots: MemorySnapshot[];
|
||||
peakHeapUsed: number;
|
||||
peakRss: number;
|
||||
peakExternal: number;
|
||||
finalHeapUsed: number;
|
||||
finalRss: number;
|
||||
finalExternal: number;
|
||||
baseline: MemoryBaseline | undefined;
|
||||
withinTolerance: boolean;
|
||||
deltaPercent: number;
|
||||
@@ -207,13 +209,17 @@ export class MemoryTestHarness {
|
||||
withinTolerance = deltaPercent <= tolerance;
|
||||
}
|
||||
|
||||
const peakExternal = Math.max(...snapshots.map((s) => s.external));
|
||||
|
||||
const result: MemoryTestResult = {
|
||||
scenarioName: name,
|
||||
snapshots,
|
||||
peakHeapUsed,
|
||||
peakRss,
|
||||
peakExternal,
|
||||
finalHeapUsed: afterSnap.heapUsed,
|
||||
finalRss: afterSnap.rss,
|
||||
finalExternal: afterSnap.external,
|
||||
baseline,
|
||||
withinTolerance,
|
||||
deltaPercent,
|
||||
@@ -254,7 +260,8 @@ export class MemoryTestHarness {
|
||||
` Baseline: ${formatMB(result.baseline.heapUsedBytes)} heap used\n` +
|
||||
` Delta: ${deltaPercent.toFixed(1)}% (tolerance: ${tolerance}%)\n` +
|
||||
` Peak heap: ${formatMB(result.peakHeapUsed)}\n` +
|
||||
` Peak RSS: ${formatMB(result.peakRss)}`,
|
||||
` Peak RSS: ${formatMB(result.peakRss)}\n` +
|
||||
` Peak External: ${formatMB(result.peakExternal)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -268,6 +275,7 @@ export class MemoryTestHarness {
|
||||
heapTotalBytes:
|
||||
result.snapshots[result.snapshots.length - 1]?.heapTotal ?? 0,
|
||||
rssBytes: result.finalRss,
|
||||
externalBytes: result.finalExternal,
|
||||
});
|
||||
// Reload baselines after update
|
||||
this.baselines = loadBaselines(this.baselinesPath);
|
||||
|
||||
Reference in New Issue
Block a user