feat(duplex): bound aggregate duplex route resource consumption with a process-owned byte ledger (#12003)

## Thinking Path

> - Paperclip runs AI agents through adapters and sandboxed execution
targets.
> - Duplex routes retain bytes across route data, broker messages,
decoder buffers, and readiness replay.
> - Per-route limits bound each route but do not bound the total
retained bytes across many routes.
> - A process-owned ledger must charge each retained buffer before
allocation and release the charge during cleanup.
> - This pull request adds the aggregate ledger, connects it to host and
sandbox duplex paths, and adds route coverage.
> - The benefit is a fail-closed process-wide byte limit that keeps
concurrent duplex work within a safe resource budget.

## Linked Issues or Issue Description

**Subsystem affected**

This change affects packages/adapter-utils and server duplex
orchestration.

**Problem or motivation**

Many routes can each stay below their per-route limits while their
combined retained bytes exceed a safe process budget.

**Proposed solution**

Add a process-owned aggregate byte ledger. Charge route data, broker
bytes, decoder buffers, and readiness replay bytes before allocation.
Release each charge during cleanup. Use a separate sandbox_process
decoder cap for the in-sandbox path.

**Alternatives considered**

Keep only per-route limits. This does not bound the combined process
use. Set a fixed limit at one call site. This misses retained bytes in
other duplex paths.

**Roadmap alignment**

This is a tightly scoped reliability and resource-safety improvement. It
does not duplicate a roadmap feature.

**Additional context**

The aggregate ceiling uses a safe 256 MiB default. An invalid override
falls back to that default and reports the rejected value.

## What Changed

- Add a process-owned aggregate byte ledger for duplex route resource
use.
- Charge and release route data, broker forward and response bytes,
decoder buffers, and readiness replay bytes.
- Bound host-to-worker pending writes and standard input transport
bytes.
- Add a separate decoder cap for the sandbox_process path.
- Make invalid aggregate-ceiling overrides fall back to the safe default
without host startup failure.
- Add adapter-utils and server tests for charging, release, rejection,
cleanup, and many-route aggregate limits.

## Verification

- pnpm --filter @paperclipai/adapter-utils exec tsc --noEmit
- pnpm --filter @paperclipai/server exec tsc --noEmit
- Run the focused adapter-utils duplex ledger and execution-target
tests.
- Run the server aggregate-ledger route test.
- Confirm all required pull request checks pass on this branch.

## Risks

The ledger touches several duplex buffer paths. A missed release could
reduce later capacity until process restart. The tests cover charge,
release, rejection, cleanup, and route aggregation. The change uses a
safe default when configuration input is invalid.

## Model Used

OpenAI GPT-5 Codex. The runtime model ID and context window are not
exposed to this task. The model used tool calls, shell commands, and
code review workflow support.

## 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 issue references)
- [x] My branch name describes the change 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>
This commit is contained in:
Nicky Leach 2026-08-22 22:14:35 -07:00 committed by GitHub
parent c5050396c7
commit 05b35d4669
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 4087 additions and 109 deletions

View File

@ -0,0 +1,221 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
DUPLEX_AGGREGATE_TOKEN_OWNERS,
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX,
assertDuplexAggregateCeilingBytes,
resolveDuplexAggregateCeilingBytes,
type DuplexAggregateByteLedgerTelemetry,
} from "./duplex-aggregate-byte-ledger.js";
// A telemetry surface that counts each fixed record, so a test asserts the exact
// gauge value and the exact counter counts.
function createCountingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
rejections: number;
underflows: number;
} {
const state = { gauge: 0, rejections: 0, underflows: 0 };
return {
...state,
setBytesInUse(bytes: number) {
this.gauge = bytes;
},
recordReservationRejection() {
this.rejections += 1;
},
recordAccountingUnderflow() {
this.underflows += 1;
},
};
}
describe("DuplexAggregateByteLedger", () => {
it("reserves under the ceiling and tracks the gauge and the live-token count", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const a = ledger.reserve("buffered_chunk", 400);
const b = ledger.reserve("buffered_chunk", 500);
expect(a).not.toBeNull();
expect(b).not.toBeNull();
expect(ledger.bytesInUse).toBe(900);
expect(ledger.liveTokenCount).toBe(2);
expect(telemetry.gauge).toBe(900);
expect(a?.bytes).toBe(400);
expect(a?.owner).toBe("buffered_chunk");
expect(a?.state).toBe("held");
});
it("rejects a one-byte-over reservation, retains nothing, and increments the rejection counter", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
ledger.reserve("buffered_chunk", 1000);
const over = ledger.reserve("buffered_chunk", 1);
expect(over).toBeNull();
expect(ledger.bytesInUse).toBe(1000);
expect(ledger.liveTokenCount).toBe(1);
expect(telemetry.rejections).toBe(1);
// The public rejection marker is the fixed string the caller reports.
expect(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED).toBe("DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED");
});
it("transfers ownership without a decrement or a re-reserve, so no admission gap opens", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 100, telemetry });
// Reserve the whole ceiling as a pre-bind event, then transfer to buffered.
const token = ledger.reserve("pre_bind_event", 100);
expect(token).not.toBeNull();
// A second reservation cannot fit while the token stays held.
expect(ledger.reserve("buffered_chunk", 1)).toBeNull();
ledger.transfer(token!, "buffered_chunk");
// The transfer changed only the owner. The gauge never moved, so no window
// opened where the bytes were released and re-admitted.
expect(ledger.bytesInUse).toBe(100);
expect(ledger.liveTokenCount).toBe(1);
expect(token?.owner).toBe("buffered_chunk");
expect(telemetry.gauge).toBe(100);
// No reservation fit during the transfer, so no rejection or underflow fired
// beyond the one rejection above.
expect(telemetry.rejections).toBe(1);
expect(telemetry.underflows).toBe(0);
});
it("releases a token one time and decrements the gauge", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const token = ledger.reserve("response_body", 250);
expect(token).not.toBeNull();
ledger.release(token!);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(token?.state).toBe("released");
expect(telemetry.gauge).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("records the underflow counter on a second release and does not clamp the gauge again", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const kept = ledger.reserve("request_frame", 300);
const doubled = ledger.reserve("request_frame", 200);
expect(kept).not.toBeNull();
expect(doubled).not.toBeNull();
ledger.release(doubled!);
expect(ledger.bytesInUse).toBe(300);
// A forced second release of the same token is an accounting defect.
ledger.release(doubled!);
expect(telemetry.underflows).toBe(1);
// The gauge did not drop a second time, so the defect stays visible.
expect(ledger.bytesInUse).toBe(300);
expect(ledger.liveTokenCount).toBe(1);
});
it("records the underflow counter on a transfer of a released token", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const token = ledger.reserve("pre_bind_event", 100);
ledger.release(token!);
ledger.transfer(token!, "buffered_chunk");
expect(telemetry.underflows).toBe(1);
expect(ledger.bytesInUse).toBe(0);
});
it("throws on a non-integer or negative reservation, so a programming error fails loud", () => {
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000 });
expect(() => ledger.reserve("buffered_chunk", -1)).toThrow();
expect(() => ledger.reserve("buffered_chunk", 1.5)).toThrow();
expect(() => ledger.reserve("buffered_chunk", Number.NaN)).toThrow();
});
it("admits a zero-byte reservation as a valid held token", () => {
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000 });
const token = ledger.reserve("seen_request_id", 0);
expect(token).not.toBeNull();
expect(token?.bytes).toBe(0);
expect(ledger.liveTokenCount).toBe(1);
});
it("exposes the closed owner-label set", () => {
expect([...DUPLEX_AGGREGATE_TOKEN_OWNERS]).toEqual([
"pre_bind_event",
"buffered_chunk",
"terminal_buffered",
"request_frame",
"request_payload",
"response_body",
"seen_request_id",
"decoder_buffer",
"readiness_buffer",
"readiness_replay",
"pending_write",
"stdin_write",
]);
});
});
describe("aggregate ceiling configuration", () => {
it("uses the default when the override is absent", () => {
expect(resolveDuplexAggregateCeilingBytes()).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(resolveDuplexAggregateCeilingBytes(null)).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES).toBe(256 * 1024 * 1024);
});
it("accepts a valid positive override under the process allocation minus the reserve", () => {
expect(resolveDuplexAggregateCeilingBytes(64 * 1024 * 1024)).toBe(64 * 1024 * 1024);
const maxCeiling =
MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX - DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
expect(resolveDuplexAggregateCeilingBytes(maxCeiling)).toBe(maxCeiling);
});
it("rejects an invalid override to the safe default and reports each rejection", () => {
const overMaximum = MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX;
const invalidOverrides = [0, -1, 1.5, Number.POSITIVE_INFINITY, Number.NaN, overMaximum];
for (const invalid of invalidOverrides) {
const rejected: number[] = [];
// An invalid present override returns the safe default. It never throws, so a
// single bad environment value never fails host startup.
expect(resolveDuplexAggregateCeilingBytes(invalid, (value) => rejected.push(value))).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
// The resolver reports the rejection one time with the rejected numeric value.
expect(rejected).toEqual([invalid]);
}
});
it("does not report a rejection for a valid or a missing override", () => {
const rejected: number[] = [];
const reporter = (value: number): void => {
rejected.push(value);
};
expect(resolveDuplexAggregateCeilingBytes(64 * 1024 * 1024, reporter)).toBe(64 * 1024 * 1024);
expect(resolveDuplexAggregateCeilingBytes(undefined, reporter)).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
expect(resolveDuplexAggregateCeilingBytes(null, reporter)).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
expect(rejected).toEqual([]);
});
it("still throws for a direct ceiling assertion on an unsafe integer", () => {
// The ledger constructor asserts its ceiling and must fail loud on a
// programming error, so `assertDuplexAggregateCeilingBytes` keeps throwing.
expect(() => assertDuplexAggregateCeilingBytes(Number.MAX_SAFE_INTEGER + 2)).toThrow();
});
});

View File

@ -0,0 +1,400 @@
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel.
*
* The route-count controller bounds how many concurrent duplex routes stay live.
* It does not bound the aggregate bytes those routes retain. At the maximum route
* count the per-route byte bounds multiply to many gigabytes of retained bytes.
* This ledger closes that gap. One ledger per host server process owns a single
* byte ceiling. Every host-side retention site reserves its exact retained bytes
* against this ledger before it allocates, so the aggregate retained bytes across
* all live routes never pass the ceiling.
*
* The ledger is fail-closed. A reservation that would pass the ceiling returns no
* token. The caller retains nothing and reports the fixed rejection marker
* {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED}.
*
* Ownership is transfer-only. A token holds an immutable byte amount, an owner
* label from a closed enum, and a one-way state: `held` then `released`. The
* ledger moves a token from one representation to the next with
* {@link DuplexAggregateByteLedger.transfer}, which changes only the owner label.
* It never decrements and re-reserves, so no admission gap opens between two
* representations of the same retained bytes.
*
* The ledger holds every live token in one owner registry. Registry removal and
* the release of a token run in one synchronous critical section, before any
* `await` or external callback. A second release of the same token is an
* accounting defect. The ledger records the fixed underflow counter and does not
* clamp the gauge down a second time, so a real defect stays visible.
*
* The ledger carries no payload data and no arbitrary labels. It exposes only the
* byte gauge, the live-token count, and the closed owner enum.
*/
import {
DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL,
DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL,
DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE,
} from "./duplex-telemetry.js";
/**
* The public rejection marker. A retention site that cannot reserve its bytes
* reports this fixed string. It carries no payload, no route, and no raw value.
*/
export const DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED = "DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED";
/**
* The default aggregate ceiling, in bytes: 256 MiB. This is one eighth of the
* documented minimum host process memory allocation for the sandbox duplex
* transport ({@link MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX}, 2 GiB). The
* remaining seven eighths cover the Node runtime, ordinary server work, parser
* and transient headroom, and allocator or garbage-collector variance.
*/
export const DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES = 256 * 1024 * 1024;
/**
* The documented minimum host process memory allocation to enable the sandbox
* duplex transport, in bytes: 2 GiB. An operator override of the aggregate
* ceiling must not pass this value minus the documented reserve.
*/
export const MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX = 2 * 1024 * 1024 * 1024;
/**
* The closed set of owner labels a reservation token can carry. Each label names
* one host-side representation that retains bytes. The ledger accepts no label
* outside this set, so no arbitrary label ever reaches a token.
*
* - `pre_bind_event`: a held pre-bind duplex event, before route bind.
* - `buffered_chunk`: a buffered data chunk retained for a late listener.
* - `terminal_buffered`: a buffered chunk moved to the terminal registry at
* route terminalization, held for a late listener drain.
* - `request_frame`: a bounded raw request frame the broker retains at dispatch.
* - `request_payload`: a normalized request payload the broker retains at dispatch.
* - `response_body`: a response-body chunk or replacement buffer the reader retains.
* - `seen_request_id`: one entry of the broker no-replay request-id set.
* - `decoder_buffer`: the raw partial-frame bytes the host frame decoder retains
* between chunks, plus the peak replacement buffer it allocates on concat.
* - `readiness_buffer`: the raw untrusted bytes the readiness gate retains before
* the READY frame completes, before the broker binds and the decoder takes over.
* - `readiness_replay`: the post-READY suffix bytes the readiness gate holds in the
* pending replay buffer, from the READY accept until the broker binds and the
* frame decoder charges its own retention. This is a separate retention from
* `readiness_buffer`: the gate drops the whole pre-READY buffer on READY, then
* charges only the retained suffix and each later pre-bind chunk under this owner.
* - `pending_write`: the raw host-to-worker write payload a pending duplex write
* RPC retains, from the enqueue seam until the RPC settles.
* - `stdin_write`: the serialized host-to-worker frame the child-stdin transport
* buffer retains, from the write until the stream flushes the chunk, the stream
* errors, the stream closes, or the worker exits. This is a separate retention
* from `pending_write`: the RPC holds the raw payload while the transport buffer
* holds the larger serialized frame, so the two tokens cover the peak of both.
*/
export const DUPLEX_AGGREGATE_TOKEN_OWNERS = [
"pre_bind_event",
"buffered_chunk",
"terminal_buffered",
"request_frame",
"request_payload",
"response_body",
"seen_request_id",
"decoder_buffer",
"readiness_buffer",
"readiness_replay",
"pending_write",
"stdin_write",
] as const;
/** One owner label from the closed {@link DUPLEX_AGGREGATE_TOKEN_OWNERS} set. */
export type DuplexAggregateTokenOwner = (typeof DUPLEX_AGGREGATE_TOKEN_OWNERS)[number];
/** The one-way lifecycle state of a reservation token. */
export type DuplexReservationTokenState = "held" | "released";
/**
* An opaque reservation token. The byte amount is immutable. The owner label
* moves through the closed enum by transfer only. The state moves one way, from
* `held` to `released`. A caller reads these fields but never mutates them; only
* the owning ledger changes the owner and the state.
*/
export interface ReservationToken {
/** The immutable exact byte amount this token reserves. */
readonly bytes: number;
/** The current owner label. The ledger changes it only through `transfer`. */
readonly owner: DuplexAggregateTokenOwner;
/** The one-way lifecycle state. The ledger sets it to `released` one time. */
readonly state: DuplexReservationTokenState;
}
/**
* The internal mutable token. The ledger owns it and returns it typed as the
* read-only {@link ReservationToken}. Only code in this module changes the owner
* or the state.
*/
class ReservationTokenImpl implements ReservationToken {
readonly bytes: number;
owner: DuplexAggregateTokenOwner;
state: DuplexReservationTokenState = "held";
constructor(bytes: number, owner: DuplexAggregateTokenOwner) {
this.bytes = bytes;
this.owner = owner;
}
}
/**
* The fixed telemetry surface for the aggregate byte ledger. The host binds it to
* the closed duplex telemetry contract. Each record uses only fixed constant
* dimensions. No company, agent, run, route, URL, header, request id, token, byte
* payload, or raw provider value ever reaches a record. The default is a no-op.
*/
export interface DuplexAggregateByteLedgerTelemetry {
/** Set the aggregate-bytes-in-use gauge to the current value. */
setBytesInUse(bytes: number): void;
/** Increment the reservation-rejection counter one time. */
recordReservationRejection(): void;
/** Increment the accounting-underflow counter one time. */
recordAccountingUnderflow(): void;
}
/** A no-op telemetry surface. Every method does nothing, so the ledger stays inert. */
export const NOOP_DUPLEX_AGGREGATE_BYTE_LEDGER_TELEMETRY: DuplexAggregateByteLedgerTelemetry = {
setBytesInUse() {},
recordReservationRejection() {},
recordAccountingUnderflow() {},
};
/**
* The low-level process metric sink the host binds. It carries the fixed metric
* name and the value only, so no dynamic dimension reaches a sink. The host maps
* the gauge and the two counters to the process metric pipeline and to a warn log
* for a defect.
*/
export interface DuplexAggregateByteLedgerMetricSink {
/** Set a named process gauge to a value. */
setGauge(name: string, value: number): void;
/** Increment a named process counter one time. */
incrementCounter(name: string): void;
}
/**
* Build a {@link DuplexAggregateByteLedgerTelemetry} that maps each ledger event
* to the fixed metric name on the process metric sink. The gauge uses
* {@link DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE}. The rejection counter uses
* {@link DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL}. The defect
* counter uses {@link DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL}.
* Each call sits in an error swallow, so a sink failure never breaks the reserve
* or the release path.
*/
export function createDuplexAggregateByteLedgerTelemetry(
sink: DuplexAggregateByteLedgerMetricSink,
): DuplexAggregateByteLedgerTelemetry {
return {
setBytesInUse(bytes: number): void {
try {
sink.setGauge(DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE, bytes);
} catch {
// A telemetry failure never breaks the reserve or the release path.
}
},
recordReservationRejection(): void {
try {
sink.incrementCounter(DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL);
} catch {
// A telemetry failure never breaks the reserve path.
}
},
recordAccountingUnderflow(): void {
try {
sink.incrementCounter(DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL);
} catch {
// A telemetry failure never breaks the release path.
}
},
};
}
/** The options for {@link DuplexAggregateByteLedger}. */
export interface DuplexAggregateByteLedgerOptions {
/** The aggregate ceiling, in bytes. It must be a positive safe integer. */
ceilingBytes: number;
/** The fixed telemetry surface. The default is the no-op surface. */
telemetry?: DuplexAggregateByteLedgerTelemetry;
}
/**
* Assert an aggregate ceiling byte value. The value must be a positive safe
* integer that is no larger than the documented process allocation minus the
* documented reserve. The function throws on any other value, so an invalid
* ceiling fails startup instead of silently disabling the ledger.
*
* The reserve equals the documented process allocation minus the default
* ceiling ({@link DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES}), so the maximum
* accepted ceiling is exactly the documented process allocation minus that
* reserve.
*/
export function assertDuplexAggregateCeilingBytes(value: number): void {
const maxCeiling = maxDuplexAggregateCeilingBytes();
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(
`The duplex aggregate byte ceiling must be a positive safe integer; got ${String(value)}.`,
);
}
if (value > maxCeiling) {
throw new Error(
`The duplex aggregate byte ceiling ${value} must not pass the documented process allocation minus the reserve (${maxCeiling}).`,
);
}
}
/**
* The maximum accepted aggregate ceiling, in bytes: the documented process
* allocation minus the documented reserve. Both {@link assertDuplexAggregateCeilingBytes}
* and {@link isValidDuplexAggregateCeilingBytes} read this one bound.
*/
function maxDuplexAggregateCeilingBytes(): number {
return MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX - DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
}
/**
* Report whether `value` is a valid aggregate ceiling. A valid ceiling is a
* positive safe integer that is no larger than {@link maxDuplexAggregateCeilingBytes}.
* This is the non-throwing form of {@link assertDuplexAggregateCeilingBytes}. The
* override resolver uses it to reject an invalid override without a throw.
*/
export function isValidDuplexAggregateCeilingBytes(value: number): boolean {
return Number.isSafeInteger(value) && value >= 1 && value <= maxDuplexAggregateCeilingBytes();
}
/**
* A reporter the host binds to receive one invalid-override rejection signal.
* {@link resolveDuplexAggregateCeilingBytes} calls it one time when it rejects a
* present invalid override and falls back to the safe default. The signal carries
* only the rejected numeric value. It carries no route, company, run, or payload
* value.
*/
export type DuplexAggregateCeilingOverrideRejectionReporter = (rejectedValue: number) => void;
/**
* Resolve an aggregate ceiling from an optional operator override. A missing or
* `null` override uses {@link DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES}. A present
* valid value passes through unchanged.
*
* A present invalid override does not fail host startup. The host process is
* multi-tenant, so one invalid environment value must not brick the whole host.
* The resolver rejects the invalid override, reports it through the optional
* `onRejectedOverride` reporter, and returns the conservative safe default. This
* fails loud without failing closed on availability.
*/
export function resolveDuplexAggregateCeilingBytes(
override?: number | null,
onRejectedOverride?: DuplexAggregateCeilingOverrideRejectionReporter,
): number {
if (override === undefined || override === null) {
return DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
}
if (!isValidDuplexAggregateCeilingBytes(override)) {
onRejectedOverride?.(override);
return DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
}
return override;
}
/**
* The process-owned aggregate byte ledger. Create one per host server process.
* Inject the same object into every host-side retention site, so one shared gauge
* bounds the aggregate retained bytes across all live duplex routes.
*/
export class DuplexAggregateByteLedger {
private readonly ceiling: number;
private readonly telemetry: DuplexAggregateByteLedgerTelemetry;
private used = 0;
/** The owner registry. It holds every live token. */
private readonly liveTokens = new Set<ReservationTokenImpl>();
constructor(options: DuplexAggregateByteLedgerOptions) {
assertDuplexAggregateCeilingBytes(options.ceilingBytes);
this.ceiling = options.ceilingBytes;
this.telemetry = options.telemetry ?? NOOP_DUPLEX_AGGREGATE_BYTE_LEDGER_TELEMETRY;
}
/** The configured aggregate ceiling, in bytes. */
get ceilingBytes(): number {
return this.ceiling;
}
/** The current aggregate bytes in use across every live token. */
get bytesInUse(): number {
return this.used;
}
/** The current number of live tokens in the owner registry. */
get liveTokenCount(): number {
return this.liveTokens.size;
}
/**
* Reserve `bytes` for `owner`. Check and increment the gauge synchronously
* before the caller allocates. Return a held token when the reservation fits
* under the ceiling. Return `null` when `used + bytes` would pass the ceiling;
* the caller retains nothing and reports
* {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED}.
*
* The byte amount must be a non-negative safe integer. A caller that passes any
* other value made a programming error, so the ledger throws and fails loud.
*/
reserve(owner: DuplexAggregateTokenOwner, bytes: number): ReservationToken | null {
if (!Number.isSafeInteger(bytes) || bytes < 0) {
throw new Error(
`A duplex byte reservation must be a non-negative safe integer; got ${String(bytes)}.`,
);
}
if (this.used + bytes > this.ceiling) {
this.telemetry.recordReservationRejection();
return null;
}
this.used += bytes;
const token = new ReservationTokenImpl(bytes, owner);
this.liveTokens.add(token);
this.telemetry.setBytesInUse(this.used);
return token;
}
/**
* Transfer a held token to a new owner label. Change only the owner. Never
* decrement and never re-reserve. This is the atomic pre-bind handoff: it moves
* the same reserved bytes from one representation to the next with no admission
* gap.
*
* A transfer of a token the ledger does not hold, or of an already-released
* token, is an accounting defect. The ledger records the fixed underflow
* counter and leaves the gauge unchanged.
*/
transfer(token: ReservationToken, owner: DuplexAggregateTokenOwner): void {
const impl = token as ReservationTokenImpl;
if (impl.state !== "held" || !this.liveTokens.has(impl)) {
this.telemetry.recordAccountingUnderflow();
return;
}
impl.owner = owner;
}
/**
* Release a held token. Remove it from the owner registry, mark it `released`,
* and decrement the gauge, all in one synchronous critical section before any
* `await` or external callback. A token releases one time.
*
* A second release of the same token, or a release of a token the ledger does
* not hold, is an accounting defect. The ledger records the fixed underflow
* counter and does not clamp the gauge a second time, so a real defect stays
* visible on the counter.
*/
release(token: ReservationToken): void {
const impl = token as ReservationTokenImpl;
if (impl.state === "released" || !this.liveTokens.delete(impl)) {
this.telemetry.recordAccountingUnderflow();
return;
}
impl.state = "released";
this.used -= impl.bytes;
this.telemetry.setBytesInUse(this.used);
}
}

