diff --git a/server/src/http2-bridge-admission-env.test.ts b/server/src/http2-bridge-admission-env.test.ts new file mode 100644 index 00000000000..915434073d6 --- /dev/null +++ b/server/src/http2-bridge-admission-env.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS, + DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS, +} from "@paperclipai/adapter-utils/http2-bridge-admission"; +import { + resolveHttp2BridgeAdmissionCapsFromEnv, + resolveMaxLiveHttp2BridgeSessionsFromEnv, + resolveMaxParallelHttp2BridgeRequestsFromEnv, +} from "./http2-bridge-admission-env.js"; + +describe("resolveMaxParallelHttp2BridgeRequestsFromEnv", () => { + it("an absent variable uses the default", () => { + const onRejectedOverride = vi.fn(); + + const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv(undefined, onRejectedOverride); + + expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS); + expect(onRejectedOverride).not.toHaveBeenCalled(); + }); + + it("a blank or whitespace-only value is invalid and reports a rejection", () => { + const onRejectedOverride = vi.fn(); + + const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv(" ", onRejectedOverride); + + expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS); + expect(onRejectedOverride).toHaveBeenCalledTimes(1); + // The reporter receives only the parsed number, never the raw string. + const reportedValue = onRejectedOverride.mock.calls[0]?.[0]; + expect(typeof reportedValue).toBe("number"); + }); + + it("a value above the hard range is invalid and reports a rejection", () => { + const onRejectedOverride = vi.fn(); + + const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv("65", onRejectedOverride); + + expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS); + expect(onRejectedOverride).toHaveBeenCalledTimes(1); + }); + + it("a valid positive integer inside the range passes through unchanged", () => { + const onRejectedOverride = vi.fn(); + + const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv("32", onRejectedOverride); + + expect(resolved).toBe(32); + expect(onRejectedOverride).not.toHaveBeenCalled(); + }); +}); + +describe("resolveMaxLiveHttp2BridgeSessionsFromEnv", () => { + it("an absent variable uses the default", () => { + const onRejectedOverride = vi.fn(); + + const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv(undefined, onRejectedOverride); + + expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS); + expect(onRejectedOverride).not.toHaveBeenCalled(); + }); + + it("a blank or whitespace-only value is invalid and reports a rejection", () => { + const onRejectedOverride = vi.fn(); + + const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("", onRejectedOverride); + + expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS); + expect(onRejectedOverride).toHaveBeenCalledTimes(1); + }); + + it("a value above the hard range is invalid and reports a rejection", () => { + const onRejectedOverride = vi.fn(); + + const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("65", onRejectedOverride); + + expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS); + expect(onRejectedOverride).toHaveBeenCalledTimes(1); + }); + + it("a valid positive integer inside the range passes through unchanged", () => { + const onRejectedOverride = vi.fn(); + + const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("4", onRejectedOverride); + + expect(resolved).toBe(4); + expect(onRejectedOverride).not.toHaveBeenCalled(); + }); +}); + +describe("resolveHttp2BridgeAdmissionCapsFromEnv", () => { + it("an absent pair uses both defaults", () => { + const onRejectedOverride = vi.fn(); + const onRejectedPair = vi.fn(); + + const resolved = resolveHttp2BridgeAdmissionCapsFromEnv( + undefined, + undefined, + onRejectedOverride, + onRejectedPair, + ); + + expect(resolved).toEqual({ + maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS, + maxSessions: DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS, + }); + expect(onRejectedOverride).not.toHaveBeenCalled(); + expect(onRejectedPair).not.toHaveBeenCalled(); + }); + + it("a rejected per-field override calls the override reporter exactly once", () => { + const onRejectedOverride = vi.fn(); + const onRejectedPair = vi.fn(); + + const resolved = resolveHttp2BridgeAdmissionCapsFromEnv( + "not-a-number", + "4", + onRejectedOverride, + onRejectedPair, + ); + + expect(resolved).toEqual({ + maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS, + maxSessions: 4, + }); + // The pair resolver reruns each per-field check with no reporter bound, so + // only the env helper's own per-field pass reports the rejection. + expect(onRejectedOverride).toHaveBeenCalledTimes(1); + expect(onRejectedPair).not.toHaveBeenCalled(); + }); + + it("an over-budget environment pair of 64 and 64 resolves to both defaults and reports an error", () => { + const onRejectedOverride = vi.fn(); + const onRejectedPair = vi.fn(); + + const resolved = resolveHttp2BridgeAdmissionCapsFromEnv("64", "64", onRejectedOverride, onRejectedPair); + + expect(resolved).toEqual({ + maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS, + maxSessions: DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS, + }); + expect(onRejectedOverride).not.toHaveBeenCalled(); + expect(onRejectedPair).toHaveBeenCalledTimes(1); + const rejection = onRejectedPair.mock.calls[0]?.[0]; + expect(rejection).toEqual({ maxParallel: 64, maxSessions: 64, totalBytes: expect.any(Number) }); + }); +}); diff --git a/server/src/http2-bridge-admission-env.ts b/server/src/http2-bridge-admission-env.ts new file mode 100644 index 00000000000..30d12ecbd63 --- /dev/null +++ b/server/src/http2-bridge-admission-env.ts @@ -0,0 +1,79 @@ +import { + resolveHttp2BridgeAdmissionCaps, + resolveMaxLiveHttp2BridgeSessions, + resolveMaxParallelHttp2BridgeRequests, + type Http2BridgeAdmissionCapOverrideRejectionReporter, + type Http2BridgeAdmissionCaps, + type Http2BridgeAdmissionPairRejectionReporter, +} from "@paperclipai/adapter-utils/http2-bridge-admission"; + +/** + * Resolve the parallel-stream admission cap from the raw operator override + * string. The host reads the override from + * PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS. + * + * 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 resolveMaxParallelHttp2BridgeRequests} 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 resolveMaxParallelHttp2BridgeRequestsFromEnv( + rawOverride: string | undefined, + onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter, +): number { + return resolveMaxParallelHttp2BridgeRequests( + rawOverride === undefined ? undefined : Number(rawOverride), + onRejectedOverride, + ); +} + +/** + * Resolve the live-session admission cap from the raw operator override + * string. The host reads the override from + * PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS. Same present-versus-absent + * behavior as {@link resolveMaxParallelHttp2BridgeRequestsFromEnv}. + */ +export function resolveMaxLiveHttp2BridgeSessionsFromEnv( + rawOverride: string | undefined, + onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter, +): number { + return resolveMaxLiveHttp2BridgeSessions( + rawOverride === undefined ? undefined : Number(rawOverride), + onRejectedOverride, + ); +} + +/** + * Resolve one admission cap pair from the two raw operator override strings. + * + * {@link resolveHttp2BridgeAdmissionCaps} runs the two per-field resolvers + * without a rejection reporter, so a per-field rejection would stay silent if + * this helper only called that function. This helper runs each per-field + * resolver itself first, with `onRejectedOverride` bound, so a rejected + * PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS or + * PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS value always reports through + * `onRejectedOverride` exactly once. It then passes the two resolved numbers + * into {@link resolveHttp2BridgeAdmissionCaps}, which reruns the same + * per-field checks on two already-valid numbers (harmless) and then applies + * the joint budget rule. A pair that fails the joint rule reports through + * `onRejectedPair` and returns both defaults; the host never keeps one + * operator value and one default from a refused pair. + */ +export function resolveHttp2BridgeAdmissionCapsFromEnv( + rawParallel: string | undefined, + rawSessions: string | undefined, + onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter, + onRejectedPair?: Http2BridgeAdmissionPairRejectionReporter, +): Http2BridgeAdmissionCaps { + const maxParallel = resolveMaxParallelHttp2BridgeRequestsFromEnv(rawParallel, onRejectedOverride); + const maxSessions = resolveMaxLiveHttp2BridgeSessionsFromEnv(rawSessions, onRejectedOverride); + return resolveHttp2BridgeAdmissionCaps({ maxParallel, maxSessions }, onRejectedPair); +} diff --git a/server/src/index.ts b/server/src/index.ts index 3890076b041..c0804e963f4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -92,6 +92,12 @@ import { } 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-observability"; +import { + configureHttp2BridgeAdmissionGate, + http2BridgeAdmissionBudgetBytes, + HTTP2_BRIDGE_ADMISSION_RESERVED_BUDGET_BYTES, +} from "@paperclipai/adapter-utils/http2-bridge-admission"; +import { resolveHttp2BridgeAdmissionCapsFromEnv } from "./http2-bridge-admission-env.js"; import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; @@ -792,6 +798,51 @@ export async function startServer(): Promise { ); }, ); + // The process-wide admission gate for the host HTTP/2 sandbox bridge. Two + // optional operator overrides read PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS + // and PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS. 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 resolver rejects it, reports it at error level, and uses the + // safe default. Each per-field cap can pass its own range and the pair can + // still spend more than the reserved memory budget, so the resolver also + // checks the joint rule on the resolved pair. A pair that fails the joint + // rule gives both defaults and one error log line; the host never keeps one + // operator value and one default from a refused pair. Every log line carries + // only the parsed number, never the raw operator string. + const http2BridgeAdmissionCaps = resolveHttp2BridgeAdmissionCapsFromEnv( + process.env.PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS, + process.env.PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS, + (rejectedValue) => { + logger.error( + { rejectedValue }, + "HTTP/2 bridge admission cap override rejected; using the safe default", + ); + }, + (rejection) => { + logger.error( + { + maxParallel: rejection.maxParallel, + maxSessions: rejection.maxSessions, + totalBytes: rejection.totalBytes, + reservedBudgetBytes: HTTP2_BRIDGE_ADMISSION_RESERVED_BUDGET_BYTES, + }, + "HTTP/2 bridge admission cap pair refused; using the safe default pair", + ); + }, + ); + configureHttp2BridgeAdmissionGate(http2BridgeAdmissionCaps); + logger.info( + { + maxParallel: http2BridgeAdmissionCaps.maxParallel, + maxSessions: http2BridgeAdmissionCaps.maxSessions, + totalBytes: http2BridgeAdmissionBudgetBytes( + http2BridgeAdmissionCaps.maxParallel, + http2BridgeAdmissionCaps.maxSessions, + ), + }, + "HTTP/2 bridge admission gate configured", + ); // 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