diff --git a/src/server/json-serialized-bytes.ts b/src/server/json-serialized-bytes.ts new file mode 100644 index 00000000000..ea35dd57da4 --- /dev/null +++ b/src/server/json-serialized-bytes.ts @@ -0,0 +1,44 @@ +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 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; + 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); + 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); + 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/src/server/request-decompress.ts b/src/server/request-decompress.ts index 1c9a56ec127..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 = budget ? Buffer.byteLength(JSON.stringify(parsed), "utf8") : decoded.byteLength; + // 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 3eefc7360a8..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, @@ -105,7 +108,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 ffe4096ec88..2d3739a59b1 100644 --- a/tests/server/inbound-body-admission.test.ts +++ b/tests/server/inbound-body-admission.test.ts @@ -591,7 +591,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; @@ -602,7 +602,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 { @@ -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..676dd05b718 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,33 @@ 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); + }); + + 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")); + }); +});