fix(browser): ignore Playwright dialog race rejections

Carries forward #40067 from @randyjtw.

Validated:
- OPENCLAW_TESTBOX=1 pnpm check:changed (tbx_01kqc44esqmt15ygzvfxd1pqng)
- CI: https://github.com/openclaw/openclaw/actions/runs/25097879442
This commit is contained in:
openclaw-clownfish[bot]
2026-04-29 01:11:54 -07:00
committed by GitHub
parent 72cf700fbf
commit 4d43daa7bb
5 changed files with 263 additions and 22 deletions

View File

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

View File

@@ -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<void>((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<void>((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?.();
}
}

View File

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

View File

@@ -37,6 +37,7 @@ export type BrowserServerState = {
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
stopTrackedTabCleanup?: () => void;
stopUnhandledRejectionHandler?: () => void;
};
type BrowserProfileActions = {

View File

@@ -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<unknown>();
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<string, unknown>;
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);
}