View File

@ -0,0 +1,317 @@
import { afterEach, describe, expect, it } from "vitest";
import {
createDuplexBridgeBroker,
type DuplexBridgeBroker,
type DuplexBrokerForwardResult,
} from "./duplex-bridge-broker.js";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
} from "./duplex-aggregate-byte-ledger.js";
import {
DUPLEX_FRAME_VERSION,
DuplexFrameDecoder,
encodeDuplexFrame,
type DuplexRequestFrame,
type DuplexResponseFrame,
} from "./duplex-frame-codec.js";
import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js";
/**
* Regression harness for the broker aggregate byte ledger charging.
*
* The harness feeds request frames straight into the broker through an in-memory
* channel, the same as a provider that controls the transport. A test controls
* when each forward settles, so it can assert the ledger charge while the forward
* is still in flight and after it settles.
*/
/** One pending forward a test settles by hand. */
interface PendingForward {
id: string;
resolve: (result: DuplexBrokerForwardResult) => void;
reject: (error: Error) => void;
settled: boolean;
}
/** The in-memory channel plus the levers a test uses to drive the broker. */
interface FakeChannelHarness {
channel: CommandManagedDuplexChannel;
feed: (frame: DuplexRequestFrame) => void;
exit: () => void;
responses: DuplexResponseFrame[];
forwards: PendingForward[];
resolveForward: (id: string, body?: string) => void;
}
/** Build one valid request frame. */
function requestFrame(id: string, method = "POST"): DuplexRequestFrame {
return {
version: DUPLEX_FRAME_VERSION,
type: "request",
id,
method,
path: `/api/issues/${id}`,
query: "",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id }),
};
}
function createFakeChannelHarness(): FakeChannelHarness {
const responses: DuplexResponseFrame[] = [];
const forwards: PendingForward[] = [];
const writtenDecoder = new DuplexFrameDecoder();
let dataListener: ((chunk: string) => void) | null = null;
let exitListener: ((exit: { exitCode: number | null }) => void) | null = null;
const channel: CommandManagedDuplexChannel = {
write: (data) => {
for (const result of writtenDecoder.push(data)) {
if (result.ok && result.frame.type === "response") responses.push(result.frame);
}
},
onData: (listener) => {
dataListener = listener;
},
onExit: (listener) => {
exitListener = listener;
},
stop: () => undefined,
close: () => Promise.resolve(),
};
return {
channel,
feed: (frame) => {
if (!dataListener) throw new Error("The broker did not bind the data listener.");
dataListener(encodeDuplexFrame(frame));
},
exit: () => {
if (!exitListener) throw new Error("The broker did not bind the exit listener.");
exitListener({ exitCode: 0 });
},
responses,
forwards,
resolveForward: (id, body = JSON.stringify({ ok: true })) => {
const forward = [...forwards].reverse().find((entry) => entry.id === id && !entry.settled);
if (!forward) throw new Error(`No unsettled forward for id ${id}.`);
forward.settled = true;
forward.resolve({ status: 200, headers: { "content-type": "application/json" }, body });
},
};
}
/** A forward handler that hands each call to the harness and never auto-resolves. */
function controllableForward(harness: FakeChannelHarness) {
return (request: DuplexRequestFrame): Promise<DuplexBrokerForwardResult> =>
new Promise<DuplexBrokerForwardResult>((resolve, reject) => {
harness.forwards.push({ id: request.id, resolve, reject, settled: false });
});
}
/** Wait for the microtask queue to drain, so a settled promise runs its handlers. */
async function flush(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("duplex bridge broker aggregate byte ledger", () => {
const brokers: DuplexBridgeBroker[] = [];
afterEach(async () => {
while (brokers.length > 0) {
const broker = brokers.pop();
if (broker) await broker.close();
}
});
it("charges request-frame, request-payload, and seen-id tokens, then releases the request tokens on forward settlement", async () => {
const harness = createFakeChannelHarness();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const broker = createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1"));
// The dispatch retains three tokens: the raw frame, the normalized payload,
// and the no-replay set entry.
expect(ledger.liveTokenCount).toBe(3);
expect(ledger.bytesInUse).toBeGreaterThan(0);
const chargedInFlight = ledger.bytesInUse;
harness.resolveForward("req-1");
await flush();
// The forward settled. The finally owner released the request-frame and the
// request-payload tokens. The seen-id token stays charged for the channel
// lifetime, so exactly one token remains.
expect(ledger.liveTokenCount).toBe(1);
expect(ledger.bytesInUse).toBeGreaterThan(0);
expect(ledger.bytesInUse).toBeLessThan(chargedInFlight);
// A close releases the seen-id token, so the ledger returns to zero.
await broker.close();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("keeps request tokens charged when the response timer answers before the forward settles, and releases them on settlement", async () => {
const harness = createFakeChannelHarness();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const broker = createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
// Squeeze the nested budgets so the response timer fires quickly while the
// forward stays unsettled.
budgets: { forwardTimeoutMs: 5, responseBudgetMs: 10, gatewayWaitMs: 20 },
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1", "GET"));
expect(ledger.liveTokenCount).toBe(3);
// Wait past the response budget so the backstop answers the gateway while the
// forward is still in flight.
await new Promise((resolve) => setTimeout(resolve, 40));
expect(harness.responses.some((frame) => frame.id === "req-1")).toBe(true);
// The gateway got its answer, but the forward has not settled. The request
// tokens and the seen-id token stay charged: nothing released yet.
expect(ledger.liveTokenCount).toBe(3);
// Settle the orphaned forward. Its finally owner now releases the two request
// tokens; the seen-id token still stays for the channel lifetime.
harness.resolveForward("req-1");
await flush();
expect(ledger.liveTokenCount).toBe(1);
await broker.close();
expect(ledger.bytesInUse).toBe(0);
});
it("refuses a one-byte-over dispatch with the fixed marker and retains nothing", async () => {
const harness = createFakeChannelHarness();
// Measure the exact per-request charge with a throwaway broker, so the test
// can size a ceiling one byte below it and force the reservation to fail.
const probeHarness = createFakeChannelHarness();
const measured = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const measuringBroker = createDuplexBridgeBroker({
channel: probeHarness.channel,
forwardRequest: controllableForward(probeHarness),
duplexAggregateByteLedger: measured,
});
measuringBroker.start();
probeHarness.feed(requestFrame("req-1"));
const perRequestBytes = measured.bytesInUse;
await measuringBroker.close();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: perRequestBytes - 1 });
const broker = createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1"));
await flush();
// The broker retained nothing: no token, no forward, no seen id.
expect(ledger.liveTokenCount).toBe(0);
expect(ledger.bytesInUse).toBe(0);
expect(harness.forwards.length).toBe(0);
// The refusal carries the fixed marker and is a bounded terminal response.
const refusal = harness.responses.find((frame) => frame.id === "req-1");
expect(refusal).toBeTruthy();
expect(refusal?.status).toBe(503);
expect(refusal?.body).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
// The refusal did not retain the id, so a resend after pressure eases is
// admitted: raise the ceiling and re-feed.
const roomyLedger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const roomyHarness = createFakeChannelHarness();
const roomyBroker = createDuplexBridgeBroker({
channel: roomyHarness.channel,
forwardRequest: controllableForward(roomyHarness),
duplexAggregateByteLedger: roomyLedger,
});
brokers.push(roomyBroker);
roomyBroker.start();
roomyHarness.feed(requestFrame("req-1"));
expect(roomyHarness.forwards.length).toBe(1);
});
it("releases every retained token on a terminal channel loss", async () => {
const harness = createFakeChannelHarness();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 });
const broker = createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1"));
harness.feed(requestFrame("req-2"));
expect(ledger.liveTokenCount).toBe(6);
// The channel exits mid-flight. The broker latches the loss, transfers the
// in-flight forwards to the orphan registry, and releases the seen-id tokens.
harness.exit();
expect(broker.runDisposition.failed).toBe(true);
// The seen-id tokens released at loss; the two request tokens per forward stay
// charged until each aborted forward settles.
expect(ledger.liveTokenCount).toBe(4);
// The aborted forwards settle. Each finally owner releases its two request
// tokens, so the ledger returns to zero.
harness.forwards[0]?.reject(new Error("aborted"));
harness.forwards[1]?.reject(new Error("aborted"));
await flush();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("does not double-release a token when a forward settles after the channel closed", async () => {
const harness = createFakeChannelHarness();
let underflow = 0;
const ledger = new DuplexAggregateByteLedger({
ceilingBytes: 1_000_000,
telemetry: {
setBytesInUse() {},
recordReservationRejection() {},
recordAccountingUnderflow() {
underflow += 1;
},
},
});
const broker = createDuplexBridgeBroker({
channel: harness.channel,
forwardRequest: controllableForward(harness),
duplexAggregateByteLedger: ledger,
});
brokers.push(broker);
broker.start();
harness.feed(requestFrame("req-1"));
await broker.close();
// The close aborted the in-flight forward and orphaned it. The forward now
// settles after the close: its finally owner releases the request tokens once.
harness.forwards[0]?.reject(new Error("aborted"));
await flush();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
// No accounting defect: every token released exactly one time.
expect(underflow).toBe(0);
});
});

View File

@ -42,6 +42,11 @@
*/
import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
type DuplexAggregateByteLedger,
type ReservationToken,
} from "./duplex-aggregate-byte-ledger.js";
import {
DEFAULT_MAX_DUPLEX_FRAME_BYTES,
DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES,
@ -169,6 +174,17 @@ export const DEFAULT_DUPLEX_BROKER_MAX_IN_FLIGHT_REQUESTS = 64;
*/
export const DEFAULT_DUPLEX_BROKER_MAX_LIFETIME_REQUESTS = 50_000;
/**
* The fixed, documented per-entry allocation the broker charges the aggregate byte
* ledger for one no-replay request-id set entry. The type and the cardinality of
* `seenRequestIds` are bounded: the codec caps each id at
* {@link DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES}, and the lifetime limit caps the
* entry count. The broker charges the exact raw id bytes plus this fixed entry
* overhead, so the retained set never grows uncharged. This constant models the
* fixed per-entry cost of the string key and the Set slot, not the id bytes.
*/
export const DUPLEX_SEEN_REQUEST_ID_SET_ENTRY_BYTES = 64;
/** The result of one forward call. The broker turns it into one response frame. */
export interface DuplexBrokerForwardResult {
status: number;
@ -250,6 +266,17 @@ export interface DuplexBrokerOptions {
* raw error rides a span or a counter. The default records nothing.
*/
telemetry?: DuplexTelemetry;
/**
* The process-owned aggregate byte ledger. The broker reserves the exact retained
* bytes of each dispatched request against it before it retains the frame: the
* raw request frame, the normalized request payload, and the no-replay set entry.
* A reservation that would pass the ceiling makes the broker retain nothing and
* refuse the request with the fixed marker
* {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED}. When the ledger is absent the
* broker charges nothing and behaves as before, so a non-duplex or a legacy path
* stays unchanged.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}
/** The broker handle the factory returns. */
@ -358,6 +385,27 @@ interface PendingRequest {
responseTimer: ReturnType<typeof setTimeout>;
/** The point the broker started to dispatch the request. It sets the span latency. */
dispatchStartMs: number;
/** True once the forward promise settled. The finally owner sets it one time. */
forwardSettled: boolean;
/**
* Release the request-frame token and the request-payload token exactly one time.
* The single forward-promise finally owner calls it after the forward settles.
* A second call is a no-op, so no token releases twice.
*/
releaseForwardTokens: () => void;
}
/**
* One orphaned forward. The broker answered the gateway and removed the request
* from `pending`, but the forward promise or its response-body reader had not
* settled. The orphan keeps the request tokens charged and the controller live
* until the forward finally releases them, so the ledger reports nonzero ownership
* until the async work settles.
*/
interface OrphanedForward {
controller: AbortController;
/** Release the request-frame and request-payload tokens exactly one time. */
releaseForwardTokens: () => void;
}
/**
@ -382,12 +430,42 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_DUPLEX_BROKER_HEARTBEAT_INTERVAL_MS;
const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_DUPLEX_BROKER_CLOSE_TIMEOUT_MS;
const now = options.now ?? (() => Date.now());
// The process-owned aggregate byte ledger, or `null` when the caller injected
// none. When `null` the broker charges nothing and behaves as before.
const ledger = options.duplexAggregateByteLedger ?? null;
// The one frame size bound the broker enforces on both sides. The decoder
// rejects an inbound frame over this bound, and the encode guard refuses to
// write an outbound frame over it. Encode and decode share one value, so a
// frame the broker writes always decodes on the peer.
const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_DUPLEX_FRAME_BYTES;
const decoder = new DuplexFrameDecoder({ maxFrameBytes });
// The decoder charges its retained partial-frame bytes against the same host
// ledger. It receives the ledger object directly, so one gauge bounds the
// decoder buffer with every other host retention site.
const decoder = new DuplexFrameDecoder({
maxFrameBytes,
...(ledger ? { aggregateByteLedger: ledger } : {}),
});
// Release one token exactly one time. A `null` token or an absent ledger is a
// no-op, so the broker never records a false accounting defect.
const releaseToken = (token: ReservationToken | null): void => {
if (ledger && token) ledger.release(token);
};
// The byte size of the normalized request payload the broker retains across the
// forward. It counts the exact retained scalar and header bytes, so the charge
// matches the retained bytes and never a parsed object graph.
const requestPayloadBytes = (frame: DuplexRequestFrame): number => {
let bytes =
Buffer.byteLength(frame.id, "utf8") +
Buffer.byteLength(frame.method, "utf8") +
Buffer.byteLength(frame.path, "utf8") +
Buffer.byteLength(frame.query, "utf8") +
Buffer.byteLength(frame.body, "utf8");
for (const [key, value] of Object.entries(frame.headers)) {
bytes += Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8");
}
return bytes;
};
let state: DuplexBrokerState = "opening";
let stopped = false;
@ -432,7 +510,24 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
// The ids the broker already dispatched. The broker forwards one id one time,
// so a repeated frame never reaches the API twice.
const seenRequestIds = new Set<string>();
// The aggregate-ledger tokens for the no-replay set entries. The broker holds
// one token per live set entry and releases every token one time at terminal
// teardown, so the retained set never leaves bytes charged after the channel
// ends.
const seenRequestIdTokens = new Set<ReservationToken>();
const pending = new Map<string, PendingRequest>();
// The forwards that answered the gateway but whose async work has not settled.
// The broker keeps their tokens charged and their controller live until each
// forward finally releases its tokens, so the ledger reports the real ownership.
const orphanedForwards = new Map<string, OrphanedForward>();
// Release every no-replay set-entry token one time and clear the set. The
// ledger release is one way, and the cleared set stops any second release, so
// this helper is safe to call more than one time at terminal teardown.
const releaseSeenRequestIdTokens = (): void => {
for (const token of seenRequestIdTokens) releaseToken(token);
seenRequestIdTokens.clear();
};
const setState = (next: DuplexBrokerState): void => {
if (state === next) return;
@ -448,12 +543,27 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
};
const clearPending = (): void => {
for (const entry of pending.values()) {
for (const [id, entry] of pending) {
clearTimeout(entry.forwardTimer);
clearTimeout(entry.responseTimer);
entry.controller.abort(new Error("Duplex broker stopped."));
// A forward that has not settled still owns its request tokens. Transfer it
// to the orphan registry, so its tokens stay charged until the forward
// finally releases them. The abort only asks the forward to stop; it never
// releases a token by itself.
if (!entry.forwardSettled) {
orphanedForwards.set(id, {
controller: entry.controller,
releaseForwardTokens: entry.releaseForwardTokens,
});
}
}
pending.clear();
// Ask every already-orphaned forward to stop as well. The forward-promise
// finally owner releases each orphan token when the forward settles.
for (const orphan of orphanedForwards.values()) {
orphan.controller.abort(new Error("Duplex broker stopped."));
}
};
const recordLoss = (reason: DuplexBrokerLossReason, message: string): void => {
@ -470,6 +580,8 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
stopped = true;
clearHeartbeat();
clearPending();
releaseSeenRequestIdTokens();
decoder.dispose();
if (state !== "closing") setState("closed");
return;
}
@ -493,6 +605,8 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
}
clearHeartbeat();
clearPending();
releaseSeenRequestIdTokens();
decoder.dispose();
lossRecord = { reason, message, atMs: now() };
setState("lost");
// Log the internal reason only. The broker never writes the raw provider
@ -600,6 +714,17 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
clearTimeout(entry.forwardTimer);
clearTimeout(entry.responseTimer);
pending.delete(id);
// The response timer, an abort, or a broker loss can answer the gateway before
// the forward promise settles. When the forward has not settled, transfer it to
// the orphan registry so its request tokens stay charged until the forward
// finally releases them. A settled forward already released or will release
// through its finally owner, so it needs no orphan record.
if (!entry.forwardSettled) {
orphanedForwards.set(id, {
controller: entry.controller,
releaseForwardTokens: entry.releaseForwardTokens,
});
}
// Do not write on a lost or closed channel. The gateway answers its own
// outstanding request on loss, so a late write would go to a dead channel.
if (state !== "open") return;
@ -662,6 +787,34 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
writeFrame(frame);
};
const respondAggregateExceeded = (id: string): void => {
// Answer a request the aggregate byte ledger refused with a bounded terminal
// response. The broker reserved nothing, added no id to the seen set, and made
// no controller, timer, or forward, so the host API stays untouched. The
// response carries the fixed marker only; it holds no route, query, body, or
// token. The `unavailable` outcome tells the gateway this is not a delivered
// host response. The refusal is retryable, because the broker did not retain
// the id: the aggregate pressure can ease, and a resend can then get through.
if (state !== "open") return;
const frame: DuplexResponseFrame = {
version: DUPLEX_FRAME_VERSION,
type: "response",
id,
status: 503,
headers: {
"content-type": "application/json",
"x-paperclip-bridge-outcome": "unavailable",
},
body: JSON.stringify({
error: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
outcome: "unavailable",
retryable: true,
}),
outcome: "unavailable",
};
writeFrame(frame);
};
const dispatch = (frame: DuplexRequestFrame): void => {
// Dispatch only while open. After loss or close the broker forwards nothing.
if (state !== "open") return;
@ -699,7 +852,54 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
respondSaturated(frame.id, true);
return;
}
// Reserve the exact retained bytes against the aggregate ledger before the
// broker retains anything. It reserves three tokens in order: the raw request
// frame, the normalized request payload, and the no-replay set entry. A
// reservation that would pass the ceiling makes the broker retain nothing,
// release the tokens it already took, and refuse the request with the fixed
// marker. The broker adds no id to the seen set and makes no controller, timer,
// or forward, so the host API stays untouched. When the ledger is absent all
// tokens stay `null` and the broker behaves as before.
let requestFrameToken: ReservationToken | null = null;
let requestPayloadToken: ReservationToken | null = null;
let seenRequestIdToken: ReservationToken | null = null;
if (ledger) {
const rawFrameBytes = Buffer.byteLength(encodeDuplexFrame(frame), "utf8");
requestFrameToken = ledger.reserve("request_frame", rawFrameBytes);
if (!requestFrameToken) {
respondAggregateExceeded(frame.id);
return;
}
requestPayloadToken = ledger.reserve("request_payload", requestPayloadBytes(frame));
if (!requestPayloadToken) {
ledger.release(requestFrameToken);
respondAggregateExceeded(frame.id);
return;
}
const seenEntryBytes =
Buffer.byteLength(frame.id, "utf8") + DUPLEX_SEEN_REQUEST_ID_SET_ENTRY_BYTES;
seenRequestIdToken = ledger.reserve("seen_request_id", seenEntryBytes);
if (!seenRequestIdToken) {
ledger.release(requestFrameToken);
ledger.release(requestPayloadToken);
respondAggregateExceeded(frame.id);
return;
}
}
seenRequestIds.add(frame.id);
if (seenRequestIdToken) seenRequestIdTokens.add(seenRequestIdToken);
// Release the request-frame and the request-payload tokens exactly one time.
// The single forward-promise finally owner calls it after the forward settles.
// The seen-id token stays charged for the channel lifetime, so it is not part
// of this release; the terminal teardown releases it.
let forwardTokensReleased = false;
const releaseForwardTokens = (): void => {
if (forwardTokensReleased) return;
forwardTokensReleased = true;
releaseToken(requestFrameToken);
releaseToken(requestPayloadToken);
};
const record: DuplexBrokerRequestRecord = {
id: frame.id,
@ -770,23 +970,53 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
forwardTimer,
responseTimer,
dispatchStartMs: record.dispatchStartMs,
forwardSettled: false,
releaseForwardTokens,
});
forwardRequest(frame, { signal: controller.signal }).then(
(result) => {
// Keep the outcome classification consistent with the file path. A
// possibly-committed mutation carries the indeterminate marker header, so
// map it to the indeterminate outcome. Any other result is completed.
const outcome: DuplexResponseOutcome =
result.headers?.["x-paperclip-bridge-outcome"] === "indeterminate"
? "indeterminate"
: "completed";
// The host delivered a real response, so the request span outcome is `ok`.
// A host application status (200, a 4xx, a 5xx) is still a delivered
// response; only a broker-synthesized failure below is `error`.
respond(frame.id, result, outcome, "ok");
},
(error) => {
// The forward promise has one cleanup owner. The two settle handlers mark the
// forward settled and answer the gateway. The `finally` then releases the
// request tokens exactly one time and removes the request from both the pending
// map and the orphan map. The forward resolves only after the forward and its
// response-body reader both settle, so this owner waits for both.
const markForwardSettled = (): void => {
// Mark the pending entry settled before `respond` runs, so `respond` never
// moves a just-settled forward to the orphan registry. An already-orphaned
// forward needs no flag; its finally owner releases and drains it.
const entry = pending.get(frame.id);
if (entry) entry.forwardSettled = true;
};
forwardRequest(frame, { signal: controller.signal })
.then(
(result) => {
markForwardSettled();
// Keep the outcome classification consistent with the file path. A
// possibly-committed mutation carries the indeterminate marker header, so
// map it to the indeterminate outcome. Any other result is completed.
const outcome: DuplexResponseOutcome =
result.headers?.["x-paperclip-bridge-outcome"] === "indeterminate"
? "indeterminate"
: "completed";
// The host delivered a real response, so the request span outcome is `ok`.
// A host application status (200, a 4xx, a 5xx) is still a delivered
// response; only a broker-synthesized failure below is `error`.
respond(frame.id, result, outcome, "ok");
},
(error) => {
markForwardSettled();
return handleForwardRejection(error);
},
)
.finally(() => {
pending.delete(frame.id);
orphanedForwards.delete(frame.id);
releaseForwardTokens();
});
// The forward rejection handler. It classifies the rejection by method safety
// and answers the gateway. The `markForwardSettled` call above runs first, so
// the response never re-orphans a settled forward.
function handleForwardRejection(error: unknown): void {
if (controller.signal.aborted) {
// The forward budget aborted the call. A safe method never changes
// host state, so a forward timeout stays retryable for it. Return a
@ -873,8 +1103,7 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
"indeterminate",
"error",
);
},
);
}
};
const handleFrame = (frame: DuplexFrame): void => {
@ -947,6 +1176,8 @@ export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBr
setState("closing");
clearHeartbeat();
clearPending();
releaseSeenRequestIdTokens();
decoder.dispose();
// Send an orderly close frame. Ignore a write failure here; the broker is
// already closing, so a dead channel needs no loss record.
try {

View File

@ -12,6 +12,7 @@ import {
type DuplexDecodeResult,
type DuplexFrame,
} from "./duplex-frame-codec.js";
import { DuplexAggregateByteLedger } from "./duplex-aggregate-byte-ledger.js";
// One expected decode result in the fixture: either a decoded frame or the code
// of a protocol error. The codec must never throw on the read path.
@ -322,6 +323,117 @@ describe("request id byte bound", () => {
});
});
describe("decoder aggregate byte ledger charging", () => {
// The host injects the process-owned ledger into the decoder. The decoder
// charges the raw partial-frame bytes it retains between chunks, plus the peak
// replacement buffer it allocates on concat. It never retains an uncharged
// buffer, and it fails closed when a reservation would pass the ceiling.
function makeLedger(ceilingBytes = 1024): DuplexAggregateByteLedger {
return new DuplexAggregateByteLedger({ ceilingBytes });
}
it("charges the retained partial frame, then releases it when the frame completes", () => {
const ledger = makeLedger();
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const bytes = Buffer.from(line, "utf8");
// A partial frame with no newline stays retained and stays charged.
const first = decoder.push(bytes.subarray(0, 3));
expect(first).toHaveLength(0);
expect(ledger.bytesInUse).toBe(3);
expect(ledger.liveTokenCount).toBe(1);
// The completing chunk delivers the frame and releases the retained bytes.
const second = decoder.push(bytes.subarray(3));
expect(second).toHaveLength(1);
expect(second[0].ok).toBe(true);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("covers the peak concat buffer and settles on the remaining bytes", () => {
const ledger = makeLedger();
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
const first = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const partial = '{"version":1,"type":"hea';
// One full frame plus a partial second frame arrive in one chunk. The full
// frame decodes and releases; the partial tail stays charged at its exact size.
const results = decoder.push(Buffer.from(first + partial, "utf8"));
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(true);
expect(ledger.bytesInUse).toBe(Buffer.byteLength(partial, "utf8"));
expect(ledger.liveTokenCount).toBe(1);
});
it("fails closed when a chunk would pass the ceiling and retains nothing", () => {
const ledger = makeLedger(16);
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
const results = decoder.push(Buffer.from("z".repeat(64), "utf8"));
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(false);
if (!results[0].ok) {
expect(results[0].error.code).toBe("aggregate_bytes_exceeded");
}
// The decoder retained nothing, so the ledger stays at zero.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("resynchronizes on the next newline after an aggregate rejection", () => {
const ledger = makeLedger(64);
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
// A first chunk over the ceiling fails closed.
const rejected = decoder.push(Buffer.from("z".repeat(128), "utf8"));
expect(rejected[0].ok).toBe(false);
// The next chunk starts with a newline, so the decoder drops the stale tail
// and decodes the good frame that follows.
const good = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const results = decoder.push(Buffer.from(`\n${good}`, "utf8"));
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(true);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("releases the retained token when an oversized frame is discarded", () => {
const ledger = makeLedger();
const decoder = new DuplexFrameDecoder({ maxFrameBytes: 16, aggregateByteLedger: ledger });
// An oversized frame with no newline is discarded and its bytes are released.
const results = decoder.push(Buffer.from("z".repeat(64), "utf8"));
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(false);
if (!results[0].ok) expect(results[0].error.code).toBe("frame_too_large");
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("releases the retained partial frame on dispose", () => {
const ledger = makeLedger();
const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
decoder.push(Buffer.from('{"version":1', "utf8"));
expect(ledger.bytesInUse).toBeGreaterThan(0);
decoder.dispose();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
// A second dispose is a no-op and records no accounting defect.
decoder.dispose();
expect(ledger.bytesInUse).toBe(0);
});
it("charges nothing when the caller injects no ledger", () => {
const decoder = new DuplexFrameDecoder();
const results = decoder.push(Buffer.from('{"version":1', "utf8"));
expect(results).toHaveLength(0);
// No ledger means no charge; the decoder still buffers and decodes as before.
const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const done = decoder.push(Buffer.from(`}\n${line}`, "utf8"));
// The stitched first frame is malformed JSON; the second is a valid heartbeat.
expect(done.some((result) => result.ok)).toBe(true);
});
});
describe("size-checked encode", () => {
it("runs every shared encode vector and matches the expected result", () => {
// Every codec copy runs the same encode vectors. This copy proves it enforces

View File

@ -17,6 +17,12 @@
* keeps one bad frame from crashing the read loop.
*/
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
type DuplexAggregateByteLedger,
type ReservationToken,
} from "./duplex-aggregate-byte-ledger.js";
/** The wire version this codec reads and writes. */
export const DUPLEX_FRAME_VERSION = 1;
@ -134,7 +140,8 @@ export type DuplexProtocolErrorCode =
| "unknown_type"
| "version_mismatch"
| "frame_too_large"
| "id_too_large";
| "id_too_large"
| "aggregate_bytes_exceeded";
/** A decode-time protocol error. The read path returns it; it never throws. */
export interface DuplexProtocolError {
@ -344,6 +351,18 @@ function validateError(frame: Record<string, unknown>): DuplexDecodeResult {
export interface DuplexFrameDecoderOptions {
/** The maximum size of one frame, in bytes. Defaults to {@link DEFAULT_MAX_DUPLEX_FRAME_BYTES}. */
maxFrameBytes?: number;
/**
* The process-owned aggregate byte ledger. When present, the decoder reserves
* the exact bytes of the raw partial frame it retains between chunks, and the
* peak replacement buffer it allocates on concat, against this ledger under the
* `decoder_buffer` owner. The host injects the same ledger object it injects at
* every other retention site, so one gauge bounds the aggregate retained bytes.
* When absent the decoder charges nothing and behaves as before.
*
* The generated sandbox decoder runs in a separate operating-system process. It
* cannot share this host ledger. It receives its own separate cap instead.
*/
aggregateByteLedger?: DuplexAggregateByteLedger;
}
/**
@ -364,15 +383,41 @@ export class DuplexFrameDecoder {
private buffer: Buffer = EMPTY;
private discarding = false;
private readonly maxFrameBytes: number;
private readonly ledger: DuplexAggregateByteLedger | null;
/**
* The token for the bytes currently retained in `this.buffer`. Its byte amount
* equals `this.buffer.length` at the end of every `push`. It is `null` when the
* buffer is empty or the decoder has no ledger.
*/
private retainedToken: ReservationToken | null = null;
constructor(options: DuplexFrameDecoderOptions = {}) {
this.maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_DUPLEX_FRAME_BYTES;
this.ledger = options.aggregateByteLedger ?? null;
}
/** Feed one chunk. Return the frames and protocol errors that complete on it. */
push(chunk: Buffer | Uint8Array | string): DuplexDecodeResult[] {
const incoming =
typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
// Reserve the incoming bytes before the concat allocates the replacement
// buffer. The retained token already covers the current buffer, so the two
// tokens together cover the peak `old + incoming` allocation. A rejected
// reservation fails closed: the decoder drops the incoming chunk, releases
// the retained buffer, resynchronizes at the next newline, and reports the
// fixed aggregate rejection marker. It retains nothing uncharged.
let incomingToken: ReservationToken | null = null;
if (this.ledger && incoming.length > 0) {
incomingToken = this.ledger.reserve("decoder_buffer", incoming.length);
if (incomingToken === null) {
this.releaseRetained();
this.buffer = EMPTY;
this.discarding = true;
return [fail("aggregate_bytes_exceeded", DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED)];
}
}
this.buffer =
this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]);
@ -411,6 +456,54 @@ export class DuplexFrameDecoder {
}
results.push(decodeDuplexLine(line));
}
// Reconcile the ledger to the bytes still retained in `this.buffer`. Release
// the old retained token and the incoming token, then reserve one token for
// the remainder. The remainder is a subset of the just-released
// `old + incoming` bytes, so this reserve always fits.
this.reconcileRetained(incomingToken);
return results;
}
/**
* Release the retained-buffer token and drop the buffer. The host calls this at
* channel teardown, so the decoder never leaks a `decoder_buffer` token after
* the channel ends. A second call is a no-op, because the field is already
* `null`.
*/
dispose(): void {
this.releaseRetained();
this.buffer = EMPTY;
this.discarding = false;
}
/** Release the retained-buffer token one time and clear the field. */
private releaseRetained(): void {
if (this.ledger && this.retainedToken) {
this.ledger.release(this.retainedToken);
}
this.retainedToken = null;
}
/**
* Move the ledger charge to the bytes still in `this.buffer`. Release the old
* retained token and the `incoming` token, then reserve one token for the
* remaining bytes. A `null` reserve here is an accounting defect, because the
* remainder never passes the just-released bytes; the decoder fails closed and
* drops the buffer so it retains nothing uncharged.
*/
private reconcileRetained(incomingToken: ReservationToken | null): void {
if (!this.ledger) return;
this.releaseRetained();
if (incomingToken) {
this.ledger.release(incomingToken);
}
if (this.buffer.length > 0) {
this.retainedToken = this.ledger.reserve("decoder_buffer", this.buffer.length);
if (this.retainedToken === null) {
this.buffer = EMPTY;
this.discarding = true;
}
}
}
}

