mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox providers carry agent work through controlled execution channels > - The Daytona callback bridge uses a bespoke line-framed protocol over its duplex channel > - The bespoke protocol adds framing work and does not use the Node transport that already supports multiplexed streams > - This pull request carries raw bytes across the channel, adds a Node HTTP/2 bridge, and selects it for Daytona > - The benefit is one authenticated, multiplexed callback session with queue_v1 as the bounded fallback ## Linked Issues or Issue Description **Subsystem affected** The packages/plugins Daytona provider and the shared duplex execution path. **Problem or motivation** The Daytona callback bridge uses a bespoke line-framed protocol over the provider duplex channel. This adds protocol work and limits stream handling. **Proposed solution** Carry raw bytes through the cross-layer channel. Add an authenticated Node HTTP/2 host server and sandbox client gateway. Select http2_v1 for Daytona and retain queue_v1 as the fallback. **Alternatives considered** Keep the current duplex_v1 protocol. This keeps the bespoke framing path and does not provide one HTTP/2 session for callback streams. **Roadmap alignment** ROADMAP.md lists Daytona under cloud and sandbox agents. This change improves the shipped Daytona provider path. **Additional context** The branch adds no dependency. Node 24 provides the http2 module. The host token check and canonical path parser remain the single dispatch path. ## What Changed - Carry raw Uint8Array chunks through the adapter, plugin, worker, runtime, and Daytona layers. - Encode bytes as base64 only across the JSON-RPC hop, because JSON has no binary type. - Add the bounded host HTTP/2 server and the in-sandbox HTTP/2 client gateway. - Authenticate every stream with the per-run bridge token before route work. - Parse the path once and reuse the canonical result for route and forwarding work. - Select http2_v1 for Daytona and fall back once to queue_v1 when the client preface is absent. - Add transport, session, stream, and fallback telemetry. - Mark HTTP/2 as the preferred transport and queue_v1 as the soft-deprecated fallback. ## Verification - `npx vitest run packages/adapter-utils/src` — 990 passed and 4 skipped. - `npx vitest run server/src/__tests__/plugin-worker-manager-duplex.test.ts` — 32 passed. - `npx vitest run --config packages/plugins/sandbox-providers/daytona/vitest.config.ts` — 220 passed and 6 skipped. - `npx tsc --noEmit` in `packages/adapter-utils`, `packages/shared`, `packages/plugins/sdk`, and `server` — clean. - No `package.json` or `pnpm-lock.yaml` file changed. - The live Daytona test skips when `DAYTONA_API_KEY` is absent. - The root `npx tsc --noEmit` command has a pre-existing missing `packages/adapters/droid-local` reference on this branch and on `master`. ## Risks - The transport change affects several duplex layers and could expose byte-boundary errors. - A missing HTTP/2 client preface falls back once to queue_v1 and records `preface_missing`. - The host token check and canonical path parser must remain on the shared dispatch path. - The live Daytona test needs `DAYTONA_API_KEY` and does not run in this agent sandbox. ## Model Used OpenAI GPT-5, tool-enabled coding agent with repository inspection, GitHub CLI, and shell execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
414 lines
18 KiB
TypeScript
414 lines
18 KiB
TypeScript
/**
|
|
* 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-observability.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.
|
|
* - `http2_preface_scan`: the raw untrusted bytes the `http2_v1` preface scan
|
|
* retains while it searches for the client connection preface, from the
|
|
* readiness-replay handoff until the preface match or the scan cap. This
|
|
* is a separate retention from `readiness_replay`: it starts only after
|
|
* the readiness gate hands its own retained suffix to the preface scan.
|
|
* - `http2_preface_replay`: the bytes the `http2_v1` preface scan holds after the
|
|
* client connection preface, from the preface match until the HTTP/2 server
|
|
* binds its downstream listener. This is a separate retention from
|
|
* `http2_preface_scan`: the scan drops its own buffer on the preface match,
|
|
* 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",
|
|
"http2_preface_scan",
|
|
"http2_preface_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);
|
|
}
|
|
}
|