diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts index 5af2eb42ead..d1775af29e9 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts @@ -297,6 +297,23 @@ describe('ChatHubController', () => { expect(await handlerSettled).toEqual({ error: undefined }); }); + it('should reject when the file stream closes before it ends', async () => { + const stream = await openFileStream(); + chatAttachmentService.getAttachment.mockResolvedValue(attachmentResult(stream)); + const { port, handlerSettled } = await startAttachmentServer(); + + const clientReq = request(port); + clientReq.on('response', (clientRes) => { + clientRes.once('data', () => stream.destroy()); + clientRes.on('error', () => {}); + }); + + expect(await handlerSettled).toEqual({ + error: expect.objectContaining({ code: 'ERR_STREAM_PREMATURE_CLOSE' }), + }); + await vi.waitFor(() => expect(stream.closed).toBe(true), { timeout: CLOSE_TIMEOUT_MS }); + }); + it('should close the file stream and settle when the client disconnects before the response starts', async () => { const stream = await openFileStream(); const requestReceived = createDeferredPromise(); diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index c9a5b39a05d..a3a45e85070 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -164,12 +164,20 @@ export class ChatHubController { return; } + // pipeline also rejects with ERR_STREAM_PREMATURE_CLOSE when the source closes early, + // so only treat it as a disconnect if the response closed while the source was open. + let clientDisconnected = false; + res.once('close', () => { + clientDisconnected = !res.writableFinished && !stream.destroyed; + }); + // pipeline destroys the source when the client disconnects during the download, // so aborted downloads don't leak file descriptors. try { await pipeline(stream, res); } catch (error) { if ( + clientDisconnected && error instanceof Error && 'code' in error && error.code === 'ERR_STREAM_PREMATURE_CLOSE'