View File

@ -35,6 +35,40 @@ export const DUPLEX_COUNTER_LOSS_TOTAL = "sandbox_duplex_loss_total";
/** The guarded counter for one leaked provider session on teardown. */
export const DUPLEX_COUNTER_SESSION_LEAK_TOTAL = "sandbox_duplex_session_leak_total";
/**
* The process-scoped gauge for the aggregate retained bytes across every live
* duplex route. The host aggregate byte ledger sets it on each reserve and each
* release. The record carries no dynamic dimension.
*/
export const DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE = "sandbox_duplex_aggregate_bytes_in_use";
/**
* The counter for one rejected aggregate byte reservation. The host ledger
* increments it when a reservation would pass the aggregate ceiling. The record
* carries no dynamic dimension.
*/
export const DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL =
"sandbox_duplex_aggregate_byte_reservation_rejections_total";
/**
* The counter for one aggregate byte accounting defect. The host ledger
* increments it on a double release or a transfer of a token it does not hold.
* The record carries no dynamic dimension.
*/
export const DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL =
"sandbox_duplex_aggregate_byte_accounting_underflow_total";
/**
* The closed set of aggregate byte ledger metric names. A test pins this exact
* set, so a new ledger metric name needs an explicit review. Each record uses
* only closed constant dimensions and no dynamic label. The telemetry contract
* documents these metrics under "Aggregate byte ledger metrics" in
* `packages/shared/src/telemetry/README.md`.
*/
export const DUPLEX_AGGREGATE_BYTE_LEDGER_METRIC_NAMES = [
DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE,
DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL,
DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL,
] as const;
/**
* The closed dimension-key set. Every span attribute, counter label, and event
* field uses only these keys. A test asserts the exact set, so a new key never
@ -64,6 +98,9 @@ export type DuplexOutcomeValue = "ok" | "error";
* stage: the process-scoped route ceiling was full (`route_busy`), the entrypoint
* sync failed (`entrypoint_sync_failed`), the broker construction failed
* (`broker_construction_failed`), or the channel open failed (`channel_open_failed`).
* The `aggregate_bytes_exceeded` reason names a readiness handshake the host fell
* back because the process aggregate byte ceiling had no room for the readiness
* buffer.
*/
export type DuplexFallbackReason =
| "gate_off"
@ -75,7 +112,8 @@ export type DuplexFallbackReason =
| "ready_invalid"
| "ready_nonce_mismatch"
| "ready_timeout"
| "contaminated";
| "contaminated"
| "aggregate_bytes_exceeded";
/** The class of a terminal loss, relative to the first request dispatch. */
export type DuplexLossClass = "pre_dispatch" | "post_dispatch";

View File

