Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/__tests__/journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>).__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<string, unknown>;
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);
});
});
});
41 changes: 40 additions & 1 deletion src/journal.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }));
Expand Down
Loading