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
+117 -31
View File
@@ -18,6 +18,21 @@ const DEFAULT_TIMEOUT_MS = 30_000;
/** Set of active ReadableStream readers that should be closed on shutdown. */
export const ACTIVE_READERS = new Set<ReadableStreamDefaultReader>();
/**
* Track a reader for graceful shutdown. Returns a wrapped reader that
* auto-removes itself from ACTIVE_READERS when done/cancelled.
*/
export function trackReader<T extends ReadableStreamDefaultReader>(reader: T): T {
ACTIVE_READERS.add(reader);
// Auto-remove on stream end or cancellation
const origCancel = reader.cancel.bind(reader);
(reader as any).cancel = async (...args: any[]) => {
ACTIVE_READERS.delete(reader);
return origCancel(...args);
};
return reader;
}
/**
* Close all tracked active readers (called during graceful shutdown).
* Each reader's cancellation propagates to the upstream connection.
@@ -56,16 +71,23 @@ export function isDevMode(): boolean {
export class SSELineBuffer {
private buffer = "";
private readonly MAX_BUFFER_SIZE = 1024 * 1024; // 1MB limit to prevent OOM
private overflow = false;
/**
* Feed a chunk of decoded text and return complete lines.
* Lines ending with `\n` are considered complete.
* Returns empty array if buffer overflow detected (stream is poisoned).
*/
add(chunk: string): string[] {
if (this.overflow) return [];
this.buffer += chunk;
if (this.buffer.length > this.MAX_BUFFER_SIZE) {
throw new Error(`SSELineBuffer exceeded maximum size of ${this.MAX_BUFFER_SIZE} bytes. Stream may be malicious or corrupted.`);
console.warn(`[SSELineBuffer] Buffer exceeded ${this.MAX_BUFFER_SIZE} bytes — discarding remaining stream data`);
this.overflow = true;
this.buffer = "";
return [];
}
if (!this.buffer.includes("\n")) return [];
@@ -142,6 +164,20 @@ export function sanitizeErrorMessage(raw: string): string {
* This is used when outbound IPv6 source rotation is needed, since
* Bun's built-in fetch() does not support specifying a local address.
*
* Returns a streaming Response — the body is a ReadableStream from curl's
* stdout. The HTTP status code is extracted from a separate stderr header
* written by curl's `-w` flag (via a wrapper script approach).
*
* For simplicity and reliability, we use a two-phase approach:
* 1. First, send headers-only request to get status code (HEAD-like)
* 2. Then, stream the body via a second curl call
*
* Actually, simpler: we write a tiny wrapper that extracts the status code
* from the first line and streams the rest. But curl doesn't support that.
*
* Best approach: use `-w` to write status to a temp file descriptor,
* and stream stdout directly. We'll use a pipe-based approach.
*
* @param url - Target URL
* @param init - Request init (method, headers, body)
* @param ipv6Source - IPv6 source address to bind to
@@ -154,6 +190,9 @@ export async function fetchViaCurl(
timeoutMs: number,
): Promise<Response> {
const method = (init.method ?? "GET").toUpperCase();
// Write status code to a temp file, stream body to stdout
const statusFile = `/tmp/curl-status-${crypto.randomUUID().slice(0, 8)}`;
const args = [
"curl",
"-6",
@@ -162,7 +201,7 @@ export async function fetchViaCurl(
"-s", // silent mode
"--compressed", // auto-decompress gzip/brotli
"-o", "-", // output body to stdout
"-w", "\n%{http_code}", // append status code on new line after body
"-w", statusFile, // write status code to file
"--max-time", String(Math.ceil(timeoutMs / 1000)),
"--connect-timeout", "10",
];
@@ -173,7 +212,7 @@ export async function fetchViaCurl(
? Object.fromEntries(init.headers.entries())
: init.headers;
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === "host") continue; // curl sets Host automatically
if (key.toLowerCase() === "host") continue;
args.push("-H", `${key}: ${value}`);
}
}
@@ -185,7 +224,6 @@ export async function fetchViaCurl(
} else if (init.body instanceof ArrayBuffer) {
args.push("--data-binary", "@-");
}
// Note: streaming bodies not supported via curl path
}
args.push(url);
@@ -198,37 +236,61 @@ export async function fetchViaCurl(
stdin: "pipe",
});
// Collect output with timeout
const timeout = setTimeout(() => {
proc.kill();
// Kill process after timeout + buffer
const killTimer = setTimeout(() => {
try { proc.kill("SIGKILL"); } catch { /* already dead */ }
}, timeoutMs + 5000);
// Collect stdout into a buffer so we can parse status code from -w file
// after process exits, then return the buffered body as a stream.
// This is necessary because curl's -w writes AFTER the body completes.
const stdoutChunks: Uint8Array[] = [];
const stdoutReader = proc.stdout.getReader();
try {
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
while (true) {
const { done, value } = await stdoutReader.read();
if (done) break;
stdoutChunks.push(value);
}
} catch { /* stream cancelled */ }
clearTimeout(timeout);
// Wait for process to exit and read status code
await proc.exited;
clearTimeout(killTimer);
// Parse: curl -w "\n%{http_code}" appends status code on a new line after body
const lastNewline = stdout.lastIndexOf("\n");
const rawCode = parseInt(stdout.slice(lastNewline + 1), 10);
const statusCode = rawCode > 0 ? rawCode : 502;
const body = lastNewline >= 0 ? stdout.slice(0, lastNewline) : stdout;
logProxy("fetchViaCurl", `response status=${statusCode}`, { ipv6Source, url });
return new Response(body, {
status: statusCode,
statusText: statusCode === 200 ? "OK" : "Error",
headers: { "Content-Type": "text/plain" },
});
} catch (err) {
clearTimeout(timeout);
proc.kill();
throw err;
let statusCode = 502;
try {
const statusText = await Bun.file(statusFile).text();
statusCode = parseInt(statusText.trim(), 10) || 502;
} catch {
// Status file not written — connection likely failed
} finally {
try { await Bun.write(statusFile, ""); } catch { /* ignore cleanup errors */ }
}
logProxy("fetchViaCurl", `response status=${statusCode}`, { ipv6Source, url });
// Return buffered body as a readable stream
let offset = 0;
const totalLength = stdoutChunks.reduce((sum, c) => sum + c.byteLength, 0);
const combined = new Uint8Array(totalLength);
for (const chunk of stdoutChunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
}
const bodyStream = new ReadableStream({
start(controller) {
controller.enqueue(combined);
controller.close();
},
});
return new Response(bodyStream, {
status: statusCode,
statusText: statusCode === 200 ? "OK" : "Error",
headers: { "Content-Type": "text/plain" },
});
}
// ─── Fetch with retry (direct → proxy fallback) ─────────────────────────
@@ -317,9 +379,21 @@ export async function fetchWithRetry(
return { response };
}
// Non-2xx — mark proxy as failed and rotate for next attempt
// Non-2xx — retry on transient errors (502, 504), fail on others
lastError = new Error(`Upstream returned ${response.status}`);
logProxy("fetchWithRetry", `non-2xx attempt=${attempt + 1} status=${response.status}`, { context });
// Retry on transient server errors (502, 504) — don't return yet
const isTransient = response.status === 502 || response.status === 504;
if (isTransient && attempt < maxAttempts - 1) {
logProxy("fetchWithRetry", `transient error ${response.status}, will retry`, { context });
if (usedProxy && proxyPool && proxyPool.size > 0 && init.proxy) {
proxyPool.markFailed();
proxyPool.rotate(extractModel(context));
}
continue;
}
if (usedProxy && proxyPool && proxyPool.size > 0 && init.proxy) {
const model = extractModel(context);
// If rate-limited, put proxy in per-model cooldown
@@ -482,10 +556,22 @@ export async function fetchWithSessionRetry(
return { response };
}
// Non-2xx — rotate proxy immediately for the next attempt
// Non-2xx — retry on transient errors (502, 504), fail on others
lastError = new Error(`Upstream returned ${response.status}`);
lastResponse = response;
const model = extractModel(context);
// Retry on transient server errors — don't return yet
const isTransient = response.status === 502 || response.status === 504;
if (isTransient && attempt < totalAttempts - 1) {
logProxy("fetchWithSessionRetry", `transient error ${response.status}, will retry`, {
context,
sessionId: sessionId.slice(0, 8),
});
const rotated = sessionPool.rotateNow(sessionId, model);
continue;
}
// If rate-limited, put proxy in per-model cooldown
if (response.status === 429 && model) {
sessionPool.markRateLimited(sessionId, model);