gateway: tail-read session transcripts to prevent chat.history freezes

readSessionMessages() previously called fs.readFileSync to load the entire
JSONL transcript synchronously, then split and JSON.parse every line. On
large sessions — or when a single record is unusually large — this blocked
the Node.js event loop long enough to freeze the Gateway WebSocket and make
the Web UI / WebChat appear hung.

Root cause confirmed via a 447 KB single-line JSONL record that reproduced
the hang reliably; moving the file aside restored UI responsiveness
immediately.

Fix: two-layer guard in readSessionMessages():

1. Tail-read: files larger than 2 MB are read from the trailing 2 MB only
   (partial first line is discarded). This covers the common case where a
   session grows gradually over many turns.

2. Per-line cap: lines longer than 200 KB are skipped without parsing.
   A normal assistant reply is well under 50 KB; anything beyond 200 KB is
   a runaway prompt or model output that would stall JSON.parse and bloat
   the UI. The 200 KB threshold catches the confirmed 447 KB case that was
   too small to trigger the previous 1 MB guard.

readSessionTitleFieldsFromTranscript() already uses head/tail chunk reads
and is unaffected.
This commit is contained in:
威哥Wego 2026-03-18 08:22:02 +08:00
parent 0fb7add7d6
commit 53aca43ee7

View File

@ -82,14 +82,53 @@ export function readSessionMessages(
return [];
}
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
// NOTE: This is on the Gateway hot path (chat.history). Reading + splitting an entire transcript
// file can freeze the UI when a session grows large (or when a single JSONL record is huge).
// We therefore tail-read large files and apply a per-line size guard.
const MAX_TAIL_BYTES = 2 * 1024 * 1024; // 2MB tail is plenty for the last ~100-1000 messages
// 200KB per line: a normal assistant reply is well under 50KB. Anything larger is a runaway
// prompt/response that would only stall JSON.parse and bloat the UI — skip it entirely.
// (The confirmed 447KB line causing Gateway freezes is caught by this threshold.)
const MAX_LINE_CHARS = 200 * 1024;
let content = "";
try {
const stat = fs.statSync(filePath);
if (stat.size > MAX_TAIL_BYTES) {
const fd = fs.openSync(filePath, "r");
try {
const start = Math.max(0, stat.size - MAX_TAIL_BYTES);
const buf = Buffer.allocUnsafe(stat.size - start);
fs.readSync(fd, buf, 0, buf.length, start);
content = buf.toString("utf-8");
// If we started mid-line, drop the partial first line.
const firstNewline = content.indexOf("\n");
if (firstNewline >= 0 && start > 0) {
content = content.slice(firstNewline + 1);
}
} finally {
fs.closeSync(fd);
}
} else {
content = fs.readFileSync(filePath, "utf-8");
}
} catch {
return [];
}
const lines = content.split(/\r?\n/);
const messages: unknown[] = [];
for (const line of lines) {
if (!line.trim()) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (trimmed.length > MAX_LINE_CHARS) {
// Skip lines that are too large to safely parse on the RPC path.
continue;
}
try {
const parsed = JSON.parse(line);
const parsed = JSON.parse(trimmed);
if (parsed?.message) {
messages.push(parsed.message);
continue;