Skip to content

Commit b964ba2

Browse files
authored
fix: cap uncapped string accumulators (RangeError: Invalid string length) (#307)
## Problem `RangeError: Invalid string length` was firing ~1/sec in prod (caught at `server.ts:1230`). V8's max string length on 64-bit is `2^29-1` (~512MiB of UTF-16 code units); appending past it throws. Two **uncapped** in-memory string accumulators in the aimock binary build a JS string past that limit: 1. **`src/agui-recorder.ts`** — on stream end the recorder does `Buffer.concat(chunks).toString()` over every buffered upstream SSE chunk with **no byte cap** (unlike the generic proxy path in `recorder.ts`, which is capped at 64MiB `maxProxyBufferBytes` / 256MiB `PROXY_BUFFER_HARD_CEILING`). 2. **`src/stream-collapse.ts`** — the `content += delta` / `reasoning += delta` / `entry.arguments += delta` / `audioB64 += …` / `argsStr += …` accumulators (plus `orderAtoms` coalescing) grow unbounded across every collapser. Amplified 07-13 by the real-key fixture-miss passthrough (`b2ef945`): large **real** upstream responses now flow into these accumulators. The `recorder.ts` comment claiming "stream-collapse never throws Invalid string length" was **false** for these paths and is fixed here. ## Fix Mirrors the existing `PROXY_BUFFER_HARD_CEILING` / `maxProxyBufferBytes` discipline — **bound then mark truncated, never fail the client**. The client always receives its full response; only the *recording* is degraded to a clean skip. - **agui-recorder.ts**: new configurable record-buffer cap (`AGUIRecordConfig.maxRecordBufferBytes`, default 64MiB, clamped to a 256MiB `AGUI_RECORD_BUFFER_HARD_CEILING`). On trip: stop accumulating, free the buffer, skip fixture construction, keep the live client relay untouched — never `Buffer.concat().toString()` the oversized buffer. - **stream-collapse.ts**: guard each collapser's input under `MAX_COLLAPSE_STRING_LENGTH` (256Mi code units) *before* accumulation. The sum of any single accumulator can never exceed the total input, so bounding the input bounds every accumulator at one chokepoint (no per-`+=` whack-a-mole). Over-ceiling input is truncated and the result stamped `truncated: true` so the recorder skips journaling a partial fixture. Also covers the **exported direct-call** collapse entry points where no byte cap ran. - Fixed the false `recorder.ts:31` comment. - Test-only overrides (`setAGUIRecordBufferCeilingForTests` / `setCollapseStringLimitForTests`) let the cap suites exercise truncation without allocating hundreds of MB. ## Local red-green proof (real failure surface) **RED — AG-UI recorder** (streaming ~535MiB of real SSE through `proxyAndRecordAGUI` on the *unmodified* build): ``` [repro] streaming ~535 MiB through the AG-UI recorder… [aimock] NO AG-UI FIXTURE MATCH — proxying to http://127.0.0.1:49292 [RED] uncaughtException in recorder: Error: Cannot create a string longer than 0x1fffffe8 characters RED EXIT: 1 ``` (`0x1fffffe8` = `2^29-1` — the exact V8 max-string-length boundary, i.e. the prod `Invalid string length`.) **GREEN — same repro, post-fix:** ``` [repro] streaming ~535 MiB through the AG-UI recorder… [aimock] AG-UI upstream response exceeded the 67108864-byte record buffer cap — relaying full body to client, but skipping fixture recording to bound memory [aimock] AG-UI record buffer cap exceeded — response relayed to client, recording skipped [repro] client got status=200, bytesReceived=562041225 [GREEN] client received full body and recorder did not throw ``` Client received **all 562,041,225 bytes** — relay unaffected, only recording skipped. **stream-collapse mutation-guard** (real `collapseOpenAISSE`, input just over the 256Mi ceiling): - pre-fix: `content.length=276824064, truncated=undefined` (unbounded, no clamp) - post-fix: `content.length=260046848, truncated=true` (clamped at ceiling, flagged) ## Tests - `src/__tests__/agui-record-buffer-cap.test.ts` (2) — full relay + no crash + recording skipped over cap; normal recording under cap. - `src/__tests__/stream-collapse-string-cap.test.ts` (6) — every collapser clamps + flags `truncated`, never throws; no truncation under cap; Bedrock byte-cap. - Full suite: **4501 passed (153 files)**. Lint + prettier + build + tsc clean.
2 parents fd93a52 + 1d08625 commit b964ba2

9 files changed

Lines changed: 594 additions & 15 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @copilotkit/aimock
22

3+
## [1.37.1] - 2026-07-16
4+
5+
### Fixed
6+
7+
- Capped the uncapped in-memory record buffers that could grow a string past V8's ~512 MiB max string length and throw `RangeError: Invalid string length` (the ~1/sec production crash, amplified once the real-key fixture-miss passthrough landed). The AG-UI recorder, the generic recorder, and the stream-collapse path now bound the buffer they `Buffer.concat(chunks).toString()` for fixture construction to a configurable `maxRecordBufferBytes` (default 64 MiB) clamped to a 256 MiB hard ceiling. Once the cap is crossed the recorder frees the buffer, marks the recording truncated, and skips fixture construction — the full upstream response is still relayed to the client byte-for-byte in real time, so capping never truncates what the client receives ("don't journal", not "don't answer") (#307)
8+
39
## [1.37.0] - 2026-07-15
410

511
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@copilotkit/aimock",
3-
"version": "1.37.0",
3+
"version": "1.37.1",
44
"description": "Mock infrastructure for AI application testing — LLM APIs, image generation, image editing, text-to-speech, transcription, audio translation, audio generation, video generation, embeddings, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.",
55
"license": "MIT",
66
"keywords": [
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { describe, it, expect, afterEach, vi } from "vitest";
2+
import * as http from "node:http";
3+
import * as fs from "node:fs";
4+
import * as os from "node:os";
5+
import * as path from "node:path";
6+
import { AGUIMock } from "../agui-mock.js";
7+
import { setAGUIRecordBufferCeilingForTests } from "../agui-recorder.js";
8+
import { Logger } from "../logger.js";
9+
10+
// ---------------------------------------------------------------------------
11+
// AG-UI record-buffer cap.
12+
//
13+
// The AG-UI recorder tees every upstream SSE chunk to the client AND buffers a
14+
// copy so it can `Buffer.concat(chunks).toString()` + parse the events into a
15+
// fixture once the stream ends. With no cap, a large upstream response builds a
16+
// string past V8's ~512 MiB max string length and throws
17+
// `RangeError: Invalid string length` (the ~1/sec prod crash). These tests
18+
// exercise the REAL proxyAndRecordAGUI/teeUpstreamStream path via a raw local
19+
// upstream streaming a large SSE body, with a low test-only cap so the run
20+
// stays fast: the client must still receive every relayed byte, the server must
21+
// not crash, and recording must be skipped (no fixture written).
22+
// ---------------------------------------------------------------------------
23+
24+
let upstream: http.Server | undefined;
25+
let agui: AGUIMock | undefined;
26+
let tmpDir: string | undefined;
27+
28+
afterEach(async () => {
29+
if (agui) {
30+
try {
31+
await agui.stop();
32+
} catch {
33+
/* already stopped */
34+
}
35+
agui = undefined;
36+
}
37+
if (upstream) {
38+
await new Promise<void>((resolve) => upstream!.close(() => resolve()));
39+
upstream = undefined;
40+
}
41+
if (tmpDir) {
42+
fs.rmSync(tmpDir, { recursive: true, force: true });
43+
tmpDir = undefined;
44+
}
45+
setAGUIRecordBufferCeilingForTests(undefined);
46+
vi.restoreAllMocks();
47+
});
48+
49+
/**
50+
* A raw upstream that streams `totalBytes` of valid AG-UI SSE frames in
51+
* `chunkBytes`-sized frames, then closes. Each frame is a TEXT_MESSAGE_CONTENT
52+
* event so the recorder's SSE parser exercises normally under the cap.
53+
*/
54+
function createLargeAguiUpstream(totalBytes: number, chunkBytes: number): Promise<string> {
55+
return new Promise((resolve) => {
56+
upstream = http.createServer((_req, res) => {
57+
res.writeHead(200, {
58+
"Content-Type": "text/event-stream",
59+
"Cache-Control": "no-cache",
60+
Connection: "keep-alive",
61+
});
62+
let sent = 0;
63+
const writeNext = () => {
64+
if (res.writableEnded || res.destroyed) return;
65+
if (sent >= totalBytes) {
66+
res.end();
67+
return;
68+
}
69+
const payload = JSON.stringify({
70+
type: "TEXT_MESSAGE_CONTENT",
71+
messageId: "m1",
72+
delta: "x".repeat(Math.max(1, chunkBytes)),
73+
});
74+
const frame = `data: ${payload}\n\n`;
75+
sent += Buffer.byteLength(frame);
76+
if (res.write(frame)) {
77+
setImmediate(writeNext);
78+
} else {
79+
res.once("drain", writeNext);
80+
}
81+
};
82+
writeNext();
83+
});
84+
upstream.listen(0, "127.0.0.1", () => {
85+
const { port } = upstream!.address() as { port: number };
86+
resolve(`http://127.0.0.1:${port}`);
87+
});
88+
});
89+
}
90+
91+
/** Stream a POST and count relayed bytes without buffering a huge string. */
92+
function postStreaming(url: string): Promise<{ status: number; bytesReceived: number }> {
93+
return new Promise((resolve, reject) => {
94+
const data = JSON.stringify({
95+
threadId: "t1",
96+
runId: "r1",
97+
messages: [{ id: "u1", role: "user", content: "stream me a lot" }],
98+
tools: [],
99+
context: [],
100+
state: {},
101+
forwardedProps: {},
102+
});
103+
const parsed = new URL(url);
104+
const req = http.request(
105+
{
106+
hostname: parsed.hostname,
107+
port: parsed.port,
108+
path: parsed.pathname,
109+
method: "POST",
110+
headers: {
111+
"Content-Type": "application/json",
112+
"Content-Length": Buffer.byteLength(data),
113+
},
114+
},
115+
(res) => {
116+
let bytesReceived = 0;
117+
res.on("data", (c: Buffer) => {
118+
bytesReceived += c.length;
119+
});
120+
res.on("end", () => resolve({ status: res.statusCode ?? 0, bytesReceived }));
121+
res.on("error", reject);
122+
},
123+
);
124+
req.on("error", reject);
125+
req.write(data);
126+
req.end();
127+
});
128+
}
129+
130+
describe("AG-UI record-buffer cap", () => {
131+
it("relays the full streamed body, does not throw, and skips recording when over the cap", async () => {
132+
// 4 MB total, ~4 KB frames, with a low 256 KB test-only ceiling so the
133+
// buffer must truncate well before the full body accumulates. The client
134+
// must still receive every relayed byte.
135+
const TOTAL = 4 * 1024 * 1024;
136+
const CHUNK = 4 * 1024;
137+
setAGUIRecordBufferCeilingForTests(256 * 1024);
138+
139+
const upstreamUrl = await createLargeAguiUpstream(TOTAL, CHUNK);
140+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agui-bufcap-"));
141+
142+
const warnings: string[] = [];
143+
vi.spyOn(Logger.prototype, "warn").mockImplementation((...args: unknown[]) => {
144+
warnings.push(args.map(String).join(" "));
145+
});
146+
147+
agui = new AGUIMock({ port: 0, logLevel: "warn" });
148+
agui.enableRecording({ upstream: upstreamUrl, proxyOnly: false, fixturePath: tmpDir });
149+
await agui.start();
150+
151+
const resp = await postStreaming(agui.url);
152+
153+
// Client correctness: full relay regardless of the cap. If the server had
154+
// crashed with Invalid string length the relay would have been cut short.
155+
expect(resp.status).toBe(200);
156+
expect(resp.bytesReceived).toBeGreaterThanOrEqual(TOTAL);
157+
158+
// The record-buffer cap tripped and recording was skipped.
159+
expect(warnings.some((w) => /record buffer cap/i.test(w))).toBe(true);
160+
161+
// No fixture written (the partial buffer was dropped, not journaled).
162+
const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json"));
163+
expect(files).toHaveLength(0);
164+
});
165+
166+
it("records normally when the response stays under the cap", async () => {
167+
const TOTAL = 32 * 1024;
168+
const CHUNK = 4 * 1024;
169+
setAGUIRecordBufferCeilingForTests(4 * 1024 * 1024);
170+
171+
const upstreamUrl = await createLargeAguiUpstream(TOTAL, CHUNK);
172+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agui-bufcap-ok-"));
173+
174+
agui = new AGUIMock({ port: 0 });
175+
agui.enableRecording({ upstream: upstreamUrl, proxyOnly: false, fixturePath: tmpDir });
176+
await agui.start();
177+
178+
const resp = await postStreaming(agui.url);
179+
expect(resp.status).toBe(200);
180+
expect(resp.bytesReceived).toBeGreaterThanOrEqual(TOTAL);
181+
182+
// Under the cap → a fixture IS written.
183+
const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json"));
184+
expect(files.length).toBeGreaterThan(0);
185+
});
186+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import {
3+
collapseOpenAISSE,
4+
collapseAnthropicSSE,
5+
collapseGeminiSSE,
6+
collapseCohereSSE,
7+
collapseOllamaNDJSON,
8+
collapseGeminiInteractionsSSE,
9+
collapseBedrockEventStream,
10+
setCollapseStringLimitForTests,
11+
MAX_COLLAPSE_STRING_LENGTH,
12+
} from "../stream-collapse.js";
13+
14+
// ---------------------------------------------------------------------------
15+
// Accumulated-string cap on the stream collapsers.
16+
//
17+
// Each collapser accumulates per-channel strings (`content`, `reasoning`, a
18+
// tool call's `arguments`, `audioB64`, ...) from stream fragments with `+=`.
19+
// With no cap a large upstream response builds a string past V8's ~512 MiB max
20+
// string length and throws `RangeError: Invalid string length` — the ~1/sec
21+
// prod crash, caught at server.ts:1230. These tests use a tiny test-only limit
22+
// so they exercise the REAL accumulator/guard code without allocating 512 MiB:
23+
// the collapser must NOT throw, must clamp `content` at the ceiling, and must
24+
// stamp `truncated: true` so the recorder skips journaling a partial fixture.
25+
// ---------------------------------------------------------------------------
26+
27+
afterEach(() => {
28+
setCollapseStringLimitForTests(undefined);
29+
});
30+
31+
/** Build an OpenAI SSE body whose accumulated `content` is `totalChars` long. */
32+
function openAiSseBody(totalChars: number, perFrame: number): string {
33+
const frames: string[] = [];
34+
let emitted = 0;
35+
while (emitted < totalChars) {
36+
const n = Math.min(perFrame, totalChars - emitted);
37+
frames.push(
38+
`data: ${JSON.stringify({ choices: [{ delta: { content: "a".repeat(n) } }] })}\n\n`,
39+
);
40+
emitted += n;
41+
}
42+
frames.push("data: [DONE]\n\n");
43+
return frames.join("");
44+
}
45+
46+
describe("stream-collapse accumulated-string cap", () => {
47+
it("has a real ceiling comfortably below V8's max string length", () => {
48+
// V8 max string length on 64-bit is 2^29 - 1 (~536.87M). The ceiling must
49+
// be strictly below it so an accumulator clamped at the ceiling can never
50+
// reach the throwing boundary.
51+
expect(MAX_COLLAPSE_STRING_LENGTH).toBeLessThan(2 ** 29 - 1);
52+
expect(MAX_COLLAPSE_STRING_LENGTH).toBeGreaterThan(0);
53+
});
54+
55+
it("clamps OpenAI content at the ceiling, marks truncated, and never throws", () => {
56+
setCollapseStringLimitForTests(1000);
57+
// 5000 chars of content across 100-char frames — 5x over the 1000 ceiling.
58+
const body = openAiSseBody(5000, 100);
59+
60+
let result: ReturnType<typeof collapseOpenAISSE> | undefined;
61+
expect(() => {
62+
result = collapseOpenAISSE(body);
63+
}).not.toThrow();
64+
65+
// content clamped at (or below) the ceiling — never past it.
66+
expect(result!.content!.length).toBeLessThanOrEqual(1000);
67+
// The over-ceiling input is flagged so the recorder skips journaling.
68+
expect(result!.truncated).toBe(true);
69+
});
70+
71+
it("does not truncate when content stays under the ceiling", () => {
72+
setCollapseStringLimitForTests(1_000_000);
73+
const body = openAiSseBody(500, 100);
74+
const result = collapseOpenAISSE(body);
75+
expect(result.content).toBe("a".repeat(500));
76+
expect(result.truncated).toBeUndefined();
77+
});
78+
79+
it("caps Anthropic content and marks truncated without throwing", () => {
80+
setCollapseStringLimitForTests(1000);
81+
const frames: string[] = [];
82+
for (let i = 0; i < 50; i++) {
83+
frames.push(
84+
`event: content_block_delta\ndata: ${JSON.stringify({
85+
delta: { type: "text_delta", text: "b".repeat(100) },
86+
})}\n\n`,
87+
);
88+
}
89+
const body = frames.join("");
90+
let result: ReturnType<typeof collapseAnthropicSSE> | undefined;
91+
expect(() => {
92+
result = collapseAnthropicSSE(body);
93+
}).not.toThrow();
94+
expect(result!.content!.length).toBeLessThanOrEqual(1000);
95+
expect(result!.truncated).toBe(true);
96+
});
97+
98+
it("caps Gemini, Cohere, Ollama, and Gemini-Interactions without throwing", () => {
99+
setCollapseStringLimitForTests(500);
100+
101+
const gemini = collapseGeminiSSE(
102+
Array.from(
103+
{ length: 20 },
104+
() =>
105+
`data: ${JSON.stringify({
106+
candidates: [{ content: { parts: [{ text: "g".repeat(100) }] } }],
107+
})}\n\n`,
108+
).join(""),
109+
);
110+
expect(gemini.content!.length).toBeLessThanOrEqual(500);
111+
expect(gemini.truncated).toBe(true);
112+
113+
const cohere = collapseCohereSSE(
114+
Array.from(
115+
{ length: 20 },
116+
() =>
117+
`event: content-delta\ndata: ${JSON.stringify({
118+
delta: { message: { content: { text: "c".repeat(100) } } },
119+
})}\n\n`,
120+
).join(""),
121+
);
122+
expect(cohere.content!.length).toBeLessThanOrEqual(500);
123+
expect(cohere.truncated).toBe(true);
124+
125+
const ollama = collapseOllamaNDJSON(
126+
Array.from(
127+
{ length: 20 },
128+
() => `${JSON.stringify({ message: { content: "o".repeat(100) } })}\n`,
129+
).join(""),
130+
);
131+
expect(ollama.content!.length).toBeLessThanOrEqual(500);
132+
expect(ollama.truncated).toBe(true);
133+
134+
const geminiInteractions = collapseGeminiInteractionsSSE(
135+
Array.from(
136+
{ length: 20 },
137+
() =>
138+
`data: ${JSON.stringify({
139+
event_type: "step.delta",
140+
index: 0,
141+
delta: { type: "text", text: "i".repeat(100) },
142+
})}\n\n`,
143+
).join(""),
144+
);
145+
expect(geminiInteractions.content!.length).toBeLessThanOrEqual(500);
146+
expect(geminiInteractions.truncated).toBe(true);
147+
});
148+
149+
it("caps Bedrock EventStream input by byte length and marks truncated", () => {
150+
setCollapseStringLimitForTests(64);
151+
// A raw buffer over the (byte) ceiling — even garbage bytes must be bounded
152+
// and flagged truncated rather than fed unbounded into the accumulators.
153+
const big = Buffer.alloc(4096, 0x41); // 4 KiB of 'A'
154+
let result: ReturnType<typeof collapseBedrockEventStream> | undefined;
155+
expect(() => {
156+
result = collapseBedrockEventStream(big);
157+
}).not.toThrow();
158+
expect(result!.truncated).toBe(true);
159+
});
160+
});

0 commit comments

Comments
 (0)