fix(adapter-utils): give the http2 bridge gateway response wait headroom over the forward budget

The in-sandbox HTTP/2 gateway waited for one response frame using the
same default as the host's own forward budget (30 s), with no relation
between the two even when the caller set a custom forward budget. A
valid host response landing near the forward deadline could lose the
race back across the stream before the gateway gave up. The gateway
wait now derives from the forward budget plus a fixed headroom, the
same gap the retired duplex_v1 broker's nested budgets already
reserved between its forward and gateway-wait entries.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Priya RamanandPaperclip committed 2026-08-27 01:35:15 +00:00
1 parent 72994853ff
commit 5f0dd15a66
2 files changed
+143 -6

No files matched your search

@@ -58,7 +58,7 @@ import {
decodeDuplexLine,
encodeDuplexFrame,
} from "./duplex-frame-codec.js";
import { DUPLEX_CHANNEL_LOST_ERROR_CODE } from "./bridge-transport-contract.js";
import { DEFAULT_DUPLEX_BROKER_BUDGETS, DUPLEX_CHANNEL_LOST_ERROR_CODE } from "./bridge-transport-contract.js";
import {
configureHttp2BridgeAdmissionGate,
getHttp2BridgeAdmissionGate,
@@ -5382,10 +5382,11 @@ describe("sandbox adapter execution targets", () => {
effectiveCapabilities: duplexCapabilities(true),
};
// A forward budget past the default response budget (32 s). The http2 path
// holds no nested-budget derivation (that budget set belonged to the retired
// duplex_v1 broker only), so a large forward budget must still select and
// serve normally.
// A forward budget past the default response budget (32 s). The host's
// own forward call may run for up to this whole budget, so the gateway's
// own response wait must carry headroom above it — otherwise the gateway
// gives up on a still-valid host response before it arrives.
const forwardTimeoutMs = 60_000;
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-budget",
target,
@@ -5394,10 +5395,17 @@ describe("sandbox adapter execution targets", () => {
hostApiToken: "real-run-jwt",
hostApiUrl: api.origin,
enableSandboxDuplexBridge: true,
forwardTimeoutMs: 60_000,
forwardTimeoutMs,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
// The gateway response wait carries the same headroom over the forward
// budget that `DEFAULT_DUPLEX_BROKER_BUDGETS` reserves between
// `forwardTimeoutMs` and `gatewayWaitMs`, so it scales with a custom
// forward budget instead of staying pinned at the 30 s default.
const expectedHeadroomMs =
DEFAULT_DUPLEX_BROKER_BUDGETS.gatewayWaitMs - DEFAULT_DUPLEX_BROKER_BUDGETS.forwardTimeoutMs;
expect(bridge?.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS).toBe(String(forwardTimeoutMs + expectedHeadroomMs));
await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000);
const response = await http2TestRequest(sessionRef.current!, {
method: "GET",
@@ -5412,6 +5420,118 @@ describe("sandbox adapter execution targets", () => {
}
}, 20000);
it("gives the in-sandbox http2 gateway response wait headroom over the default forward budget", async () => {
// With no forward-budget override, the gateway wait must still carry
// headroom above the default 30 s forward budget, not collapse to the
// same value. A response that lands right at the forward deadline needs
// this gap to cross back over the stream before the gateway gives up.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-default-budget-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const api = await startRecordingApiServer();
const { runner } = makeHttp2SelectionRunner((ctx) => {
ctx.emitReady();
ctx.connectHttp2();
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-default-budget",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: api.origin,
enableSandboxDuplexBridge: true,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
const responseTimeoutMs = Number(bridge?.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS);
expect(Number.isFinite(responseTimeoutMs)).toBe(true);
expect(responseTimeoutMs).toBeGreaterThan(DEFAULT_DUPLEX_BROKER_BUDGETS.forwardTimeoutMs);
} finally {
await bridge?.stop();
await api.close();
}
}, 20000);
it("delivers a host response that lands right before the forward deadline", async () => {
// The host response completes near, but under, the forward deadline. The
// gateway must still deliver it: the headroom on its own wait, not the
// forward budget itself, is what this test proves.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-near-deadline-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const forwardTimeoutMs = 300;
const nearDeadlineDelayMs = forwardTimeoutMs - 50;
const api = createServer((_req, res) => {
setTimeout(() => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
}, nearDeadlineDelayMs);
});
await new Promise<void>((resolve, reject) => {
api.once("error", reject);
api.listen(0, "127.0.0.1", () => resolve());
});
const apiAddress = api.address();
if (!apiAddress || typeof apiAddress === "string") {
throw new Error("Expected the near-deadline API server to listen on a TCP port.");
}
const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null };
let bridgeToken = "";
const { runner } = makeHttp2SelectionRunner((ctx) => {
bridgeToken = ctx.bridgeToken;
ctx.emitReady();
sessionRef.current = ctx.connectHttp2();
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-near-deadline",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${apiAddress.port}`,
enableSandboxDuplexBridge: true,
forwardTimeoutMs,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000);
const response = await http2TestRequest(sessionRef.current!, {
method: "GET",
path: "/api/agents/me",
headers: { authorization: `Bearer ${bridgeToken}` },
});
expect(response.status).toBe(200);
expect(JSON.parse(response.body)).toEqual({ ok: true });
} finally {
sessionRef.current?.close();
await bridge?.stop();
await new Promise<void>((resolve) => api.close(() => resolve()));
}
}, 20000);
it("test_a_missing_preface_aborts_the_open_and_falls_back_to_queue_v1", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-no-preface-"));
cleanupDirs.push(rootDir);
@@ -4219,6 +4219,18 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// forward budget (30 s) when the caller sets no option, so current behavior
// does not change.
const forwardTimeoutMs = input.forwardTimeoutMs ?? DEFAULT_DUPLEX_BROKER_BUDGETS.forwardTimeoutMs;
// The in-sandbox HTTP/2 gateway's own wait for one response frame, passed
// through `PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS` below. It must stay above
// `forwardTimeoutMs`: the host bounds its own forward call at that budget,
// so a host response that lands right at that deadline still needs time to
// cross back over the stream before the gateway gives up. This mirrors the
// gap `DEFAULT_DUPLEX_BROKER_BUDGETS` already reserves between its
// `forwardTimeoutMs` (30 s) and `gatewayWaitMs` (35 s) entries. The result
// stays finite; the gateway never waits forever after the settings
// handshake.
const http2GatewayResponseTimeoutMs =
forwardTimeoutMs +
(DEFAULT_DUPLEX_BROKER_BUDGETS.gatewayWaitMs - DEFAULT_DUPLEX_BROKER_BUDGETS.forwardTimeoutMs);
const runtimeRootDir =
input.runtimeRootDir?.trim().length
@@ -4731,6 +4743,11 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
PAPERCLIP_API_URL: sandboxOrigin,
PAPERCLIP_API_KEY: bridgeToken,
PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE,
// Without this, the generated gateway falls back to its own
// default wait, which equals the host's default forward
// budget and gives a near-deadline response no headroom to
// cross back over the stream before the gateway gives up.
PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: String(http2GatewayResponseTimeoutMs),
},
runLogTail: duplexRunLogTail,
readRunDisposition: (): DuplexBrokerRunDisposition => dispositionLatch.disposition,