fix(browser): dedupe concurrent lazy start (#61772) (#61772)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Sukhdeep
2026-04-25 13:39:14 +08:00
committed by GitHub
parent d79b9e0af4
commit 6c1d4414d9
3 changed files with 50 additions and 1 deletions

View File

@@ -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.

View File

@@ -200,7 +200,9 @@ export function createProfileAvailability({
throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError));
};
const ensureBrowserAvailable = async (): Promise<void> => {
let inflightEnsureBrowserAvailable: Promise<void> | null = null;
const ensureBrowserAvailableOnce = async (): Promise<void> => {
await reconcileProfileRuntime();
if (capabilities.usesChromeMcp) {
if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) {
@@ -305,6 +307,16 @@ export function createProfileAvailability({
}
};
const ensureBrowserAvailable = async (): Promise<void> => {
if (inflightEnsureBrowserAvailable) {
return inflightEnsureBrowserAvailable;
}
inflightEnsureBrowserAvailable = ensureBrowserAvailableOnce().finally(() => {
inflightEnsureBrowserAvailable = null;
});
return inflightEnsureBrowserAvailable;
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
await reconcileProfileRuntime();
if (capabilities.usesChromeMcp) {

View File

@@ -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();