mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 17:57:26 +00:00
* 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>
789 lines
23 KiB
TypeScript
789 lines
23 KiB
TypeScript
import type {
|
|
Browser,
|
|
BrowserContext,
|
|
ConsoleMessage,
|
|
Page,
|
|
Request,
|
|
Response,
|
|
} from "playwright-core";
|
|
import { chromium } from "playwright-core";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
|
|
import { normalizeCdpWsUrl } from "./cdp.js";
|
|
import { getChromeWebSocketUrl } from "./chrome.js";
|
|
|
|
export type BrowserConsoleMessage = {
|
|
type: string;
|
|
text: string;
|
|
timestamp: string;
|
|
location?: { url?: string; lineNumber?: number; columnNumber?: number };
|
|
};
|
|
|
|
export type BrowserPageError = {
|
|
message: string;
|
|
name?: string;
|
|
stack?: string;
|
|
timestamp: string;
|
|
};
|
|
|
|
export type BrowserNetworkRequest = {
|
|
id: string;
|
|
timestamp: string;
|
|
method: string;
|
|
url: string;
|
|
resourceType?: string;
|
|
status?: number;
|
|
ok?: boolean;
|
|
failureText?: string;
|
|
};
|
|
|
|
type SnapshotForAIResult = { full: string; incremental?: string };
|
|
type SnapshotForAIOptions = { timeout?: number; track?: string };
|
|
|
|
export type WithSnapshotForAI = {
|
|
_snapshotForAI?: (options?: SnapshotForAIOptions) => Promise<SnapshotForAIResult>;
|
|
};
|
|
|
|
type TargetInfoResponse = {
|
|
targetInfo?: {
|
|
targetId?: string;
|
|
};
|
|
};
|
|
|
|
type ConnectedBrowser = {
|
|
browser: Browser;
|
|
cdpUrl: string;
|
|
onDisconnected?: () => void;
|
|
};
|
|
|
|
type PageState = {
|
|
console: BrowserConsoleMessage[];
|
|
errors: BrowserPageError[];
|
|
requests: BrowserNetworkRequest[];
|
|
requestIds: WeakMap<Request, string>;
|
|
nextRequestId: number;
|
|
armIdUpload: number;
|
|
armIdDialog: number;
|
|
armIdDownload: number;
|
|
/**
|
|
* Role-based refs from the last role snapshot (e.g. e1/e2).
|
|
* Mode "role" refs are generated from ariaSnapshot and resolved via getByRole.
|
|
* Mode "aria" refs are Playwright aria-ref ids and resolved via `aria-ref=...`.
|
|
*/
|
|
roleRefs?: Record<string, { role: string; name?: string; nth?: number }>;
|
|
roleRefsMode?: "role" | "aria";
|
|
roleRefsFrameSelector?: string;
|
|
};
|
|
|
|
type RoleRefs = NonNullable<PageState["roleRefs"]>;
|
|
type RoleRefsCacheEntry = {
|
|
refs: RoleRefs;
|
|
frameSelector?: string;
|
|
mode?: NonNullable<PageState["roleRefsMode"]>;
|
|
};
|
|
|
|
type ContextState = {
|
|
traceActive: boolean;
|
|
};
|
|
|
|
const pageStates = new WeakMap<Page, PageState>();
|
|
const contextStates = new WeakMap<BrowserContext, ContextState>();
|
|
const observedContexts = new WeakSet<BrowserContext>();
|
|
const observedPages = new WeakSet<Page>();
|
|
|
|
// Best-effort cache to make role refs stable even if Playwright returns a different Page object
|
|
// for the same CDP target across requests.
|
|
const roleRefsByTarget = new Map<string, RoleRefsCacheEntry>();
|
|
const MAX_ROLE_REFS_CACHE = 50;
|
|
|
|
const MAX_CONSOLE_MESSAGES = 500;
|
|
const MAX_PAGE_ERRORS = 200;
|
|
const MAX_NETWORK_REQUESTS = 500;
|
|
|
|
let cached: ConnectedBrowser | null = null;
|
|
let connecting: Promise<ConnectedBrowser> | null = null;
|
|
|
|
function normalizeCdpUrl(raw: string) {
|
|
return raw.replace(/\/$/, "");
|
|
}
|
|
|
|
function roleRefsKey(cdpUrl: string, targetId: string) {
|
|
return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
|
|
}
|
|
|
|
export function rememberRoleRefsForTarget(opts: {
|
|
cdpUrl: string;
|
|
targetId: string;
|
|
refs: RoleRefs;
|
|
frameSelector?: string;
|
|
mode?: NonNullable<PageState["roleRefsMode"]>;
|
|
}): void {
|
|
const targetId = opts.targetId.trim();
|
|
if (!targetId) {
|
|
return;
|
|
}
|
|
roleRefsByTarget.set(roleRefsKey(opts.cdpUrl, targetId), {
|
|
refs: opts.refs,
|
|
...(opts.frameSelector ? { frameSelector: opts.frameSelector } : {}),
|
|
...(opts.mode ? { mode: opts.mode } : {}),
|
|
});
|
|
while (roleRefsByTarget.size > MAX_ROLE_REFS_CACHE) {
|
|
const first = roleRefsByTarget.keys().next();
|
|
if (first.done) {
|
|
break;
|
|
}
|
|
roleRefsByTarget.delete(first.value);
|
|
}
|
|
}
|
|
|
|
export function storeRoleRefsForTarget(opts: {
|
|
page: Page;
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
refs: RoleRefs;
|
|
frameSelector?: string;
|
|
mode: NonNullable<PageState["roleRefsMode"]>;
|
|
}): void {
|
|
const state = ensurePageState(opts.page);
|
|
state.roleRefs = opts.refs;
|
|
state.roleRefsFrameSelector = opts.frameSelector;
|
|
state.roleRefsMode = opts.mode;
|
|
if (!opts.targetId?.trim()) {
|
|
return;
|
|
}
|
|
rememberRoleRefsForTarget({
|
|
cdpUrl: opts.cdpUrl,
|
|
targetId: opts.targetId,
|
|
refs: opts.refs,
|
|
frameSelector: opts.frameSelector,
|
|
mode: opts.mode,
|
|
});
|
|
}
|
|
|
|
export function restoreRoleRefsForTarget(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
page: Page;
|
|
}): void {
|
|
const targetId = opts.targetId?.trim() || "";
|
|
if (!targetId) {
|
|
return;
|
|
}
|
|
const cached = roleRefsByTarget.get(roleRefsKey(opts.cdpUrl, targetId));
|
|
if (!cached) {
|
|
return;
|
|
}
|
|
const state = ensurePageState(opts.page);
|
|
if (state.roleRefs) {
|
|
return;
|
|
}
|
|
state.roleRefs = cached.refs;
|
|
state.roleRefsFrameSelector = cached.frameSelector;
|
|
state.roleRefsMode = cached.mode;
|
|
}
|
|
|
|
export function ensurePageState(page: Page): PageState {
|
|
const existing = pageStates.get(page);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const state: PageState = {
|
|
console: [],
|
|
errors: [],
|
|
requests: [],
|
|
requestIds: new WeakMap(),
|
|
nextRequestId: 0,
|
|
armIdUpload: 0,
|
|
armIdDialog: 0,
|
|
armIdDownload: 0,
|
|
};
|
|
pageStates.set(page, state);
|
|
|
|
if (!observedPages.has(page)) {
|
|
observedPages.add(page);
|
|
page.on("console", (msg: ConsoleMessage) => {
|
|
const entry: BrowserConsoleMessage = {
|
|
type: msg.type(),
|
|
text: msg.text(),
|
|
timestamp: new Date().toISOString(),
|
|
location: msg.location(),
|
|
};
|
|
state.console.push(entry);
|
|
if (state.console.length > MAX_CONSOLE_MESSAGES) {
|
|
state.console.shift();
|
|
}
|
|
});
|
|
page.on("pageerror", (err: Error) => {
|
|
state.errors.push({
|
|
message: err?.message ? String(err.message) : String(err),
|
|
name: err?.name ? String(err.name) : undefined,
|
|
stack: err?.stack ? String(err.stack) : undefined,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
if (state.errors.length > MAX_PAGE_ERRORS) {
|
|
state.errors.shift();
|
|
}
|
|
});
|
|
page.on("request", (req: Request) => {
|
|
state.nextRequestId += 1;
|
|
const id = `r${state.nextRequestId}`;
|
|
state.requestIds.set(req, id);
|
|
state.requests.push({
|
|
id,
|
|
timestamp: new Date().toISOString(),
|
|
method: req.method(),
|
|
url: req.url(),
|
|
resourceType: req.resourceType(),
|
|
});
|
|
if (state.requests.length > MAX_NETWORK_REQUESTS) {
|
|
state.requests.shift();
|
|
}
|
|
});
|
|
page.on("response", (resp: Response) => {
|
|
const req = resp.request();
|
|
const id = state.requestIds.get(req);
|
|
if (!id) {
|
|
return;
|
|
}
|
|
let rec: BrowserNetworkRequest | undefined;
|
|
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
|
|
const candidate = state.requests[i];
|
|
if (candidate && candidate.id === id) {
|
|
rec = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!rec) {
|
|
return;
|
|
}
|
|
rec.status = resp.status();
|
|
rec.ok = resp.ok();
|
|
});
|
|
page.on("requestfailed", (req: Request) => {
|
|
const id = state.requestIds.get(req);
|
|
if (!id) {
|
|
return;
|
|
}
|
|
let rec: BrowserNetworkRequest | undefined;
|
|
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
|
|
const candidate = state.requests[i];
|
|
if (candidate && candidate.id === id) {
|
|
rec = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!rec) {
|
|
return;
|
|
}
|
|
rec.failureText = req.failure()?.errorText;
|
|
rec.ok = false;
|
|
});
|
|
page.on("close", () => {
|
|
pageStates.delete(page);
|
|
observedPages.delete(page);
|
|
});
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
function observeContext(context: BrowserContext) {
|
|
if (observedContexts.has(context)) {
|
|
return;
|
|
}
|
|
observedContexts.add(context);
|
|
ensureContextState(context);
|
|
|
|
for (const page of context.pages()) {
|
|
ensurePageState(page);
|
|
}
|
|
context.on("page", (page) => ensurePageState(page));
|
|
}
|
|
|
|
export function ensureContextState(context: BrowserContext): ContextState {
|
|
const existing = contextStates.get(context);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const state: ContextState = { traceActive: false };
|
|
contextStates.set(context, state);
|
|
return state;
|
|
}
|
|
|
|
function observeBrowser(browser: Browser) {
|
|
for (const context of browser.contexts()) {
|
|
observeContext(context);
|
|
}
|
|
}
|
|
|
|
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
|
|
const normalized = normalizeCdpUrl(cdpUrl);
|
|
if (cached?.cdpUrl === normalized) {
|
|
return cached;
|
|
}
|
|
if (connecting) {
|
|
return await connecting;
|
|
}
|
|
|
|
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
|
|
let lastErr: unknown;
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
try {
|
|
const timeout = 5000 + attempt * 2000;
|
|
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
|
|
const endpoint = wsUrl ?? normalized;
|
|
const headers = getHeadersWithAuth(endpoint);
|
|
const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
|
|
const onDisconnected = () => {
|
|
if (cached?.browser === browser) {
|
|
cached = null;
|
|
}
|
|
};
|
|
const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected };
|
|
cached = connected;
|
|
browser.on("disconnected", onDisconnected);
|
|
observeBrowser(browser);
|
|
return connected;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
const delay = 250 + attempt * 250;
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
}
|
|
}
|
|
if (lastErr instanceof Error) {
|
|
throw lastErr;
|
|
}
|
|
const message = lastErr ? formatErrorMessage(lastErr) : "CDP connect failed";
|
|
throw new Error(message);
|
|
};
|
|
|
|
connecting = connectWithRetry().finally(() => {
|
|
connecting = null;
|
|
});
|
|
|
|
return await connecting;
|
|
}
|
|
|
|
async function getAllPages(browser: Browser): Promise<Page[]> {
|
|
const contexts = browser.contexts();
|
|
const pages = contexts.flatMap((c) => c.pages());
|
|
return pages;
|
|
}
|
|
|
|
async function pageTargetId(page: Page): Promise<string | null> {
|
|
const session = await page.context().newCDPSession(page);
|
|
try {
|
|
const info = (await session.send("Target.getTargetInfo")) as TargetInfoResponse;
|
|
const targetId = String(info?.targetInfo?.targetId ?? "").trim();
|
|
return targetId || null;
|
|
} finally {
|
|
await session.detach().catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function findPageByTargetId(
|
|
browser: Browser,
|
|
targetId: string,
|
|
cdpUrl?: string,
|
|
): Promise<Page | null> {
|
|
const pages = await getAllPages(browser);
|
|
// First, try the standard CDP session approach
|
|
for (const page of pages) {
|
|
const tid = await pageTargetId(page).catch(() => null);
|
|
if (tid && tid === targetId) {
|
|
return page;
|
|
}
|
|
}
|
|
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
|
|
// fall back to URL-based matching using the /json/list endpoint
|
|
if (cdpUrl) {
|
|
try {
|
|
const baseUrl = cdpUrl
|
|
.replace(/\/+$/, "")
|
|
.replace(/^ws:/, "http:")
|
|
.replace(/\/cdp$/, "");
|
|
const listUrl = `${baseUrl}/json/list`;
|
|
const response = await fetch(listUrl, { headers: getHeadersWithAuth(listUrl) });
|
|
if (response.ok) {
|
|
const targets = (await response.json()) as Array<{
|
|
id: string;
|
|
url: string;
|
|
title?: string;
|
|
}>;
|
|
const target = targets.find((t) => t.id === targetId);
|
|
if (target) {
|
|
// Try to find a page with matching URL
|
|
const urlMatch = pages.filter((p) => p.url() === target.url);
|
|
if (urlMatch.length === 1) {
|
|
return urlMatch[0];
|
|
}
|
|
// If multiple URL matches, use index-based matching as fallback
|
|
// This works when Playwright and the relay enumerate tabs in the same order
|
|
if (urlMatch.length > 1) {
|
|
const sameUrlTargets = targets.filter((t) => t.url === target.url);
|
|
if (sameUrlTargets.length === urlMatch.length) {
|
|
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
|
|
if (idx >= 0 && idx < urlMatch.length) {
|
|
return urlMatch[idx];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore fetch errors and fall through to return null
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function getPageForTargetId(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
}): Promise<Page> {
|
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
const pages = await getAllPages(browser);
|
|
if (!pages.length) {
|
|
throw new Error("No pages available in the connected browser.");
|
|
}
|
|
const first = pages[0];
|
|
if (!opts.targetId) {
|
|
return first;
|
|
}
|
|
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
|
if (!found) {
|
|
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
|
|
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
|
|
// only exposes a single Page, use it as a best-effort fallback.
|
|
if (pages.length === 1) {
|
|
return first;
|
|
}
|
|
throw new Error("tab not found");
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function refLocator(page: Page, ref: string) {
|
|
const normalized = ref.startsWith("@")
|
|
? ref.slice(1)
|
|
: ref.startsWith("ref=")
|
|
? ref.slice(4)
|
|
: ref;
|
|
|
|
if (/^e\d+$/.test(normalized)) {
|
|
const state = pageStates.get(page);
|
|
if (state?.roleRefsMode === "aria") {
|
|
const scope = state.roleRefsFrameSelector
|
|
? page.frameLocator(state.roleRefsFrameSelector)
|
|
: page;
|
|
return scope.locator(`aria-ref=${normalized}`);
|
|
}
|
|
const info = state?.roleRefs?.[normalized];
|
|
if (!info) {
|
|
throw new Error(
|
|
`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`,
|
|
);
|
|
}
|
|
const scope = state?.roleRefsFrameSelector
|
|
? page.frameLocator(state.roleRefsFrameSelector)
|
|
: page;
|
|
const locAny = scope as unknown as {
|
|
getByRole: (
|
|
role: never,
|
|
opts?: { name?: string; exact?: boolean },
|
|
) => ReturnType<Page["getByRole"]>;
|
|
};
|
|
const locator = info.name
|
|
? locAny.getByRole(info.role as never, { name: info.name, exact: true })
|
|
: locAny.getByRole(info.role as never);
|
|
return info.nth !== undefined ? locator.nth(info.nth) : locator;
|
|
}
|
|
|
|
return page.locator(`aria-ref=${normalized}`);
|
|
}
|
|
|
|
export async function closePlaywrightBrowserConnection(): Promise<void> {
|
|
const cur = cached;
|
|
cached = null;
|
|
connecting = null;
|
|
if (!cur) {
|
|
return;
|
|
}
|
|
if (cur.onDisconnected && typeof cur.browser.off === "function") {
|
|
cur.browser.off("disconnected", cur.onDisconnected);
|
|
}
|
|
await cur.browser.close().catch(() => {});
|
|
}
|
|
|
|
function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
|
|
try {
|
|
const url = new URL(cdpUrl);
|
|
if (url.protocol === "ws:") {
|
|
url.protocol = "http:";
|
|
} else if (url.protocol === "wss:") {
|
|
url.protocol = "https:";
|
|
}
|
|
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
|
|
url.pathname = url.pathname.replace(/\/cdp$/, "");
|
|
return url.toString().replace(/\/$/, "");
|
|
} catch {
|
|
// Best-effort fallback for non-URL-ish inputs.
|
|
return cdpUrl
|
|
.replace(/^ws:/, "http:")
|
|
.replace(/^wss:/, "https:")
|
|
.replace(/\/devtools\/browser\/.*$/, "")
|
|
.replace(/\/cdp$/, "")
|
|
.replace(/\/$/, "");
|
|
}
|
|
}
|
|
|
|
function cdpSocketNeedsAttach(wsUrl: string): boolean {
|
|
try {
|
|
const pathname = new URL(wsUrl).pathname;
|
|
return (
|
|
pathname === "/cdp" || pathname.endsWith("/cdp") || pathname.includes("/devtools/browser/")
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function tryTerminateExecutionViaCdp(opts: {
|
|
cdpUrl: string;
|
|
targetId: string;
|
|
}): Promise<void> {
|
|
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(opts.cdpUrl);
|
|
const listUrl = appendCdpPath(cdpHttpBase, "/json/list");
|
|
|
|
const pages = await fetchJson<
|
|
Array<{
|
|
id?: string;
|
|
webSocketDebuggerUrl?: string;
|
|
}>
|
|
>(listUrl, 2000).catch(() => null);
|
|
if (!pages || pages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const target = pages.find((p) => String(p.id ?? "").trim() === opts.targetId);
|
|
const wsUrlRaw = String(target?.webSocketDebuggerUrl ?? "").trim();
|
|
if (!wsUrlRaw) {
|
|
return;
|
|
}
|
|
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpHttpBase);
|
|
const needsAttach = cdpSocketNeedsAttach(wsUrl);
|
|
|
|
const runWithTimeout = async <T>(work: Promise<T>, ms: number): Promise<T> => {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error("CDP command timed out")), ms);
|
|
});
|
|
try {
|
|
return await Promise.race([work, timeoutPromise]);
|
|
} finally {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
};
|
|
|
|
await withCdpSocket(
|
|
wsUrl,
|
|
async (send) => {
|
|
let sessionId: string | undefined;
|
|
try {
|
|
if (needsAttach) {
|
|
const attached = (await runWithTimeout(
|
|
send("Target.attachToTarget", { targetId: opts.targetId, flatten: true }),
|
|
1500,
|
|
)) as { sessionId?: unknown };
|
|
if (typeof attached?.sessionId === "string" && attached.sessionId.trim()) {
|
|
sessionId = attached.sessionId;
|
|
}
|
|
}
|
|
await runWithTimeout(send("Runtime.terminateExecution", undefined, sessionId), 1500);
|
|
if (sessionId) {
|
|
// Best-effort cleanup; not required for termination to take effect.
|
|
void send("Target.detachFromTarget", { sessionId }).catch(() => {});
|
|
}
|
|
} catch {
|
|
// Best-effort; ignore
|
|
}
|
|
},
|
|
{ handshakeTimeoutMs: 2000 },
|
|
).catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Best-effort cancellation for stuck page operations.
|
|
*
|
|
* Playwright serializes CDP commands per page; a long-running or stuck operation (notably evaluate)
|
|
* can block all subsequent commands. We cannot safely "cancel" an individual command, and we do
|
|
* not want to close the actual Chromium tab. Instead, we disconnect Playwright's CDP connection
|
|
* so in-flight commands fail fast and the next request reconnects transparently.
|
|
*
|
|
* IMPORTANT: We CANNOT call Connection.close() because Playwright shares a single Connection
|
|
* across all objects (BrowserType, Browser, etc.). Closing it corrupts the entire Playwright
|
|
* instance, preventing reconnection.
|
|
*
|
|
* Instead we:
|
|
* 1. Null out `cached` so the next call triggers a fresh connectOverCDP
|
|
* 2. Fire-and-forget browser.close() — it may hang but won't block us
|
|
* 3. The next connectBrowser() creates a completely new CDP WebSocket connection
|
|
*
|
|
* The old browser.close() eventually resolves when the in-browser evaluate timeout fires,
|
|
* or the old connection gets GC'd. Either way, it doesn't affect the fresh connection.
|
|
*/
|
|
export async function forceDisconnectPlaywrightForTarget(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
reason?: string;
|
|
}): Promise<void> {
|
|
const normalized = normalizeCdpUrl(opts.cdpUrl);
|
|
if (cached?.cdpUrl !== normalized) {
|
|
return;
|
|
}
|
|
const cur = cached;
|
|
cached = null;
|
|
// Also clear `connecting` so the next call does a fresh connectOverCDP
|
|
// rather than awaiting a stale promise.
|
|
connecting = null;
|
|
if (cur) {
|
|
// Remove the "disconnected" listener to prevent the old browser's teardown
|
|
// from racing with a fresh connection and nulling the new `cached`.
|
|
if (cur.onDisconnected && typeof cur.browser.off === "function") {
|
|
cur.browser.off("disconnected", cur.onDisconnected);
|
|
}
|
|
|
|
// Best-effort: kill any stuck JS to unblock the target's execution context before we
|
|
// disconnect Playwright's CDP connection.
|
|
const targetId = opts.targetId?.trim() || "";
|
|
if (targetId) {
|
|
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {});
|
|
}
|
|
|
|
// Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe.
|
|
cur.browser.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all pages/tabs from the persistent Playwright connection.
|
|
* Used for remote profiles where HTTP-based /json/list is ephemeral.
|
|
*/
|
|
export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise<
|
|
Array<{
|
|
targetId: string;
|
|
title: string;
|
|
url: string;
|
|
type: string;
|
|
}>
|
|
> {
|
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
const pages = await getAllPages(browser);
|
|
const results: Array<{
|
|
targetId: string;
|
|
title: string;
|
|
url: string;
|
|
type: string;
|
|
}> = [];
|
|
|
|
for (const page of pages) {
|
|
const tid = await pageTargetId(page).catch(() => null);
|
|
if (tid) {
|
|
results.push({
|
|
targetId: tid,
|
|
title: await page.title().catch(() => ""),
|
|
url: page.url(),
|
|
type: "page",
|
|
});
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Create a new page/tab using the persistent Playwright connection.
|
|
* Used for remote profiles where HTTP-based /json/new is ephemeral.
|
|
* Returns the new page's targetId and metadata.
|
|
*/
|
|
export async function createPageViaPlaywright(opts: { cdpUrl: string; url: string }): Promise<{
|
|
targetId: string;
|
|
title: string;
|
|
url: string;
|
|
type: string;
|
|
}> {
|
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
const context = browser.contexts()[0] ?? (await browser.newContext());
|
|
ensureContextState(context);
|
|
|
|
const page = await context.newPage();
|
|
ensurePageState(page);
|
|
|
|
// Navigate to the URL
|
|
const targetUrl = opts.url.trim() || "about:blank";
|
|
if (targetUrl !== "about:blank") {
|
|
await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
|
// Navigation might fail for some URLs, but page is still created
|
|
});
|
|
}
|
|
|
|
// Get the targetId for this page
|
|
const tid = await pageTargetId(page).catch(() => null);
|
|
if (!tid) {
|
|
throw new Error("Failed to get targetId for new page");
|
|
}
|
|
|
|
return {
|
|
targetId: tid,
|
|
title: await page.title().catch(() => ""),
|
|
url: page.url(),
|
|
type: "page",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Close a page/tab by targetId using the persistent Playwright connection.
|
|
* Used for remote profiles where HTTP-based /json/close is ephemeral.
|
|
*/
|
|
export async function closePageByTargetIdViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId: string;
|
|
}): Promise<void> {
|
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
|
if (!page) {
|
|
throw new Error("tab not found");
|
|
}
|
|
await page.close();
|
|
}
|
|
|
|
/**
|
|
* Focus a page/tab by targetId using the persistent Playwright connection.
|
|
* Used for remote profiles where HTTP-based /json/activate can be ephemeral.
|
|
*/
|
|
export async function focusPageByTargetIdViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId: string;
|
|
}): Promise<void> {
|
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
|
if (!page) {
|
|
throw new Error("tab not found");
|
|
}
|
|
try {
|
|
await page.bringToFront();
|
|
} catch (err) {
|
|
const session = await page.context().newCDPSession(page);
|
|
try {
|
|
await session.send("Page.bringToFront");
|
|
return;
|
|
} catch {
|
|
throw err;
|
|
} finally {
|
|
await session.detach().catch(() => {});
|
|
}
|
|
}
|
|
}
|