From 1f55b133036d23b38e03087caf49d52d44a70ba6 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 01:54:04 +0000 Subject: [PATCH 1/5] fix(server): account for normalized inbound JSON --- src/server/request-decompress.ts | 2 +- structure/transports/byte-accounting.md | 2 +- tests/server/inbound-body-admission.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/request-decompress.ts b/src/server/request-decompress.ts index 1c9a56ec127..87ca19ef3bc 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -334,7 +334,7 @@ export async function readBoundedJsonRequestBody( } const parsed = JSON.parse(text); // Keep the serialized-size contract: normalization can expand numeric literals. - const parsedBytes = budget ? Buffer.byteLength(JSON.stringify(parsed), "utf8") : decoded.byteLength; + const parsedBytes = Buffer.byteLength(JSON.stringify(parsed), "utf8"); budget?.observeAcceptedRequestCopy(parsedBytes); updateInboundBodyReservation(req, Math.max(raw.byteLength, decoded.byteLength, parsedBytes)); return parsed; diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 4f0cba5eca5..52f798d7fca 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -105,7 +105,7 @@ discarding active leases or FIFO waiters; lowering capacity cannot erase outstan checks, before any request body is read. Default and smaller allowances do not consume this budget. Missing or small Content-Length and compressed wire bodies do not reduce the upload reservation. After successful bounded JSON parsing, the reader reduces it to the largest observed wire/decoded -size, including normalized JSON bytes when the existing translator measurement is enabled. +size, including normalized JSON bytes whether or not the route uses a translator budget. This reduction never exceeds the original allowance. The measured charge remains live; parsing never frees the whole lease. An already-aborted request or explicitly oversized declaration keeps the existing abort/413 path. diff --git a/tests/server/inbound-body-admission.test.ts b/tests/server/inbound-body-admission.test.ts index 2e42acef4ee..3939ed67f9a 100644 --- a/tests/server/inbound-body-admission.test.ts +++ b/tests/server/inbound-body-admission.test.ts @@ -590,7 +590,7 @@ describe("raised inbound body admission lifetime", () => { } for (const normalized of [false, true]) { - test(`retains measured JSON bytes with translator normalization ${normalized}`, async () => { + test(`retains normalized JSON bytes with translator budget ${normalized}`, async () => { const text = "1e20"; const req = admissionRequest("/v1/responses", text); const translator = normalized ? createTranslatorBudget() : undefined; @@ -601,7 +601,7 @@ describe("raised inbound body admission lifetime", () => { assert.equal(await readJsonRequestBody(req, translator, RAISED_BODY_LIMIT), 1e20); return new Response("held"); }, req); - const expected = normalized ? Buffer.byteLength(JSON.stringify(1e20)) : Buffer.byteLength(text); + const expected = Buffer.byteLength(JSON.stringify(1e20)); assert.deepEqual(testBudget.snapshot(), { reservedBytes: expected, activeLeases: 1, queuedRequests: 0 }); await response.text(); } finally { From c47070491cb9212da846725ad3cc0266cfca6f3e Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 15:32:20 +0900 Subject: [PATCH 2/5] fix(server): measure parsed JSON size without recursion Normalized JSON byte accounting now walks the parsed value iteratively and returns the UTF-8 length JSON.stringify would produce, so deeply nested valid bodies are measured instead of throwing. --- src/server/json-serialized-bytes.ts | 28 +++++++++++++++++++++ src/server/request-decompress.ts | 6 +++-- structure/transports/byte-accounting.md | 7 ++++-- tests/server/inbound-body-admission.test.ts | 23 +++++++++++++++++ tests/usage/request-decompress.test.ts | 24 ++++++++++++++++++ 5 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/server/json-serialized-bytes.ts diff --git a/src/server/json-serialized-bytes.ts b/src/server/json-serialized-bytes.ts new file mode 100644 index 00000000000..c01d6b8eabc --- /dev/null +++ b/src/server/json-serialized-bytes.ts @@ -0,0 +1,28 @@ +import { Buffer } from "node:buffer"; + +/** + * UTF-8 byte length of JSON.stringify(value) for a JSON.parse result, computed without + * recursion: a valid body nested deeper than the call stack must be measured, not crash. + */ +export function serializedJsonByteLength(value: unknown): number { + let bytes = 0; + const stack: unknown[] = [value]; + while (stack.length > 0) { + const item = stack.pop(); + if (item === null || typeof item !== "object") { + bytes += Buffer.byteLength(JSON.stringify(item) ?? "null", "utf8"); + } else if (Array.isArray(item)) { + bytes += 2 + Math.max(0, item.length - 1); + for (let i = item.length - 1; i >= 0; i--) stack.push(item[i]); + } else { + const record = item as Record; + const keys = Object.keys(record); + bytes += 2 + Math.max(0, keys.length - 1); + for (const key of keys) { + bytes += Buffer.byteLength(JSON.stringify(key), "utf8") + 1; + stack.push(record[key]); + } + } + } + return bytes; +} diff --git a/src/server/request-decompress.ts b/src/server/request-decompress.ts index 87ca19ef3bc..297ac0af90a 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -2,6 +2,7 @@ import { Buffer } from "node:buffer"; import { gunzipSync, inflateRawSync, inflateSync, zstdDecompressSync } from "node:zlib"; import type { TranslatorBudget } from "../lib/translator-budget"; import { updateInboundBodyReservation } from "./inbound-body-budget"; +import { serializedJsonByteLength } from "./json-serialized-bytes"; /** * Request-body decompression for the /v1/responses data plane. @@ -333,8 +334,9 @@ export async function readBoundedJsonRequestBody( return options.emptyBodyFallback; } const parsed = JSON.parse(text); - // Keep the serialized-size contract: normalization can expand numeric literals. - const parsedBytes = Buffer.byteLength(JSON.stringify(parsed), "utf8"); + // Keep the serialized-size contract: normalization can expand numeric literals. The + // measurement walks iteratively, so a deeply nested valid body cannot overflow the stack. + const parsedBytes = serializedJsonByteLength(parsed); budget?.observeAcceptedRequestCopy(parsedBytes); updateInboundBodyReservation(req, Math.max(raw.byteLength, decoded.byteLength, parsedBytes)); return parsed; diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index a637d17001d..4da2a12db7d 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -75,11 +75,14 @@ only. It copies no request content and adds no bytes to the upstream wire; see t `src/server/request-decompress.ts` observes the UTF-8 sizes of decoded text and reserialized JSON without allocating encoded byte arrays solely to count them. Parsed-body accounting still uses -`JSON.stringify(parsed)`: numeric normalization can make it larger than the input text. These +the byte length of `JSON.stringify(parsed)`: numeric normalization can make it larger than the input +text. `src/server/json-serialized-bytes.ts` computes that length with an iterative walk, so a valid +body nested deeper than the call stack is measured instead of throwing during accounting. These observations retain the existing ownership and release lifecycle and do not consume the translator's hard byte cap. Per-body limits, parsing, compression, and reader error envelopes are unchanged. `tests/usage/request-decompress.test.ts` covers exact accounting across codecs and Unicode/numeric -normalization, UTF-8 counting without encoded copies, and release after malformed or optional empty input. +normalization, UTF-8 counting without encoded copies, release after malformed or optional empty input, +and serialized-length parity with `JSON.stringify` including nesting it cannot walk. The final native ChatGPT Responses HTTP send in `src/server/responses/fetch-helpers.ts` encodes JSON strings of at least 1 MiB as a UTF-8 buffer for Bun upload compatibility. This is a transport copy, diff --git a/tests/server/inbound-body-admission.test.ts b/tests/server/inbound-body-admission.test.ts index de06e0ee061..2d3739a59b1 100644 --- a/tests/server/inbound-body-admission.test.ts +++ b/tests/server/inbound-body-admission.test.ts @@ -612,6 +612,29 @@ describe("raised inbound body admission lifetime", () => { }); } + for (const normalized of [false, true]) { + test(`measures a deeply nested valid JSON body with translator budget ${normalized}`, async () => { + // Deeper than the call stack allows a recursive serializer to walk, and well under the limit. + const depth = 500_000; + const text = "[".repeat(depth) + "]".repeat(depth); + const req = admissionRequest("/v1/responses", text); + const translator = normalized ? createTranslatorBudget() : undefined; + let response: Response | undefined; + try { + response = await admittedBodyWork(async () => { + assert.equal(Array.isArray(await readJsonRequestBody(req, translator, RAISED_BODY_LIMIT)), true); + return new Response("held"); + }, req); + assert.equal(response.status, 200); + assert.deepEqual(testBudget.snapshot(), { reservedBytes: text.length, activeLeases: 1, queuedRequests: 0 }); + await response.text(); + } finally { + await response?.body?.cancel().catch(() => undefined); + translator?.dispose(); + } + }); + } + for (const end of ["eof", "error", "cancel"] as const) { test(`retains decoded compressed bytes until response ${end} settles exactly once`, async () => { const text = JSON.stringify({ input: "x".repeat(10_000) }); diff --git a/tests/usage/request-decompress.test.ts b/tests/usage/request-decompress.test.ts index e6c0fcfc565..9cb6a0f112f 100644 --- a/tests/usage/request-decompress.test.ts +++ b/tests/usage/request-decompress.test.ts @@ -16,6 +16,7 @@ import { import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { handleManagementAPI } from "../../src/server/management-api"; import { decodeRequestErrorResponse } from "../../src/server/responses/core"; +import { serializedJsonByteLength } from "../../src/server/json-serialized-bytes"; import type { OcxConfig } from "../../src/types"; const PAYLOAD = { model: "gpt-5.5", input: "hello", stream: true }; @@ -624,3 +625,26 @@ describe("readJsonRequestBody", () => { await expect(readJsonRequestBody(req)).rejects.toBeInstanceOf(SyntaxError); }); }); + +describe("serializedJsonByteLength", () => { + test("matches the UTF-8 length of JSON.stringify for parsed JSON", () => { + const sources = [ + "null", "true", "false", "0", "-0", "1e20", "1e400", "-1e400", "0.1", "1.5e-7", "123456789012345678901234567890", + "\"\"", "\"plain\"", "\"\\ud800\"", "\"\\udc00x\\ud83d\\ude00\"", "\"\\u0000\\u001f\\u007f\"", "\"é漢字\\\"\\\\\\n\"", + "[]", "{}", "[[]]", "[{}]", "{\"a\":{}}", "[[],[[]],{}]", "[1,\"x\",null,true,[2,3],{\"k\":[]}]", + "{\"__proto__\":{\"x\":1},\"constructor\":[1]}", "{\"a\":1,\"a\":2}", "{\"2\":1,\"1\":2,\"b\":3,\"a\":4}", + "{\"\\ud800\":\"\\udfff\",\"é\":\"漢\"}", JSON.stringify({ input: [{ role: "user", content: "x".repeat(1000) }] }), + ]; + for (const source of sources) { + const parsed: unknown = JSON.parse(source); + expect([source, serializedJsonByteLength(parsed)]).toEqual([source, Buffer.byteLength(JSON.stringify(parsed), "utf8")]); + } + }); + + test("measures nesting deeper than a recursive serializer can walk", () => { + const depth = 500_000; + const parsed: unknown = JSON.parse("[".repeat(depth) + "]".repeat(depth)); + expect(() => JSON.stringify(parsed)).toThrow(RangeError); + expect(serializedJsonByteLength(parsed)).toBe(2 * depth); + }); +}); From 99adde5029ab6ef07ae7eefe9c3b1e0baeee2619 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 15:38:37 +0900 Subject: [PATCH 3/5] fix(server): keep JSON size measurement memory bounded by depth Walk containers with one cursor frame each instead of queuing every element, so wide arrays add no allocation proportional to their length. --- src/server/json-serialized-bytes.ts | 31 +++++++++++++++++++------- tests/usage/request-decompress.test.ts | 7 ++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/server/json-serialized-bytes.ts b/src/server/json-serialized-bytes.ts index c01d6b8eabc..6c2fe9847d2 100644 --- a/src/server/json-serialized-bytes.ts +++ b/src/server/json-serialized-bytes.ts @@ -1,27 +1,42 @@ import { Buffer } from "node:buffer"; +type Frame = + | { items: readonly unknown[]; index: number } + | { record: Record; keys: readonly string[]; index: number }; + /** * UTF-8 byte length of JSON.stringify(value) for a JSON.parse result, computed without * recursion: a valid body nested deeper than the call stack must be measured, not crash. + * Each container keeps one cursor frame, so extra memory grows with depth, not width. */ export function serializedJsonByteLength(value: unknown): number { let bytes = 0; - const stack: unknown[] = [value]; - while (stack.length > 0) { - const item = stack.pop(); + const stack: Frame[] = []; + const visit = (item: unknown): void => { if (item === null || typeof item !== "object") { bytes += Buffer.byteLength(JSON.stringify(item) ?? "null", "utf8"); } else if (Array.isArray(item)) { bytes += 2 + Math.max(0, item.length - 1); - for (let i = item.length - 1; i >= 0; i--) stack.push(item[i]); + stack.push({ items: item, index: 0 }); } else { const record = item as Record; const keys = Object.keys(record); bytes += 2 + Math.max(0, keys.length - 1); - for (const key of keys) { - bytes += Buffer.byteLength(JSON.stringify(key), "utf8") + 1; - stack.push(record[key]); - } + stack.push({ record, keys, index: 0 }); + } + }; + visit(value); + while (stack.length > 0) { + const frame = stack[stack.length - 1]!; + if ("items" in frame) { + if (frame.index < frame.items.length) visit(frame.items[frame.index++]); + else stack.pop(); + } else if (frame.index < frame.keys.length) { + const key = frame.keys[frame.index++]!; + bytes += Buffer.byteLength(JSON.stringify(key), "utf8") + 1; + visit(frame.record[key]); + } else { + stack.pop(); } } return bytes; diff --git a/tests/usage/request-decompress.test.ts b/tests/usage/request-decompress.test.ts index 9cb6a0f112f..676dd05b718 100644 --- a/tests/usage/request-decompress.test.ts +++ b/tests/usage/request-decompress.test.ts @@ -647,4 +647,11 @@ describe("serializedJsonByteLength", () => { expect(() => JSON.stringify(parsed)).toThrow(RangeError); expect(serializedJsonByteLength(parsed)).toBe(2 * depth); }); + + test("measures wide and mixed containers exactly", () => { + const wide = JSON.parse("[" + Array(1_000_000).fill("0").join(",") + "]"); + expect(serializedJsonByteLength(wide)).toBe(Buffer.byteLength(JSON.stringify(wide), "utf8")); + const mixed = JSON.parse(JSON.stringify({ a: Array.from({ length: 50_000 }, (_, i) => ({ [`k${i}`]: [i, "é", null] })) })); + expect(serializedJsonByteLength(mixed)).toBe(Buffer.byteLength(JSON.stringify(mixed), "utf8")); + }); }); From f591e21b2afab9e33fc6408674e7ad94a9becf67 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 15:53:21 +0900 Subject: [PATCH 4/5] docs(server): describe JSON measurement memory precisely --- src/server/json-serialized-bytes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/json-serialized-bytes.ts b/src/server/json-serialized-bytes.ts index 6c2fe9847d2..ea35dd57da4 100644 --- a/src/server/json-serialized-bytes.ts +++ b/src/server/json-serialized-bytes.ts @@ -7,7 +7,8 @@ type Frame = /** * UTF-8 byte length of JSON.stringify(value) for a JSON.parse result, computed without * recursion: a valid body nested deeper than the call stack must be measured, not crash. - * Each container keeps one cursor frame, so extra memory grows with depth, not width. + * Each open container keeps one cursor frame, so arrays add no per-element queue; an object + * frame retains its key list, which stays bounded by the input size. */ export function serializedJsonByteLength(value: unknown): number { let bytes = 0; From 6d3450c70f60e9fd580d3dea9060b64c1c932572 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 16:31:50 +0900 Subject: [PATCH 5/5] ci: run the full Ubuntu shards for this head