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
+33
View File
@@ -576,12 +576,37 @@ function transformStream(
const encoder = new TextEncoder();
const lineBuffer = new SSELineBuffer();
// 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 {
// Stream already closed
if (keepaliveTimer) clearInterval(keepaliveTimer);
}
}, KEEPALIVE_INTERVAL_MS);
}
function stopKeepalive() {
if (keepaliveTimer) {
clearInterval(keepaliveTimer);
keepaliveTimer = null;
}
}
return new ReadableStream({
async pull(controller) {
try {
startKeepalive(controller);
while (true) {
const { done, value } = await reader.read();
if (done) {
stopKeepalive();
// Flush remaining text after stream ends
const remaining = lineBuffer.flush();
if (remaining.length > 0) {
@@ -612,8 +637,12 @@ function transformStream(
controller.enqueue(encoder.encode(line + "\n\n"));
}
}
// Yield to event loop after each chunk to prevent starvation
await new Promise((r) => setTimeout(r, 0));
}
} catch (err) {
stopKeepalive();
if (isDevMode()) {
controller.enqueue(
encoder.encode(
@@ -630,6 +659,10 @@ function transformStream(
controller.close();
}
},
cancel() {
stopKeepalive();
reader.cancel();
},
});
}