Skip to content

Commit d06cc77

Browse files
AaronWandersteipete
authored andcommitted
fix(browser): wait for CDP readiness after start (#21149)
1 parent 0d620a5 commit d06cc77

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
2+
import { EventEmitter } from "node:events";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import * as chromeModule from "./chrome.js";
5+
import "./server-context.chrome-test-harness.js";
6+
import type { BrowserServerState } from "./server-context.js";
7+
import { createBrowserRouteContext } from "./server-context.js";
8+
9+
function makeBrowserState(): BrowserServerState {
10+
return {
11+
// oxlint-disable-next-line typescript/no-explicit-any
12+
server: null as any,
13+
port: 0,
14+
resolved: {
15+
enabled: true,
16+
controlPort: 18791,
17+
cdpProtocol: "http",
18+
cdpHost: "127.0.0.1",
19+
cdpIsLoopback: true,
20+
evaluateEnabled: false,
21+
remoteCdpTimeoutMs: 1500,
22+
remoteCdpHandshakeTimeoutMs: 3000,
23+
extraArgs: [],
24+
color: "#FF4500",
25+
headless: true,
26+
noSandbox: false,
27+
attachOnly: false,
28+
ssrfPolicy: { allowPrivateNetwork: true },
29+
defaultProfile: "openclaw",
30+
profiles: {
31+
openclaw: { cdpPort: 18800, color: "#FF4500" },
32+
},
33+
},
34+
profiles: new Map(),
35+
};
36+
}
37+
38+
afterEach(() => {
39+
vi.useRealTimers();
40+
vi.restoreAllMocks();
41+
});
42+
43+
describe("browser server-context ensureBrowserAvailable", () => {
44+
it("waits for CDP readiness after launching to avoid follow-up PortInUseError races (#21149)", async () => {
45+
vi.useFakeTimers();
46+
47+
const launchOpenClawChrome = vi.mocked(chromeModule.launchOpenClawChrome);
48+
const stopOpenClawChrome = vi.mocked(chromeModule.stopOpenClawChrome);
49+
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
50+
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
51+
52+
isChromeReachable.mockResolvedValue(false);
53+
isChromeCdpReady.mockResolvedValueOnce(false).mockResolvedValue(true);
54+
55+
const proc = new EventEmitter() as unknown as ChildProcessWithoutNullStreams;
56+
launchOpenClawChrome.mockResolvedValue({
57+
pid: 123,
58+
exe: { kind: "chromium", path: "/usr/bin/chromium" },
59+
userDataDir: "/tmp/openclaw-test",
60+
cdpPort: 18800,
61+
startedAt: Date.now(),
62+
proc,
63+
});
64+
65+
const state = makeBrowserState();
66+
const ctx = createBrowserRouteContext({ getState: () => state });
67+
const profile = ctx.forProfile("openclaw");
68+
69+
const promise = profile.ensureBrowserAvailable();
70+
await vi.advanceTimersByTimeAsync(100);
71+
await expect(promise).resolves.toBeUndefined();
72+
73+
expect(launchOpenClawChrome).toHaveBeenCalledTimes(1);
74+
expect(isChromeCdpReady).toHaveBeenCalled();
75+
expect(stopOpenClawChrome).not.toHaveBeenCalled();
76+
});
77+
});

src/browser/server-context.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,21 @@ function createProfileContext(
282282
const isExtension = profile.driver === "extension";
283283
const profileState = getProfileState();
284284
const httpReachable = await isHttpReachable();
285+
const waitForCdpReadyAfterLaunch = async () => {
286+
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
287+
// If a follow-up call (snapshot/screenshot/etc.) races ahead, we can hit PortInUseError trying to
288+
// launch again on the same port. Poll briefly so browser(action="start"/"open") is stable.
289+
const maxAttempts = 50;
290+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
291+
if (await isReachable(1200)) {
292+
return;
293+
}
294+
await new Promise((r) => setTimeout(r, 100));
295+
}
296+
throw new Error(
297+
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`,
298+
);
299+
};
285300

286301
if (isExtension && remoteCdp) {
287302
throw new Error(
@@ -319,6 +334,13 @@ function createProfileContext(
319334
}
320335
const launched = await launchOpenClawChrome(current.resolved, profile);
321336
attachRunning(launched);
337+
try {
338+
await waitForCdpReadyAfterLaunch();
339+
} catch (err) {
340+
await stopOpenClawChrome(launched).catch(() => {});
341+
setProfileRunning(null);
342+
throw err;
343+
}
322344
return;
323345
}
324346

0 commit comments

Comments
 (0)