Files
PaperClipAI/packages/adapter-utils/src/duplex-frame-codec.ts
T
Nicky LeachandPaperclip 64b7dce0ad refactor(adapter-utils): replace the process-wide byte ledger with route-local byte bounds (#12465)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The adapter layer carries sandbox requests to host processes.
> - The HTTP/2 bridge used one process-wide byte ledger for all routes.
> - One busy route could exhaust that shared budget and move another
route to file transport.
> - This pull request gives each host retention site a fixed byte bound
and limits concurrent HTTP/2 streams.
> - The benefit is local protection: one route cannot consume the byte
budget of another route.

## Linked Issues or Issue Description

**What happened?**

The HTTP/2 bridge used one aggregate byte ledger for retained bytes
across all routes. A busy route could exhaust the shared budget and
force an unrelated route to use file transport.

**Expected behavior**

Each route should protect its own retained bytes. A reset on one HTTP/2
stream should cancel only that stream's host forward.

**Steps to reproduce**

1. Start the HTTP/2 bridge with multiple sandbox routes.
2. Send enough retained data through one route to reach the aggregate
byte limit.
3. Send a request through a sibling route.
4. Observe that the sibling route can fall back to file transport
because the first route used the shared ledger.

**Paperclip version or commit**

`47639e227e78e3c5e0dd1a3c0e2d792fe86895a3`

**Deployment mode**

Built from source with the adapter-utils and server test suites.

## What Changed

- Bound each host retention site with a fixed local byte limit.
- Limited concurrent live HTTP/2 streams with one built-in stream limit.
- Bound each host forward and response-body read to its own HTTP/2
stream lifetime.
- Removed the process-wide byte ledger, its environment override, its
metrics, and its file-transport fallbacks.
- Added tests for the stream limit, host body budget, and sibling-stream
cancellation.

## Verification

- Run `pnpm vitest run --project adapter-utils`.
- Confirm that 996 adapter-utils tests pass.
- Confirm that `test_live_forward_work_never_passes_the_stream_limit`
passes.
- Confirm that `test_the_host_body_budget_matches_the_stream_limit`
passes.
- Confirm that the sibling-stream cancellation test passes.
- Run `pnpm tsc --noEmit`.
- Confirm that all pull request checks pass.

## Risks

The bridge no longer uses a process-wide byte ledger. A local bound or
stream limit that is too low can reject or delay valid work. The tests
cover the new limits and stream cancellation behavior.

## Model Used

OpenAI GPT-5 Codex. Runtime model ID: GPT-5. The model used code
execution and repository tools. The runtime does not expose the context
window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-28 14:36:18 -07:00

222 lines
8.3 KiB
TypeScript

/**
* Versioned frame codec for the sandbox duplex channel.
*
* The channel carries newline-delimited JSON frames. One frame is one line. The
* host and the generated gateway each hold a copy of this codec. A shared
* fixture file (`duplex-frame-vectors.json`) proves the two copies stay wire
* compatible: both copies decode the same bytes to the same frames.
*
* The decoder never throws on the read path. A malformed or version-mismatch
* frame becomes a protocol-error result, not an exception. This keeps one bad
* frame from crashing the read loop.
*
* The `http2_v1` host readiness gate imports {@link decodeDuplexLine} from this
* file to read the one READY line every gateway sends. That is the only frame
* this module's decode side still reads in production; the gateway itself
* never decodes, it only writes one READY line with {@link encodeDuplexFrame}.
*/
/** The wire version this codec reads and writes. */
export const DUPLEX_FRAME_VERSION = 2;
/**
* The default maximum size of one frame, in bytes. The decoder rejects a longer
* frame with a `frame_too_large` protocol error. The value matches the per-chunk
* byte bound of the host duplex route, and the host body limit for one HTTP/2
* bridge stream ({@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES} in
* `sandbox-callback-bridge.ts`).
*/
export const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 262_144;
/**
* The READY control frame. The gateway sends it one time after it binds the
* host-assigned listener port. READY is a liveness signal, not an address
* source. The frame carries exactly the frame version and the `nonce` string.
* The gateway echoes the nonce the host passed through the launch environment,
* so the host correlates the READY frame with this channel open. The frame
* carries no address data; the host builds the endpoint from its own stored
* port, never from the channel.
*/
export interface DuplexReadyFrame {
version: number;
type: "ready";
nonce: string;
}
/** The heartbeat control frame. Each side sends it on an interval to prove liveness. */
export interface DuplexHeartbeatFrame {
version: number;
type: "heartbeat";
}
/** The orderly close control frame. A side sends it to end the channel cleanly. */
export interface DuplexCloseFrame {
version: number;
type: "close";
}
/**
* The protocol-error control frame. A peer sends it to report a bad frame. This
* frame is distinct from a decode-time protocol error: the decoder produces a
* {@link DuplexProtocolError} result, while a peer sends this frame on the wire.
*/
export interface DuplexErrorFrame {
version: number;
type: "error";
code: string;
message?: string;
}
/** Any frame the codec reads or writes. */
export type DuplexFrame = DuplexReadyFrame | DuplexHeartbeatFrame | DuplexCloseFrame | DuplexErrorFrame;
/** The reason the decoder rejected one line. */
export type DuplexProtocolErrorCode =
| "malformed_frame"
| "unknown_type"
| "version_mismatch"
| "frame_too_large"
| "id_too_large";
/** A decode-time protocol error. The read path returns it; it never throws. */
export interface DuplexProtocolError {
code: DuplexProtocolErrorCode;
message: string;
}
/** One decode result: a valid frame, or a protocol error. */
export type DuplexDecodeResult =
| { ok: true; frame: DuplexFrame }
| { ok: false; error: DuplexProtocolError };
/**
* One size-checked encode result: one line, or a `frame_too_large` error. The
* shape mirrors {@link DuplexDecodeResult}, so the encode side reports the
* over-limit case as a typed outcome, not an exception.
*/
export type DuplexEncodeResult =
| { ok: true; line: string }
| { ok: false; error: { code: "frame_too_large"; message: string } };
function ok(frame: DuplexFrame): DuplexDecodeResult {
return { ok: true, frame };
}
function fail(code: DuplexProtocolErrorCode, message: string): DuplexDecodeResult {
return { ok: false, error: { code, message } };
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Encode one frame to a single line of JSON with a trailing newline. `JSON.stringify`
* escapes any newline inside a string value, so the returned line holds no
* interior newline. This keeps one frame on one line.
*/
export function encodeDuplexFrame(frame: DuplexFrame): string {
return `${JSON.stringify(frame)}\n`;
}
/**
* Encode one frame to a single line and enforce the maximum frame size. The
* function measures the encoded JSON in bytes, without the trailing newline, so
* it matches the decoder bound exactly: a line the decoder accepts encodes, and a
* line the decoder rejects returns a `frame_too_large` result. The function never
* throws; it reports the over-limit case as a typed outcome.
*
* The size bound follows request and response bodies the host does not control,
* so an over-limit frame is an expected condition, not a programming error. Every
* write path that can carry a large body uses this function, so no path emits a
* frame the peer decoder rejects. The bound applies to every frame type; the
* guard is a no-op for a small control frame.
*/
export function encodeDuplexFrameChecked(
frame: DuplexFrame,
maxFrameBytes: number = DEFAULT_MAX_DUPLEX_FRAME_BYTES,
): DuplexEncodeResult {
const json = JSON.stringify(frame);
if (Buffer.byteLength(json, "utf8") > maxFrameBytes) {
return { ok: false, error: { code: "frame_too_large", message: "frame exceeds the maximum size" } };
}
return { ok: true, line: `${json}\n` };
}
/**
* Decode one line (no trailing newline) to a frame or a protocol error. The
* streaming decoder calls this for each complete line. It is exported so a
* caller with its own line splitter can reuse the same validation.
*
* The check order matters. A parseable frame with the wrong version becomes a
* `version_mismatch`, so the version check runs before the type check.
*/
export function decodeDuplexLine(line: string | Buffer): DuplexDecodeResult {
const text = typeof line === "string" ? line : line.toString("utf8");
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return fail("malformed_frame", "frame is not valid JSON");
}
if (!isPlainObject(parsed)) {
return fail("malformed_frame", "frame is not a JSON object");
}
if (parsed.version !== DUPLEX_FRAME_VERSION) {
return fail(
"version_mismatch",
`frame version ${String(parsed.version)} is not ${DUPLEX_FRAME_VERSION}`,
);
}
return validateFrame(parsed);
}
function validateFrame(frame: Record<string, unknown>): DuplexDecodeResult {
switch (frame.type) {
case "ready":
return validateReady(frame);
case "heartbeat":
return validateHeartbeat(frame);
case "close":
return validateClose(frame);
case "error":
return validateError(frame);
default:
return fail("unknown_type", `unknown frame type ${JSON.stringify(frame.type)}`);
}
}
function validateReady(frame: Record<string, unknown>): DuplexDecodeResult {
// READY carries a liveness nonce, not an address. The schema is strict: a valid
// READY frame holds exactly `version`, `type`, and `nonce`. The decoder rejects
// an absent nonce, a wrong-typed nonce, or any extra field, so a READY frame
// that smuggles an `address`, a `port`, a `host`, or a URL never decodes.
if (typeof frame.nonce !== "string") {
return fail("malformed_frame", "ready frame has a missing or wrong-typed nonce");
}
for (const key of Object.keys(frame)) {
if (key !== "version" && key !== "type" && key !== "nonce") {
return fail("malformed_frame", "ready frame has an unexpected field");
}
}
return ok(frame as unknown as DuplexReadyFrame);
}
function validateHeartbeat(frame: Record<string, unknown>): DuplexDecodeResult {
return ok(frame as unknown as DuplexHeartbeatFrame);
}
function validateClose(frame: Record<string, unknown>): DuplexDecodeResult {
return ok(frame as unknown as DuplexCloseFrame);
}
function validateError(frame: Record<string, unknown>): DuplexDecodeResult {
if (typeof frame.code !== "string") {
return fail("malformed_frame", "error frame has a missing or wrong-typed code");
}
if (frame.message !== undefined && typeof frame.message !== "string") {
return fail("malformed_frame", "error frame has a wrong-typed message");
}
return ok(frame as unknown as DuplexErrorFrame);
}