@ -43,12 +43,18 @@ import {
type StartupTraceContext,
type StartupTracer,
} from "./acpx-engine/startup-timing.js";
import {
DuplexAggregateByteLedger,
DUPLEX_AGGREGATE_TOKEN_OWNERS,
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
} from "./duplex-aggregate-byte-ledger.js";
import { createSandboxRunLogTailFactory, type SandboxRunLogTailFactory } from "./sandbox-run-log-stream.js";
import { runChildProcess } from "./server-utils.js";
import { shellQuote } from "./ssh.js";
import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js";
import {
DEFAULT_MAX_DUPLEX_FRAME_BYTES,
DuplexFrameDecoder,
DUPLEX_FRAME_VERSION,
decodeDuplexLine,
encodeDuplexFrame,
@ -66,10 +72,14 @@ import {
} from "./duplex-bridge-broker.js";
import {
createDuplexTelemetry,
DUPLEX_AGGREGATE_BYTE_LEDGER_METRIC_NAMES,
DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL,
DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL,
DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL,
DUPLEX_COUNTER_FALLBACK_TOTAL,
DUPLEX_COUNTER_LOSS_TOTAL,
DUPLEX_DIMENSION_KEYS,
DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE,
DUPLEX_SPAN_CHANNEL_OPEN,
DUPLEX_SPAN_REQUEST,
DUPLEX_TRANSPORT_EVENT,
@ -2678,6 +2688,155 @@ describe("sandbox adapter execution targets", () => {
}
});
it("charges the response-body bytes against the host aggregate ledger and releases every token on success", async () => {
// The host stamps one process-owned aggregate byte ledger on the sandbox
// target. The forward response-body reader charges its retained bytes against
// that ledger. A successful read charges the chunk bytes and the
// concatenation buffer, then releases every token, so the ledger returns to
// zero after the forward completes.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-ledger-ok-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
await mkdir(runtimeRootDir, { recursive: true });
const responseBody = JSON.stringify({ id: "issue-1" });
const apiServer = createServer((_req, res) => {
res.writeHead(200, {
"content-type": "application/json",
"content-length": String(Buffer.byteLength(responseBody, "utf8")),
});
res.end(responseBody);
});
await new Promise<void>((resolve, reject) => {
apiServer.once("error", reject);
apiServer.listen(0, "127.0.0.1", () => resolve());
});
const address = apiServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected the bridge test API server to listen on a TCP port.");
}
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1024 * 1024 });
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
duplexAggregateByteLedger: ledger,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-bridge-ledger-ok",
target,
runtimeRootDir,
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
maxBodyBytes: 512,
});
try {
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/issues/issue-1`, {
method: "GET",
headers: {
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
},
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ id: "issue-1" });
// The reader released every token, so the aggregate gauge and the live-token
// registry both return to zero.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
} finally {
await bridge?.stop();
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
}
});
it("fails a response-body read closed when the host aggregate ledger has no room and retains no bytes", async () => {
// The aggregate ledger sits at a tiny ceiling, so a response body larger than
// the ceiling cannot reserve its bytes. The reader fails closed: it cancels
// the stream reader, retains nothing, and reports the fixed marker. The safe
// GET maps the marker to a retryable 502. The ledger returns to zero, because
// the reader released the tokens it held before the rejection.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-ledger-full-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
await mkdir(runtimeRootDir, { recursive: true });
// The body sits under the per-request size limit but over the aggregate
// ceiling, so the aggregate ledger, not the per-request limit, rejects it.
const responseBody = "x".repeat(256);
const apiServer = createServer((_req, res) => {
res.writeHead(200, {
"content-type": "application/json",
"content-length": String(Buffer.byteLength(responseBody, "utf8")),
});
res.end(responseBody);
});
await new Promise<void>((resolve, reject) => {
apiServer.once("error", reject);
apiServer.listen(0, "127.0.0.1", () => resolve());
});
const address = apiServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected the bridge test API server to listen on a TCP port.");
}
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 });
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
duplexAggregateByteLedger: ledger,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-bridge-ledger-full",
target,
runtimeRootDir,
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
maxBodyBytes: 4096,
});
try {
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/issues/issue-1`, {
method: "GET",
headers: {
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
},
});
// The safe GET maps the aggregate rejection to a retryable 502 with no
// indeterminate marker. The body carries only the fixed rejection marker.
expect(response.status).toBe(502);
expect(response.headers.get("x-paperclip-bridge-outcome")).toBeNull();
await expect(response.json()).resolves.toEqual({
error: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
});
// The reader released the tokens it held before the rejection, so the
// aggregate gauge and the live-token registry both return to zero.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
} finally {
await bridge?.stop();
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
}
});
it("forwards the host indeterminate-outcome header so the sandbox server maps the 504 to a non-retryable 409", async () => {
// The host marks a possibly-committed mutation with a 504 and the
// `x-paperclip-bridge-outcome: indeterminate` header. The forward must keep
@ -3130,6 +3289,66 @@ describe("sandbox adapter execution targets", () => {
expect(control.stopCount).toBeGreaterThanOrEqual(1);
}, 20000);
it("falls back to the file bridge when a post-READY pre-bind flood exceeds the aggregate ceiling", async () => {
// The gateway sends a valid READY, then floods the channel before the broker
// binds. The pre-READY buffer cap does not bound the post-READY replay buffer,
// so the replay reservation must. The ceiling admits the small READY frame but
// rejects the flood. The host drops the buffer, stops the channel, and selects
// the file bridge with the aggregate marker. No request forwards, and the
// aggregate ledger returns to zero.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-replay-flood-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const api = await startRecordingApiServer();
// The flood is larger than the ceiling; the READY frame is far smaller.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 4096 });
const { runner, control } = makeDuplexSelectionRunner((ctx) => {
ctx.emitFrame({ version: 1, type: "ready", nonce: ctx.nonce });
ctx.emitRaw("x".repeat(64 * 1024));
});
const { recorder, counters } = createRecordingDuplexRecorder();
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
duplexAggregateByteLedger: ledger,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-duplex-replay-flood",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: api.origin,
enableSandboxDuplexBridge: true,
duplexTelemetryRecorder: recorder,
});
try {
// The file bridge serves, not the duplex transport.
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
// The fallback names the aggregate marker on the file transport.
const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL);
expect(fallback?.dimensions.fallback_reason).toBe("aggregate_bytes_exceeded");
expect(fallback?.dimensions.transport).toBe("file");
// The gate stopped the flooded channel.
expect(control.stopCount).toBeGreaterThanOrEqual(1);
// No request forwarded, because the broker never bound.
expect(api.requests).toHaveLength(0);
// The aggregate ledger returns to zero with no live token.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
} finally {
await bridge?.stop();
await api.close();
}
}, 20000);
it("streams run logs on the duplex path under the same gate and log line as the file path", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-runlog-"));
cleanupDirs.push(rootDir);
@ -3583,6 +3802,24 @@ describe("sandbox adapter execution targets", () => {
]);
});
it("pins the exact aggregate byte ledger metric names", () => {
// The aggregate byte ledger metric names are closed. This test locks the
// exact set, so a new gauge or counter name needs an explicit change here.
// Each record carries only closed constant dimensions and no dynamic label.
expect([...DUPLEX_AGGREGATE_BYTE_LEDGER_METRIC_NAMES]).toEqual([
"sandbox_duplex_aggregate_bytes_in_use",
"sandbox_duplex_aggregate_byte_reservation_rejections_total",
"sandbox_duplex_aggregate_byte_accounting_underflow_total",
]);
expect(DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE).toBe("sandbox_duplex_aggregate_bytes_in_use");
expect(DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL).toBe(
"sandbox_duplex_aggregate_byte_reservation_rejections_total",
);
expect(DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL).toBe(
"sandbox_duplex_aggregate_byte_accounting_underflow_total",
);
});
it("records a duplex request span with latency and the fixed dimension keys", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-obs-"));
cleanupDirs.push(rootDir);
@ -3701,6 +3938,7 @@ describe("sandbox adapter execution targets", () => {
"ready_nonce_mismatch",
"ready_timeout",
"contaminated",
"aggregate_bytes_exceeded",
];
expect(approvedReasons).toContain(fallback?.dimensions.fallback_reason);
expect(fallback?.dimensions).toMatchObject({ transport: "file", outcome: "error" });
@ -4213,6 +4451,128 @@ describe("sandbox adapter execution targets", () => {
}
}, 20000);
it("fails the readiness handshake closed when the host aggregate ledger has no room for the pre-READY buffer", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-ready-ledger-full-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const api = await startRecordingApiServer();
// The host stamps one process-owned aggregate byte ledger on the sandbox
// target at a tiny ceiling. The fake gateway sends a pre-READY blob larger
// than the ceiling, so the gate cannot reserve the blob bytes. The gate fails
// closed: it retains nothing, records the aggregate fallback reason, and falls
// back to the file bridge. The blob is smaller than the readiness buffer cap,
// so the aggregate ledger, not the buffer cap, drives the failure.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 256 });
const preReadyBlob = "x".repeat(4_096);
const { runner, control } = makeDuplexSelectionRunner((ctx) => {
ctx.emitRaw(preReadyBlob);
});
const { recorder, counters } = createRecordingDuplexRecorder();
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
duplexAggregateByteLedger: ledger,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-ready-ledger-full",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: api.origin,
enableSandboxDuplexBridge: true,
// A long readiness timeout, so the aggregate ledger, not the timeout, drives
// the failure.
duplexReadinessTimeoutMs: 5_000,
duplexTelemetryRecorder: recorder,
});
try {
expect(bridge).not.toBeNull();
expect(control.openCount).toBe(1);
// The aggregate rejection drove the failure, so the file bridge serves.
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL);
expect(fallback?.dimensions.fallback_reason).toBe("aggregate_bytes_exceeded");
// The gate retained nothing after the rejection, so the aggregate gauge and
// the live-token registry both return to zero.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1);
} finally {
await bridge?.stop();
await api.close();
}
}, 20000);
it("charges the pre-READY buffer against the injected host ledger and releases it when readiness passes", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-ready-ledger-ok-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const api = await startRecordingApiServer();
// The host stamps one process-owned aggregate byte ledger on the sandbox
// target at a generous ceiling. The fake gateway sends one pre-READY noise
// line, then the valid READY frame. The gate charges the noise bytes against
// the injected ledger, passes readiness, and releases the pre-READY tokens.
// The broker's frame decoder re-charges any post-READY bytes, so the ledger
// returns to zero after the handshake.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1024 * 1024 });
const reserveSpy = vi.spyOn(ledger, "reserve");
const { runner, control } = makeDuplexSelectionRunner((ctx) => {
ctx.emitRaw("pty-echo-noise");
ctx.emitFrame({ version: 1, type: "ready", nonce: ctx.nonce });
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
duplexAggregateByteLedger: ledger,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-ready-ledger-ok",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: api.origin,
enableSandboxDuplexBridge: true,
duplexReadinessTimeoutMs: 5_000,
});
try {
expect(bridge).not.toBeNull();
// Readiness passed, so the duplex transport serves.
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1");
// The gate charged the pre-READY noise against the exact injected ledger, so
// the identity holds at this seam.
expect(reserveSpy).toHaveBeenCalledWith("readiness_buffer", expect.any(Number));
// The gate released every readiness-buffer token on settle, so the aggregate
// gauge and the live-token registry both return to zero.
await waitForCondition(
() => ledger.bytesInUse === 0 && ledger.liveTokenCount === 0,
"the readiness gate to release every pre-READY token",
4000,
);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
} finally {
reserveSpy.mockRestore();
await bridge?.stop();
await api.close();
}
}, 20000);
it("bounds the pre-READY newline-scan work by the bytes received", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-scan-"));
cleanupDirs.push(rootDir);
@ -4850,11 +5210,16 @@ interface EmbeddedCodec {
encodeDuplexFrame: (frame: unknown) => string;
encodeDuplexFrameChecked: (frame: unknown, maxFrameBytes?: number) => EmbeddedEncodeResult;
decodeDuplexLine: (line: string | Buffer) => EmbeddedDecodeResult;
DuplexFrameDecoder: new (options?: { maxFrameBytes?: number }) => {
DuplexFrameDecoder: new (options?: { maxFrameBytes?: number; maxAggregateBytes?: number }) => {
push: (chunk: Buffer) => EmbeddedDecodeResult[];
scope: string;
bytesInUse: number;
aggregateRejections: number;
};
DUPLEX_FRAME_VERSION: number;
DEFAULT_MAX_DUPLEX_FRAME_BYTES: number;
DUPLEX_DECODER_SCOPE: string;
DEFAULT_MAX_DUPLEX_DECODER_BYTES: number;
}
type ExpectedVectorResult = { frame: unknown } | { error: string };
@ -5099,7 +5464,7 @@ describe("sandbox duplex gateway", () => {
it("embedded gateway codec passes every vector in the shared fixture", async () => {
const codecFactory = new Function(
`${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, encodeDuplexFrameChecked, decodeDuplexLine, DuplexFrameDecoder, DUPLEX_FRAME_VERSION, DEFAULT_MAX_DUPLEX_FRAME_BYTES };`,
`${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, encodeDuplexFrameChecked, decodeDuplexLine, DuplexFrameDecoder, DUPLEX_FRAME_VERSION, DEFAULT_MAX_DUPLEX_FRAME_BYTES, DUPLEX_DECODER_SCOPE, DEFAULT_MAX_DUPLEX_DECODER_BYTES };`,
) as unknown as () => EmbeddedCodec;
const codec = codecFactory();
@ -5199,6 +5564,61 @@ describe("sandbox duplex gateway", () => {
expect(encodeFailures).toEqual([]);
});
it("bounds the sandbox decoder with a separate sandbox_process scope, distinct from the host ledger scope", () => {
// The generated gateway runs in a separate operating-system process, so its
// decoder cannot share the host aggregate byte ledger. It enforces a separate
// local cap under the `sandbox_process` scope. This test wraps the embedded
// source and the host decoder, then proves the two scopes never overlap and the
// sandbox cap fails closed.
const codecFactory = new Function(
`${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, decodeDuplexLine, DuplexFrameDecoder, DUPLEX_FRAME_VERSION, DEFAULT_MAX_DUPLEX_FRAME_BYTES, DUPLEX_DECODER_SCOPE, DEFAULT_MAX_DUPLEX_DECODER_BYTES };`,
) as unknown as () => EmbeddedCodec;
const codec = codecFactory();
// The sandbox scope is the fixed `sandbox_process` label.
expect(codec.DUPLEX_DECODER_SCOPE).toBe("sandbox_process");
expect(codec.DEFAULT_MAX_DUPLEX_DECODER_BYTES).toBeGreaterThan(codec.DEFAULT_MAX_DUPLEX_FRAME_BYTES);
// The two scopes are distinct: the host owner set never carries the sandbox
// scope, so the sandbox counter can never map to a host aggregate token.
expect((DUPLEX_AGGREGATE_TOKEN_OWNERS as readonly string[]).includes("sandbox_process")).toBe(false);
// The sandbox decoder tracks its own `sandbox_process` counter. A frame under
// the cap charges the local counter, and the counter returns to zero once the
// frame drains.
const sandboxDecoder = new codec.DuplexFrameDecoder({ maxAggregateBytes: 64 });
expect(sandboxDecoder.scope).toBe("sandbox_process");
const partial = sandboxDecoder.push(Buffer.from('{"version":1,', "utf8"));
expect(partial).toEqual([]);
expect(sandboxDecoder.bytesInUse).toBe(Buffer.byteLength('{"version":1,', "utf8"));
sandboxDecoder.push(Buffer.from('"type":"heartbeat"}\n', "utf8"));
expect(sandboxDecoder.bytesInUse).toBe(0);
// A chunk over the local cap fails closed. The decoder retains nothing, reports
// the aggregate rejection, and increments only its local `sandbox_process`
// counter.
const cappedDecoder = new codec.DuplexFrameDecoder({ maxAggregateBytes: 8 });
const overCap = cappedDecoder.push(Buffer.from("x".repeat(64), "utf8"));
expect(overCap.length).toBe(1);
const rejection = overCap[0];
expect(rejection.ok).toBe(false);
expect(rejection.error?.code).toBe("aggregate_bytes_exceeded");
expect(cappedDecoder.bytesInUse).toBe(0);
expect(cappedDecoder.aggregateRejections).toBe(1);
// The host decoder charges the host aggregate byte ledger under the
// `decoder_buffer` owner. It never uses the sandbox scope. This proves the two
// implementations use distinct scopes: the host uses the injected ledger; the
// sandbox uses its local counter.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1024 });
const hostDecoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger });
hostDecoder.push(Buffer.from('{"version":1,', "utf8"));
expect(ledger.bytesInUse).toBe(Buffer.byteLength('{"version":1,', "utf8"));
expect(ledger.liveTokenCount).toBeGreaterThan(0);
hostDecoder.dispose();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
it("returns the same HTTP response as the file gateway for a forwarded request", async () => {
const token = "duplex-token-forward";
const gateway = await startDuplexGateway({ PAPERCLIP_BRIDGE_TOKEN: token });
@ -6172,3 +6592,213 @@ describe("CLI-lane run-disposition seam", () => {
expect(settleCalls).toBe(0);
});
});
describe("duplex readiness gate replay-buffer reservation", () => {
const READY_NONCE = "0123456789abcdef0123456789abcdef";
// A fake duplex channel the test drives directly. `control.emitData` re-enters
// the data listener the gate bound at construction. `control.emitExit` re-enters
// the exit listener. The fake records the stop and the close calls.
function makeFakeReadinessChannel(): {
channel: CommandManagedDuplexChannel;
control: {
stopCount: number;
closeCount: number;
written: string[];
emitData: (chunk: string) => void;
emitExit: (exit: { exitCode: number | null }) => void;
};
} {
let dataListener: ((chunk: string) => void) | null = null;
let exitListener: ((exit: { exitCode: number | null }) => void) | null = null;
const control = {
stopCount: 0,
closeCount: 0,
written: [] as string[],
emitData: (chunk: string): void => dataListener?.(chunk),
emitExit: (exit: { exitCode: number | null }): void => exitListener?.(exit),
};
const channel: CommandManagedDuplexChannel = {
write(data: string): void {
control.written.push(data);
},
onData(listener: (chunk: string) => void): void {
dataListener = listener;
},
onExit(listener: (exit: { exitCode: number | null }) => void): void {
exitListener = listener;
},
stop(): void {
control.stopCount += 1;
},
close(): Promise<void> {
control.closeCount += 1;
return Promise.resolve();
},
};
return { channel, control };
}
// A ledger that counts the reservation-rejection and the accounting-underflow
// signals, so a test proves the one-owner-one-release invariant holds.
function makeCountingLedger(ceilingBytes: number): {
ledger: DuplexAggregateByteLedger;
counts: { rejections: number; underflows: number };
} {
const counts = { rejections: 0, underflows: 0 };
const ledger = new DuplexAggregateByteLedger({
ceilingBytes,
telemetry: {
setBytesInUse(): void {},
recordReservationRejection(): void {
counts.rejections += 1;
},
recordAccountingUnderflow(): void {
counts.underflows += 1;
},
},
});
return { ledger, counts };
}
function readyLine(): string {
return `${JSON.stringify({ version: 1, type: "ready", nonce: READY_NONCE })}\n`;
}
it("charges the post-READY suffix and releases it after the broker handoff", async () => {
const { channel, control } = makeFakeReadinessChannel();
const { ledger, counts } = makeCountingLedger(1024 * 1024);
const gate = __duplexReadinessTesting.createReadinessGate(channel, {
nonce: READY_NONCE,
timeoutMs: 5_000,
ledger,
});
const suffix = "hello-post-ready-suffix";
// The READY line and the suffix arrive in one chunk. The gate drops the whole
// pre-READY buffer charge, then charges only the retained suffix.
control.emitData(`${readyLine()}${suffix}`);
const readiness = await gate.ready;
expect(readiness.ok).toBe(true);
expect(gate.replayOverflowed()).toBe(false);
// The gate holds the suffix under one readiness_replay token before the bind.
expect(ledger.bytesInUse).toBe(Buffer.byteLength(suffix, "utf8"));
expect(ledger.liveTokenCount).toBe(1);
// The broker binds and replays the suffix; the gate releases the token after
// the synchronous handoff.
const replayed: string[] = [];
gate.brokerChannel.onData((chunk) => replayed.push(chunk));
expect(replayed).toEqual([suffix]);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(counts.underflows).toBe(0);
});
it("releases the suffix token on disposePendingReplay without a broker handoff", async () => {
const { channel, control } = makeFakeReadinessChannel();
const { ledger, counts } = makeCountingLedger(1024 * 1024);
const gate = __duplexReadinessTesting.createReadinessGate(channel, {
nonce: READY_NONCE,
timeoutMs: 5_000,
ledger,
});
const suffix = "abandoned-suffix";
control.emitData(`${readyLine()}${suffix}`);
expect((await gate.ready).ok).toBe(true);
expect(ledger.bytesInUse).toBe(Buffer.byteLength(suffix, "utf8"));
// A broker-construction failure abandons the buffer, so the caller disposes it.
gate.disposePendingReplay();
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
// A second dispose is a no-op and never underflows.
gate.disposePendingReplay();
expect(ledger.bytesInUse).toBe(0);
expect(counts.underflows).toBe(0);
});
it("releases the suffix token after a pre-bind exit then a broker handoff", async () => {
const { channel, control } = makeFakeReadinessChannel();
const { ledger, counts } = makeCountingLedger(1024 * 1024);
const gate = __duplexReadinessTesting.createReadinessGate(channel, {
nonce: READY_NONCE,
timeoutMs: 5_000,
ledger,
});
const suffix = "pre-bind-exit-suffix";
control.emitData(`${readyLine()}${suffix}`);
expect((await gate.ready).ok).toBe(true);
// The channel exits after READY but before the broker binds. The gate holds the
// exit and keeps the pending suffix charged.
control.emitExit({ exitCode: 0 });
expect(ledger.bytesInUse).toBe(Buffer.byteLength(suffix, "utf8"));
expect(ledger.liveTokenCount).toBe(1);
// The broker binds, replays the suffix and the exit, then the gate releases the
// reservation.
const replayed: string[] = [];
const exits: Array<{ exitCode: number | null }> = [];
gate.brokerChannel.onData((chunk) => replayed.push(chunk));
gate.brokerChannel.onExit((exit) => exits.push(exit));
expect(replayed).toEqual([suffix]);
expect(exits).toEqual([{ exitCode: 0 }]);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(counts.underflows).toBe(0);
});
it("fails closed when a post-READY pre-bind chunk floods past the ceiling", async () => {
const { channel, control } = makeFakeReadinessChannel();
// The ceiling admits the small READY line but not the flood chunk.
const { ledger, counts } = makeCountingLedger(256);
const gate = __duplexReadinessTesting.createReadinessGate(channel, {
nonce: READY_NONCE,
timeoutMs: 5_000,
ledger,
});
// The READY line arrives alone, so the pending suffix starts empty.
control.emitData(readyLine());
expect((await gate.ready).ok).toBe(true);
expect(ledger.bytesInUse).toBe(0);
// A post-READY chunk larger than the ceiling floods the replay buffer before
// the broker binds. The gate refuses the reservation and fails closed.
control.emitData("x".repeat(512));
expect(gate.replayOverflowed()).toBe(true);
expect(control.stopCount).toBe(1);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(counts.rejections).toBe(1);
expect(counts.underflows).toBe(0);
// Binding the broker replays nothing, because the gate dropped the buffer.
const replayed: string[] = [];
gate.brokerChannel.onData((chunk) => replayed.push(chunk));
expect(replayed).toEqual([]);
});
it("drops the retained pre-READY buffer on READY acceptance", async () => {
const { channel, control } = makeFakeReadinessChannel();
const { ledger, counts } = makeCountingLedger(1024 * 1024);
const gate = __duplexReadinessTesting.createReadinessGate(channel, {
nonce: READY_NONCE,
timeoutMs: 5_000,
ledger,
});
// A large pre-READY noise line and a large suffix arrive with the READY line
// in one chunk. A sandbox controls every byte here.
const noise = `${"n".repeat(4096)}\n`;
const suffix = "s".repeat(2048);
control.emitData(`${noise}${readyLine()}${suffix}`);
expect((await gate.ready).ok).toBe(true);
// The gate drops the pre-READY buffer, so the process no longer retains the
// noise prefix. Without this, the process holds the full sandbox string while
// the ledger charges only the suffix, so retention passes the ceiling.
expect(gate.retainedReadinessBufferLength()).toBe(0);
// The ledger charges only the retained suffix, not the dropped prefix.
expect(ledger.bytesInUse).toBe(Buffer.byteLength(suffix, "utf8"));
expect(ledger.liveTokenCount).toBe(1);
// The broker binds, replays the suffix, and the gate releases the token.
const replayed: string[] = [];
gate.brokerChannel.onData((chunk) => replayed.push(chunk));
expect(replayed).toEqual([suffix]);
expect(ledger.bytesInUse).toBe(0);
expect(gate.retainedReadinessBufferLength()).toBe(0);
expect(counts.underflows).toBe(0);
});
});

View File

@ -29,6 +29,7 @@ import {
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES,
DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES,
SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE,
SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT,
sandboxCallbackBridgeDirectories,
@ -63,6 +64,11 @@ import {
type DuplexFallbackReason,
type DuplexTelemetryRecorder,
} from "./duplex-telemetry.js";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
type DuplexAggregateByteLedger,
type ReservationToken,
} from "./duplex-aggregate-byte-ledger.js";
import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
import {
ensureCommandResolvable,
@ -184,6 +190,16 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor
* observability surface. Absent means the safe no-op default.
*/
duplexTelemetryRecorder?: DuplexTelemetryRecorder | null;
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel. The
* host stamps this same object on every sandbox target on the same seam as
* `runner`, so one shared gauge bounds the aggregate retained bytes across all
* live duplex routes. The live object stays on the host and never enters the
* sandbox environment. The bridge passes it to the broker, the decoder, and the
* response-body reader. Absent means no host ledger; a non-duplex run keeps the
* bridge inert for this seam.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}
export type AdapterExecutionTarget =
@ -428,6 +444,20 @@ export function adapterExecutionTargetDuplexTelemetryRecorder(
: null;
}
/**
* Read the injected aggregate byte ledger off a target. Only a sandbox target
* with a ledger attached returns it. Every other target returns null, so the
* bridge stays inert for this seam. The reader never makes a fresh ledger, so a
* host duplex run always uses the one process-owned ledger the host stamped.
*/
export function adapterExecutionTargetDuplexAggregateByteLedger(
target: AdapterExecutionTarget | null | undefined,
): DuplexAggregateByteLedger | null {
return target?.kind === "remote" && target.transport === "sandbox"
? target.duplexAggregateByteLedger ?? null
: null;
}
export function adapterExecutionTargetRemoteCwd(
target: AdapterExecutionTarget | null | undefined,
localCwd: string,
@ -1468,7 +1498,27 @@ function bridgeResponseBodyLimitError(maxBodyBytes: number): Error {
return new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
}
async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: number): Promise<string> {
/**
* Read the forward response body into a string. The reader bounds the body with
* two controls. The per-request `maxBodyBytes` limit rejects a body larger than
* the configured per-request ceiling. The optional host aggregate byte ledger
* bounds the retained bytes across all live routes.
*
* The reader charges the ledger for every retained buffer before it allocates
* that buffer. It reserves the exact chunk bytes before it copies a chunk into a
* retained `Buffer`. It reserves the concatenation buffer before it allocates it.
* A reservation that would pass the aggregate ceiling returns no token; the
* reader retains nothing more, cancels the stream reader, and throws the fixed
* marker {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED}. The `finally` releases
* every token exactly one time, so the reader charges the retained bytes only
* while the raw buffers live and never leaves a token held after it returns or
* throws.
*/
async function readBridgeForwardResponseBody(
response: Response,
maxBodyBytes: number,
ledger?: DuplexAggregateByteLedger | null,
): Promise<string> {
const rawContentLength = response.headers.get("content-length");
if (rawContentLength) {
const contentLength = Number.parseInt(rawContentLength, 10);
@ -1483,19 +1533,54 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n
const reader = response.body.getReader();
const chunks: Buffer[] = [];
// Every response-body reservation token the reader holds. The `finally` block
// releases each token one time, so a return, a size error, an aggregate
// rejection, and a read error all release every token.
const tokens: ReservationToken[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
await reader.cancel().catch(() => undefined);
throw bridgeResponseBodyLimitError(maxBodyBytes);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
const chunkBytes = value.byteLength;
totalBytes += chunkBytes;
if (totalBytes > maxBodyBytes) {
await reader.cancel().catch(() => undefined);
throw bridgeResponseBodyLimitError(maxBodyBytes);
}
// Reserve the exact chunk bytes before the host copies the chunk into a
// retained buffer. A rejection fails closed: cancel the stream reader and
// report the fixed marker; the reader retains nothing more.
if (ledger) {
const token = ledger.reserve("response_body", chunkBytes);
if (!token) {
await reader.cancel().catch(() => undefined);
throw new Error(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
}
tokens.push(token);
}
chunks.push(Buffer.from(value));
}
// Reserve the concatenation buffer before the reader allocates it. The
// concatenation buffer is a second copy of the body bytes that lives next to
// the chunk buffers during the concatenation, so it is the peak retained
// allocation. A rejection fails closed with the fixed marker.
if (ledger && totalBytes > 0) {
const concatToken = ledger.reserve("response_body", totalBytes);
if (!concatToken) {
throw new Error(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
}
tokens.push(concatToken);
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
} finally {
if (ledger) {
for (const token of tokens) {
ledger.release(token);
}
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
}
const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs";
@ -2394,7 +2479,8 @@ type DuplexReadinessFailure =
| "protocol_contamination"
| "nonce_mismatch"
| "channel_exit"
| "timeout";
| "timeout"
| "aggregate_bytes_exceeded";
/** The outcome of the duplex readiness handshake. */
type DuplexReadinessResult =
@ -2416,6 +2502,8 @@ function duplexReadinessFallbackReason(reason: DuplexReadinessFailure): DuplexFa
return "ready_timeout";
case "channel_exit":
return "ready_invalid";
case "aggregate_bytes_exceeded":
return "aggregate_bytes_exceeded";
}
}
@ -2542,6 +2630,13 @@ export const __duplexReadinessTesting = {
resetNewlineScanUnits: (): void => {
duplexReadinessNewlineScanUnits = 0;
},
// Build one readiness gate over a supplied channel, so a test can drive the
// readiness-replay reservation lifecycle across every terminal path without the
// whole bridge. Production code never reads this factory.
createReadinessGate: (
channel: CommandManagedDuplexChannel,
options: { nonce: string; timeoutMs: number; ledger?: DuplexAggregateByteLedger | null },
) => createDuplexReadinessGate(channel, options),
};
interface DuplexReadinessGate {
@ -2554,14 +2649,82 @@ interface DuplexReadinessGate {
* the channel.
*/
readonly brokerChannel: CommandManagedDuplexChannel;
/**
* Report whether a post-READY pre-bind chunk could not reserve its replay bytes
* against the aggregate ledger. On such a refusal the gate drops the pending
* replay buffer and stops the channel. The caller reads this after `ready`
* resolves `ok`, and before it binds the broker. A `true` result means the caller
* must abandon the broker and select the file bridge with the aggregate marker.
*/
replayOverflowed(): boolean;
/**
* Release every held readiness-replay reservation exactly once and drop the
* pending replay buffer. The caller runs this on a terminal path that abandons
* the pending replay without a broker handoff: a readiness failure, a replay
* overflow, or a broker-construction failure. The normal handoff releases the
* reservation inside `brokerChannel.onData`, so a later call here is a no-op.
*/
disposePendingReplay(): void;
/**
* Test-only. Report the length of the retained pre-READY buffer, in UTF-16 code
* units. A test reads this to prove the gate drops the pre-READY buffer on READY
* acceptance, so the process does not retain the sandbox-controlled prefix.
* Production code does not read this.
*/
retainedReadinessBufferLength(): number;
}
function createDuplexReadinessGate(
channel: CommandManagedDuplexChannel,
options: { nonce: string; timeoutMs: number },
options: {
nonce: string;
timeoutMs: number;
// The one host-process aggregate byte ledger. The gate charges the untrusted
// pre-READY buffer bytes against it, so a pre-READY flood counts toward the
// aggregate ceiling across all live routes. A gate with no ledger stays inert
// for this seam. The gate holds the same object every other host retention
// site holds, so the aggregate identity holds at this seam.
ledger?: DuplexAggregateByteLedger | null;
},
): DuplexReadinessGate {
const ledger = options.ledger ?? null;
let settled = false;
let readyOk = false;
// Every readiness-buffer reservation token the gate holds for the pre-READY
// bytes. The gate releases each token one time when it settles or when it
// accepts READY. On a failed handshake the gate drops the buffer, so the release
// frees the untrusted bytes. On READY the gate discards the whole pre-READY
// buffer, then re-charges only the retained suffix under `readiness_replay`.
const retainedTokens: ReservationToken[] = [];
// Every readiness-replay reservation token the gate holds for the post-READY
// suffix and each later pre-bind chunk. The gate releases each token one time
// after the synchronous handoff to the broker, or on a terminal path that
// abandons the pending replay without a broker handoff.
const replayTokens: ReservationToken[] = [];
// The gate sets this when a post-READY pre-bind chunk cannot reserve its replay
// bytes. On that refusal the gate drops the pending buffer and stops the channel.
// The caller reads it through `replayOverflowed` and selects the file bridge.
let replayOverflow = false;
// Release every readiness-buffer token exactly once and clear the registry. A
// second call is a no-op, because the array is empty.
function releaseReadinessBufferTokens(): void {
if (!ledger) return;
for (const token of retainedTokens) {
ledger.release(token);
}
retainedTokens.length = 0;
}
// Release every readiness-replay token exactly once and clear the registry. A
// second call is a no-op, because the array is empty.
function releaseReplayTokens(): void {
if (!ledger) return;
for (const token of replayTokens) {
ledger.release(token);
}
replayTokens.length = 0;
}
// The raw bytes the host reads before the READY frame completes. The buffer is
// append-only, so the O(1) cap check on `buffer.length` stays valid.
let buffer = "";
@ -2590,6 +2753,11 @@ function createDuplexReadinessGate(
settled = true;
clearTimeout(timer);
if (result.ok) readyOk = true;
// Release every readiness-buffer token exactly once. The gate no longer owns
// the pre-READY bytes: a failed handshake drops the buffer. The READY-accept
// path already released these tokens and charged the retained suffix under
// `readiness_replay`, so this call is a no-op there.
releaseReadinessBufferTokens();
resolveReady(result);
}
@ -2599,7 +2767,24 @@ function createDuplexReadinessGate(
return;
}
if (readyOk) {
// READY already passed; hold the bytes until the broker binds.
// READY already passed; hold the bytes until the broker binds. Reserve the
// exact UTF-8 bytes under `readiness_replay` before the append, so the replay
// buffer counts toward the aggregate ceiling. A refusal fails closed: the gate
// drops the pending buffer, releases the replay tokens, stops the channel, and
// sets the overflow flag. The caller reads the flag and selects the file bridge
// with the aggregate marker, because `ready` already resolved before this
// synchronous post-READY chunk arrived.
if (ledger) {
const token = ledger.reserve("readiness_replay", Buffer.byteLength(chunk, "utf8"));
if (!token) {
replayOverflow = true;
pending = "";
releaseReplayTokens();
channel.stop();
return;
}
replayTokens.push(token);
}
pending += chunk;
return;
}
@ -2609,6 +2794,18 @@ function createDuplexReadinessGate(
// never grows the buffer after the gate settles.
return;
}
// Reserve the exact UTF-8 bytes of this chunk against the aggregate ledger
// before the gate retains it. The pre-READY buffer holds untrusted bytes, so
// a flood counts toward the process aggregate ceiling. A rejection fails
// closed: the gate retains nothing more and falls back to the file bridge.
if (ledger) {
const token = ledger.reserve("readiness_buffer", Buffer.byteLength(chunk, "utf8"));
if (!token) {
finish({ ok: false, reason: "aggregate_bytes_exceeded" });
return;
}
retainedTokens.push(token);
}
// Append the new bytes and continue the newline search from `scanFrom`, the
// first index not yet examined. Each code unit is read at most one time for
// the search, so the total scan work stays linear in the bytes received.
@ -2682,8 +2879,33 @@ function createDuplexReadinessGate(
finish({ ok: false, reason: "nonce_mismatch" });
return;
}
// Hold the bytes that follow the READY line for the broker to replay.
pending = buffer.slice(newlineIndex + 1);
// The bytes that follow the READY line become the replay buffer for the
// broker. Drop the whole pre-READY buffer charge first, then reserve the
// retained suffix under `readiness_replay`. The release-before-reserve order
// keeps the transient charge equal to the suffix, not the sum of the dropped
// prefix and the retained suffix. The two steps run in one synchronous
// section, so no other route can take the freed bytes in between.
const suffix = buffer.slice(newlineIndex + 1);
// Drop the original pre-READY buffer now. The gate keeps only the
// retained suffix as `pending`, and it charges that suffix under
// `readiness_replay` below. If the gate keeps the buffer, the process
// retains the full sandbox-controlled string while the ledger counts
// only the suffix, so aggregate retention passes the ceiling. This clear
// also covers the broker handoff and the replay disposal. Both run later
// and read no buffer bytes.
buffer = "";
releaseReadinessBufferTokens();
if (ledger && suffix.length > 0) {
const token = ledger.reserve("readiness_replay", Buffer.byteLength(suffix, "utf8"));
if (!token) {
// The retained suffix passes the aggregate ceiling. Fail closed: drop
// the suffix and fall back to the file bridge with the aggregate marker.
finish({ ok: false, reason: "aggregate_bytes_exceeded" });
return;
}
replayTokens.push(token);
}
pending = suffix;
finish({ ok: true });
return;
}
@ -2723,6 +2945,11 @@ function createDuplexReadinessGate(
pending = "";
listener(replay);
}
// Release every readiness-replay token exactly once, after the synchronous
// handoff to the broker. The broker charges its own decode retention inside
// the `listener(replay)` call above, so the release here never opens an
// admission gap for the same retained bytes.
releaseReplayTokens();
},
onExit: (listener: (exit: { exitCode: number | null }) => void) => {
exitSink = listener;
@ -2736,7 +2963,16 @@ function createDuplexReadinessGate(
close: () => channel.close(),
};
return { ready, brokerChannel };
return {
ready,
brokerChannel,
replayOverflowed: () => replayOverflow,
disposePendingReplay: () => {
pending = "";
releaseReplayTokens();
},
retainedReadinessBufferLength: () => buffer.length,
};
}
/**
@ -2817,6 +3053,12 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
}
const target = input.target;
// The process-owned aggregate byte ledger the host stamped on this sandbox
// target. The forward response-body reader charges its retained bytes against
// this one ledger, so the aggregate retained bytes across all live routes stay
// under the ceiling. A target with no ledger keeps the reader inert for this
// seam.
const duplexAggregateByteLedger = adapterExecutionTargetDuplexAggregateByteLedger(target);
const onLog = input.onLog ?? (async () => {});
const hostApiToken = input.hostApiToken?.trim() ?? "";
if (hostApiToken.length === 0) {
@ -2943,7 +3185,11 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// to a non-retryable 409 for both the file bridge and the duplex broker.
let responseBody: string;
try {
responseBody = await readBridgeForwardResponseBody(response, maxBodyBytes);
responseBody = await readBridgeForwardResponseBody(
response,
maxBodyBytes,
duplexAggregateByteLedger,
);
} catch (error) {
if (isSafeBridgeMethod(method)) {
// The method is safe, so a retry cannot double-apply a mutation. Return a
@ -3049,6 +3295,11 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
PAPERCLIP_BRIDGE_PORT: String(assignedPort),
PAPERCLIP_BRIDGE_NONCE: nonce,
PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(maxBodyBytes),
// The separate sandbox-process raw-decoder cap. The generated gateway runs
// in a different operating-system process, so it cannot share the host
// aggregate byte ledger. It enforces this cap locally under the
// `sandbox_process` scope, and the provider memory allocation bounds it.
PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES: String(DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES),
};
const command = buildDuplexGatewayLaunchArgv({
shellCommand,
@ -3080,7 +3331,14 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
}
if (channel) {
const gate = createDuplexReadinessGate(channel, { nonce, timeoutMs: readinessTimeoutMs });
const gate = createDuplexReadinessGate(channel, {
nonce,
timeoutMs: readinessTimeoutMs,
// Inject the one host-process aggregate byte ledger, the same object the
// broker and the response-body reader hold. The gate charges the untrusted
// pre-READY buffer against it, so the aggregate identity holds at this seam.
ledger: duplexAggregateByteLedger,
});
const readiness = await gate.ready;
if (!readiness.ok) {
// Fail closed. Close the partial channel inside a bounded budget, then
@ -3088,12 +3346,26 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// carries the bridge token reached the channel or any endpoint. The reason
// is a fixed enum, so it rides the log line and the fallback telemetry
// with no raw value.
gate.disposePendingReplay();
await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS);
duplexChannelOpen.fallback(duplexReadinessFallbackReason(readiness.reason));
await onLog(
"stderr",
`[paperclip] Sandbox duplex readiness failed (${readiness.reason}). Using the file bridge.\n`,
);
} else if (gate.replayOverflowed()) {
// Readiness passed, but a post-READY pre-bind chunk passed the aggregate
// byte ceiling. The gate dropped the replay buffer and stopped the channel.
// Release any held replay reservation, close the partial channel inside the
// cleanup budget, and select the file bridge with the aggregate marker. The
// broker never bound, so no request reached the channel or any endpoint.
gate.disposePendingReplay();
await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS);
duplexChannelOpen.fallback(duplexReadinessFallbackReason("aggregate_bytes_exceeded"));
await onLog(
"stderr",
"[paperclip] Sandbox duplex readiness replay exceeded the aggregate byte ceiling (aggregate_bytes_exceeded). Using the file bridge.\n",
);
} else {
// Readiness passed. Construct the broker inside the guarded region, so a
// construction throw closes the channel within the cleanup budget and
@ -3149,12 +3421,20 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
`[paperclip] Sandbox duplex channel lost (${typedDuplexLossReason(record.reason)}). The run fails.\n`,
);
},
// Inject the one host-process aggregate byte ledger. The broker
// reserves the retained request-frame, request-payload, and no-replay
// set-entry bytes against it, so the aggregate retained bytes across all
// live routes stay under the ceiling. It is the same object the
// response-body reader reads off the stamped target above.
duplexAggregateByteLedger,
});
} catch {
// The broker construction failed, so no broker owns the channel. Close
// the channel within the cleanup budget, then select the file bridge.
// The log line names no raw error, so no raw error rides a log line on
// the duplex path.
// The broker construction failed, so no broker owns the channel. The
// broker never bound, so it never released the pending replay reservation;
// release it here. Then close the channel within the cleanup budget and
// select the file bridge. The log line names no raw error, so no raw error
// rides a log line on the duplex path.
gate.disposePendingReplay();
await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS);
duplexChannelOpen.fallback("broker_construction_failed");
await onLog(

View File

@ -19,6 +19,14 @@ 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 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
// separate cap locally under the `sandbox_process` scope, and the provider memory
// allocation bounds it. The host passes the value through
// 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;
// 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
@ -89,6 +97,18 @@ const CALLBACK_BRIDGE_WORKER_FAILED_SPAN = "sandbox.callbackBridge.workerFailed"
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES = DEFAULT_BRIDGE_MAX_BODY_BYTES;
/**
* The default cap on the aggregate raw bytes the generated in-sandbox duplex frame
* decoder retains. The host passes it through
* `PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES`. The in-sandbox decoder enforces the
* cap locally under the `sandbox_process` scope; it never touches the host
* aggregate byte ledger.
*/
export const DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES = DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES;
/** The scope label the in-sandbox duplex decoder reports for its local byte counter. */
export const SANDBOX_DUPLEX_DECODER_SCOPE = "sandbox_process";
export interface SandboxCallbackBridgeRouteRule {
method: string;
path: RegExp;
@ -1746,6 +1766,8 @@ export async function startSandboxCallbackBridgeServer(input: {
const DUPLEX_GATEWAY_CODEC_SOURCE = `const DUPLEX_FRAME_VERSION = 1;
const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1000000;
const DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES = 256;
const DEFAULT_MAX_DUPLEX_DECODER_BYTES = ${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES};
const DUPLEX_DECODER_SCOPE = "${SANDBOX_DUPLEX_DECODER_SCOPE}";
const DUPLEX_NEWLINE_BYTE = 0x0a;
const DUPLEX_EMPTY = Buffer.alloc(0);
const DUPLEX_RESPONSE_OUTCOMES = new Set(["completed", "indeterminate", "unavailable"]);
@ -1881,10 +1903,32 @@ class DuplexFrameDecoder {
this.discarding = false;
this.maxFrameBytes =
options && options.maxFrameBytes != null ? options.maxFrameBytes : DEFAULT_MAX_DUPLEX_FRAME_BYTES;
// The in-sandbox decoder runs in a separate operating-system process, so it
// cannot share the host aggregate byte ledger. It bounds its own retained
// bytes with a local cap under the "sandbox_process" scope. The counters here
// never increment or release a host aggregate token.
this.scope = DUPLEX_DECODER_SCOPE;
this.maxAggregateBytes =
options && options.maxAggregateBytes != null
? options.maxAggregateBytes
: DEFAULT_MAX_DUPLEX_DECODER_BYTES;
this.bytesInUse = 0;
this.aggregateRejections = 0;
}
push(chunk) {
const incoming = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
// Enforce the local sandbox-process cap before the concat allocates the peak
// "old + incoming" buffer. A rejection fails closed: the decoder drops the
// retained buffer and the incoming chunk, resynchronizes at the next newline,
// and reports the aggregate rejection. It retains nothing over the cap.
if (this.buffer.length + incoming.length > this.maxAggregateBytes) {
this.buffer = DUPLEX_EMPTY;
this.discarding = true;
this.bytesInUse = 0;
this.aggregateRejections += 1;
return [duplexFail("aggregate_bytes_exceeded", "aggregate retained bytes exceeded the sandbox-process cap")];
}
this.buffer = this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]);
const results = [];
for (;;) {
@ -1916,6 +1960,8 @@ class DuplexFrameDecoder {
}
results.push(decodeDuplexLine(line));
}
// Reconcile the local sandbox-process counter to the bytes still retained.
this.bytesInUse = this.buffer.length;
return results;
}
}`;
@ -1959,6 +2005,12 @@ const responseTimeoutMs = Number(
);
const maxQueueDepth = Number(process.env.PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH || "${DEFAULT_BRIDGE_MAX_QUEUE_DEPTH}");
const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_BODY_BYTES}");
// The host passes the separate sandbox-process raw-decoder cap here. The in-sandbox
// decoder enforces it locally under the "sandbox_process" scope; it never shares the
// host aggregate byte ledger.
const maxDuplexDecoderBytes = Number(
process.env.PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES || "${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES}",
);
const heartbeatIntervalMs = Number(
process.env.PAPERCLIP_BRIDGE_HEARTBEAT_INTERVAL_MS || "${DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_INTERVAL_MS}",
);
@ -2220,7 +2272,7 @@ function runDuplexGateway() {
// local action here.
}
const decoder = new DuplexFrameDecoder();
const decoder = new DuplexFrameDecoder({ maxAggregateBytes: maxDuplexDecoderBytes });
process.stdin.on("data", (chunk) => {
lastInboundAt = Date.now();
for (const result of decoder.push(chunk)) {

View File

@ -350,6 +350,24 @@ field.
| `sandbox_duplex_loss_total` | One terminal duplex channel loss. The `loss_class` dimension records the phase. |
| `sandbox_duplex_session_leak_total` | One leaked provider session at teardown. |
### Aggregate byte ledger metrics
The host aggregate byte ledger owns one process-scoped gauge and two
process-scoped counters. The ledger bounds the retained bytes across every live
duplex route in one process. It sets the gauge on each reserve and each release.
It increments a counter on a rejected reservation and on an accounting defect.
These records carry no dimension label. The guarded counter store keys each
counter on `(companyId, metric)`, and the gauge reports one process value, so no
dynamic dimension rides them. The code owner is
`packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts`, and the metric
names are literal constants in `duplex-telemetry.ts`.
| Metric | Type | Scope |
| --- | --- | --- |
| `sandbox_duplex_aggregate_bytes_in_use` | gauge | The aggregate retained bytes across every live duplex route. The ledger sets it on each reserve and each release. |
| `sandbox_duplex_aggregate_byte_reservation_rejections_total` | counter | One rejected aggregate byte reservation. The ledger increments it when a reservation would pass the aggregate ceiling. |
| `sandbox_duplex_aggregate_byte_accounting_underflow_total` | counter | One aggregate byte accounting defect. The ledger increments it on a double release or on a transfer of a token it does not hold. |
### Dimension keys
Counters carry no dimension labels. The guarded counter store keys each counter

View File

@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { resolveDuplexAggregateCeilingBytesFromEnv } from "../duplex-aggregate-ceiling-env.js";
describe("resolveDuplexAggregateCeilingBytesFromEnv", () => {
it("uses the safe default and reports nothing when the variable is absent", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(undefined, onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
it("treats a whitespace-only value as a present invalid override, not as absent", () => {
const rawOverride = " ";
const onRejectedOverride = vi.fn();
// The resolver must continue with the safe default. Startup does not fail.
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(rawOverride, onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
// The rejection reporter fires one time. A blank value is now visible as
// invalid instead of silent as absent.
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
// The reporter receives only the parsed number. The raw whitespace string
// never reaches the reporter, so no operator-supplied text can reach a log
// line.
const reportedValue = onRejectedOverride.mock.calls[0]?.[0];
expect(typeof reportedValue).toBe("number");
expect(reportedValue).not.toBe(rawOverride);
expect(Number.isFinite(reportedValue)).toBe(true);
});
it("treats an empty-string value as a present invalid override", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("", onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
});
it("rejects a present non-numeric value and uses the safe default", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("not-a-number", onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
});
it("passes a valid override through unchanged", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("1048576", onRejectedOverride);
expect(resolved).toBe(1048576);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
it("accepts a valid override that carries surrounding whitespace", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(" 1048576 ", onRejectedOverride);
expect(resolved).toBe(1048576);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
});

View File

@ -21,6 +21,11 @@ vi.mock("../services/plugin-environment-driver.js", async (importActual) => ({
}));
import type { EffectiveSandboxCapabilities } from "@paperclipai/adapter-utils/execution-target";
import { adapterExecutionTargetDuplexAggregateByteLedger } from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
DuplexAggregateByteLedger,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { createSshCommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/ssh";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
@ -293,6 +298,50 @@ describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => {
});
});
describe("sandbox execution target aggregate byte ledger stamp", () => {
it("stamps the injected ledger on the target with object identity", async () => {
const ledger = new DuplexAggregateByteLedger({
ceilingBytes: DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-1",
leaseMetadata: { remoteCwd: "/work" },
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
duplexAggregateByteLedger: ledger,
});
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
throw new Error("expected a sandbox execution target");
}
// The stamped field and the accessor both return the one injected object, so
// one process-owned ledger reaches the host bridge seam.
expect(target.duplexAggregateByteLedger).toBe(ledger);
expect(adapterExecutionTargetDuplexAggregateByteLedger(target)).toBe(ledger);
});
it("leaves the ledger absent when the host injects none", async () => {
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-1",
leaseMetadata: { remoteCwd: "/work" },
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
});
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
throw new Error("expected a sandbox execution target");
}
// A run with no injected ledger keeps the seam null; the accessor never makes
// a fresh ledger.
expect(target.duplexAggregateByteLedger).toBeNull();
expect(adapterExecutionTargetDuplexAggregateByteLedger(target)).toBeNull();
});
});
// Build a sandbox execution target with a fixed capability snapshot and a fake
// environment runtime whose openDuplexChannel is a spy. The helper returns the
// runner and the spy so a test reads the capability-gated member.

View File

@ -23,6 +23,18 @@
// and never lets the exit crowd a data frame out of the pre-open hold.
// - `echoInput`: when true, the fixture echoes each `duplexChannelWrite` back as
// one data notification for the bound session.
// - `writeReplyDelayMs`: when a positive number, the fixture delays each
// `duplexChannelWrite` reply by that many milliseconds, so a test proves the
// host holds the pending-write reservation until the RPC settles.
// - `stopReadingStdinAfterOpen`: when true, the fixture stops reading its stdin
// right after it sends the open reply. The host writes then stay in the host
// stdin write buffer, so a test proves the host meters the transport buffer and
// never releases the transport token on the write-RPC timeout.
// - `exitAfterStopMs`: when a positive number, and the fixture stopped reading
// stdin, the fixture exits after that many milliseconds. The worker exit
// discards the stdin buffer, so a test proves the host releases every held
// transport token on the worker exit. It gives a fast exit for a worker that no
// longer reads its stdin and never sees the shutdown request.
// - `closeMode`: "ack" | "bad-ack" | "no-ack" (default "ack"). It controls the
// close reply, so a test proves the host retires the worker on an unconfirmed
// close.
@ -134,6 +146,8 @@ rl.on("line", (line) => {
closeMode,
echoInput: directive.echoInput === true,
noWriteReply: mode === "no-write-reply",
writeReplyDelayMs:
typeof directive.writeReplyDelayMs === "number" ? directive.writeReplyDelayMs : 0,
emitAfterCloseChunk:
typeof directive.emitAfterCloseChunk === "string" ? directive.emitAfterCloseChunk : null,
});
@ -170,6 +184,20 @@ rl.on("line", (line) => {
reply();
}
if (directive.stopReadingStdinAfterOpen === true) {
// Stop reading stdin, so every later host write stays in the host stdin
// write buffer. The host meters the transport buffer and holds the transport
// token until the stream flush, the stream error, or the worker exit.
rl.pause();
process.stdin.pause();
if (typeof directive.exitAfterStopMs === "number" && directive.exitAfterStopMs >= 0) {
// Exit after the delay, so a test observes the worker exit release the held
// transport tokens without a slow shutdown drain on an unread stdin.
setTimeout(() => process.exit(0), directive.exitAfterStopMs);
}
return;
}
// Emit the scripted data and the exit after the open reply, so the host
// binds the route first. Each frame echoes the exact pair; a test overrides
// `sid` or `rid` to force a mismatch.
@ -205,7 +233,14 @@ rl.on("line", (line) => {
},
});
}
send({ jsonrpc: "2.0", id: message.id, result: null });
const replyWrite = () => send({ jsonrpc: "2.0", id: message.id, result: null });
if (entry.writeReplyDelayMs > 0) {
// Delay the write reply, so the host holds the pending-write reservation for
// a measurable time before the RPC settles.
setTimeout(replyWrite, entry.writeReplyDelayMs);
} else {
replyWrite();
}
return;
}

View File

@ -0,0 +1,348 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerTelemetry,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import {
createDuplexRouteSlotController,
createPluginWorkerHandle,
} from "../services/plugin-worker-manager.js";
// This suite proves the plugin worker manager charges every retained duplex route
// representation against the injected aggregate byte ledger, and releases each
// token exactly once through the one cleanup path. The tests drive a real worker
// fixture process, so they exercise the true frame order across the bind boundary.
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-duplex-channel.cjs",
);
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Test plugin",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
};
function makeDuplexHandle(extra?: Record<string, unknown>) {
return createPluginWorkerHandle("test.plugin", {
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
hostHandlers: {},
...extra,
});
}
// The test directive rides in `providerLeaseId`, an opaque field the manager
// forwards to the worker unchanged.
function duplexOpenInput(directive: unknown, companyId = "company-1") {
return {
driverKey: "daytona",
companyId,
environmentId: "env-1",
providerLeaseId: JSON.stringify(directive),
command: "bridge-callback",
};
}
// A counting telemetry surface. It records how many times the ledger rejected a
// reservation and how many accounting defects it saw, so a test asserts a
// fail-closed rejection and proves the cleanup never double-releases.
function countingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
rejections: number;
underflows: number;
} {
const state = {
rejections: 0,
underflows: 0,
setBytesInUse() {},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
describe("plugin worker manager duplex aggregate byte ledger", () => {
it("keeps terminalized buffered bytes charged until a late listener drains them, then a worker exit is harmless", async () => {
const telemetry = countingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
// Lower the per-chunk char bound, so the second chunk ends the route while the
// first chunk stays buffered.
const handle = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexChannelLimits: { maxChunkChars: 4 },
});
try {
await handle.start();
const session = await handle.openDuplexChannel(
// No listener attaches, so "ok" buffers and charges its two raw bytes. The
// "toolong" chunk passes the per-chunk bound, so the route terminalizes and
// moves the still-buffered "ok" token to the terminal registry.
duplexOpenInput({ data: [{ chunk: "ok" }, { chunk: "toolong" }] }),
);
// The buffered "ok" stays charged after the route leaves the live map.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(2);
expect(ledger.liveTokenCount).toBe(1);
});
// A late listener drains the terminal buffered record and releases its token.
const chunks: string[] = [];
session.onData((chunk) => chunks.push(chunk));
await vi.waitFor(() => {
expect(chunks).toEqual(["ok"]);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
// The over-bound chunk retained nothing, so no reservation ever rejected.
expect(telemetry.rejections).toBe(0);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit sweep runs on stop. The ledger stays at zero, and the sweep
// records no accounting defect, so the drain and the exit never double-release.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("transfers pre-bind held bytes to the buffered representation across the bind, then releases them on drain", async () => {
const telemetry = countingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const session = await handle.openDuplexChannel(
// The worker writes the open reply and the two data frames in one stdout
// write, so both frames arrive before the route binds. The host holds them
// as pre-bind events, then the bind replays and transfers each token to the
// buffered representation.
duplexOpenInput({ batchWithOpenReply: true, data: [{ chunk: "aa" }, { chunk: "bb" }] }),
);
// The two held events keep their exact reserved bytes across the transfer, so
// the live-token count never grows and the gauge never re-admits.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(4);
expect(ledger.liveTokenCount).toBe(2);
});
const chunks: string[] = [];
session.onData((chunk) => chunks.push(chunk));
await vi.waitFor(() => {
expect(chunks).toEqual(["aa", "bb"]);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
} finally {
await handle.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("fails closed and retains nothing when a buffered reservation would pass the ceiling", async () => {
const telemetry = countingTelemetry();
// A four-byte ceiling. One five-byte chunk cannot fit.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 4, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
await handle.openDuplexChannel(
// No listener attaches, so "hello" tries to buffer. Its five raw bytes pass
// the four-byte ceiling, so the reservation rejects and the route ends.
duplexOpenInput({ data: [{ chunk: "hello" }] }),
);
await vi.waitFor(() => {
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
} finally {
await handle.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(telemetry.underflows).toBe(0);
});
});
// A telemetry surface that also tracks the peak gauge value. A test asserts the
// peak never passes the ceiling, so it proves aggregate admission stops at the
// ceiling instead of overshooting it.
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
peak: number;
rejections: number;
underflows: number;
} {
const state = {
gauge: 0,
peak: 0,
rejections: 0,
underflows: 0,
setBytesInUse(bytes: number) {
state.gauge = bytes;
if (bytes > state.peak) state.peak = bytes;
},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
// The exact raw byte count of one buffered chunk each route retains.
const LOAD_CHUNK_BYTES = 8;
const LOAD_CHUNK = "x".repeat(LOAD_CHUNK_BYTES);
describe("plugin worker manager duplex aggregate byte ledger load across workers", () => {
it("stops aggregate byte admission at the ceiling across many routes and workers while the route-count controller stays open", async () => {
const telemetry = peakTrackingTelemetry();
// The ceiling holds exactly six eight-byte buffered chunks.
const fittingRoutes = 6;
const ceilingBytes = fittingRoutes * LOAD_CHUNK_BYTES;
const ledger = new DuplexAggregateByteLedger({ ceilingBytes, telemetry });
// The route-count controller has ample room, so the byte ledger binds first.
const routeSlots = createDuplexRouteSlotController(100);
const handleCount = 3;
const routesPerHandle = 3;
const totalRoutes = handleCount * routesPerHandle;
const rejectedRoutes = totalRoutes - fittingRoutes;
const handles: Array<ReturnType<typeof makeDuplexHandle>> = [];
try {
for (let h = 0; h < handleCount; h += 1) {
const handle = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexRouteSlots: routeSlots,
});
await handle.start();
handles.push(handle);
}
// Open every route in order. Each worker batches one data chunk with the open
// reply, so the host holds the chunk as a pre-bind event and reserves its exact
// raw bytes before the route binds. No listener attaches, so a bound route holds
// the chunk. The first six chunks fit the ceiling. Each later reservation passes
// the ceiling, so the ledger rejects it and the route fails closed with the open
// marker (not the route-busy marker), because the route-count controller has room.
let opened = 0;
let failedClosed = 0;
for (let h = 0; h < handleCount; h += 1) {
for (let r = 0; r < routesPerHandle; r += 1) {
try {
await handles[h].openDuplexChannel(
duplexOpenInput({
workerSessionId: `ws-${h}-${r}`,
batchWithOpenReply: true,
data: [{ chunk: LOAD_CHUNK }],
}),
);
opened += 1;
} catch (err) {
// A byte-ceiling rejection fails the route closed with the open marker.
// The route-count controller had room, so this is never the route-busy
// marker.
expect(String(err)).toContain("DUPLEX_CHANNEL_OPEN_FAILED");
expect(String(err)).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
failedClosed += 1;
}
}
}
// Six routes bound and hold their bytes; three failed closed at the ceiling.
expect(opened).toBe(fittingRoutes);
expect(failedClosed).toBe(rejectedRoutes);
expect(opened + failedClosed).toBe(totalRoutes);
// The aggregate gauge holds at the ceiling and the ledger recorded one rejection
// per over-ceiling route.
await vi.waitFor(() => {
expect(telemetry.rejections).toBe(rejectedRoutes);
expect(ledger.bytesInUse).toBe(ceilingBytes);
expect(ledger.liveTokenCount).toBe(fittingRoutes);
});
// The gauge never passed the ceiling, so admission stopped at the ceiling and
// never overshot it.
expect(telemetry.peak).toBe(ceilingBytes);
} finally {
for (const handle of handles) await handle.stop().catch(() => undefined);
}
// Every worker exit sweep released its buffered tokens once, so the ledger ends
// at zero with no accounting defect.
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("still bounds the route count for fixed per-route overhead when the byte ceiling has ample room", async () => {
const telemetry = peakTrackingTelemetry();
// A large byte ceiling, so retained bytes never bind admission.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
// The route-count controller holds two slots for the whole process.
const routeSlots = createDuplexRouteSlotController(2);
const handleA = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexRouteSlots: routeSlots,
});
const handleB = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexRouteSlots: routeSlots,
});
let opened = 0;
let busy = 0;
try {
await handleA.start();
await handleB.start();
// Attempt four routes across the two workers. The shared controller admits the
// first two and rejects the last two with the fixed route-busy error.
const attempts: Array<[ReturnType<typeof makeDuplexHandle>, string]> = [
[handleA, "ws-a1"],
[handleA, "ws-a2"],
[handleB, "ws-b1"],
[handleB, "ws-b2"],
];
for (const [handle, ws] of attempts) {
try {
await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: ws, data: [{ chunk: "aa" }] }),
);
opened += 1;
} catch (err) {
expect(String(err)).toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
busy += 1;
}
}
// The route-count controller admitted two routes and rejected the rest, so it
// still bounds the fixed per-route overhead on its own.
expect(opened).toBe(2);
expect(busy).toBe(2);
// The two open routes buffered four raw bytes, far under the byte ceiling, and
// the byte ledger rejected nothing.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(4);
expect(ledger.liveTokenCount).toBe(2);
});
expect(telemetry.rejections).toBe(0);
} finally {
await handleA.stop().catch(() => undefined);
await handleB.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.underflows).toBe(0);
});
});

View File

@ -0,0 +1,251 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerTelemetry,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
// Mock the shared logger, so a test reads the fixed rejection marker the manager
// logs when a pending-write reservation fails. The child logger returns the same
// object, so `log.warn` is this mock's `warn`.
vi.mock("../middleware/logger.js", () => {
const mockLogger: Record<string, unknown> = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => mockLogger),
};
return { logger: mockLogger, httpLogger: vi.fn() };
});
import { logger } from "../middleware/logger.js";
import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js";
// This suite proves the plugin worker manager charges every host→worker write raw
// payload against the injected aggregate byte ledger under the `pending_write`
// owner, and releases each token one time when the write RPC settles. The child
// reads its stdin here, so the separate `stdin_write` transport token flushes and
// releases at once. Each test waits for that flush, so the assertions isolate the
// raw-payload token. The transport token has its own suite.
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-duplex-channel.cjs",
);
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Test plugin",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
};
function makeDuplexHandle(extra?: Record<string, unknown>) {
return createPluginWorkerHandle("test.plugin", {
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
hostHandlers: {},
...extra,
});
}
// The test directive rides in `providerLeaseId`, an opaque field the manager
// forwards to the worker unchanged.
function duplexOpenInput(directive: unknown, companyId = "company-1") {
return {
driverKey: "daytona",
companyId,
environmentId: "env-1",
providerLeaseId: JSON.stringify(directive),
command: "bridge-callback",
};
}
// A telemetry surface that tracks the peak gauge value and the two counters. A
// test asserts the peak never passes the ceiling, so it proves aggregate admission
// stops at the ceiling instead of overshooting it.
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
peak: number;
rejections: number;
underflows: number;
} {
const state = {
gauge: 0,
peak: 0,
rejections: 0,
underflows: 0,
setBytesInUse(bytes: number) {
state.gauge = bytes;
if (bytes > state.peak) state.peak = bytes;
},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
// Return every reason string the manager logged through the mock `warn` sink.
function loggedWarnReasons(): string[] {
const calls = (logger.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls;
return calls
.map((call) => {
const first = call[0];
return first && typeof first === "object"
? (first as { reason?: unknown }).reason
: undefined;
})
.filter((reason): reason is string => typeof reason === "string");
}
describe("plugin worker manager duplex pending-write byte ledger", () => {
it("charges each held raw payload and returns to zero after worker exit", async () => {
const telemetry = peakTrackingTelemetry();
// The ceiling has ample room, so a transient transport reservation never
// rejects. Each write holds its raw payload until the RPC settles.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
const writeBytes = 1000;
const writeData = "x".repeat(writeBytes);
try {
await handle.start();
// The worker reads its stdin but never replies to a write, so each write RPC
// stays pending and holds its raw payload.
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-a", mode: "no-write-reply" }),
);
route.write(writeData);
route.write(writeData);
route.write(writeData);
// The worker reads its stdin, so each transport token flushes and releases.
// Only the three held raw payloads remain, so the gauge settles at three
// times the payload byte count with three live tokens.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(3);
expect(ledger.bytesInUse).toBe(3 * writeBytes);
});
expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit settles each pending write, so each token releases one time
// and the ledger ends at zero with no accounting defect.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("releases the raw-payload token after a delayed write RPC settles, with no worker exit", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
// The worker reads its stdin and delays its write reply, so the host holds the
// raw-payload reservation for a measurable time before the RPC settles.
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-d", writeReplyDelayMs: 150 }),
);
const writeBytes = 1000;
route.write("x".repeat(writeBytes));
// The transport token flushes at once, so one raw-payload token stays held for
// the full write byte count while the RPC is in flight.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(1);
expect(ledger.bytesInUse).toBe(writeBytes);
});
// The delayed reply settles the RPC, and the `finally` releases the token.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
} finally {
await handle.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("reserves the exact UTF-8 byte count of the raw payload, not the character length", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-u", mode: "no-write-reply" }),
);
// "a€" is two characters but four UTF-8 bytes (one plus three). The raw-payload
// reservation must charge four bytes, so it uses the UTF-8 byte count.
const data = "a€";
expect(data.length).toBe(2);
expect(Buffer.byteLength(data, "utf8")).toBe(4);
route.write(data);
// The transport token flushes, so one raw-payload token of four bytes remains.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(1);
expect(ledger.bytesInUse).toBe(4);
});
} finally {
await handle.stop().catch(() => undefined);
}
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("ends an over-ceiling single write fail-closed with the aggregate marker, not the route-busy marker", async () => {
vi.mocked(logger.warn).mockClear();
const telemetry = peakTrackingTelemetry();
// The ceiling is smaller than one raw payload, so the raw-payload reservation
// fails before the frame reaches the transport. The manager retains nothing.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 100, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-o", mode: "no-write-reply" }),
);
// The single write is larger than the ceiling. The raw-payload reservation
// fails, so the manager ends the route with the aggregate marker, never the
// route-busy marker, and never writes the frame.
route.write("x".repeat(200));
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes);
const reasons = loggedWarnReasons();
expect(reasons).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
expect(reasons).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
} finally {
await handle.stop().catch(() => undefined);
}
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
});

