mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-16 19:44:31 +00:00
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:
parent
341b90cd9c
commit
bf24fff1f7
2 changed files with 45 additions and 7 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue