feat(browser): configure local startup timeouts

This commit is contained in:
Peter Steinberger
2026-04-25 12:29:47 +01:00
parent 4ac6729d12
commit b2b898c2a8
27 changed files with 231 additions and 14 deletions

View File

@@ -18,6 +18,8 @@ function buildResolvedConfig(): ResolvedBrowserConfig {
cdpIsLoopback: true,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
localLaunchTimeoutMs: 15_000,
localCdpReadyTimeoutMs: 8_000,
extraArgs: [],
color: DEFAULT_OPENCLAW_BROWSER_COLOR,
executablePath: undefined,

View File

@@ -1,3 +1,5 @@
import { DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS } from "./constants.js";
export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500;
export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000;
export const CDP_JSON_NEW_TIMEOUT_MS = 1500;
@@ -8,7 +10,7 @@ export const CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS = 10_000;
export const CHROME_BOOTSTRAP_PREFS_POLL_MS = 100;
export const CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS = 5000;
export const CHROME_BOOTSTRAP_EXIT_POLL_MS = 50;
export const CHROME_LAUNCH_READY_WINDOW_MS = 15_000;
export const CHROME_LAUNCH_READY_WINDOW_MS = DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS;
export const CHROME_LAUNCH_READY_POLL_MS = 200;
export const CHROME_STOP_TIMEOUT_MS = 2500;
export const CHROME_STOP_PROBE_TIMEOUT_MS = 200;

View File

@@ -374,6 +374,8 @@ describe("chrome.ts internal", () => {
headless: true,
noSandbox: true,
extraArgs: [],
localLaunchTimeoutMs: 15_000,
localCdpReadyTimeoutMs: 8_000,
}) as unknown as ResolvedBrowserConfig;
it("rejects a remote profile before attempting to spawn", async () => {
@@ -544,7 +546,11 @@ describe("chrome.ts internal", () => {
try {
vi.spyOn(fs, "existsSync").mockImplementation((p) => {
const s = String(p);
if (s.includes("google-chrome")) {
if (
s.includes("Google Chrome") ||
s.includes("google-chrome") ||
s.includes("/usr/bin/chromium")
) {
return true;
}
if (s.endsWith("Local State") || s.endsWith("Preferences")) {
@@ -574,6 +580,41 @@ describe("chrome.ts internal", () => {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
});
it("uses the configured local launch timeout while waiting for CDP discovery", async () => {
vi.useFakeTimers();
try {
const executablePath = path.join(tmpDir, "chrome");
await fsp.writeFile(executablePath, "");
const existsSync = fs.existsSync.bind(fs);
vi.spyOn(fs, "existsSync").mockImplementation((p) => {
const s = String(p);
if (s.endsWith("Local State") || s.endsWith("Preferences")) {
return true;
}
return existsSync(p);
});
const fakeProc = makeFakeProc();
spawnMock.mockReturnValue(fakeProc);
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
const resolved = {
...makeResolved(),
executablePath,
localLaunchTimeoutMs: 1,
};
const profile = makeProfile(55556);
const rejection = expect(launchOpenClawChrome(resolved, profile)).rejects.toThrow(
/Failed to start Chrome CDP/,
);
await vi.advanceTimersByTimeAsync(10);
await rejection;
expect(fakeProc.kill).toHaveBeenCalledWith("SIGKILL");
} finally {
vi.useRealTimers();
}
});
});
describe("stopOpenClawChrome SIGKILL fallback", () => {

View File

@@ -768,6 +768,8 @@ describe("browser chrome launch args", () => {
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
localLaunchTimeoutMs: 15_000,
localCdpReadyTimeoutMs: 8_000,
actionTimeoutMs: 60_000,
extraArgs: [],
color: "#FF4500",

View File

@@ -520,7 +520,8 @@ export async function launchOpenClawChrome(
proc.stderr?.on("data", onStderr);
try {
const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS;
const readyDeadline =
Date.now() + (resolved.localLaunchTimeoutMs ?? CHROME_LAUNCH_READY_WINDOW_MS);
while (Date.now() < readyDeadline) {
if (await isChromeReachable(profile.cdpUrl)) {
break;

View File

@@ -445,6 +445,35 @@ describe("browser config", () => {
});
});
describe("managed browser startup timeouts", () => {
it("uses defaults for local launch and post-launch readiness windows", () => {
const resolved = resolveBrowserConfig({});
expect(resolved.localLaunchTimeoutMs).toBe(15_000);
expect(resolved.localCdpReadyTimeoutMs).toBe(8_000);
});
it("accepts custom local startup timeout values", () => {
const resolved = resolveBrowserConfig({
localLaunchTimeoutMs: 45_000,
localCdpReadyTimeoutMs: 30_000,
});
expect(resolved.localLaunchTimeoutMs).toBe(45_000);
expect(resolved.localCdpReadyTimeoutMs).toBe(30_000);
});
it("clamps oversized local startup timeout values", () => {
const resolved = resolveBrowserConfig({
localLaunchTimeoutMs: 999_999,
localCdpReadyTimeoutMs: 999_999,
});
expect(resolved.localLaunchTimeoutMs).toBe(120_000);
expect(resolved.localCdpReadyTimeoutMs).toBe(120_000);
});
});
it("inherits executablePath from global browser config when profile override is not set", () => {
const resolved = resolveBrowserConfig({
executablePath: "~/bin/chrome-global",

View File

@@ -24,6 +24,8 @@ import {
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS,
DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS,
DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES,
DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION,
DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES,
@@ -39,6 +41,8 @@ export {
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS,
DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
@@ -69,6 +73,8 @@ export type ResolvedBrowserConfig = {
cdpIsLoopback: boolean;
remoteCdpTimeoutMs: number;
remoteCdpHandshakeTimeoutMs: number;
localLaunchTimeoutMs: number;
localCdpReadyTimeoutMs: number;
actionTimeoutMs: number;
color: string;
executablePath?: string;
@@ -106,6 +112,7 @@ export type ResolvedBrowserProfile = {
};
const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
const MAX_BROWSER_STARTUP_TIMEOUT_MS = 120_000;
export const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS";
export type ManagedBrowserHeadlessSource =
@@ -144,6 +151,14 @@ function normalizeTimeoutMs(raw: number | undefined, fallback: number): number {
return value < 0 ? fallback : value;
}
function normalizeStartupTimeoutMs(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
if (value <= 0) {
return fallback;
}
return Math.min(value, MAX_BROWSER_STARTUP_TIMEOUT_MS);
}
function normalizeNonNegativeInteger(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value < 0 ? fallback : value;
@@ -297,6 +312,14 @@ export function resolveBrowserConfig(
cfg?.remoteCdpHandshakeTimeoutMs,
Math.max(2000, remoteCdpTimeoutMs * 2),
);
const localLaunchTimeoutMs = normalizeStartupTimeoutMs(
cfg?.localLaunchTimeoutMs,
DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS,
);
const localCdpReadyTimeoutMs = normalizeStartupTimeoutMs(
cfg?.localCdpReadyTimeoutMs,
DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS,
);
const actionTimeoutMs = normalizeTimeoutMs(
cfg?.actionTimeoutMs,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
@@ -382,6 +405,8 @@ export function resolveBrowserConfig(
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
remoteCdpTimeoutMs,
remoteCdpHandshakeTimeoutMs,
localLaunchTimeoutMs,
localCdpReadyTimeoutMs,
actionTimeoutMs,
color: defaultColor,
executablePath,

View File

@@ -4,6 +4,8 @@ export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000;
export const DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS = 15_000;
export const DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS = 8_000;
export const DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS = 20_000;
export const DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES = 120;
export const DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION = 8;

View File

@@ -191,7 +191,8 @@ export function createProfileAvailability({
const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
// If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port.
const deadlineMs = Date.now() + CDP_READY_AFTER_LAUNCH_WINDOW_MS;
const deadlineMs =
Date.now() + (state().resolved.localCdpReadyTimeoutMs ?? CDP_READY_AFTER_LAUNCH_WINDOW_MS);
while (Date.now() < deadlineMs) {
const remainingMs = Math.max(0, deadlineMs - Date.now());
// Keep each attempt short; loopback profiles derive a WS timeout from this value.

View File

@@ -1,9 +1,11 @@
import { DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS } from "./constants.js";
export const MANAGED_BROWSER_PAGE_TAB_LIMIT = 8;
export const OPEN_TAB_DISCOVERY_WINDOW_MS = 2000;
export const OPEN_TAB_DISCOVERY_POLL_MS = 100;
export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = 8000;
export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS;
export const CDP_READY_AFTER_LAUNCH_POLL_MS = 100;
export const CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS = 75;
export const CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS = 250;

View File

@@ -90,6 +90,22 @@ describe("browser server-context ensureBrowserAvailable", () => {
expect(stopOpenClawChrome).toHaveBeenCalledTimes(1);
});
it("uses configured local CDP readiness timeout after launching", async () => {
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } =
setupEnsureBrowserAvailableHarness();
state.resolved.localCdpReadyTimeoutMs = 250;
isChromeCdpReady.mockResolvedValue(false);
mockLaunchedChrome(launchOpenClawChrome, 322);
const promise = profile.ensureBrowserAvailable();
const rejected = expect(promise).rejects.toThrow("not reachable after start");
await vi.advanceTimersByTimeAsync(300);
await rejected;
expect(launchOpenClawChrome).toHaveBeenCalledTimes(1);
expect(stopOpenClawChrome).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent lazy-start calls to prevent PortInUseError", async () => {
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
setupEnsureBrowserAvailableHarness();

View File

@@ -44,6 +44,8 @@ function makeState(): BrowserServerState {
cdpIsLoopback: true,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
localLaunchTimeoutMs: 15_000,
localCdpReadyTimeoutMs: 8_000,
actionTimeoutMs: 60_000,
color: "#FF4500",
headless: false,

View File

@@ -24,6 +24,8 @@ export function makeState(
cdpIsLoopback: profile !== "remote",
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
localLaunchTimeoutMs: 15_000,
localCdpReadyTimeoutMs: 8_000,
actionTimeoutMs: 60_000,
evaluateEnabled: false,
extraArgs: [],

View File

@@ -37,6 +37,8 @@ export function makeBrowserServerState(params?: {
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
localLaunchTimeoutMs: 15_000,
localCdpReadyTimeoutMs: 8_000,
actionTimeoutMs: 60_000,
extraArgs: [],
color: profile.color,