Skip to content

Commit 5c2c81d

Browse files
jinye.djyqwencoder
andcommitted
fix(cli): fix listener leak, silent failures, and error handling in early input capture
- Register cleanup for stdin listener in gemini.tsx to prevent orphaned listener on any error path before UI mounts - Add try-catch and cancellation guard to setImmediate replay in KeypressContext to handle component unmount and replay errors gracefully - Stop capture immediately and warn when buffer limit is reached instead of silently dropping data with a debug-level log - Capture stdin reference at registration time so removeListener always operates on the correct stream instance - Add debug log when early capture is skipped due to non-TTY stdin Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent 8f1e0de commit 5c2c81d

File tree

3 files changed

+35
-8
lines changed

3 files changed

+35
-8
lines changed

packages/cli/src/gemini.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
5858
import { getCliVersion } from './utils/version.js';
5959
import { writeStderrLine } from './utils/stdioHelpers.js';
6060
import { computeWindowTitle } from './utils/windowTitle.js';
61-
import { startEarlyInputCapture } from './utils/earlyInputCapture.js';
61+
import {
62+
startEarlyInputCapture,
63+
stopAndGetCapturedInput,
64+
} from './utils/earlyInputCapture.js';
6265
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
6366
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
6467
import { initializeLlmOutputLanguage } from './utils/languageUtils.js';
@@ -385,6 +388,8 @@ export async function main() {
385388

386389
// Startup optimization: start early input capture
387390
startEarlyInputCapture();
391+
// Ensure the stdin listener is removed on any exit path (error, signal, etc.)
392+
registerCleanup(() => stopAndGetCapturedInput());
388393

389394
// This cleanup isn't strictly needed but may help in certain situations.
390395
process.on('SIGTERM', () => {

packages/cli/src/ui/contexts/KeypressContext.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -990,19 +990,29 @@ export function KeypressProvider({
990990
}
991991

992992
// Startup optimization: replay captured input if available
993+
let replayPending = false;
993994
if (capturedInput.length > 0) {
994995
debugLogger.debug(
995996
`Replaying ${capturedInput.length} bytes of captured input`,
996997
);
997998
// Process in next event loop tick to ensure subscribers are ready.
998999
// Always emit on stdin so that handleRawKeypress processes paste markers
9991000
// correctly in passthrough mode.
1001+
// In non-passthrough mode, readline.emitKeypressEvents installs an internal
1002+
// 'data' listener on stdin that converts data events to keypress events.
1003+
replayPending = true;
10001004
setImmediate(() => {
1001-
stdin.emit('data', capturedInput);
1005+
if (!replayPending) return;
1006+
try {
1007+
stdin.emit('data', capturedInput);
1008+
} catch (err) {
1009+
debugLogger.error('Failed to replay captured input:', err);
1010+
}
10021011
});
10031012
}
10041013

10051014
return () => {
1015+
replayPending = false;
10061016
if (usePassthrough) {
10071017
keypressStream.removeListener('keypress', handleKeypress);
10081018
stdin.removeListener('data', handleRawKeypress);

packages/cli/src/utils/earlyInputCapture.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ let inputBuffer: InputBuffer = {
3838
};
3939

4040
let captureHandler: ((data: Buffer) => void) | null = null;
41+
let captureStdin: NodeJS.ReadStream | null = null;
4142
let isCapturing = false;
4243

4344
/**
@@ -191,6 +192,9 @@ function filterTerminalResponses(data: Buffer): Buffer {
191192
*/
192193
export function startEarlyInputCapture(): void {
193194
if (isCapturing || !process.stdin.isTTY) {
195+
if (!process.stdin.isTTY) {
196+
debugLogger.debug('Early input capture skipped: stdin is not a TTY');
197+
}
194198
return;
195199
}
196200

@@ -216,7 +220,10 @@ export function startEarlyInputCapture(): void {
216220

217221
// Check buffer size limit
218222
if (inputBuffer.totalBytes >= MAX_BUFFER_SIZE) {
219-
debugLogger.debug('Buffer size limit reached, stopping capture');
223+
debugLogger.warn(
224+
`Early input capture buffer full (${MAX_BUFFER_SIZE} bytes). Stopping capture; additional keystrokes during startup will be lost.`,
225+
);
226+
stopEarlyInputCapture();
220227
return;
221228
}
222229

@@ -243,19 +250,21 @@ export function startEarlyInputCapture(): void {
243250
}
244251
};
245252

246-
process.stdin.on('data', captureHandler);
253+
captureStdin = process.stdin;
254+
captureStdin.on('data', captureHandler);
247255
}
248256

249257
/**
250258
* Stop early input capture
251259
* Call before KeypressProvider mounts
252260
*/
253261
export function stopEarlyInputCapture(): void {
254-
if (!isCapturing || !captureHandler) {
262+
if (!isCapturing || !captureHandler || !captureStdin) {
255263
return;
256264
}
257265

258-
process.stdin.removeListener('data', captureHandler);
266+
captureStdin.removeListener('data', captureHandler);
267+
captureStdin = null;
259268
captureHandler = null;
260269
isCapturing = false;
261270
inputBuffer.captured = true;
@@ -300,10 +309,13 @@ export function hasCapturedInput(): boolean {
300309
* Reset capture state (for testing only)
301310
*/
302311
export function resetCaptureState(): void {
303-
if (captureHandler) {
312+
if (captureHandler && captureStdin) {
313+
captureStdin.removeListener('data', captureHandler);
314+
} else if (captureHandler) {
304315
process.stdin.removeListener('data', captureHandler);
305-
captureHandler = null;
306316
}
317+
captureStdin = null;
318+
captureHandler = null;
307319
isCapturing = false;
308320
inputBuffer = {
309321
chunks: [],

0 commit comments

Comments
 (0)