diff --git a/src/__tests__/journal.test.ts b/src/__tests__/journal.test.ts index 4209aa17..6a92b163 100644 --- a/src/__tests__/journal.test.ts +++ b/src/__tests__/journal.test.ts @@ -457,4 +457,99 @@ 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("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. + // 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..59dc2578 100644 --- a/src/journal.ts +++ b/src/journal.ts @@ -1,8 +1,46 @@ 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 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 (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 (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. + 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 +139,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" } }));