diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index b2c8c11edbe..a636116e084 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -3292,8 +3292,10 @@ describe("sandbox callback bridge", () => { async function startHttp2GatewayForTest(options: { bridgeToken: string; maxBodyBytes?: number; + holdProbeOpen?: () => boolean; + deferStderrUntilExit?: boolean; forwardRequest: (request: Http2BridgeForwardRequest) => Promise; - }): Promise<{ baseUrl: string; stop: () => Promise }> { + }, attempt = 0): Promise<{ baseUrl: string; stop: () => Promise }> { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-http2-test-")); cleanupDirs.push(rootDir); const entrypoint = path.join(rootDir, "paperclip-bridge-server.mjs"); @@ -3308,7 +3310,12 @@ describe("sandbox callback bridge", () => { reject(new Error("Could not reserve a loopback port for the test.")); return; } - probe.close(() => resolve(address.port)); + if (options.holdProbeOpen?.()) { + cleanupFns.push(() => new Promise((resolve) => probe.close(() => resolve()))); + resolve(address.port); + } else { + probe.close(() => resolve(address.port)); + } }); }); @@ -3330,6 +3337,16 @@ describe("sandbox callback bridge", () => { stderr += chunk.toString("utf8"); }); + if (options.deferStderrUntilExit) { + const resume = child.stderr.resume.bind(child.stderr); + child.stderr.pause(); + child.stderr.resume = () => child.stderr; + child.once("exit", () => setImmediate(() => { + child.stderr.resume = resume; + resume(); + })); + } + const dataListeners: Array<(chunk: Uint8Array) => void> = []; const pending: Buffer[] = []; let dispatchStarted = false; @@ -3367,7 +3384,7 @@ describe("sandbox callback bridge", () => { } deliver(chunk); }); - child.once("exit", (code) => { + child.once("close", (code) => { clearTimeout(readyTimer); if (!sawReadyLine) { rejectReady(new Error("The http2 gateway exited early with code " + String(code) + ". stderr: " + stderr)); @@ -3392,7 +3409,16 @@ describe("sandbox callback bridge", () => { }, }; - await readyPromise; + try { + await readyPromise; + } catch (error) { + child.kill(); + if (attempt < 2 && child.exitCode === 1 + && stderr.includes("http2 gateway could not bind port") && stderr.includes("EADDRINUSE")) { + return startHttp2GatewayForTest(options, attempt + 1); + } + throw error; + } const handle = createHttp2BridgeServer({ bridgeToken: options.bridgeToken, @@ -3828,15 +3854,38 @@ describe("sandbox callback bridge", () => { expect(seenRequests[0]?.body).toBe(requestBodyText); }); - it("accepts the bridge token in X-API-Key on the HTTP/2 path and strips the header", async () => { + it("does not retry unrelated HTTP/2 fixture startup failures", async () => { + let attempts = 0; + await expect(startHttp2GatewayForTest({ + bridgeToken: "", + holdProbeOpen: () => { attempts++; return false; }, + forwardRequest: async () => { throw new Error("Unexpected request to an unstarted gateway"); }, + })).rejects.toThrow("PAPERCLIP_BRIDGE_TOKEN"); + expect(attempts).toBe(1); + }); + + it("limits HTTP/2 fixture retries when every assigned port is occupied", async () => { + let attempts = 0; + await expect(startHttp2GatewayForTest({ + bridgeToken: createSandboxCallbackBridgeToken(), + holdProbeOpen: () => { attempts++; return true; }, + forwardRequest: async () => { throw new Error("Unexpected request to an unstarted gateway"); }, + })).rejects.toThrow("EADDRINUSE"); + expect(attempts).toBe(3); + }, 15000); + + it.each([[false, false], [true, false], [true, true]])("accepts the bridge token in X-API-Key on the HTTP/2 path and strips the header (collision=%s, late stderr=%s)", async (portCollision, lateStderr) => { // Both generated gateways share one token reader, so the HTTP/2 transport // must accept "X-API-Key" exactly as the file-mode transport does. The // header allowlist must still strip the credential before the gateway // forwards the request to the host. const bridgeToken = createSandboxCallbackBridgeToken(); const seenHeaders: Array> = []; + let attempts = 0; const gateway = await startHttp2GatewayForTest({ bridgeToken, + holdProbeOpen: () => { attempts++; return portCollision && attempts === 1; }, + deferStderrUntilExit: lateStderr, forwardRequest: async (request) => { seenHeaders.push(request.headers); return { @@ -3847,6 +3896,9 @@ describe("sandbox callback bridge", () => { }, }); + expect(attempts).toBeGreaterThanOrEqual(portCollision ? 2 : 1); + expect(attempts).toBeLessThanOrEqual(3); + const response = await fetch(`${gateway.baseUrl}/api/agents/me`, { headers: { "x-api-key": bridgeToken,