diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 69ea6db56e..fd19ffa79c 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -528,6 +528,62 @@ describe('gemini.tsx main function kitty protocol', () => { ); }); + it('should call process.stdin.resume when isInteractive is true to protect against implicit Node pause', async () => { + const resumeSpy = vi.spyOn(process.stdin, 'resume'); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({ + model: undefined, + sandbox: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + query: undefined, + yolo: undefined, + approvalMode: undefined, + policy: undefined, + adminPolicy: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + experimentalAcp: undefined, + extensions: undefined, + listExtensions: undefined, + includeDirectories: undefined, + screenReader: undefined, + useWriteTodos: undefined, + resume: undefined, + listSessions: undefined, + deleteSession: undefined, + outputFormat: undefined, + fakeResponses: undefined, + recordResponses: undefined, + rawOutput: undefined, + acceptRawOutputRisk: undefined, + isCommand: undefined, + }); + + await act(async () => { + await main(); + }); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + resumeSpy.mockRestore(); + }); + it.each([ { flag: 'listExtensions' }, { flag: 'listSessions' }, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 707774df57..4b43d7d81b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -613,8 +613,17 @@ export async function main() { } cliStartupHandle?.end(); + // Render UI, passing necessary config values. Check that there is no command line question. if (config.isInteractive()) { + // Earlier initialization phases (like TerminalCapabilityManager resolving + // or authWithWeb) may have added and removed 'data' listeners on process.stdin. + // When the listener count drops to 0, Node.js implicitly pauses the stream buffer. + // React Ink's useInput hooks will silently fail to receive keystrokes if the stream remains paused. + if (process.stdin.isTTY) { + process.stdin.resume(); + } + await startInteractiveUI( config, settings, diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index afe35ce665..84a777820a 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -860,6 +860,85 @@ describe('oauth2', () => { global.setTimeout = originalSetTimeout; }); + it('should clear the authorization timeout immediately upon successful web login to prevent memory leaks', async () => { + const mockAuthUrl = 'https://example.com/auth'; + const mockCode = 'test-code'; + const mockState = 'test-state'; + + const mockOAuth2Client = { + generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), + getToken: vi.fn().mockResolvedValue({ + tokens: { + access_token: 'test-token', + refresh_token: 'test-refresh', + }, + }), + setCredentials: vi.fn().mockImplementation(function ( + this: { credentials?: unknown }, + creds: unknown, + ) { + this.credentials = creds; + }), + getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), + on: vi.fn(), + credentials: {}, + } as unknown as OAuth2Client; + vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); + + vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); + vi.mocked(open).mockImplementation( + async () => ({ on: vi.fn() }) as never, + ); + + let requestCallback!: http.RequestListener; + let serverListeningCallback: (value: unknown) => void; + const serverListeningPromise = new Promise( + (resolve) => (serverListeningCallback = resolve), + ); + + const mockHttpServer = { + listen: vi.fn( + (_port: number, _host: string, callback?: () => void) => { + if (callback) callback(); + serverListeningCallback(undefined); + }, + ), + close: vi.fn(), + on: vi.fn(), + address: () => ({ port: 3000 }), + }; + (http.createServer as Mock).mockImplementation((cb) => { + requestCallback = cb; + return mockHttpServer as unknown as http.Server; + }); + + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + const clientPromise = getOauthClient( + AuthType.LOGIN_WITH_GOOGLE, + mockConfig, + ); + await serverListeningPromise; + + const mockReq = { + url: `/oauth2callback?code=${mockCode}&state=${mockState}`, + } as http.IncomingMessage; + const mockRes = { + writeHead: vi.fn(), + end: vi.fn(), + on: vi.fn(), + } as unknown as http.ServerResponse; + + // Trigger the successful server response + requestCallback(mockReq, mockRes); + await clientPromise; + + // Verify that the watchdog timer was cleared correctly + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + }); + it('should handle OAuth callback errors with descriptive messages', async () => { const mockAuthUrl = 'https://example.com/auth'; const mockOAuth2Client = { diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index e238a4a860..0ae523dc94 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -332,8 +332,9 @@ async function initOauthClient( // Add timeout to prevent infinite waiting when browser tab gets stuck const authTimeout = 5 * 60 * 1000; // 5 minutes timeout + let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { + timeoutId = setTimeout(() => { reject( new FatalAuthenticationError( 'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. ' + @@ -371,6 +372,9 @@ async function initOauthClient( cancellationPromise, ]); } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } if (sigIntHandler) { process.removeListener('SIGINT', sigIntHandler); }