From de5a1bdbe73e47adee904660ab5e4101efdc1c8f Mon Sep 17 00:00:00 2001 From: Priya Raman Date: Sun, 23 Aug 2026 18:21:06 +0000 Subject: [PATCH] feat(duplex): raise bridge body cap to the API JSON limit and add safety bounds Raise the sandbox-to-host duplex bridge body cap from 256 KiB to the API JSON body limit plus one byte (10 MiB + 1). The bridge is a buffering backstop, not the policy limit, so a body of exactly the API limit passes through and the API issues its own 413. Fix and bound the change with these parts: - Add DEFAULT_JSON_BODY_LIMIT_BYTES and derive the "10mb" string from it so the byte form and the string form cannot drift. A cross-package test asserts the bridge cap equals this byte limit plus one. - Answer a clean 413 request_too_large, not a retryable 502 with the internal message, when a request body passes the bridge cap. readBody throws a typed error that each gateway maps to 413. - Add local in-sandbox body-read admission control. The gateway reserves one worst-case body per active read against a process-owned bound and sheds a new read with 503 when the bound is full. It releases the reserve on every terminal path. This protects the sandbox process; it is separate from the host ledger. - Pin the SSH command output buffer at 1 MiB with its own constant, so it no longer tracks the bridge body cap. - Raise the host pre-bind buffer to 16 MiB, sized from the body cap and the base64 body-chunk expansion, so one in-spec body cannot terminate its route. Co-authored-by: Paperclip --- .../src/duplex-bridge-local-e2e.test.ts | 121 ++++++++++++++++++ .../src/execution-target-sandbox.test.ts | 14 +- .../src/execution-target-ssh-buffer.test.ts | 23 ++++ .../adapter-utils/src/execution-target.ts | 9 +- .../src/sandbox-callback-bridge.ts | 93 +++++++++++++- .../plugin-worker-manager-duplex.test.ts | 32 +++++ server/src/http/body-limits.test.ts | 25 ++++ server/src/http/body-limits.ts | 7 +- server/src/services/plugin-worker-manager.ts | 10 +- 9 files changed, 321 insertions(+), 13 deletions(-) create mode 100644 packages/adapter-utils/src/execution-target-ssh-buffer.test.ts create mode 100644 server/src/http/body-limits.test.ts diff --git a/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts b/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts index 91485d79080..17194fdd549 100644 --- a/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts +++ b/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts @@ -225,6 +225,8 @@ interface HarnessOptions { hostApiToken?: string; runId?: string; maxBodyBytes?: number; + maxInflightBodyBytes?: number; + responseTimeoutMs?: number; lossExitGraceMs?: number; } @@ -294,6 +296,12 @@ async function createHarness(options: HarnessOptions = {}): Promise { await expect(response.json()).resolves.toEqual({ error: testCase.error }); } }, 20000); + it("carries a request body of exactly the API limit through the bridge to the API", async () => { + // The bridge cap is the API JSON body limit plus one byte. A body of exactly + // the API limit must pass through the bridge and reach the API. The bridge is + // a buffering backstop, not the policy limit, so it must not reject an in-spec + // body one byte early. + const apiLimitBytes = 10 * 1024 * 1024; + const bridgeCapBytes = apiLimitBytes + 1; + const harness = await createHarness({ maxBodyBytes: bridgeCapBytes }); + harnesses.push(harness); + await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); + + let receivedBodyLength = -1; + harness.api.setResponder((_req, res, body) => { + receivedBodyLength = Buffer.byteLength(body, "utf8"); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + + const body = "a".repeat(apiLimitBytes); + expect(Buffer.byteLength(body, "utf8")).toBe(apiLimitBytes); + const response = await fetch(`${harness.baseUrl}/api/issues/issue-1/comments`, { + method: "POST", + headers: { + authorization: `Bearer ${harness.bridgeToken}`, + "content-type": "application/json", + }, + body, + }); + // The bridge did not reject the body. It reached the API unchanged. + expect(response.status).toBe(200); + expect(receivedBodyLength).toBe(apiLimitBytes); + }, 30000); + + it("answers 413 request_too_large, not 502, when the body passes the bridge cap", async () => { + // Regression test: the body-size overflow path threw a generic Error that + // unwound to the 502 catch. A 502 is retryable and leaked the internal + // message. The overflow must answer a clean 413 request_too_large. Assert the + // status code specifically, because the wrong 502 stood for a long time. + const maxBodyBytes = 4096; + const harness = await createHarness({ maxBodyBytes }); + harnesses.push(harness); + await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); + + let apiWasCalled = false; + harness.api.setResponder((_req, res) => { + apiWasCalled = true; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + + const body = "a".repeat(maxBodyBytes + 1024); + const response = await fetch(`${harness.baseUrl}/api/issues/issue-1/comments`, { + method: "POST", + headers: { + authorization: `Bearer ${harness.bridgeToken}`, + "content-type": "application/json", + }, + body, + }); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ error: "request_too_large" }); + // The oversize body never reached the API, and the response leaked no + // internal message. + expect(apiWasCalled).toBe(false); + }, 20000); + + it("sheds a concurrent body read past the admission bound, then releases the reserve", async () => { + // The gateway reserves one worst-case body per active read against the local + // admission bound. With the bound set to one body, the first read is admitted + // and the second read is shed with a 503. The gateway releases the reserve on + // the response timeout, so a later request is admitted again. + const maxBodyBytes = 4096; + const harness = await createHarness({ + maxBodyBytes, + // The bound admits exactly one concurrent body read. + maxInflightBodyBytes: maxBodyBytes, + // A short response deadline releases the first (hung) read fast. + responseTimeoutMs: 700, + }); + harnesses.push(harness); + await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); + + // The first admitted request hangs on the forward until it times out. The + // second request cannot reserve, so the gateway sheds it with a 503. + harness.setForwardMode("hang"); + const body = "a".repeat(1024); + const post = () => + fetch(`${harness.baseUrl}/api/issues/issue-1/comments`, { + method: "POST", + headers: { + authorization: `Bearer ${harness.bridgeToken}`, + "content-type": "application/json", + }, + body, + }); + const [first, second] = await Promise.all([post(), post()]); + const statuses = [first.status, second.status].sort((a, b) => a - b); + // One request was admitted (it later times out with a 502). One request was + // shed with a 503 before it could buffer a second body. + expect(statuses).toEqual([502, 503]); + const shed = first.status === 503 ? first : second; + await expect(shed.json()).resolves.toEqual({ error: "bridge_busy" }); + + // The admitted read timed out and released its reserve. A later request is + // admitted again and reaches the API. + harness.setForwardMode("proxy"); + harness.api.setResponder((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + const later = await post(); + expect(later.status).toBe(200); + }, 20000); }); diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index b5c5c6b1481..26cea1c56b7 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -5757,10 +5757,10 @@ describe("sandbox duplex gateway", () => { headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: oversizeBody, }); - expect(tooLarge.status).toBe(502); - await expect(tooLarge.json()).resolves.toEqual({ - error: "Bridge request body exceeded the configured size limit.", - }); + // The body-size overflow answers a clean 413 request_too_large, not a + // retryable 502 with the internal message. + expect(tooLarge.status).toBe(413); + await expect(tooLarge.json()).resolves.toEqual({ error: "request_too_large" }); // The oversized request forwarded no frame. It never left the gateway. expect(gateway.frames.filter((frame) => frame.type === "request")).toHaveLength(0); @@ -5819,10 +5819,8 @@ describe("sandbox duplex gateway", () => { headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ body: "x".repeat(64) }), }); - expect(oversizeBody.status).toBe(502); - await expect(oversizeBody.json()).resolves.toEqual({ - error: "Bridge request body exceeded the configured size limit.", - }); + expect(oversizeBody.status).toBe(413); + await expect(oversizeBody.json()).resolves.toEqual({ error: "request_too_large" }); // No request frame forwarded so far: the guards rejected before forwarding. const framesBeforeDepth = gateway.frames.filter((frame) => frame.type === "request").length; diff --git a/packages/adapter-utils/src/execution-target-ssh-buffer.test.ts b/packages/adapter-utils/src/execution-target-ssh-buffer.test.ts new file mode 100644 index 00000000000..f63474a51f1 --- /dev/null +++ b/packages/adapter-utils/src/execution-target-ssh-buffer.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES } from "./execution-target.js"; +import { DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES } from "./sandbox-callback-bridge.js"; + +describe("SSH command output buffer", () => { + it("pins the SSH output buffer at 1 MiB", () => { + expect(SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES).toBe(1024 * 1024); + }); + + it("keeps the SSH output buffer decoupled from the bridge body cap", () => { + // The SSH output buffer once tracked the bridge body cap (cap * 4). At a + // 10 MiB bridge cap that coupling would push the SSH buffer to about 40 MiB + // per stream. The SSH buffer must stay pinned and must not move with the + // bridge cap. + expect(SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES).not.toBe( + DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4, + ); + expect(SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES).toBeLessThan( + DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, + ); + }); +}); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index c21b7d3a874..112b7c932c1 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -624,6 +624,13 @@ export function formatAdapterExecutionTimeoutStartLogLine( ); } +// The cap on the buffered output of one SSH command stream (stdout and stderr +// each). This bound is separate from the sandbox bridge body cap and must not +// move with it. An SSH command relays interactive shell output, not a bridge +// request body, so a large bridge body cap must not enlarge this per-stream +// buffer. 1 MiB is enough for the command output the SSH runner carries. +export const SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES = 1024 * 1024; + function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner { if (target.runner) return target.runner; throw new Error( @@ -642,7 +649,7 @@ function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecut return createSshCommandManagedRuntimeRunner({ spec: target.spec, defaultCwd: target.remoteCwd, - maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4, + maxBufferBytes: SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES, }); } return requireSandboxRunner(target); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 5d8907ce048..46ddab29e3b 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -18,7 +18,19 @@ const DEFAULT_BRIDGE_POLL_INTERVAL_MS = 100; const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000; const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000; const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64; -const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024; +// The cap on one whole request body the in-sandbox gateway buffers before it +// forwards the body to the host. The value is the API JSON body limit (10 MiB) +// plus one byte. The `+ 1` is load-bearing: the bridge must not reject a body +// that the API itself accepts. At exactly the API limit the body must pass +// through the bridge and reach the API, so the API issues its own 413. The +// bridge cap is a buffering backstop, not a policy limit. Do not remove the +// `+ 1`; without it the bridge rejects an in-spec body one byte early. +// +// `@paperclipai/adapter-utils` cannot import the API limit from `server` +// (that creates a package cycle), so the literal lives here. A cross-package +// agreement test asserts this value equals `DEFAULT_JSON_BODY_LIMIT_BYTES + 1` +// so the two never drift. +export const DEFAULT_BRIDGE_MAX_BODY_BYTES = 10 * 1024 * 1024 + 1; // The default cap on the aggregate raw bytes the in-sandbox duplex frame decoder // retains between chunks. The generated gateway runs in a separate operating-system // process, so it cannot share the host aggregate byte ledger. It enforces this @@ -27,6 +39,17 @@ const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024; // PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES. The default is well above one maximum // frame, so a legitimate single frame never trips it. const DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES = 8 * 1024 * 1024; +// The cap on the total request-body bytes the in-sandbox gateway may hold +// across all concurrent reads at once. The generated gateway runs in a separate +// operating-system process inside the sandbox, so it cannot share the host +// aggregate byte ledger. At a 10 MiB body cap, the 64-deep pending queue could +// let many concurrent reads each retain a full body and exhaust the sandbox +// process memory before any host limit applies. The gateway reserves one +// worst-case body per active read against this bound, and sheds a new read with +// a 503 when the bound is full. The host passes the value through +// PAPERCLIP_BRIDGE_MAX_INFLIGHT_BODY_BYTES. This bound protects the sandbox +// process; it is separate from the host aggregate byte ledger. +const DEFAULT_BRIDGE_MAX_INFLIGHT_BODY_BYTES = 64 * 1024 * 1024; // Per-iteration timeout for one poll-loop client call. A healthy control-plane // round trip finishes in well under one second, so 10s is far above a normal // iteration and never false-fires on a slow-but-live call. It is also well @@ -2031,6 +2054,13 @@ const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DE const maxDuplexDecoderBytes = Number( process.env.PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES || "${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES}", ); +// The host passes the local in-sandbox body-read admission bound here. The +// gateway reserves one worst-case body (maxBodyBytes) per active read and sheds +// a new read with a 503 when the reserve is full. This bound protects the +// sandbox process memory; it never shares the host aggregate byte ledger. +const maxInflightBodyBytes = Number( + process.env.PAPERCLIP_BRIDGE_MAX_INFLIGHT_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_INFLIGHT_BODY_BYTES}", +); const heartbeatIntervalMs = Number( process.env.PAPERCLIP_BRIDGE_HEARTBEAT_INTERVAL_MS || "${DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_INTERVAL_MS}", ); @@ -2074,6 +2104,37 @@ function normalizeHeaders(headers) { return out; } +// Typed marker for a request body that exceeds maxBodyBytes. readBody throws it, +// so each gateway answers a clean 413 request_too_large instead of the generic +// 502. A 502 is retryable by convention and would leak the internal message. +class BridgeBodyTooLargeError extends Error { + constructor() { + super("request_too_large"); + this.code = "request_too_large"; + } +} + +// The running total of reserved body-read bytes across all active reads. +let reservedBodyBytes = 0; +const noopRelease = () => {}; + +// Reserve capacity for one body read against the local admission bound. Return a +// release function on success, or null when the bound is full. Each caller +// releases on every terminal path: success, size rejection, other error, +// timeout, and connection close. +function reserveBodyRead() { + if (reservedBodyBytes + maxBodyBytes > maxInflightBodyBytes) { + return null; + } + reservedBodyBytes += maxBodyBytes; + let released = false; + return () => { + if (released) return; + released = true; + reservedBodyBytes -= maxBodyBytes; + }; +} + async function readBody(req) { const chunks = []; let totalBytes = 0; @@ -2082,7 +2143,7 @@ async function readBody(req) { chunks.push(nextChunk); totalBytes += nextChunk.byteLength; if (totalBytes > maxBodyBytes) { - throw new Error("Bridge request body exceeded the configured size limit."); + throw new BridgeBodyTooLargeError(); } } return Buffer.concat(chunks).toString("utf8"); @@ -2147,6 +2208,13 @@ async function runFileGateway() { return; } const requestId = randomUUID(); + const hasRequestBody = req.method && req.method !== "GET" && req.method !== "HEAD"; + const releaseBodyRead = hasRequestBody ? reserveBodyRead() : noopRelease; + if (!releaseBodyRead) { + writeJsonResponse(res, 503, { error: "bridge_busy" }); + return; + } + try { const requestBody = await readBody(req); const payload = { id: requestId, @@ -2182,7 +2250,14 @@ async function runFileGateway() { res.setHeader(key, value); } res.end(typeof response.body === "string" ? response.body : ""); + } finally { + releaseBodyRead(); + } } catch (error) { + if (error && error.code === "request_too_large") { + writeJsonResponse(res, 413, { error: "request_too_large" }); + return; + } writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) }); } }); @@ -2442,6 +2517,13 @@ function runDuplexGateway() { return; } const requestId = randomUUID(); + const hasRequestBody = req.method && req.method !== "GET" && req.method !== "HEAD"; + const releaseBodyRead = hasRequestBody ? reserveBodyRead() : noopRelease; + if (!releaseBodyRead) { + writeJsonResponse(res, 503, { error: "bridge_busy" }); + return; + } + try { const requestBodyBuffer = Buffer.from(await readBody(req), "utf8"); if (unavailable) { writeJsonResponse(res, 503, { error: "bridge_unavailable" }); @@ -2506,7 +2588,14 @@ function runDuplexGateway() { res.setHeader(key, value); } res.end(typeof response.body === "string" ? response.body : ""); + } finally { + releaseBodyRead(); + } } catch (error) { + if (error && error.code === "request_too_large") { + writeJsonResponse(res, 413, { error: "request_too_large" }); + return; + } writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) }); } }); diff --git a/server/src/__tests__/plugin-worker-manager-duplex.test.ts b/server/src/__tests__/plugin-worker-manager-duplex.test.ts index 0b746e8bd01..6d19007fbc6 100644 --- a/server/src/__tests__/plugin-worker-manager-duplex.test.ts +++ b/server/src/__tests__/plugin-worker-manager-duplex.test.ts @@ -786,4 +786,36 @@ describe("plugin worker manager duplex channel route", () => { await handle.stop().catch(() => undefined); } }); + it("keeps the route open while all 40 pre-bind body chunks of one maximum body arrive", async () => { + // The default pre-bind buffer cap is 16 MiB. One maximum bridge body rides 40 + // body_chunk frames of 349,528 base64 characters each, which is 13,981,120 + // characters. That total plus the request envelope must arrive before the + // route binds without ending the route. The old 8 MiB cap was below this + // total, so one in-spec body could terminate its own route. The default cap + // (no override) proves 16 MiB covers the 40-chunk maximum. + const chunkChars = 349_528; + const chunkCount = 40; + const perChunk = "a".repeat(chunkChars); + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + data: Array.from({ length: chunkCount }, () => ({ chunk: perChunk })), + exitCode: 0, + }), + ); + let receivedChars = 0; + session.onData((chunk) => { + receivedChars += chunk.length; + }); + // The route ends on the worker exit (exitCode 0), not on the pre-bind byte + // bound. A pre-bind bound trip would resolve exitCode null instead. + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + expect(receivedChars).toBe(chunkChars * chunkCount); + } finally { + await handle.stop().catch(() => undefined); + } + }, 30000); }); diff --git a/server/src/http/body-limits.test.ts b/server/src/http/body-limits.test.ts new file mode 100644 index 00000000000..c7b833ee6f2 --- /dev/null +++ b/server/src/http/body-limits.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_BRIDGE_MAX_BODY_BYTES } from "@paperclipai/adapter-utils/sandbox-callback-bridge"; + +import { DEFAULT_JSON_BODY_LIMIT, DEFAULT_JSON_BODY_LIMIT_BYTES } from "./body-limits.js"; + +describe("JSON body limit constants", () => { + it("keeps the byte form at 10 MiB", () => { + expect(DEFAULT_JSON_BODY_LIMIT_BYTES).toBe(10 * 1024 * 1024); + }); + + it("derives the string form from the byte form so the two cannot drift", () => { + // `express.json` parses the string form. The byte form derives the string, + // so a change to one changes the other. + expect(DEFAULT_JSON_BODY_LIMIT).toBe("10mb"); + expect(DEFAULT_JSON_BODY_LIMIT).toBe(`${DEFAULT_JSON_BODY_LIMIT_BYTES / (1024 * 1024)}mb`); + }); + + it("keeps the bridge body cap at the API limit plus one byte", () => { + // The bridge cap lives in `@paperclipai/adapter-utils` and cannot import the + // API limit from `server` (that creates a package cycle). This test imports + // both packages and asserts the two agree. The `+ 1` lets a body of exactly + // the API limit pass through the bridge so the API issues its own 413. + expect(DEFAULT_BRIDGE_MAX_BODY_BYTES).toBe(DEFAULT_JSON_BODY_LIMIT_BYTES + 1); + }); +}); diff --git a/server/src/http/body-limits.ts b/server/src/http/body-limits.ts index 6601dadb772..15a482833ec 100644 --- a/server/src/http/body-limits.ts +++ b/server/src/http/body-limits.ts @@ -1,4 +1,9 @@ -export const DEFAULT_JSON_BODY_LIMIT = "10mb"; +// The default maximum size of one inline JSON request body the API accepts. +// The byte form and the string form must agree: `express.json` parses the +// string form, and other code (the sandbox bridge agreement test) compares the +// byte form. Derive the string form from the byte form so the two cannot drift. +export const DEFAULT_JSON_BODY_LIMIT_BYTES = 10 * 1024 * 1024; +export const DEFAULT_JSON_BODY_LIMIT = `${DEFAULT_JSON_BODY_LIMIT_BYTES / (1024 * 1024)}mb`; export const PORTABLE_JSON_BODY_LIMIT = "64mb"; export const PORTABLE_JSON_BODY_LIMIT_BYTES = 64 * 1024 * 1024; diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 373a6225905..90b49ea0189 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -193,8 +193,16 @@ const MAX_DUPLEX_CHANNEL_CHUNK_CHARS = 1_000_000; * The default maximum cumulative characters the host buffers for one duplex * channel route before a data listener attaches. A worker that streams data * before the consumer binds cannot grow the host buffer without limit. + * + * The value derives from the bridge body cap `DEFAULT_BRIDGE_MAX_BODY_BYTES` + * (10 MiB + 1) and the base64 body-chunk expansion. One maximum body rides 40 + * body_chunk frames of 349,528 base64 characters each, which is 13,981,120 + * characters, plus the request envelope and per-frame overhead. 16 MiB covers + * that (about 2 MiB of headroom), so one in-spec request cannot terminate its + * own route before the data listener binds. Keep this cap coupled to the bridge + * body cap: if the body cap moves, re-size this value from the new cap. */ -const MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS = 8 * 1024 * 1024; +const MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS = 16 * 1024 * 1024; /** * The default maximum number of data frames the host buffers for one duplex * channel route before a data listener attaches.