fix(cli): destroy stdout instead of process.exit on EPIPE

Routine CLI patterns like `qwen -p ... | head -1` / `| less` / `| grep -m1`
close the downstream pipe and trigger EPIPE. The previous handler called
process.exit(0), which bypassed the caller's runExitCleanup -> Config
.shutdown -> chat-recording flush() chain and silently dropped queued
JSONL writes (most recent assistant turn + tool results).

Destroying stdout instead lets writes fail fast and the natural function
return drive cleanup. We deliberately do not also abortController.abort()
here: the abort path runs handleCancellationError which itself calls
process.exit(130), re-introducing the same bypass.

Reported by zhangxy-zju on #3581.
This commit is contained in:
wenshao 2026-04-24 20:03:39 +08:00
parent 341b90cd9c
commit bf24fff1f7
2 changed files with 45 additions and 7 deletions

View file

@ -273,6 +273,38 @@ describe('runNonInteractive', () => {
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
it('on EPIPE, destroys stdout and returns normally instead of process.exit', async () => {
// Regression: process.exit(0) on EPIPE bypassed runExitCleanup → flush()
// and dropped queued JSONL writes for `qwen -p ... | head -1` patterns.
// process.exit is mocked to throw in beforeEach, so reaching the
// assertion also proves the bypass route is gone.
setupMetricsMock();
const stdoutDestroySpy = vi
.spyOn(process.stdout, 'destroy')
.mockReturnValue(process.stdout);
mockGeminiClient.sendMessageStream.mockImplementation(
async function* mockStream(): AsyncGenerator<ServerGeminiStreamEvent> {
process.stdout.emit(
'error',
Object.assign(new Error('EPIPE'), { code: 'EPIPE' }),
);
yield { type: GeminiEventType.Content, value: 'Hello' };
yield {
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata: { totalTokenCount: 0 },
},
};
},
);
await runNonInteractive(mockConfig, mockSettings, 'test', 'p1');
expect(stdoutDestroySpy).toHaveBeenCalled();
});
it('should handle a single tool call and respond', async () => {
setupMetricsMock();
const toolCallEvent: ServerGeminiStreamEvent = {

View file

@ -175,16 +175,22 @@ export async function runNonInteractive(
let totalApiDurationMs = 0;
const startTime = Date.now();
const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
process.stdout.removeListener('error', stdoutErrorHandler);
process.exit(0);
}
};
const geminiClient = config.getGeminiClient();
const abortController = options.abortController ?? new AbortController();
// EPIPE: don't process.exit here — that bypasses the caller's
// runExitCleanup → flush() and drops queued JSONL writes. Destroy
// stdout instead and let the natural return drive cleanup. (Aborting
// is also wrong: the abort path runs handleCancellationError → exit
// 130 and re-introduces the same bypass.)
let pipeBroken = false;
const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE' && !pipeBroken) {
pipeBroken = true;
process.stdout.destroy();
}
};
// Setup signal handlers for graceful shutdown
const shutdownHandler = () => {
debugLogger.debug('[runNonInteractive] Shutdown signal received');