diff --git a/CHANGELOG.md b/CHANGELOG.md index 470055e0b8c..1ed675222b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai - Browser/security: require `operator.admin` for the `browser.request` gateway method, matching the host/browser-node control authority exposed by that route. Thanks @RichardCao. - Browser/profiles: allow local managed profiles to override `browser.executablePath`, so different profiles can launch different Chromium-based browsers. Thanks @nobrainer-tech. - Agents/replay: repair displaced or missing tool results before strict provider replay, use Codex-compatible `aborted` outputs for OpenAI Responses history, and drop partial aborted/error transport turns before retries. +- Browser/startup: deduplicate concurrent lazy-start calls per profile so simultaneous browser tool requests no longer race into duplicate Chrome launches and `PortInUseError`. (#61772) Thanks @sukhdeepjohar. - Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev. - Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana. - CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319. diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index e72cab21a3f..27bc09f17fd 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -200,7 +200,9 @@ export function createProfileAvailability({ throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError)); }; - const ensureBrowserAvailable = async (): Promise => { + let inflightEnsureBrowserAvailable: Promise | null = null; + + const ensureBrowserAvailableOnce = async (): Promise => { await reconcileProfileRuntime(); if (capabilities.usesChromeMcp) { if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) { @@ -305,6 +307,16 @@ export function createProfileAvailability({ } }; + const ensureBrowserAvailable = async (): Promise => { + if (inflightEnsureBrowserAvailable) { + return inflightEnsureBrowserAvailable; + } + inflightEnsureBrowserAvailable = ensureBrowserAvailableOnce().finally(() => { + inflightEnsureBrowserAvailable = null; + }); + return inflightEnsureBrowserAvailable; + }; + const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { await reconcileProfileRuntime(); if (capabilities.usesChromeMcp) { diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index b6d64a9d511..9a044053834 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -88,6 +88,42 @@ describe("browser server-context ensureBrowserAvailable", () => { expect(stopOpenClawChrome).toHaveBeenCalledTimes(1); }); + it("deduplicates concurrent lazy-start calls to prevent PortInUseError", async () => { + const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } = + setupEnsureBrowserAvailableHarness(); + isChromeCdpReady.mockResolvedValue(true); + mockLaunchedChrome(launchOpenClawChrome, 456); + + const first = profile.ensureBrowserAvailable(); + const second = profile.ensureBrowserAvailable(); + await vi.advanceTimersByTimeAsync(100); + await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]); + + expect(launchOpenClawChrome).toHaveBeenCalledTimes(1); + expect(stopOpenClawChrome).not.toHaveBeenCalled(); + }); + + it("clears the concurrent lazy-start guard after launch failure", async () => { + const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } = + setupEnsureBrowserAvailableHarness(); + isChromeCdpReady.mockResolvedValue(true); + launchOpenClawChrome.mockRejectedValueOnce( + new Error("PortInUseError: listen EADDRINUSE 127.0.0.1:18800"), + ); + + const first = profile.ensureBrowserAvailable(); + const second = profile.ensureBrowserAvailable(); + await expect(Promise.all([first, second])).rejects.toThrow("PortInUseError"); + + mockLaunchedChrome(launchOpenClawChrome, 789); + const retry = profile.ensureBrowserAvailable(); + await vi.advanceTimersByTimeAsync(100); + await expect(retry).resolves.toBeUndefined(); + + expect(launchOpenClawChrome).toHaveBeenCalledTimes(2); + expect(stopOpenClawChrome).not.toHaveBeenCalled(); + }); + it("reuses a pre-existing loopback browser after an initial short probe miss", async () => { const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } = setupEnsureBrowserAvailableHarness();