From 7051445c6e3134871b97229b746afd59fc80b359 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 17 Jul 2026 10:01:26 -0700 Subject: [PATCH 1/2] fix: cap per-entry journal body at 64 KB to prevent ISL crash on GET /__aimock/journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When journal entries retain large LLM request bodies (500KB+), serializing 1000 entries via JSON.stringify exceeds V8's ~512MB string limit, crashing the server with RangeError: Invalid string length. Cap stored bodies at 64KB at the journal source (journal.add()), replacing oversized bodies with a truncation marker { __aimock_truncated: true, originalByteSize: N }. This bounds aggregate journal memory to ~64MB at maxEntries=1000 and protects all downstream consumers of journal.getAll(), not just /journal. Also unifies catch-all error logging to include err.stack unconditionally for production observability (removes [DIAG] tag from diagnosis branch). Regression test: 1000 × 100KB entries, JSON.stringify must not throw. --- src/__tests__/journal.test.ts | 66 +++++++++++++++++++++++++++++++++++ src/journal.ts | 30 +++++++++++++++- src/server.ts | 5 ++- 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/__tests__/journal.test.ts b/src/__tests__/journal.test.ts index 4209aa17..e89f633c 100644 --- a/src/__tests__/journal.test.ts +++ b/src/__tests__/journal.test.ts @@ -457,4 +457,70 @@ describe("Journal", () => { expect(journal.getFixtureMatchCount(fixture, "t-9999")).toBe(1); }); }); + + describe("journal body cap (ISL regression)", () => { + const BODY_CAP = 64 * 1024; // 64 KB — must match journal.ts constant + + it("replaces oversized bodies with a truncation marker", () => { + const journal = new Journal(); + // Build a body that serializes to well over 64 KB (100 KB of content) + const bigContent = "A".repeat(100 * 1024); + const entry = journal.add( + makeEntry({ + body: { + model: "gpt-4o", + messages: [{ role: "user", content: bigContent }], + }, + }), + ); + + // The stored body must be the truncation marker, not the original + const body = entry.body as Record; + expect(body.__aimock_truncated).toBe(true); + expect(typeof body.originalByteSize).toBe("number"); + expect(body.originalByteSize as number).toBeGreaterThan(BODY_CAP); + expect(typeof body.note).toBe("string"); + }); + + it("retains bodies that are within the 64 KB cap", () => { + const journal = new Journal(); + const smallBody = { + model: "gpt-4o", + messages: [{ role: "user" as const, content: "hello" }], + }; + const entry = journal.add(makeEntry({ body: smallBody })); + + // Small body must be stored as-is (no truncation marker) + expect((entry.body as Record).__aimock_truncated).toBeUndefined(); + expect(entry.body).toEqual(smallBody); + }); + + it("JSON.stringify(journal.getAll()) does not throw with 1000 large-body entries", () => { + // Regression anchor for RangeError: Invalid string length (ISL). + // 1000 entries × 100 KB body each = 100 MB without cap → exceeds V8 limit. + // With cap, each entry stores only the ~200-byte marker → <1 MB total. + const journal = new Journal({ maxEntries: 1000 }); + const bigContent = "B".repeat(100 * 1024); + for (let i = 0; i < 1000; i++) { + journal.add( + makeEntry({ + body: { + model: "gpt-4o", + messages: [{ role: "user", content: bigContent }], + }, + }), + ); + } + + expect(journal.size).toBe(1000); + // This must not throw RangeError: Invalid string length + let serialized: string; + expect(() => { + serialized = JSON.stringify(journal.getAll()); + }).not.toThrow(); + // Sanity-check: valid JSON, 1000 entries + const parsed = JSON.parse(serialized!) as unknown[]; + expect(parsed).toHaveLength(1000); + }); + }); }); diff --git a/src/journal.ts b/src/journal.ts index 93f3c4dc..899bac83 100644 --- a/src/journal.ts +++ b/src/journal.ts @@ -1,8 +1,35 @@ import { generateId } from "./helpers.js"; -import type { Fixture, FixtureMatch, JournalEntry } from "./types.js"; +import type { ChatCompletionRequest, Fixture, FixtureMatch, JournalEntry } from "./types.js"; import { DEFAULT_TEST_ID } from "./constants.js"; export { DEFAULT_TEST_ID } from "./constants.js"; +/** + * Maximum byte length of a serialized request body retained in a journal + * entry. Bodies exceeding this cap are replaced with a truncation marker so + * that `JSON.stringify(journal.getAll())` never exceeds V8's 512 MB string + * limit (64 KB × 1000 entries = ~64 MB aggregate, well within the limit). + */ +const JOURNAL_BODY_CAP_BYTES = 64 * 1024; // 64 KB + +/** + * If `body` serializes to more than JOURNAL_BODY_CAP_BYTES, replace it with + * a truncation marker. Returns the original body when within the cap, or null + * when `body` is null. + */ +function capBody(body: ChatCompletionRequest | null): ChatCompletionRequest | null { + if (body === null) return null; + const serialized = JSON.stringify(body); + if (serialized.length <= JOURNAL_BODY_CAP_BYTES) return body; + // Cast: the marker is not a real ChatCompletionRequest, but the field type + // is already nullable-union and downstream consumers (e.g. GET /journal) + // treat the body as opaque JSON — the truncation marker is safe to store. + return { + __aimock_truncated: true, + originalByteSize: Buffer.byteLength(serialized, "utf8"), + note: "body truncated by aimock journal cap (64 KB limit)", + } as unknown as ChatCompletionRequest; +} + /** * Compare two field values, handling RegExp by source+flags rather than reference. */ @@ -101,6 +128,7 @@ export class Journal { id: generateId("req"), timestamp: Date.now(), ...entry, + body: capBody(entry.body), }; this.entries.push(full); // FIFO eviction when over capacity. Array.prototype.shift() is O(n) diff --git a/src/server.ts b/src/server.ts index 2e9bb4b8..fea22132 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1227,7 +1227,10 @@ export async function createServer( // Delegate to async handler — catch unhandled rejections to prevent Node.js crashes handleHttpRequest(req, res).catch((err: unknown) => { const msg = err instanceof Error ? err.message : "Internal error"; - defaults.logger.warn(`Unhandled request error: ${msg}`); + const stack = err instanceof Error ? (err.stack ?? msg) : msg; + const method = req.method ?? "?"; + const url = req.url ?? "?"; + defaults.logger.warn(`Unhandled request error on ${method} ${url}: ${msg}\n${stack}`); if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: { message: msg, type: "server_error" } })); From 38a57731c69008ef7d1a16c6a34c77fd8759e007 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 17 Jul 2026 10:13:42 -0700 Subject: [PATCH 2/2] fix: use Buffer.byteLength for journal body cap gate, fix doc, add multibyte regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change `capBody` gate from `serialized.length` (UTF-16 code units) to `Buffer.byteLength(serialized, "utf8")` to match JOURNAL_BODY_CAP_BYTES constant name and correctly truncate multibyte content (CJK/emoji) whose code-unit count falls under the 64 KB threshold but whose UTF-8 byte size does not. - Rewrite the JOURNAL_BODY_CAP_BYTES JSDoc: the old comment incorrectly claimed a ~64 MB aggregate bound enforced by the cap; only the request body is capped — headers/path are retained uncapped but bounded by Node's default ~16 KB max HTTP header size. The updated doc accurately describes the actual mechanism. - Add regression test: CJK body (22,000 × U+4E2D = ~66 KB UTF-8, ~22 K code units) proves the byte-based gate truncates content the old code-unit gate missed. Confirmed RED against the old gate, GREEN after this fix. --- src/__tests__/journal.test.ts | 29 +++++++++++++++++++++++++++++ src/journal.ts | 27 +++++++++++++++++++-------- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/__tests__/journal.test.ts b/src/__tests__/journal.test.ts index e89f633c..6a92b163 100644 --- a/src/__tests__/journal.test.ts +++ b/src/__tests__/journal.test.ts @@ -495,6 +495,35 @@ describe("Journal", () => { expect(entry.body).toEqual(smallBody); }); + it("truncates multibyte (CJK) bodies whose UTF-8 byte size exceeds 64 KB even when code-unit length does not", () => { + // Regression: the original gate used serialized.length (UTF-16 code units). + // CJK characters are 3 bytes in UTF-8 but 1 JS code unit, so a body whose + // byte size > 64 KB can have code-unit length << 64 K. + // This test proves the byte-based gate catches what the code-unit gate missed. + // + // 22,000 CJK chars × 3 UTF-8 bytes = 66,000 bytes, plus ~60 bytes of JSON + // envelope → ~66,060 bytes total (> 65,536 cap). + // Code-unit length: 22,000 + ~60 = ~22,060 (well under 65,536). + const journal = new Journal(); + // U+4E2D (中) is a 3-byte UTF-8 character; repeat 22,000 times. + const cjkContent = "中".repeat(22_000); + const body = { + model: "gpt-4o", + messages: [{ role: "user" as const, content: cjkContent }], + }; + // Verify our test invariant: byte size > cap, code-unit length < cap. + const serialized = JSON.stringify(body); + expect(serialized.length).toBeLessThan(BODY_CAP); // code-unit gate would MISS this + expect(Buffer.byteLength(serialized, "utf8")).toBeGreaterThan(BODY_CAP); // byte gate catches it + + const entry = journal.add(makeEntry({ body })); + + // With a byte-based gate, the body MUST be truncated. + const stored = entry.body as Record; + expect(stored.__aimock_truncated).toBe(true); + expect(stored.originalByteSize as number).toBeGreaterThan(BODY_CAP); + }); + it("JSON.stringify(journal.getAll()) does not throw with 1000 large-body entries", () => { // Regression anchor for RangeError: Invalid string length (ISL). // 1000 entries × 100 KB body each = 100 MB without cap → exceeds V8 limit. diff --git a/src/journal.ts b/src/journal.ts index 899bac83..59dc2578 100644 --- a/src/journal.ts +++ b/src/journal.ts @@ -4,22 +4,33 @@ import { DEFAULT_TEST_ID } from "./constants.js"; export { DEFAULT_TEST_ID } from "./constants.js"; /** - * Maximum byte length of a serialized request body retained in a journal - * entry. Bodies exceeding this cap are replaced with a truncation marker so - * that `JSON.stringify(journal.getAll())` never exceeds V8's 512 MB string - * limit (64 KB × 1000 entries = ~64 MB aggregate, well within the limit). + * Maximum UTF-8 byte length of a serialized request body retained in a + * journal entry. Bodies whose JSON serialization exceeds this byte size are + * replaced with a truncation marker. + * + * Only the request `body` field is capped here. `headers` and `path` are + * retained uncapped, but they are naturally bounded by Node's default ~16 KB + * maximum HTTP header size, so the per-entry overhead beyond the body cap + * remains small. Combined with the journal's maxEntries limit (default 1000), + * the total `JSON.stringify(journal.getAll())` output stays well under V8's + * ~512 MB string limit even at maximum capacity. */ const JOURNAL_BODY_CAP_BYTES = 64 * 1024; // 64 KB /** - * If `body` serializes to more than JOURNAL_BODY_CAP_BYTES, replace it with - * a truncation marker. Returns the original body when within the cap, or null - * when `body` is null. + * If `body` serializes to more than JOURNAL_BODY_CAP_BYTES (in UTF-8 bytes), + * replace it with a truncation marker. Returns the original body when within + * the cap, or null when `body` is null. + * + * The gate uses Buffer.byteLength (UTF-8 bytes) rather than .length (UTF-16 + * code units) to match the constant name and to correctly cap multibyte + * content (e.g. CJK/emoji) whose code-unit count falls under the threshold + * but whose byte size does not. */ function capBody(body: ChatCompletionRequest | null): ChatCompletionRequest | null { if (body === null) return null; const serialized = JSON.stringify(body); - if (serialized.length <= JOURNAL_BODY_CAP_BYTES) return body; + if (Buffer.byteLength(serialized, "utf8") <= JOURNAL_BODY_CAP_BYTES) return body; // Cast: the marker is not a real ChatCompletionRequest, but the field type // is already nullable-union and downstream consumers (e.g. GET /journal) // treat the body as opaque JSON — the truncation marker is safe to store.