* fix(browser): prevent permanent timeout after stuck evaluate Thread AbortSignal from client-fetch through dispatcher to Playwright operations. When a timeout fires, force-disconnect the Playwright CDP connection to unblock the serialized command queue, allowing the next call to reconnect transparently. Key changes: - client-fetch.ts: proper AbortController with signal propagation - pw-session.ts: new forceDisconnectPlaywrightForTarget() - pw-tools-core.interactions.ts: accept signal, align inner timeout to outer-500ms, inject in-browser Promise.race for async evaluates - routes/dispatcher.ts + types.ts: propagate signal through dispatch - server.ts + bridge-server.ts: Express middleware creates AbortSignal from request lifecycle - client-actions-core.ts: add timeoutMs to evaluate type Fixes #10994 * fix(browser): v2 - force-disconnect via Connection.close() instead of browser.close() When page.evaluate() is stuck on a hung CDP transport, browser.close() also hangs because it tries to send a close command through the same stuck pipe. v2 fix: forceDisconnectPlaywrightForTarget now directly calls Playwright's internal Connection.close() which locally rejects all pending callbacks and emits 'disconnected' without touching the network. This instantly unblocks all stuck Playwright operations. closePlaywrightBrowserConnection (clean shutdown) now also has a 3s timeout fallback that drops to forceDropConnection if browser.close() hangs. Fixes permanent browser timeout after stuck evaluate. * fix(browser): v3 - fire-and-forget browser.close() instead of Connection.close() v2's forceDropConnection called browser._connection.close() which corrupts the entire Playwright instance because Connection is shared across all objects (BrowserType, Browser, Page, etc.). This prevented reconnection with cascading 'connectOverCDP: Force-disconnected' errors. v3 fix: forceDisconnectPlaywrightForTarget now: 1. Nulls cached connection immediately 2. Fire-and-forgets browser.close() (doesn't await — it may hang) 3. Next connectBrowser() creates a fresh connectOverCDP WebSocket Each connectOverCDP creates an independent WebSocket to the CDP endpoint, so the new connection is unaffected by the old one's pending close. The old browser.close() eventually resolves when the in-browser evaluate timeout fires, or the old connection gets GC'd. * fix(browser): v4 - clear connecting state and remove stale disconnect listeners The reconnect was failing because: 1. forceDisconnectPlaywrightForTarget nulled cached but not connecting, so subsequent calls could await a stale promise 2. The old browser's 'disconnected' event handler raced with new connections, nulling the fresh cached reference Fix: null both cached and connecting, and removeAllListeners on the old browser before fire-and-forget close. * fix(browser): v5 - use raw CDP Runtime.terminateExecution to kill stuck evaluate When forceDisconnectPlaywrightForTarget fires, open a raw WebSocket to the stuck page's CDP endpoint and send Runtime.terminateExecution. This kills running JS without navigating away or crashing the page. Also clear connecting state and remove stale disconnect listeners. * fix(browser): abort cancels stuck evaluate * Browser: always cleanup evaluate abort listener * Chore: remove Playwright debug scripts * Docs: add CDP evaluate refactor plan * Browser: refactor Playwright force-disconnect * Browser: abort stops evaluate promptly * Node host: extract withTimeout helper * Browser: remove disconnected listener safely * Changelog: note act:evaluate hang fix --------- Co-authored-by: Bob <bob@dutifulbob.com>
123 lines
3.5 KiB
TypeScript
123 lines
3.5 KiB
TypeScript
import type { Server } from "node:http";
|
|
import express from "express";
|
|
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
|
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
|
import { registerBrowserRoutes } from "./routes/index.js";
|
|
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
|
|
|
let state: BrowserServerState | null = null;
|
|
const log = createSubsystemLogger("browser");
|
|
const logServer = log.child("server");
|
|
|
|
export async function startBrowserControlServerFromConfig(): Promise<BrowserServerState | null> {
|
|
if (state) {
|
|
return state;
|
|
}
|
|
|
|
const cfg = loadConfig();
|
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
|
if (!resolved.enabled) {
|
|
return null;
|
|
}
|
|
|
|
const app = express();
|
|
app.use((req, res, next) => {
|
|
const ctrl = new AbortController();
|
|
const abort = () => ctrl.abort(new Error("request aborted"));
|
|
req.once("aborted", abort);
|
|
res.once("close", () => {
|
|
if (!res.writableEnded) {
|
|
abort();
|
|
}
|
|
});
|
|
// Make the signal available to browser route handlers (best-effort).
|
|
(req as unknown as { signal?: AbortSignal }).signal = ctrl.signal;
|
|
next();
|
|
});
|
|
app.use(express.json({ limit: "1mb" }));
|
|
|
|
const ctx = createBrowserRouteContext({
|
|
getState: () => state,
|
|
});
|
|
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
|
|
|
const port = resolved.controlPort;
|
|
const server = await new Promise<Server>((resolve, reject) => {
|
|
const s = app.listen(port, "127.0.0.1", () => resolve(s));
|
|
s.once("error", reject);
|
|
}).catch((err) => {
|
|
logServer.error(`openclaw browser server failed to bind 127.0.0.1:${port}: ${String(err)}`);
|
|
return null;
|
|
});
|
|
|
|
if (!server) {
|
|
return null;
|
|
}
|
|
|
|
state = {
|
|
server,
|
|
port,
|
|
resolved,
|
|
profiles: new Map(),
|
|
};
|
|
|
|
// If any profile uses the Chrome extension relay, start the local relay server eagerly
|
|
// so the extension can connect before the first browser action.
|
|
for (const name of Object.keys(resolved.profiles)) {
|
|
const profile = resolveProfile(resolved, name);
|
|
if (!profile || profile.driver !== "extension") {
|
|
continue;
|
|
}
|
|
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
|
|
logServer.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
|
|
});
|
|
}
|
|
|
|
logServer.info(`Browser control listening on http://127.0.0.1:${port}/`);
|
|
return state;
|
|
}
|
|
|
|
export async function stopBrowserControlServer(): Promise<void> {
|
|
const current = state;
|
|
if (!current) {
|
|
return;
|
|
}
|
|
|
|
const ctx = createBrowserRouteContext({
|
|
getState: () => state,
|
|
});
|
|
|
|
try {
|
|
const current = state;
|
|
if (current) {
|
|
for (const name of Object.keys(current.resolved.profiles)) {
|
|
try {
|
|
await ctx.forProfile(name).stopRunningBrowser();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logServer.warn(`openclaw browser stop failed: ${String(err)}`);
|
|
}
|
|
|
|
if (current.server) {
|
|
await new Promise<void>((resolve) => {
|
|
current.server?.close(() => resolve());
|
|
});
|
|
}
|
|
state = null;
|
|
|
|
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
|
try {
|
|
const mod = await import("./pw-ai.js");
|
|
await mod.closePlaywrightBrowserConnection();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|