From d06cc77f38a15befd5d6e591c803bdc89483392e Mon Sep 17 00:00:00 2001 From: AaronWander Date: Sat, 28 Feb 2026 14:56:34 +0800 Subject: [PATCH] fix(browser): wait for CDP readiness after start (#21149) --- ...wser-available.waits-for-cdp-ready.test.ts | 77 +++++++++++++++++++ src/browser/server-context.ts | 22 ++++++ 2 files changed, 99 insertions(+) create mode 100644 src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts diff --git a/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts new file mode 100644 index 00000000000..92146de60b9 --- /dev/null +++ b/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -0,0 +1,77 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as chromeModule from "./chrome.js"; +import "./server-context.chrome-test-harness.js"; +import type { BrowserServerState } from "./server-context.js"; +import { createBrowserRouteContext } from "./server-context.js"; + +function makeBrowserState(): BrowserServerState { + return { + // oxlint-disable-next-line typescript/no-explicit-any + server: null as any, + port: 0, + resolved: { + enabled: true, + controlPort: 18791, + cdpProtocol: "http", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + evaluateEnabled: false, + remoteCdpTimeoutMs: 1500, + remoteCdpHandshakeTimeoutMs: 3000, + extraArgs: [], + color: "#FF4500", + headless: true, + noSandbox: false, + attachOnly: false, + ssrfPolicy: { allowPrivateNetwork: true }, + defaultProfile: "openclaw", + profiles: { + openclaw: { cdpPort: 18800, color: "#FF4500" }, + }, + }, + profiles: new Map(), + }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("browser server-context ensureBrowserAvailable", () => { + it("waits for CDP readiness after launching to avoid follow-up PortInUseError races (#21149)", async () => { + vi.useFakeTimers(); + + const launchOpenClawChrome = vi.mocked(chromeModule.launchOpenClawChrome); + const stopOpenClawChrome = vi.mocked(chromeModule.stopOpenClawChrome); + const isChromeReachable = vi.mocked(chromeModule.isChromeReachable); + const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady); + + isChromeReachable.mockResolvedValue(false); + isChromeCdpReady.mockResolvedValueOnce(false).mockResolvedValue(true); + + const proc = new EventEmitter() as unknown as ChildProcessWithoutNullStreams; + launchOpenClawChrome.mockResolvedValue({ + pid: 123, + exe: { kind: "chromium", path: "/usr/bin/chromium" }, + userDataDir: "/tmp/openclaw-test", + cdpPort: 18800, + startedAt: Date.now(), + proc, + }); + + const state = makeBrowserState(); + const ctx = createBrowserRouteContext({ getState: () => state }); + const profile = ctx.forProfile("openclaw"); + + const promise = profile.ensureBrowserAvailable(); + await vi.advanceTimersByTimeAsync(100); + await expect(promise).resolves.toBeUndefined(); + + expect(launchOpenClawChrome).toHaveBeenCalledTimes(1); + expect(isChromeCdpReady).toHaveBeenCalled(); + expect(stopOpenClawChrome).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 0dea84c715e..26e8ce5c38b 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -282,6 +282,21 @@ function createProfileContext( const isExtension = profile.driver === "extension"; const profileState = getProfileState(); const httpReachable = await isHttpReachable(); + const waitForCdpReadyAfterLaunch = async () => { + // launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS. + // If a follow-up call (snapshot/screenshot/etc.) races ahead, we can hit PortInUseError trying to + // launch again on the same port. Poll briefly so browser(action="start"/"open") is stable. + const maxAttempts = 50; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (await isReachable(1200)) { + return; + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error( + `Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`, + ); + }; if (isExtension && remoteCdp) { throw new Error( @@ -319,6 +334,13 @@ function createProfileContext( } const launched = await launchOpenClawChrome(current.resolved, profile); attachRunning(launched); + try { + await waitForCdpReadyAfterLaunch(); + } catch (err) { + await stopOpenClawChrome(launched).catch(() => {}); + setProfileRunning(null); + throw err; + } return; }