diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1ec3789ae..d169aaec681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Browser/gateway: ignore Playwright dialog-close races from `Page.handleJavaScriptDialog` so browser automation no longer crashes the Gateway when a dialog disappears before Playwright accepts it. (#40067) Thanks @randyjtw. - Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus. - Doctor/TTS: migrate legacy `messages.tts.enabled`, agent TTS, channel TTS, and voice-call plugin TTS toggles to `auto` mode during `openclaw doctor --fix`, matching the documented TTS config contract. Thanks @vincentkoc. - CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator. diff --git a/extensions/browser/src/browser/runtime-lifecycle.ts b/extensions/browser/src/browser/runtime-lifecycle.ts index ae63ebb7d55..bb078900318 100644 --- a/extensions/browser/src/browser/runtime-lifecycle.ts +++ b/extensions/browser/src/browser/runtime-lifecycle.ts @@ -4,6 +4,7 @@ import { isPwAiLoaded } from "./pw-ai-state.js"; import type { BrowserServerState } from "./server-context.js"; import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js"; +import { registerBrowserUnhandledRejectionHandler } from "./unhandled-rejections.js"; export async function createBrowserRuntimeState(params: { resolved: BrowserServerState["resolved"]; @@ -25,6 +26,7 @@ export async function createBrowserRuntimeState(params: { resolved: params.resolved, onWarn: params.onWarn, }); + state.stopUnhandledRejectionHandler = registerBrowserUnhandledRejectionHandler(); return state; } @@ -39,28 +41,32 @@ export async function stopBrowserRuntime(params: { if (!params.current) { return; } - params.current.stopTrackedTabCleanup?.(); - - await stopKnownBrowserProfiles({ - getState: params.getState, - onWarn: params.onWarn, - }); - - if (params.closeServer && params.current.server) { - await new Promise((resolve) => { - params.current?.server?.close(() => resolve()); - }); - } - - params.clearState(); - - if (!isPwAiLoaded()) { - return; - } try { - const mod = await getPwAiModule({ mode: "soft" }); - await mod?.closePlaywrightBrowserConnection(); - } catch { - // ignore + params.current.stopTrackedTabCleanup?.(); + + await stopKnownBrowserProfiles({ + getState: params.getState, + onWarn: params.onWarn, + }); + + if (params.closeServer && params.current.server) { + await new Promise((resolve) => { + params.current?.server?.close(() => resolve()); + }); + } + + params.clearState(); + + if (!isPwAiLoaded()) { + return; + } + try { + const mod = await getPwAiModule({ mode: "soft" }); + await mod?.closePlaywrightBrowserConnection(); + } catch { + // ignore + } + } finally { + params.current.stopUnhandledRejectionHandler?.(); } } diff --git a/extensions/browser/src/browser/runtime-lifecycle.unhandled-rejections.test.ts b/extensions/browser/src/browser/runtime-lifecycle.unhandled-rejections.test.ts new file mode 100644 index 00000000000..f189b853870 --- /dev/null +++ b/extensions/browser/src/browser/runtime-lifecycle.unhandled-rejections.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getUnhandledRejectionHandlers, registerUnhandledRejectionHandlerMock, resetHandlers } = + vi.hoisted(() => { + let handlers: Array<(reason: unknown) => boolean> = []; + return { + getUnhandledRejectionHandlers: () => handlers, + registerUnhandledRejectionHandlerMock: vi.fn((handler: (reason: unknown) => boolean) => { + handlers.push(handler); + return () => { + handlers = handlers.filter((candidate) => candidate !== handler); + }; + }), + resetHandlers: () => { + handlers = []; + }, + }; + }); + +const { + ensureExtensionRelayForProfilesMock, + getPwAiModuleMock, + isPwAiLoadedMock, + startTrackedBrowserTabCleanupTimerMock, + stopKnownBrowserProfilesMock, + trackedTabCleanupMock, +} = vi.hoisted(() => { + const trackedTabCleanupMock = vi.fn(); + return { + ensureExtensionRelayForProfilesMock: vi.fn(async () => {}), + getPwAiModuleMock: vi.fn(), + isPwAiLoadedMock: vi.fn(() => false), + startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMock), + stopKnownBrowserProfilesMock: vi.fn(async () => {}), + trackedTabCleanupMock, + }; +}); + +vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ + registerUnhandledRejectionHandler: registerUnhandledRejectionHandlerMock, +})); + +vi.mock("./server-lifecycle.js", () => ({ + ensureExtensionRelayForProfiles: ensureExtensionRelayForProfilesMock, + stopKnownBrowserProfiles: stopKnownBrowserProfilesMock, +})); + +vi.mock("./session-tab-cleanup.js", () => ({ + startTrackedBrowserTabCleanupTimer: startTrackedBrowserTabCleanupTimerMock, +})); + +vi.mock("./pw-ai-state.js", () => ({ + isPwAiLoaded: isPwAiLoadedMock, +})); + +vi.mock("./pw-ai-module.js", () => ({ + getPwAiModule: getPwAiModuleMock, +})); + +const { createBrowserRuntimeState, stopBrowserRuntime } = await import("./runtime-lifecycle.js"); +const { isPlaywrightDialogRaceUnhandledRejection } = await import("./unhandled-rejections.js"); + +beforeEach(() => { + resetHandlers(); + registerUnhandledRejectionHandlerMock.mockClear(); + ensureExtensionRelayForProfilesMock.mockClear(); + getPwAiModuleMock.mockClear(); + isPwAiLoadedMock.mockReset().mockReturnValue(false); + startTrackedBrowserTabCleanupTimerMock.mockClear(); + stopKnownBrowserProfilesMock.mockClear(); + trackedTabCleanupMock.mockClear(); +}); + +describe("browser unhandled rejection lifecycle", () => { + it("matches direct and nested Playwright dialog-race protocol errors", () => { + const direct = Object.assign( + new Error("Protocol error (Page.handleJavaScriptDialog): No dialog is showing"), + { method: "Page.handleJavaScriptDialog" }, + ); + const nested = new Error("browser action failed", { + cause: Object.assign(new Error("No dialog is showing"), { + method: "Page.handleJavaScriptDialog", + }), + }); + const wrapped = { + error: new Error("Protocol error (Dialog.handleJavaScriptDialog): No dialog is showing"), + }; + + expect(isPlaywrightDialogRaceUnhandledRejection(direct)).toBe(true); + expect(isPlaywrightDialogRaceUnhandledRejection(nested)).toBe(true); + expect(isPlaywrightDialogRaceUnhandledRejection(wrapped)).toBe(true); + }); + + it("keeps non-dialog and non-race Playwright errors unhandled", () => { + expect( + isPlaywrightDialogRaceUnhandledRejection( + Object.assign(new Error("No dialog is showing"), { method: "Page.navigate" }), + ), + ).toBe(false); + expect( + isPlaywrightDialogRaceUnhandledRejection( + new Error("Protocol error (Page.handleJavaScriptDialog): Target closed"), + ), + ).toBe(false); + expect(isPlaywrightDialogRaceUnhandledRejection(new Error("No dialog is showing"))).toBe(false); + }); + + it("registers during startup and unregisters during shutdown", async () => { + stopKnownBrowserProfilesMock.mockImplementationOnce(async () => { + expect(getUnhandledRejectionHandlers()).toHaveLength(1); + }); + const state = await createBrowserRuntimeState({ + resolved: { profiles: {} } as never, + port: 18791, + onWarn: vi.fn(), + }); + + expect(registerUnhandledRejectionHandlerMock).toHaveBeenCalledTimes(1); + expect(getUnhandledRejectionHandlers()).toHaveLength(1); + expect( + getUnhandledRejectionHandlers()[0]?.( + new Error("Protocol error (Page.handleJavaScriptDialog): No dialog is showing"), + ), + ).toBe(true); + + const clearState = vi.fn(); + await stopBrowserRuntime({ + current: state, + getState: () => state, + clearState, + onWarn: vi.fn(), + }); + + expect(trackedTabCleanupMock).toHaveBeenCalledTimes(1); + expect(stopKnownBrowserProfilesMock).toHaveBeenCalledTimes(1); + expect(clearState).toHaveBeenCalledTimes(1); + expect(getUnhandledRejectionHandlers()).toEqual([]); + }); +}); diff --git a/extensions/browser/src/browser/server-context.types.ts b/extensions/browser/src/browser/server-context.types.ts index f495a6796bb..0a33bac03bc 100644 --- a/extensions/browser/src/browser/server-context.types.ts +++ b/extensions/browser/src/browser/server-context.types.ts @@ -37,6 +37,7 @@ export type BrowserServerState = { resolved: ResolvedBrowserConfig; profiles: Map; stopTrackedTabCleanup?: () => void; + stopUnhandledRejectionHandler?: () => void; }; type BrowserProfileActions = { diff --git a/extensions/browser/src/browser/unhandled-rejections.ts b/extensions/browser/src/browser/unhandled-rejections.ts new file mode 100644 index 00000000000..310c330b479 --- /dev/null +++ b/extensions/browser/src/browser/unhandled-rejections.ts @@ -0,0 +1,94 @@ +import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env"; + +const PLAYWRIGHT_DIALOG_METHODS = new Set([ + "Page.handleJavaScriptDialog", + "Dialog.handleJavaScriptDialog", +]); + +const NO_DIALOG_MESSAGE = "no dialog is showing"; + +function collectNestedErrorCandidates(err: unknown): unknown[] { + const queue: unknown[] = [err]; + const seen = new Set(); + const candidates: unknown[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (current == null || seen.has(current)) { + continue; + } + seen.add(current); + candidates.push(current); + + if (!current || typeof current !== "object") { + continue; + } + + const record = current as Record; + for (const nested of [ + record.cause, + record.reason, + record.original, + record.error, + record.data, + ]) { + if (nested != null && !seen.has(nested)) { + queue.push(nested); + } + } + if (Array.isArray(record.errors)) { + for (const nested of record.errors) { + if (nested != null && !seen.has(nested)) { + queue.push(nested); + } + } + } + } + + return candidates; +} + +function readMessage(err: unknown): string { + if (typeof err === "string") { + return err; + } + if (!err || typeof err !== "object") { + return ""; + } + const message = (err as { message?: unknown }).message; + return typeof message === "string" ? message : ""; +} + +function readPlaywrightMethod(err: unknown): string | undefined { + if (!err || typeof err !== "object") { + return undefined; + } + const method = (err as { method?: unknown }).method; + return typeof method === "string" ? method : undefined; +} + +export function isPlaywrightDialogRaceUnhandledRejection(reason: unknown): boolean { + for (const candidate of collectNestedErrorCandidates(reason)) { + const message = readMessage(candidate); + const normalizedMessage = message.toLowerCase(); + if (!normalizedMessage.includes(NO_DIALOG_MESSAGE)) { + continue; + } + + const method = readPlaywrightMethod(candidate); + if (method && PLAYWRIGHT_DIALOG_METHODS.has(method)) { + return true; + } + for (const playwrightMethod of PLAYWRIGHT_DIALOG_METHODS) { + if (message.includes(playwrightMethod)) { + return true; + } + } + } + + return false; +} + +export function registerBrowserUnhandledRejectionHandler(): () => void { + return registerUnhandledRejectionHandler(isPlaywrightDialogRaceUnhandledRejection); +}