View File

@ -0,0 +1,297 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerTelemetry,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
// Mock the shared logger, so a test reads the fixed rejection marker the manager
// logs when a transport reservation fails. The child logger returns the same
// object, so `log.warn` is this mock's `warn`.
vi.mock("../middleware/logger.js", () => {
const mockLogger: Record<string, unknown> = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => mockLogger),
};
return { logger: mockLogger, httpLogger: vi.fn() };
});
import { logger } from "../middleware/logger.js";
import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js";
// This suite proves the plugin worker manager charges the child-stdin transport
// buffer for every host→worker duplex write, under the `stdin_write` owner. The
// worker completes the open, then stops reading its stdin. The host write then
// stays in the host stdin write buffer. The tests prove the reservation covers the
// serialized frame, holds until the flush or the worker exit, survives the write
// RPC timeout and the route terminalization, and bounds the aggregate transport
// bytes across several routes and workers.
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-duplex-channel.cjs",
);
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Test plugin",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
};
function makeDuplexHandle(extra?: Record<string, unknown>) {
return createPluginWorkerHandle("test.plugin", {
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
hostHandlers: {},
...extra,
});
}
// The test directive rides in `providerLeaseId`, an opaque field the manager
// forwards to the worker unchanged.
function duplexOpenInput(directive: unknown, companyId = "company-1") {
return {
driverKey: "daytona",
companyId,
environmentId: "env-1",
providerLeaseId: JSON.stringify(directive),
command: "bridge-callback",
};
}
// A telemetry surface that tracks the peak gauge value and the two counters. A
// test asserts the peak never passes the ceiling, so it proves aggregate admission
// stops at the ceiling instead of overshooting it.
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
peak: number;
rejections: number;
underflows: number;
} {
const state = {
gauge: 0,
peak: 0,
rejections: 0,
underflows: 0,
setBytesInUse(bytes: number) {
state.gauge = bytes;
if (bytes > state.peak) state.peak = bytes;
},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
// Return every reason string the manager logged through the mock `warn` sink.
function loggedWarnReasons(): string[] {
const calls = (logger.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls;
return calls
.map((call) => {
const first = call[0];
return first && typeof first === "object"
? (first as { reason?: unknown }).reason
: undefined;
})
.filter((reason): reason is string => typeof reason === "string");
}
// A frame the host keeps in its stdin write buffer must pass the operating-system
// pipe buffer (about 64 KiB), so the write callback stays pending while the child
// does not read. Each write here is larger than that, so the host holds the
// transport token until the worker exit.
const HELD_WRITE_CHARS = 200_000;
describe("plugin worker manager duplex stdin transport byte ledger", () => {
it("reserves the encoded serialized-frame size, larger than the raw payload, including JSON escaping", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-a",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 500,
}),
);
// Every character is a quote. The JSON serialization escapes each quote to two
// bytes, so the serialized frame is at least twice the raw payload byte count.
const data = '"'.repeat(50_000);
const rawBytes = Buffer.byteLength(data, "utf8");
route.write(data);
// The raw-payload token and the transport token both hold at once. The worker
// does not read its stdin, so the transport token never flushes.
expect(ledger.liveTokenCount).toBe(2);
const transportBytes = ledger.bytesInUse - rawBytes;
// The transport reservation covers the serialized frame, so it is larger than
// the raw payload and it includes the doubled escaped quotes.
expect(transportBytes).toBeGreaterThan(rawBytes);
expect(transportBytes).toBeGreaterThanOrEqual(2 * rawBytes);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit discards the stdin buffer, so every token releases one time.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("holds the transport token across a write-RPC timeout and releases it on worker exit", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
// The write RPC times out fast; the worker exits later. The gap lets a test
// prove the timeout does not release the transport token.
const handle = makeDuplexHandle({
duplexAggregateByteLedger: ledger,
duplexChannelLimits: { openTimeoutMs: 150 },
});
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-t",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 900,
}),
);
const data = "x".repeat(HELD_WRITE_CHARS);
const rawBytes = Buffer.byteLength(data, "utf8");
route.write(data);
// Both tokens hold at first: the raw payload and the serialized frame.
expect(ledger.liveTokenCount).toBe(2);
const transportBytes = ledger.bytesInUse - rawBytes;
expect(transportBytes).toBeGreaterThan(0);
// The write RPC times out. The RPC settle releases the raw-payload token, so
// one token remains: the transport token. The timeout never releases it.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(1);
});
expect(ledger.bytesInUse).toBe(transportBytes);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit releases the still-held transport token.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("does not release the transport token when the route terminalizes", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-c",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 700,
}),
);
const data = "x".repeat(HELD_WRITE_CHARS);
const rawBytes = Buffer.byteLength(data, "utf8");
route.write(data);
expect(ledger.liveTokenCount).toBe(2);
const transportBytes = ledger.bytesInUse - rawBytes;
// End the route. Route terminalization releases the route-owned tokens, but it
// must never release the transport token, so at least the serialized frame
// stays charged.
void route.close();
expect(ledger.bytesInUse).toBeGreaterThanOrEqual(transportBytes);
} finally {
await handle.stop().catch(() => undefined);
}
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("bounds the aggregate transport bytes across several routes and workers, ending an over-ceiling near-limit write with the aggregate marker", async () => {
vi.mocked(logger.warn).mockClear();
const telemetry = peakTrackingTelemetry();
// The ceiling admits only a couple of held serialized frames, so a near-limit
// write across the routes passes it and rejects.
const ceilingBytes = 600_000;
const ledger = new DuplexAggregateByteLedger({ ceilingBytes, telemetry });
const handleA = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
const handleB = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handleA.start();
await handleB.start();
const routeA = await handleA.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-a",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 900,
}),
);
const routeB = await handleB.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-b",
stopReadingStdinAfterOpen: true,
exitAfterStopMs: 900,
}),
);
// Each write mixes escaped quotes and multi-byte characters near the per-write
// size. The serialized frame escapes each quote and keeps each euro sign as
// three UTF-8 bytes, so the reservation must count the encoded size.
const escaped = '"'.repeat(40_000);
const multiByte = "€".repeat(20_000);
const nearLimit = escaped + multiByte;
const routes = [routeA, routeB, routeA, routeB];
for (const route of routes) {
route.write(nearLimit);
// The aggregate bytes never pass the ceiling. Admission stops at the ceiling.
expect(ledger.bytesInUse).toBeLessThanOrEqual(ceilingBytes);
}
// At least one near-limit write passed the ceiling and rejected. The manager
// ended that route with the aggregate marker, never the route-busy marker.
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
expect(telemetry.peak).toBeLessThanOrEqual(ceilingBytes);
const reasons = loggedWarnReasons();
expect(reasons).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
expect(reasons).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
} finally {
await handleA.stop().catch(() => undefined);
await handleB.stop().catch(() => undefined);
}
// Every worker exit releases its held transport tokens, so the ledger ends at
// zero with no accounting defect.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
});

