fix(ai-chat): surface dropped-stream errors clearly + log client disconnects

A mid-stream connection drop showed a generic "Something went wrong / Load
failed" banner and left no server-side trace.

- error-message: classify the browsers' own fetch-failure strings ("Load
  failed" on WebKit, "Failed to fetch" on Chrome, "NetworkError" on Firefox)
  as a lost connection, so the banner names the cause instead of the generic
  heading.
- ai-chat.controller: log a warning in the request close handler when the
  client disconnects before completion, so a drop that reaches the app (e.g. a
  reverse proxy cutting the SSE) is visible in the server logs before the abort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-22 03:44:25 +03:00
parent b60190ff1e
commit 1c9785997a
2 changed files with 15 additions and 2 deletions

View File

@@ -85,8 +85,12 @@ function classifyProviderError(msg: string): ErrorCategory | null {
// Connection dropped / provider unreachable. ECONNRESET is the production case:
// the LLM socket was reset mid-stream. "terminated" is scoped to a connection/
// stream context so it does not match benign "... was terminated" messages.
// The browser's own fetch-failure messages also land here because they mean the
// SSE stream to /api/ai-chat/stream dropped mid-answer (e.g. a reverse proxy cut
// it): WebKit/Safari says "Load failed", Chrome "Failed to fetch", Firefox
// "NetworkError when attempting to fetch resource".
if (
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|failed to fetch|load failed|networkerror|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
head,
)
) {

View File

@@ -161,7 +161,16 @@ export class AiChatController {
// cannot simply remove it once `stream()` returns).
const controller = new AbortController();
const onClose = (): void => {
if (!res.raw.writableEnded) controller.abort();
// A genuine disconnect leaves the response unfinished (unlike a normal
// completion, which also fires `close`). Such a drop — e.g. a reverse
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
// so log it here before aborting the agent loop.
if (!res.raw.writableEnded) {
this.logger.warn(
'AI chat stream: client disconnected before completion; aborting turn',
);
controller.abort();
}
};
req.raw.once('close', onClose);
res.raw.once('finish', () => req.raw.off('close', onClose));