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) },