From 0ca1d23517eddf9ccc3fbd065ac8779038e601a0 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Thu, 16 Jul 2026 19:05:18 +0800 Subject: [PATCH] fix(sandbox): keep CDP probes within startup deadline (#108670) * fix(sandbox): bound CDP probes to startup deadline * test(sandbox): prove stalled CDP deadline --------- Co-authored-by: Peter Steinberger --- src/agents/sandbox/browser.create.test.ts | 92 +++++++++++++++++++++++ src/agents/sandbox/browser.ts | 4 +- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 3a9aa0d1bbd9..9d798daface3 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -1,6 +1,8 @@ // Sandbox browser creation tests cover Docker args, bridge auth, noVNC access, // config hashing, and cached bridge invalidation. import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo, Socket } from "node:net"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -772,6 +774,96 @@ describe("ensureSandboxBrowser create args", () => { ); }); + it("keeps a stalled CDP request inside the browser startup deadline", async () => { + const sockets = new Set(); + let requestPath: string | undefined; + let resolveRequestReceived: (() => void) | undefined; + const requestReceived = new Promise((resolve) => { + resolveRequestReceived = resolve; + }); + const server = createServer((req, _res) => { + requestPath = req.url; + req.resume(); + resolveRequestReceived?.(); + // Accept the CDP request but never send response headers. + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const cdpPort = (server.address() as AddressInfo).port; + dockerMocks.readDockerPort.mockImplementation(async (_containerName: string, port: number) => { + if (port === 9222) { + return cdpPort; + } + if (port === 6080) { + return 49101; + } + return null; + }); + bridgeMocks.startBrowserBridgeServer.mockImplementationOnce(async (params) => { + await params.onEnsureAttachTarget?.({}); + throw new Error("expected CDP startup to time out before bridge creation"); + }); + + const cfg = buildConfig(false); + cfg.browser.autoStartTimeoutMs = 25; + + const originalSetTimeout = globalThis.setTimeout; + let requestTimeoutMs: number | undefined; + let fireRequestTimeout: (() => void) | undefined; + // Fire the production request timer only after the real loopback server sees + // the request, keeping the stalled-fetch proof deterministic and fast. + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + timeout?: number, + ...args: unknown[] + ) => { + if (requestTimeoutMs === undefined) { + requestTimeoutMs = timeout; + fireRequestTimeout = () => callback(...args); + return 0 as unknown as ReturnType; + } + return originalSetTimeout(() => callback(...args), timeout); + }) as typeof setTimeout); + + try { + const startup = ensureTestSandboxBrowser({ + scopeKey: "session:test", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }); + await Promise.race([ + requestReceived, + new Promise((_resolve, reject) => { + originalSetTimeout( + () => reject(new Error("CDP request was not received")), + 1_000, + ).unref(); + }), + ]); + + expect(requestPath).toBe("/json/version"); + expect(requestTimeoutMs).toBeGreaterThanOrEqual(1); + expect(requestTimeoutMs).toBeLessThanOrEqual(cfg.browser.autoStartTimeoutMs); + fireRequestTimeout?.(); + await expect(startup).rejects.toThrow("hung container has been forcefully removed"); + } finally { + setTimeoutSpy.mockRestore(); + for (const socket of sockets) { + socket.destroy(); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("requires auth for the sandbox CDP relay without auto-derived source ranges", async () => { await ensureTestSandboxBrowser({ scopeKey: "session:test", diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 7c809baecc7a..4d57a22ba0df 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -90,8 +90,10 @@ async function waitForSandboxCdp(params: { const url = `http://127.0.0.1:${params.cdpPort}/json/version`; while (Date.now() < deadline) { try { + // Keep a stalled request inside the outer browser startup deadline. + const requestTimeoutMs = Math.max(1, Math.min(1000, deadline - Date.now())); const ctrl = new AbortController(); - const t = setTimeout(ctrl.abort.bind(ctrl), 1000); + const t = setTimeout(ctrl.abort.bind(ctrl), requestTimeoutMs); try { const res = await fetch(url, { headers: { Authorization: buildSandboxCdpAuthHeader(params.authToken) },