View File

@ -0,0 +1,32 @@
import {
resolveDuplexAggregateCeilingBytes,
type DuplexAggregateCeilingOverrideRejectionReporter,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
/**
* Resolve the aggregate duplex route byte ceiling from the raw operator override
* string. The host reads the override from
* PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES.
*
* This helper distinguishes an absent variable from a present blank value. An
* absent variable is `undefined`. The helper then uses the documented default and
* does not report a rejection. A present variable is a string, even when the
* string holds only whitespace. The helper sends every present string through
* `Number`, so a blank or whitespace-only value becomes `0`. The numeric
* validation in {@link resolveDuplexAggregateCeilingBytes} rejects `0`, reports it
* through `onRejectedOverride`, and returns the safe default. So a present blank
* value is visible as invalid instead of silent as absent.
*
* `Number` ignores surrounding whitespace, so a valid value with surrounding
* spaces still parses. The reporter receives only the parsed number, never the raw
* string, so no operator-supplied text reaches a log line.
*/
export function resolveDuplexAggregateCeilingBytesFromEnv(
rawOverride: string | undefined,
onRejectedOverride?: DuplexAggregateCeilingOverrideRejectionReporter,
): number {
return resolveDuplexAggregateCeilingBytes(
rawOverride === undefined ? undefined : Number(rawOverride),
onRejectedOverride,
);
}

View File

@ -83,6 +83,13 @@ import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-sh
import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "./runtime-api.js";
import { isLoopbackHost, rewriteLoopbackUrlPort } from "./url-utils.js";
import { createPluginWorkerManager } from "./services/plugin-worker-manager.js";
import {
createDuplexAggregateByteLedgerTelemetry,
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerMetricSink,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { resolveDuplexAggregateCeilingBytesFromEnv } from "./duplex-aggregate-ceiling-env.js";
import { DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL } from "@paperclipai/adapter-utils/duplex-telemetry";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@ -754,9 +761,57 @@ export async function startServer(): Promise<StartedServer> {
databaseBackupInFlight = false;
}
};
const pluginWorkerManager = createPluginWorkerManager();
// The process-owned aggregate byte ledger for the sandbox duplex channel. One
// ledger per host process bounds the aggregate bytes that all live duplex routes
// retain. The route-count controller bounds only the route count, so without this
// ledger the per-route byte bounds multiply to many gigabytes at the maximum
// route count. The manager injects this same object into every worker handle, so
// one shared gauge bounds every host-side retention site.
//
// The optional operator override reads PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES.
// An absent variable uses the documented default. A present invalid, blank,
// whitespace-only, non-finite, zero, negative, non-integer, unsafe, or
// over-maximum value does not fail startup. The host process is multi-tenant, so
// one invalid environment value must not brick the whole host. The helper sends
// the raw string to the resolver, so a present blank value is invalid, not
// absent. The resolver rejects the invalid override and returns the safe default.
// The reporter logs the rejection loudly at error, so the misconfiguration stays
// visible while the host stays up. The log line carries only the rejected numeric
// value, never the raw string.
const duplexAggregateCeilingBytes = resolveDuplexAggregateCeilingBytesFromEnv(
process.env.PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
(rejectedValue) => {
logger.error(
{ rejectedValue },
"duplex aggregate byte ceiling override rejected; using the safe default",
);
},
);
// The server has no process metric pipeline yet, so the ledger telemetry maps to
// the structured logger. The gauge logs at debug. A reservation rejection logs at
// warn, because it marks an availability limit hit. An accounting-underflow defect
// logs at error, because it marks a real cleanup bug. Each record carries only the
// fixed metric name and the numeric value; no route, company, run, or payload
// value reaches a log line.
const duplexAggregateByteLedgerMetricSink: DuplexAggregateByteLedgerMetricSink = {
setGauge(name, value) {
logger.debug({ metric: name, value }, "duplex aggregate byte ledger gauge");
},
incrementCounter(name) {
if (name === DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL) {
logger.error({ metric: name }, "duplex aggregate byte ledger accounting underflow");
return;
}
logger.warn({ metric: name }, "duplex aggregate byte ledger reservation rejected");
},
};
const duplexAggregateByteLedger = new DuplexAggregateByteLedger({
ceilingBytes: duplexAggregateCeilingBytes,
telemetry: createDuplexAggregateByteLedgerTelemetry(duplexAggregateByteLedgerMetricSink),
});
const pluginWorkerManager = createPluginWorkerManager({ duplexAggregateByteLedger });
const heartbeat = config.heartbeatSchedulerEnabled
? heartbeatService(db as any, { pluginWorkerManager })
? heartbeatService(db as any, { pluginWorkerManager, duplexAggregateByteLedger })
: null;
const decisionServiceOptions = {
wakeOriginAgent: createDecisionWakeOriginAgent(heartbeat?.wakeup ?? null),

View File

@ -6,6 +6,7 @@ import {
type AdapterExecutionTarget,
} from "@paperclipai/adapter-utils/execution-target";
import type { DuplexTelemetryRecorder } from "@paperclipai/adapter-utils/duplex-telemetry";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import {
clampSpanLabel,
getActiveStepContext,
@ -206,6 +207,10 @@ export async function resolveEnvironmentExecutionTarget(input: {
// enters the sandbox environment. Absent keeps the safe no-op default in the
// bridge, so the surface stays inert until the host injects a real recorder.
duplexTelemetryRecorder?: DuplexTelemetryRecorder | null;
// The process-owned aggregate byte ledger. The seam stamps it onto the sandbox
// target next to the runner, so the live object stays on the host and never
// enters the sandbox environment. Absent keeps the bridge inert for this seam.
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}): Promise<AdapterExecutionTarget | null> {
if (input.environment.driver === "local") {
return {
@ -323,6 +328,10 @@ export async function resolveEnvironmentExecutionTarget(input: {
// binds it to the fixed observability surface. Absent keeps the no-op
// default, so the surface stays inert on a run with no injected recorder.
duplexTelemetryRecorder: input.duplexTelemetryRecorder ?? null,
// Attach the process-owned aggregate byte ledger next to the runner. The
// bridge passes it to the broker, the decoder, and the response-body reader.
// Absent keeps the bridge inert for this seam.
duplexAggregateByteLedger: input.duplexAggregateByteLedger ?? null,
...(effectiveCapabilities ? { effectiveCapabilities: Object.freeze({ ...effectiveCapabilities }) } : {}),
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,

View File

@ -43,6 +43,7 @@ import {
type AdapterWorkspaceRealization,
} from "@paperclipai/adapter-utils/execution-target";
import type { DuplexTelemetryRecorder } from "@paperclipai/adapter-utils/duplex-telemetry";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { buildWorkspaceRealizationRequest } from "./workspace-realization.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { logActivity } from "./activity-log.js";
@ -154,6 +155,14 @@ export function environmentRunOrchestrator(
options: {
pluginWorkerManager?: PluginWorkerManager;
environmentRuntime?: EnvironmentRuntimeService;
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel.
* The server root creates one ledger per host process and injects the same
* object here. The orchestrator stamps it onto the sandbox execution target,
* so one shared gauge bounds the aggregate retained bytes across all live
* duplex routes. Absent keeps the bridge inert for this seam.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
} = {},
) {
const environmentsSvc = environmentService(db);
@ -523,6 +532,7 @@ export function environmentRunOrchestrator(
lease,
environmentRuntime,
duplexTelemetryRecorder: input.duplexTelemetryRecorder ?? null,
duplexAggregateByteLedger: options.duplexAggregateByteLedger ?? null,
});
const realizationMode = workspaceRealization.mode === "in_place" ? "in_place" : "copy";
const authoritativeRoot =

View File

@ -72,6 +72,7 @@ import {
import { conflict, HttpError, notFound } from "../errors.js";
import { getStartupTraceContext, getStartupTracer } from "../instrumentation.js";
import { createHostDuplexTelemetryRecorder } from "./duplex-telemetry-recorder.js";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { incrementToolRuntimeMetricCounter } from "./tool-runtime-metrics.js";
import { logger } from "../middleware/logger.js";
import {
@ -6692,6 +6693,14 @@ export interface HeartbeatServiceOptions {
pluginWorkerManager?: PluginWorkerManager;
environmentRuntime?: HeartbeatEnvironmentRuntime;
runtimeEnv?: Record<string, string | undefined>;
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel. The
* server root creates one ledger per host process and injects the same object
* here. The heartbeat threads it into the environment run orchestrator, which
* stamps it onto the sandbox execution target. Absent keeps the bridge inert
* for this seam.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}
type WorkspaceReadyCommentWriter = {
@ -6815,6 +6824,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const envOrchestrator = environmentRunOrchestrator(db, {
pluginWorkerManager: options.pluginWorkerManager,
environmentRuntime,
duplexAggregateByteLedger: options.duplexAggregateByteLedger,
});
const workspaceOperationsSvc = workspaceOperationService(db);
const liveRunExecutions = {

View File

@ -57,10 +57,31 @@ import type {
InitializeParams,
} from "@paperclipai/plugin-sdk";
import { getActiveStepContext } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
type DuplexAggregateByteLedger,
type DuplexAggregateTokenOwner,
type ReservationToken,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
import { logger } from "../middleware/logger.js";
import { traceparentFromContextToken } from "../instrumentation.js";
/**
* The host raises this error when the child-stdin transport reservation for a
* duplex write fails against the aggregate byte ledger. The host does not write
* the frame. The write path throws it, `callInternal` rejects the RPC with it
* unwrapped, and the duplex write caller ends the route fail-closed with the
* {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED} marker. Only a duplex write
* meters the transport, so this error never reaches a non-duplex control message.
*/
class DuplexAggregateBytesExceededError extends Error {
constructor() {
super(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
this.name = "DuplexAggregateBytesExceededError";
}
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@ -397,6 +418,14 @@ export interface WorkerStartOptions {
* constructs a handle this way).
*/
duplexRouteSlots?: DuplexRouteSlotController | null;
/**
* The process-owned aggregate byte ledger for the duplex channel. The manager
* injects one shared instance into every worker handle, so one gauge bounds the
* aggregate retained bytes across every route in the process. When it is absent,
* the worker retains duplex bytes unbounded (a unit test constructs a handle
* this way). The manager never makes a fresh per-handle default.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
/**
* Companies this worker may act on from proactive (no-invocation) workerhost
* calls the plugin's configured companies. Seeded onto the handle at
@ -966,11 +995,42 @@ export function createPluginWorkerHandle(
// JSON-RPC message sending
// -----------------------------------------------------------------------
function sendMessage(message: unknown): void {
function sendMessage(message: unknown, meterDuplexWrite = false): void {
if (!childProcess?.stdin?.writable) {
throw new Error(`Worker process for plugin "${pluginId}" is not writable`);
}
const serialized = serializeMessage(message as any);
const ledger = duplexAggregateByteLedger;
if (meterDuplexWrite && ledger) {
// Charge the child-stdin transport buffer for a duplex write. The host writes
// the serialized frame to the child stdin without backpressure. When the child
// stops reading its stdin, the frame stays in the host stdin write buffer.
// Reserve the exact serialized-frame byte count. That count includes the JSON
// escaping and the newline framing, so the ledger covers the retained transport
// bytes, not only the raw payload. The RPC separately holds the raw payload
// under a `pending_write` token, so the two tokens cover the peak of both
// retentions at the same time.
const bytes = Buffer.byteLength(serialized);
const token = ledger.reserve("stdin_write", bytes);
if (!token) {
// The reservation would pass the aggregate ceiling. Fail closed before the
// enqueue: do not write the frame. The duplex write caller ends the route.
throw new DuplexAggregateBytesExceededError();
}
// Hold the token until the stream flushes the chunk. The write callback fires
// when the stream hands the chunk to the operating system, so the bytes then
// leave the host stream buffer. The RPC settle and the RPC timeout never
// release this token. Only the flush, the stream error, the stream close, or
// the worker exit releases it. Release through the outstanding-token set, so a
// later stream-error or worker-exit sweep never double-releases the same token.
pendingStdinWriteTokens.add(token);
childProcess.stdin.write(serialized, () => {
if (pendingStdinWriteTokens.delete(token)) {
ledger.release(token);
}
});
return;
}
childProcess.stdin.write(serialized);
}
@ -1550,12 +1610,37 @@ export function createPluginWorkerHandle(
// 6. total data bytes — the cumulative inbound bytes over the whole life,
// counted before and after a data listener attaches;
// 7. route lifetime — the milliseconds from the open to the terminal end.
// One buffered data chunk retained for a late listener drain. It carries the raw
// chunk string and the aggregate byte token that reserved its raw bytes. The
// token is `null` when no ledger is injected.
interface BufferedDuplexChunk {
chunk: string;
token: ReservationToken | null;
}
// One pre-bind data event, normalized to the narrow duplex-event schema. The host
// retains only these bounded scalar fields plus the reservation token, never the
// original arbitrary notification graph. The bind re-resolves the pair from
// `workerSessionId`, so a frame whose pair does not match the bound pair still
// fails closed.
interface HeldDuplexEvent {
workerSessionId: string;
chunk: string;
token: ReservationToken | null;
}
// One pre-bind exit event, normalized to the narrow duplex-event schema.
interface HeldDuplexExitEvent {
workerSessionId: string;
exitCode: number | null;
token: ReservationToken | null;
}
interface DuplexChannelRoute {
hostRouteId: string;
state: RouteState;
workerSessionId: string | null;
listener: ((chunk: string) => void) | null;
buffered: string[];
buffered: BufferedDuplexChunk[];
bufferedChars: number;
pendingRequests: number;
protocolErrors: number;
@ -1563,18 +1648,23 @@ export function createPluginWorkerHandle(
lifetimeTimer: ReturnType<typeof setTimeout> | null;
terminalized: boolean;
settleWait: (value: { exitCode: number | null; transportClosed?: boolean }) => void;
// The data frames that arrived before the bind, held in order. The bind
// replays them through the exact-pair routing, so an early frame is never
// lost and a frame whose pair does not match the bound pair still fails
// closed. The hold ceiling is `maxDuplexChannelPreBindHoldFrames`, one frame
// above the buffered bound, so the replay's buffered-bound check ends the
// route, not the hold.
preBind: JsonRpcNotification[];
// The single exit frame that arrived before the bind. An exit never consumes
// a data hold slot, so a worker that batches an exit among enough data frames
// to fill the hold cannot crowd out a data frame. The bind replays the held
// data frames first, then this exit last.
preBindExit: JsonRpcNotification | null;
// The bounded raw data events that arrived before the bind, held in order. The
// bind replays them through the exact-pair routing, so an early frame is never
// lost and a frame whose pair does not match the bound pair still fails closed.
// The hold ceiling is `maxDuplexChannelPreBindHoldFrames`, one frame above the
// buffered bound, so the replay's buffered-bound check ends the route, not the
// hold. Each held event carries the aggregate byte token that reserved its raw
// bytes; the host never retains the original arbitrary notification graph.
preBind: HeldDuplexEvent[];
// The single bounded exit event that arrived before the bind. An exit never
// consumes a data hold slot, so a worker that batches an exit among enough data
// frames to fill the hold cannot crowd out a data frame. The bind replays the
// held data events first, then this exit last.
preBindExit: HeldDuplexExitEvent | null;
// The authoritative registry of every aggregate byte token this route still
// holds, across the pre-bind, buffered, and terminal-buffered representations.
// The byte cleanup releases every token here exactly once.
retainedTokens: Set<ReservationToken>;
}
// The live duplex routes on this worker, keyed by the exact
// `{ hostRouteId, workerSessionId }` pair. The host binds one pair once, at
@ -1600,6 +1690,89 @@ export function createPluginWorkerHandle(
// time per route, so a double terminalize never releases two slots.
const duplexRouteSlotHolders = new Set<DuplexChannelRoute>();
// The process-owned aggregate byte ledger. The manager injects it, so one gauge
// bounds the aggregate retained bytes across every route in the process. When it
// is absent, the worker retains duplex bytes unbounded (a unit test constructs it
// this way).
const duplexAggregateByteLedger = options.duplexAggregateByteLedger ?? null;
// The outstanding child-stdin transport tokens for duplex writes. Each token
// covers one serialized frame the host stdin write buffer still retains. The
// write callback releases a token on the flush; a stream error, a stream close,
// or a worker exit releases every remaining token, because each of those
// discards the stdin write buffer. The set is the release guard, so a token
// releases one time across the two paths.
const pendingStdinWriteTokens = new Set<ReservationToken>();
// Release every outstanding child-stdin transport token. A stream error, a
// stream close, or a worker exit calls this, because each discards the stdin
// write buffer. The `delete` guard drops each token one time, so a later flush
// callback or a second sweep releases nothing again.
function releaseAllPendingStdinWriteTokens(): void {
if (!duplexAggregateByteLedger) return;
for (const token of pendingStdinWriteTokens) {
duplexAggregateByteLedger.release(token);
}
pendingStdinWriteTokens.clear();
}
// The terminalized routes that still hold buffered bytes for a late listener
// drain. A terminalized route leaves the opening and live maps, so this registry
// keeps the worker-exit sweep able to release its still-charged buffered tokens.
const terminalDuplexRoutes = new Set<DuplexChannelRoute>();
// Reserve `bytes` for one route retention against the aggregate ledger. Return
// the held token, or `null` when the reservation would pass the ceiling. When no
// ledger is present, return `"no-ledger"`, so the caller admits the retention
// with no token. The helper adds a real token to `route.retainedTokens`.
function reserveRouteBytes(
route: DuplexChannelRoute,
owner: DuplexAggregateTokenOwner,
bytes: number,
): ReservationToken | "no-ledger" | null {
if (!duplexAggregateByteLedger) return "no-ledger";
const token = duplexAggregateByteLedger.reserve(owner, bytes);
if (!token) return null;
route.retainedTokens.add(token);
return token;
}
// Transfer a held route token to a new owner label. The token identity and the
// reserved bytes stay the same, so no admission gap opens between two
// representations of the same retained bytes.
function transferRouteToken(
token: ReservationToken | null,
owner: DuplexAggregateTokenOwner,
): void {
if (!token) return;
duplexAggregateByteLedger?.transfer(token, owner);
}
// Release one held route token and drop it from the route registry, in one
// synchronous step. A `null` token (no ledger) releases nothing.
function releaseRouteToken(route: DuplexChannelRoute, token: ReservationToken | null): void {
if (!token) return;
route.retainedTokens.delete(token);
duplexAggregateByteLedger?.release(token);
}
// Release every token a route still holds and clear its retained representations,
// exactly once. A later call finds an empty registry and releases nothing, so the
// helper is idempotent. Every terminal-discard, open-failure, bind-replay
// failure, close-acknowledgement failure, worker-exit, and shutdown path calls
// it. Terminal map deletion is never treated as proof that retained bytes are
// gone; this registry is authoritative for byte cleanup.
function discardRouteRetained(route: DuplexChannelRoute): void {
if (duplexAggregateByteLedger) {
for (const token of route.retainedTokens) {
duplexAggregateByteLedger.release(token);
}
}
route.retainedTokens.clear();
route.preBind = [];
route.preBindExit = null;
route.buffered = [];
route.bufferedChars = 0;
terminalDuplexRoutes.delete(route);
}
// Try to reserve one aggregate route slot for a route. Return true when the
// route holds a slot after the call. When no controller is present, the route
// always holds a slot.
@ -1697,15 +1870,38 @@ export function createPluginWorkerHandle(
route.terminalized = true;
route.state = "closed";
route.listener = null;
// Keep the buffered chunks the host accepted before the route ended, so a
// listener that attaches after the end still drains them. A frame can end the
// route during the pre-bind replay, before a listener attaches, and the
// chunks the host accepted before that frame are valid data the listener must
// still receive. The buffered bytes stay bounded by the pre-bind buffered
// bound, and `onData` clears them once it drains them. Drop the held pre-bind
// frames, which never bound to a listener.
// Stop admission first. Then move the byte cleanup. Keep the buffered chunks the
// host accepted before the route ended, so a listener that attaches after the
// end still drains them. A frame can end the route during the pre-bind replay,
// before a listener attaches, and the chunks the host accepted before that frame
// are valid data the listener must still receive. The buffered bytes stay
// bounded by the pre-bind buffered bound, and `onData` clears and releases them
// once it drains them.
//
// The buffered records keep their tokens; move each token to the terminal owner
// label and register the route in the terminal registry, so the worker-exit
// sweep can release the still-charged bytes later. Do not release a buffered
// token at map deletion; terminal map deletion is never proof the bytes are
// gone. Release every other token the route still holds (the pre-bind events,
// the held exit, and any stranded token), because they never reach a listener.
const bufferedTokens = new Set<ReservationToken>();
for (const record of route.buffered) {
if (record.token) {
bufferedTokens.add(record.token);
transferRouteToken(record.token, "terminal_buffered");
}
}
for (const token of [...route.retainedTokens]) {
if (!bufferedTokens.has(token)) {
route.retainedTokens.delete(token);
duplexAggregateByteLedger?.release(token);
}
}
route.preBind = [];
route.preBindExit = null;
if (route.buffered.length > 0) {
terminalDuplexRoutes.add(route);
}
clearDuplexChannelLifetimeTimer(route);
// Remove the live binding and install the tombstone in one synchronous step,
// before the worker close and before any reuse. A reserved route that never
@ -1787,14 +1983,26 @@ export function createPluginWorkerHandle(
return "violation";
}
function routeDuplexChannelData(notification: JsonRpcNotification): void {
// Route one duplex channel data frame. On a live route with a listener, deliver
// the chunk transiently. On a live route with no listener, buffer the chunk and
// charge its raw bytes against the aggregate ledger. The `carried` argument marks
// a bind-replay frame: it carries the pre-bind token the buffered record must
// reuse, so the replay transfers the token to the buffered representation with no
// decrement and no re-reserve. A live frame passes no `carried`, so it reserves a
// fresh buffered token. A `carried.token` of `null` means no ledger is present.
function routeDuplexChannelData(
notification: JsonRpcNotification,
carried?: { token: ReservationToken | null },
): void {
const params = isRecord(notification.params) ? notification.params : {};
const hostRouteId = readNonEmptyString(params.hostRouteId);
const workerSessionId = readNonEmptyString(params.workerSessionId);
const resolved = resolveDuplexRouteByPair(hostRouteId, workerSessionId);
if (resolved === "tombstoned") {
// A late frame for a closed pair. It reaches no listener and changes no
// state. Never log the raw frame content.
// state. A bind replay never lands here, because the replayed route is live;
// a carried token stays on its route and the terminal cleanup releases it.
// Never log the raw frame content.
return;
}
if (resolved === "violation") {
@ -1804,8 +2012,8 @@ export function createPluginWorkerHandle(
return;
}
if (resolved === "opening") {
// The frame arrived before the bind. Hold it; the bind replays it through the
// exact-pair routing.
// The frame arrived before the bind. A replay never lands here, because the
// route is live during replay. Hold the live frame; the bind replays it.
bufferPreBindDuplexFrame(hostRouteId, notification);
return;
}
@ -1813,13 +2021,15 @@ export function createPluginWorkerHandle(
const chunk = params.chunk;
if (typeof chunk !== "string" || chunk.length === 0) {
// The exact pair matches, but the chunk is malformed. Count one per-route
// protocol error.
// protocol error. Release a carried replay token first.
releaseRouteToken(route, carried?.token ?? null);
recordDuplexChannelProtocolError(route);
return;
}
if (chunk.length > maxDuplexChannelChunkChars) {
// One inbound chunk is larger than the per-chunk limit. End the route at
// once. Do not count the chunk as a protocol error.
releaseRouteToken(route, carried?.token ?? null);
void terminalizeDuplexChannelRoute(route);
return;
}
@ -1828,12 +2038,16 @@ export function createPluginWorkerHandle(
// bound listener cannot receive data past the cap.
const chunkBytes = Buffer.byteLength(chunk);
if (route.totalDataBytes + chunkBytes > maxDuplexChannelTotalDataBytes) {
releaseRouteToken(route, carried?.token ?? null);
void terminalizeDuplexChannelRoute(route);
return;
}
route.totalDataBytes += chunkBytes;
if (route.listener) {
// A listener is attached. Deliver the chunk transiently and release a carried
// replay token after the synchronous delivery boundary.
deliverDuplexChannelChunk(route.listener, chunk);
releaseRouteToken(route, carried?.token ?? null);
return;
}
// No listener attached yet. Buffer the frame under the pre-bind bounds. End
@ -1842,10 +2056,28 @@ export function createPluginWorkerHandle(
route.buffered.length + 1 > maxDuplexChannelPreBindFrames ||
route.bufferedChars + chunk.length > maxDuplexChannelPreBindChars
) {
releaseRouteToken(route, carried?.token ?? null);
void terminalizeDuplexChannelRoute(route);
return;
}
route.buffered.push(chunk);
let token: ReservationToken | null;
if (carried) {
// The bind replay transfers the pre-bind token to the buffered
// representation. No decrement and no re-reserve, so no admission gap opens.
token = carried.token;
transferRouteToken(token, "buffered_chunk");
} else {
// A live frame reserves a fresh buffered token for its exact raw bytes.
const reserved = reserveRouteBytes(route, "buffered_chunk", chunkBytes);
if (reserved === null) {
// The aggregate ceiling rejected the reservation. Retain nothing and fail
// closed with the fixed marker.
void terminalizeDuplexChannelRoute(route);
return;
}
token = reserved === "no-ledger" ? null : reserved;
}
route.buffered.push({ chunk, token });
route.bufferedChars += chunk.length;
}
@ -1900,15 +2132,58 @@ export function createPluginWorkerHandle(
if (!hostRouteId) return;
const route = openingDuplexRoutes.get(hostRouteId);
if (!route) return;
const params = isRecord(notification.params) ? notification.params : {};
const workerSessionId = readNonEmptyString(params.workerSessionId);
if (!workerSessionId) {
// A malformed pair. Count one per-route protocol error and hold nothing.
recordDuplexChannelProtocolError(route);
return;
}
if (notification.method === DUPLEX_CHANNEL_EXIT_NOTIFICATION) {
route.preBindExit = notification;
// Normalize the exit to the narrow duplex-event schema. An exit retains only
// a bounded scalar, so reserve a zero-byte token that carries the record
// through the one cleanup path. Release any earlier held exit token first, so
// a replaced exit never leaks its reservation.
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
if (route.preBindExit) releaseRouteToken(route, route.preBindExit.token);
const reserved = reserveRouteBytes(route, "pre_bind_event", 0);
if (reserved === null) {
// The aggregate ceiling rejected the reservation. Fail closed.
void terminalizeDuplexChannelRoute(route);
return;
}
route.preBindExit = {
workerSessionId,
exitCode,
token: reserved === "no-ledger" ? null : reserved,
};
return;
}
// A data event. Validate and normalize it to the narrow duplex-event schema
// before any retention.
const chunk = params.chunk;
if (typeof chunk !== "string" || chunk.length === 0) {
recordDuplexChannelProtocolError(route);
return;
}
if (route.preBind.length >= maxDuplexChannelPreBindHoldFrames) {
recordDuplexChannelProtocolError(route);
return;
}
route.preBind.push(notification);
// Reserve the exact retained raw byte count before the host holds the event.
const reserved = reserveRouteBytes(route, "pre_bind_event", Buffer.byteLength(chunk));
if (reserved === null) {
// The aggregate ceiling rejected the reservation. The caller retains nothing
// and the route fails closed with the fixed marker.
log.warn({ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED }, "duplex pre-bind hold rejected");
void terminalizeDuplexChannelRoute(route);
return;
}
route.preBind.push({
workerSessionId,
chunk,
token: reserved === "no-ledger" ? null : reserved,
});
}
// Replay the frames a route held before it bound. The route is live now, so the
@ -1921,14 +2196,47 @@ export function createPluginWorkerHandle(
function replayPreBindDuplexFrames(route: DuplexChannelRoute): void {
const held = route.preBind;
route.preBind = [];
for (const notification of held) {
if (route.terminalized) break;
routeDuplexChannelData(notification);
for (const event of held) {
if (route.terminalized) {
// The route ended mid-replay. Release the remaining held tokens, so the
// unreplayed events retain nothing.
releaseRouteToken(route, event.token);
continue;
}
// Reconstruct a transient data notification for the exact-pair routing. The
// host holds only the bounded event, so it builds this notification for the
// routing step and discards it at once. The carried token moves to the
// buffered representation without a decrement or a re-reserve.
routeDuplexChannelData(
{
jsonrpc: "2.0",
method: DUPLEX_CHANNEL_DATA_NOTIFICATION,
params: {
hostRouteId: route.hostRouteId,
workerSessionId: event.workerSessionId,
chunk: event.chunk,
},
},
{ token: event.token },
);
}
const heldExit = route.preBindExit;
route.preBindExit = null;
if (heldExit && !route.terminalized) {
routeDuplexChannelExit(heldExit);
if (heldExit) {
// The exit retains only a bounded scalar. Release its placeholder token, then
// resolve the wait through the exact-pair routing when the route still lives.
releaseRouteToken(route, heldExit.token);
if (!route.terminalized) {
routeDuplexChannelExit({
jsonrpc: "2.0",
method: DUPLEX_CHANNEL_EXIT_NOTIFICATION,
params: {
hostRouteId: route.hostRouteId,
workerSessionId: heldExit.workerSessionId,
exitCode: heldExit.exitCode,
},
});
}
}
}
@ -1939,23 +2247,34 @@ export function createPluginWorkerHandle(
// id never returns, so no closed pair can revive on a restart. The pending
// channel calls reject through `rejectAllPending`.
function closeDuplexChannelRouteOnWorkerExit(): void {
const routes = [...openingDuplexRoutes.values(), ...liveDuplexRoutes.values()];
// Enumerate the opening index, the live index, and the terminal registry. A
// terminalized route left the opening and live maps but may still hold buffered
// bytes for a late listener; the worker is gone, so the host releases them now.
const routes = [
...openingDuplexRoutes.values(),
...liveDuplexRoutes.values(),
...terminalDuplexRoutes,
];
openingDuplexRoutes.clear();
liveDuplexRoutes.clear();
duplexPairTombstones.clear();
for (const route of routes) {
if (route.terminalized) continue;
route.terminalized = true;
route.state = "closed";
route.listener = null;
route.buffered = [];
route.bufferedChars = 0;
route.preBind = [];
route.preBindExit = null;
clearDuplexChannelLifetimeTimer(route);
releaseDuplexRouteSlot(route);
settleRouteWait(route, { exitCode: null });
if (!route.terminalized) {
route.terminalized = true;
route.state = "closed";
route.listener = null;
route.bufferedChars = 0;
clearDuplexChannelLifetimeTimer(route);
releaseDuplexRouteSlot(route);
settleRouteWait(route, { exitCode: null });
}
// Release every token the route still holds and clear its retained
// representations, exactly once. A route already drained by a late listener
// holds no token, so this is harmless and leaves the ledger at zero.
discardRouteRetained(route);
}
// Clear the terminal registry. Every route in it was just discarded above.
terminalDuplexRoutes.clear();
}
// Open one live generic duplex channel route. Reserve the route before the open
@ -1988,6 +2307,7 @@ export function createPluginWorkerHandle(
settleWait,
preBind: [],
preBindExit: null,
retainedTokens: new Set<ReservationToken>(),
};
// Reserve one aggregate route slot before any work. When the process-scoped
// ceiling is full, reject with the fixed route-busy error and open nothing, so
@ -2074,11 +2394,61 @@ export function createPluginWorkerHandle(
void terminalizeDuplexChannelRoute(route);
return;
}
// Reserve the exact UTF-8 byte count of a host→worker write against the
// aggregate ledger before `callInternal` retains the payload. A pending write
// RPC holds `params.data` until it settles, so this reservation bounds the
// aggregate host→worker pending-write bytes across every route. Compute the
// byte count with `Buffer.byteLength`, not `data.length`, because one
// character can encode as several UTF-8 bytes. A stop request carries no
// payload, so it reserves nothing. When no ledger is present, admit the write
// with no token (a unit test constructs the handle this way).
let pendingWriteToken: ReservationToken | null = null;
if (method === "duplexChannelWrite" && duplexAggregateByteLedger) {
const data = (params as HostToWorkerMethods["duplexChannelWrite"][0]).data;
const bytes = Buffer.byteLength(data, "utf8");
pendingWriteToken = duplexAggregateByteLedger.reserve("pending_write", bytes);
if (!pendingWriteToken) {
// The reservation would pass the aggregate ceiling. Retain nothing, do
// not enqueue the RPC, and end the route fail-closed with the aggregate
// marker, not the route-busy marker.
log.warn(
{ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED },
"duplex pending write reservation rejected",
);
void terminalizeDuplexChannelRoute(route);
return;
}
}
route.pendingRequests += 1;
void callInternal(method, params, duplexChannelOpenTimeoutMs)
.catch(() => {})
// Meter the child-stdin transport buffer only for a duplex write. A stop
// request carries a tiny fixed frame that the host never lets grow, so it
// does not meter or reject. The write path reserves the serialized frame in
// `sendMessage` right before the stdin write.
const meterDuplexWrite = method === "duplexChannelWrite";
void callInternal(method, params, duplexChannelOpenTimeoutMs, undefined, meterDuplexWrite)
.catch((err: unknown) => {
if (err instanceof DuplexAggregateBytesExceededError) {
// The transport reservation failed. The host did not write the frame.
// End the route fail-closed with the aggregate marker, not the
// route-busy marker.
log.warn(
{ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED },
"duplex stdin write reservation rejected",
);
void terminalizeDuplexChannelRoute(route);
}
})
.finally(() => {
route.pendingRequests -= 1;
// Release the pending-write token one time, after the RPC settles on any
// path: success, error, timeout, worker exit, or shutdown. The token is
// not in `route.retainedTokens`, so route terminalization never releases
// it; only this settlement releases it. The separate `stdin_write` token
// covers the serialized frame and releases on the stream flush, the
// stream error, the stream close, or the worker exit, never here.
if (pendingWriteToken) {
duplexAggregateByteLedger?.release(pendingWriteToken);
}
});
};
@ -2089,7 +2459,16 @@ export function createPluginWorkerHandle(
const pending = route.buffered;
route.buffered = [];
route.bufferedChars = 0;
for (const chunk of pending) deliverDuplexChannelChunk(listener, chunk);
for (const record of pending) {
// Deliver each buffered record, drop its retained buffer, and release
// its exact token before the drain proceeds to the next record. A
// terminal route drains through this same code.
deliverDuplexChannelChunk(listener, record.chunk);
releaseRouteToken(route, record.token);
}
// The buffered records are gone, so the route no longer holds terminal
// bytes for a late listener.
terminalDuplexRoutes.delete(route);
}
},
write(data: string): void {
@ -2392,6 +2771,18 @@ export function createPluginWorkerHandle(
readline.on("line", handleLine);
}
// Release the outstanding child-stdin transport tokens when the stdin stream
// errors or closes, because each discards the stdin write buffer. The `error`
// listener also stops an unhandled EPIPE from a child that closed its stdin.
if (child.stdin) {
child.stdin.on("error", () => {
releaseAllPendingStdinWriteTokens();
});
child.stdin.on("close", () => {
releaseAllPendingStdinWriteTokens();
});
}
// Capture stderr for logging
if (child.stderr) {
stderrReadline = createInterface({ input: child.stderr });
@ -2442,6 +2833,12 @@ export function createPluginWorkerHandle(
childProcess = null;
startedAt = null;
// The worker exit discards the child-stdin write buffer, so release every
// outstanding transport token. The RPC rejections below never release these
// tokens; only this sweep, a stream flush, a stream error, or a stream close
// releases them.
releaseAllPendingStdinWriteTokens();
// Reject all pending requests
rejectAllPending(
new Error(formatWorkerFailureMessage(
@ -2777,6 +3174,7 @@ export function createPluginWorkerHandle(
params: HostToWorkerMethods[M][0],
timeoutMs?: number,
executeLogSink?: ExecuteLogSink,
meterDuplexWrite = false,
): Promise<HostToWorkerMethods[M][1]> {
const rpcPromise = new Promise<HostToWorkerMethods[M][1]>((resolve, reject) => {
if (!childProcess?.stdin?.writable) {
@ -2850,19 +3248,26 @@ export function createPluginWorkerHandle(
...createRequest(method, params, id),
...(invocation ? { paperclipInvocation: invocation } : {}),
};
sendMessage(request);
sendMessage(request, meterDuplexWrite);
} catch (err) {
clearTimeout(timer);
pendingRequests.delete(id);
clearInvocation(invocation);
clearExecuteRoute(invocation?.id);
reject(
new Error(
`Failed to send "${method}" to worker: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
if (err instanceof DuplexAggregateBytesExceededError) {
// The transport reservation failed before the write. Reject with the
// typed error unwrapped, so the duplex write caller ends the route
// fail-closed with the aggregate marker.
reject(err);
} else {
reject(
new Error(
`Failed to send "${method}" to worker: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
}
});
@ -3035,6 +3440,14 @@ export interface PluginWorkerManagerOptions {
* stays upstream admission only.
*/
maxConcurrentDuplexRoutes?: number | null;
/**
* The process-owned aggregate byte ledger. The process root creates one ledger
* from validated configuration and passes it here. The manager injects the same
* object into every worker handle, so one gauge bounds the aggregate retained
* bytes across every route in the process. When it is absent, the worker retains
* duplex bytes unbounded. The manager never makes a fresh default ledger.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}
/**
@ -3106,6 +3519,11 @@ export function createPluginWorkerManager(
const duplexRouteSlots = createDuplexRouteSlotController(
managerOptions?.maxConcurrentDuplexRoutes,
);
// The one shared, process-owned aggregate byte ledger. The manager injects the
// same object into every worker handle, so one gauge bounds the aggregate
// retained bytes across every route in the process. It is `null` when the
// process root injected no ledger.
const duplexAggregateByteLedger = managerOptions?.duplexAggregateByteLedger ?? null;
return {
async startWorker(
@ -3127,9 +3545,11 @@ export function createPluginWorkerManager(
}
const handle = createPluginWorkerHandle(pluginId, {
// Inject the shared process-scoped route-slot controller, unless the caller
// already supplied one (a test may inject its own).
// Inject the shared process-scoped route-slot controller and the shared
// process-owned aggregate byte ledger, unless the caller already supplied
// its own (a test may inject its own).
duplexRouteSlots,
duplexAggregateByteLedger,
...options,
});
workers.set(pluginId, handle);