fix: streaming reliability, timeout, memory leaks, and retry improvements

- Fix streaming timeout: use AbortController for connection-only timeout
  instead of AbortSignal.timeout() that kills active SSE streams
- Fix fetchViaCurl: stream body via ReadableStream instead of buffering
  entire response in memory
- Fix JWT/aichat race condition: add Promise dedup to prevent concurrent
  bootstrap calls (10 requests = 1 bootstrap, not 10)
- Fix ACTIVE_READERS memory leak: auto-remove readers on stream completion
- Fix WebSocket backpressure: log warning when client buffer exceeds 1MB
- Add SSE heartbeat/keepalive: send ': keepalive' every 15s to prevent
  LB/proxy timeout during AI thinking
- Fix SSELineBuffer: graceful overflow handling (warn + discard instead
  of throwing error that crashes stream)
- Fix transformStream tight loop: yield to event loop after each chunk
  to prevent starvation
- Fix fetchViaCurl process cleanup: use SIGKILL + proper timeout cleanup
- Add retry on 502/504: retry transient server errors before returning
  to caller (both fetchWithRetry and fetchWithSessionRetry)
This commit is contained in:
MythEclipse
2026-06-20 17:13:32 +07:00
parent 35c0123529
commit c62de0899a
9 changed files with 231 additions and 50 deletions
+32
View File
@@ -285,9 +285,33 @@ function transformAnthropicStream(
let phase: "init" | "block" | "done" = "init";
let messageId = `msg_${Date.now()}`;
// SSE keepalive: send a comment every 15s to prevent LB/proxy timeout
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
const KEEPALIVE_INTERVAL_MS = 15_000;
function startKeepalive(controller: ReadableStreamDefaultController) {
if (keepaliveTimer) return;
keepaliveTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
if (keepaliveTimer) clearInterval(keepaliveTimer);
}
}, KEEPALIVE_INTERVAL_MS);
}
function stopKeepalive() {
if (keepaliveTimer) {
clearInterval(keepaliveTimer);
keepaliveTimer = null;
}
}
return new ReadableStream({
async pull(controller) {
try {
startKeepalive(controller);
if (phase === "init") {
phase = "block";
messageId = `msg_${Date.now()}`;
@@ -318,6 +342,7 @@ function transformAnthropicStream(
while (phase === "block") {
const { done, value } = await reader.read();
if (done) {
stopKeepalive();
const remaining = lineBuffer.flush();
if (remaining.length > 0) {
const adapted = backendLineToAnthropicSSE(remaining, model, config);
@@ -339,6 +364,8 @@ function transformAnthropicStream(
}
}
// Yield to event loop after each chunk
await new Promise((r) => setTimeout(r, 0));
return;
}
@@ -370,6 +397,7 @@ function transformAnthropicStream(
controller.close();
}
} catch (err) {
stopKeepalive();
if (isDevMode()) {
controller.enqueue(
encoder.encode(
@@ -386,6 +414,10 @@ function transformAnthropicStream(
controller.close();
}
},
cancel() {
stopKeepalive();
reader.cancel();
},
});
}