From b6787cc542cfc4ac9e2f1fc0539860d17e384b02 Mon Sep 17 00:00:00 2001 From: claude_code Date: Thu, 25 Jun 2026 03:59:32 +0300 Subject: [PATCH] fix(ai-chat): drain stream on client disconnect to stop heap-OOM leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/ai-chat/stream and public-share streaming paths piped streamText output to the client socket via pipeUIMessageStreamToResponse, whose only reader is that socket. On a client disconnect (pervasive Safari/proxy ECONNRESET), backpressure stalled the stream: the controller aborted the turn but nothing drained it, so streamText's onFinish/onError/onAbort never fired. Cleanup (close leased MCP clients, persist partial) never ran and the whole per-turn object graph (history, per-request toolset closures, captured steps, SDK buffers) stayed rooted — accumulating across turns until the default ~2GB heap saturated and the process crashed with "Ineffective mark-compacts near heap limit - JavaScript heap out of memory". Add the AI SDK v6 documented remedy: fire-and-forget `result.consumeStream({ onError })` right after streamText(), which removes backpressure and drains the stream independently of the client socket so the terminal callbacks always fire and the turn's memory is released even when the client has gone away. Applied to both the authenticated and public-share stream services. Also add `--heapsnapshot-near-heap-limit=2` to the prod start script so any residual leak dumps a heap snapshot near OOM for diagnosis (no effect on normal operation). Heap size stays ops-tunable via NODE_OPTIONS. - apps/server/src/core/ai-chat/ai-chat.service.ts - apps/server/src/core/ai-chat/public-share-chat.service.ts - apps/server/package.json Co-Authored-By: Claude Opus 4.8 --- apps/server/package.json | 2 +- apps/server/src/core/ai-chat/ai-chat.service.ts | 13 +++++++++++++ .../src/core/ai-chat/public-share-chat.service.ts | 9 +++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/server/package.json b/apps/server/package.json index 6ee1931b..b836a30a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -11,7 +11,7 @@ "start": "cross-env NODE_ENV=development nest start", "start:dev": "cross-env NODE_ENV=development nest start --watch", "start:debug": "cross-env NODE_ENV=development nest start --debug --watch", - "start:prod": "cross-env NODE_ENV=production node dist/main", + "start:prod": "cross-env NODE_ENV=production node --heapsnapshot-near-heap-limit=2 dist/main", "collab:prod": "cross-env NODE_ENV=production node dist/collaboration/server/collab-main", "collab:dev": "cross-env NODE_ENV=development node dist/collaboration/server/collab-main", "email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails", diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index 1cce9cf3..16ba5824 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -535,6 +535,19 @@ export class AiChatService { }, }); + // Drain the stream independently of the client socket so the turn always + // runs to completion (or to its abort) and the terminal callbacks + // (onFinish/onError/onAbort) fire — releasing the per-turn object graph + // (history, the per-request toolset closures, captured steps, SDK buffers) + // and closing leased MCP clients. WITHOUT this, a client disconnect leaves + // the pipe's dead socket as the only reader; backpressure stalls the stream, + // the callbacks never run, and every dropped turn stays rooted in memory — + // the heap-OOM leak. consumeStream removes that backpressure (AI SDK v6 + // "Handling client disconnects"). NOT awaited (fire-and-forget); the stream + // errors are already logged by the streamText `onError` callback above, so + // swallow here to avoid an unhandledRejection. + void result.consumeStream({ onError: () => undefined }); + // Stream the UI-message protocol straight to the hijacked Node response. // Without onError the AI SDK masks the cause ('An error occurred.') and the // UI shows a generic failure. Surface the real provider message instead. diff --git a/apps/server/src/core/ai-chat/public-share-chat.service.ts b/apps/server/src/core/ai-chat/public-share-chat.service.ts index f2d8f0f8..8011814b 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.service.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.service.ts @@ -244,6 +244,15 @@ export class PublicShareChatService { }, }); + // Drain the stream independently of the client socket so the turn always + // runs to completion (or to its abort) even when the anonymous client + // disconnects — otherwise the dead socket is the only reader, backpressure + // stalls the stream, and the per-turn object graph stays rooted (heap-OOM + // leak). consumeStream removes that backpressure (AI SDK v6 "Handling + // client disconnects"). Fire-and-forget; stream errors are already logged + // by the streamText `onError` callback above. + void result.consumeStream({ onError: () => undefined }); + // Stream the UI-message protocol straight to the hijacked Node response. // Surface the real provider message (AI SDK error bodies never carry the // API key, so this is safe; we never dump the resolved config).