Skip to content

Commit 5fc8fea

Browse files
authored
fix: resolve lifecycle memory leaks by cleaning up listeners and root closures (google-gemini#25049)
1 parent 43b93e9 commit 5fc8fea

8 files changed

Lines changed: 109 additions & 19 deletions

File tree

packages/cli/src/gemini.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1428,12 +1428,13 @@ describe('startInteractiveUI', () => {
14281428
vi.mock('./ui/utils/updateCheck.js', () => ({
14291429
checkForUpdates: vi.fn(() => Promise.resolve(null)),
14301430
}));
1431-
14321431
vi.mock('./utils/cleanup.js', () => ({
14331432
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
14341433
registerCleanup: vi.fn(),
1434+
removeCleanup: vi.fn(),
14351435
runExitCleanup: vi.fn(),
14361436
registerSyncCleanup: vi.fn(),
1437+
removeSyncCleanup: vi.fn(),
14371438
registerTelemetryConfig: vi.fn(),
14381439
setupSignalHandlers: vi.fn(),
14391440
setupTtyCheck: vi.fn(() => vi.fn()),

packages/cli/src/gemini_cleanup.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
142142
...actual,
143143
cleanupCheckpoints: vi.fn().mockResolvedValue(undefined),
144144
registerCleanup: vi.fn(),
145+
removeCleanup: vi.fn(),
145146
registerSyncCleanup: vi.fn(),
147+
removeSyncCleanup: vi.fn(),
146148
registerTelemetryConfig: vi.fn(),
147149
runExitCleanup: vi.fn().mockResolvedValue(undefined),
148150
};

packages/cli/src/interactiveCli.tsx

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { render } from 'ink';
99
import { basename } from 'node:path';
1010
import { AppContainer } from './ui/AppContainer.js';
1111
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
12-
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
12+
import {
13+
registerCleanup,
14+
removeCleanup,
15+
setupTtyCheck,
16+
} from './utils/cleanup.js';
1317
import {
1418
type StartupWarning,
1519
type Config,
@@ -89,7 +93,6 @@ export async function startInteractiveUI(
8993
debugMode: config.getDebugMode(),
9094
});
9195
consolePatcher.patch();
92-
registerCleanup(consolePatcher.cleanup);
9396

9497
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
9598

@@ -167,11 +170,11 @@ export async function startInteractiveUI(
167170
},
168171
);
169172

173+
let cleanupLineWrapping: (() => void) | undefined;
170174
if (useAlternateBuffer) {
171175
disableLineWrapping();
172-
registerCleanup(() => {
173-
enableLineWrapping();
174-
});
176+
cleanupLineWrapping = () => enableLineWrapping();
177+
registerCleanup(cleanupLineWrapping);
175178
}
176179

177180
checkForUpdates(settings)
@@ -185,9 +188,48 @@ export async function startInteractiveUI(
185188
}
186189
});
187190

188-
registerCleanup(() => instance.unmount());
189-
190-
registerCleanup(setupTtyCheck());
191+
const cleanupUnmount = () => instance.unmount();
192+
registerCleanup(cleanupUnmount);
193+
194+
const cleanupTtyCheck = setupTtyCheck();
195+
registerCleanup(cleanupTtyCheck);
196+
197+
const cleanupConsolePatcher = () => consolePatcher.cleanup();
198+
registerCleanup(cleanupConsolePatcher);
199+
200+
try {
201+
await instance.waitUntilExit();
202+
} finally {
203+
try {
204+
removeCleanup(cleanupConsolePatcher);
205+
cleanupConsolePatcher();
206+
} catch (e: unknown) {
207+
debugLogger.error('Error cleaning up console patcher:', e);
208+
}
209+
210+
try {
211+
removeCleanup(cleanupUnmount);
212+
instance.unmount();
213+
} catch (e: unknown) {
214+
debugLogger.error('Error unmounting Ink instance:', e);
215+
}
216+
217+
try {
218+
removeCleanup(cleanupTtyCheck);
219+
cleanupTtyCheck();
220+
} catch (e: unknown) {
221+
debugLogger.error('Error in TTY cleanup:', e);
222+
}
223+
224+
if (cleanupLineWrapping) {
225+
try {
226+
removeCleanup(cleanupLineWrapping);
227+
cleanupLineWrapping();
228+
} catch (e: unknown) {
229+
debugLogger.error('Error restoring line wrapping:', e);
230+
}
231+
}
232+
}
191233
}
192234

193235
function setWindowTitle(title: string, settings: LoadedSettings) {

packages/cli/src/ui/AppContainer.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
136136
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
137137
import { type UpdateObject } from './utils/updateCheck.js';
138138
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
139-
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
139+
import {
140+
registerCleanup,
141+
removeCleanup,
142+
runExitCleanup,
143+
} from '../utils/cleanup.js';
140144
import { relaunchApp } from '../utils/processUtils.js';
141145
import type { SessionInfo } from '../utils/sessionUtils.js';
142146
import { useMessageQueue } from './hooks/useMessageQueue.js';
@@ -519,7 +523,7 @@ export const AppContainer = (props: AppContainerProps) => {
519523
debugLogger.warn('Background summary generation failed:', e);
520524
});
521525
})();
522-
registerCleanup(async () => {
526+
const cleanupFn = async () => {
523527
// Turn off mouse scroll.
524528
disableMouseEvents();
525529

@@ -535,7 +539,15 @@ export const AppContainer = (props: AppContainerProps) => {
535539

536540
// Fire SessionEnd hook on cleanup (only if hooks are enabled)
537541
await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
538-
});
542+
};
543+
registerCleanup(cleanupFn);
544+
545+
return () => {
546+
removeCleanup(cleanupFn);
547+
cleanupFn().catch((e: unknown) =>
548+
debugLogger.error('Error during cleanup:', e),
549+
);
550+
};
539551
// Disable the dependencies check here. historyManager gets flagged
540552
// but we don't want to react to changes to it because each new history
541553
// item, including the ones from the start session hook will cause a

packages/cli/src/utils/cleanup.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,24 @@ export function registerCleanup(fn: (() => void) | (() => Promise<void>)) {
2424
cleanupFunctions.push(fn);
2525
}
2626

27+
export function removeCleanup(fn: (() => void) | (() => Promise<void>)) {
28+
const index = cleanupFunctions.indexOf(fn);
29+
if (index !== -1) {
30+
cleanupFunctions.splice(index, 1);
31+
}
32+
}
33+
2734
export function registerSyncCleanup(fn: () => void) {
2835
syncCleanupFunctions.push(fn);
2936
}
3037

38+
export function removeSyncCleanup(fn: () => void) {
39+
const index = syncCleanupFunctions.indexOf(fn);
40+
if (index !== -1) {
41+
syncCleanupFunctions.splice(index, 1);
42+
}
43+
}
44+
3145
/**
3246
* Resets the internal cleanup state for testing purposes.
3347
* This allows tests to run in isolation without vi.resetModules().

packages/core/src/services/shellExecutionService.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ describe('ShellExecutionService', () => {
208208
beforeEach(() => {
209209
vi.clearAllMocks();
210210
ExecutionLifecycleService.resetForTest();
211+
ShellExecutionService.resetForTest();
211212
mockSerializeTerminalToObject.mockReturnValue([]);
212213
mockIsBinary.mockReturnValue(false);
213214
mockPlatform.mockReturnValue('linux');
@@ -1247,6 +1248,8 @@ describe('ShellExecutionService child_process fallback', () => {
12471248

12481249
beforeEach(() => {
12491250
vi.clearAllMocks();
1251+
ExecutionLifecycleService.resetForTest();
1252+
ShellExecutionService.resetForTest();
12501253

12511254
mockIsBinary.mockReturnValue(false);
12521255
mockPlatform.mockReturnValue('linux');
@@ -1662,6 +1665,8 @@ describe('ShellExecutionService execution method selection', () => {
16621665

16631666
beforeEach(() => {
16641667
vi.clearAllMocks();
1668+
ExecutionLifecycleService.resetForTest();
1669+
ShellExecutionService.resetForTest();
16651670
onOutputEventMock = vi.fn();
16661671

16671672
// Mock for pty
@@ -1786,6 +1791,8 @@ describe('ShellExecutionService environment variables', () => {
17861791

17871792
beforeEach(() => {
17881793
vi.clearAllMocks();
1794+
ExecutionLifecycleService.resetForTest();
1795+
ShellExecutionService.resetForTest();
17891796
vi.resetModules(); // Reset modules to ensure process.env changes are fresh
17901797

17911798
// Mock for pty

packages/core/src/services/shellExecutionService.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,10 @@ export class ShellExecutionService {
704704

705705
const finalStrippedOutput = stripAnsi(combinedOutput).trim();
706706
const exitCode = code;
707-
const exitSignal = signal ? os.constants.signals[signal] : null;
707+
const exitSignal =
708+
signal && os.constants.signals
709+
? (os.constants.signals[signal] ?? null)
710+
: null;
708711

709712
const resultPayload: ShellExecutionResult = {
710713
rawOutput: Buffer.from(''),
@@ -1503,4 +1506,16 @@ export class ShellExecutionService {
15031506
signal: info.signal,
15041507
}));
15051508
}
1509+
1510+
/**
1511+
* Resets the internal state of the ShellExecutionService.
1512+
* This is intended for use in tests to ensure isolation.
1513+
*/
1514+
static resetForTest(): void {
1515+
this.activePtys.clear();
1516+
this.activeChildProcesses.clear();
1517+
this.backgroundLogPids.clear();
1518+
this.backgroundLogStreams.clear();
1519+
this.backgroundProcessHistory.clear();
1520+
}
15061521
}

packages/core/src/tools/mcp-client.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -812,15 +812,15 @@ type StatusChangeListener = (
812812
serverName: string,
813813
status: MCPServerStatus,
814814
) => void;
815-
const statusChangeListeners: StatusChangeListener[] = [];
815+
const statusChangeListeners: Set<StatusChangeListener> = new Set();
816816

817817
/**
818818
* Add a listener for MCP server status changes
819819
*/
820820
export function addMCPStatusChangeListener(
821821
listener: StatusChangeListener,
822822
): void {
823-
statusChangeListeners.push(listener);
823+
statusChangeListeners.add(listener);
824824
}
825825

826826
/**
@@ -829,10 +829,7 @@ export function addMCPStatusChangeListener(
829829
export function removeMCPStatusChangeListener(
830830
listener: StatusChangeListener,
831831
): void {
832-
const index = statusChangeListeners.indexOf(listener);
833-
if (index !== -1) {
834-
statusChangeListeners.splice(index, 1);
835-
}
832+
statusChangeListeners.delete(listener);
836833
}
837834

838835
/**

0 commit comments

Comments
 (0)