2026-01-14 01:08:15 +00:00
|
|
|
import WebSocket from "ws";
|
2026-02-09 17:02:55 -08:00
|
|
|
import { isLoopbackHost } from "../gateway/net.js";
|
2026-03-15 08:22:48 -07:00
|
|
|
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { rawDataToString } from "../infra/ws.js";
|
2026-03-15 08:22:48 -07:00
|
|
|
import { redactSensitiveText } from "../logging/redact.js";
|
2026-03-02 15:41:58 +00:00
|
|
|
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
|
2026-03-02 16:02:21 +00:00
|
|
|
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
|
2026-03-10 14:49:31 -07:00
|
|
|
import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-09 17:02:55 -08:00
|
|
|
export { isLoopbackHost };
|
|
|
|
|
|
2026-03-02 00:49:55 -08:00
|
|
|
/**
|
|
|
|
|
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
|
2026-03-05 09:59:28 -08:00
|
|
|
* Used to distinguish direct-WebSocket CDP endpoints
|
2026-03-02 00:49:55 -08:00
|
|
|
* from HTTP(S) endpoints that require /json/version discovery.
|
|
|
|
|
*/
|
|
|
|
|
export function isWebSocketUrl(url: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
return parsed.protocol === "ws:" || parsed.protocol === "wss:";
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 08:22:48 -07:00
|
|
|
export async function assertCdpEndpointAllowed(
|
|
|
|
|
cdpUrl: string,
|
|
|
|
|
ssrfPolicy?: SsrFPolicy,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (!ssrfPolicy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const parsed = new URL(cdpUrl);
|
|
|
|
|
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
|
|
|
|
|
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
|
|
|
|
|
}
|
|
|
|
|
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
|
|
|
|
policy: ssrfPolicy,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {
|
|
|
|
|
if (typeof cdpUrl !== "string") {
|
|
|
|
|
return cdpUrl;
|
|
|
|
|
}
|
|
|
|
|
const trimmed = cdpUrl.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(trimmed);
|
|
|
|
|
parsed.username = "";
|
|
|
|
|
parsed.password = "";
|
|
|
|
|
return redactSensitiveText(parsed.toString().replace(/\/$/, ""));
|
|
|
|
|
} catch {
|
|
|
|
|
return redactSensitiveText(trimmed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 01:08:15 +00:00
|
|
|
type CdpResponse = {
|
|
|
|
|
id: number;
|
|
|
|
|
result?: unknown;
|
|
|
|
|
error?: { message?: string };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Pending = {
|
|
|
|
|
resolve: (value: unknown) => void;
|
|
|
|
|
reject: (err: Error) => void;
|
|
|
|
|
};
|
|
|
|
|
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
export type CdpSendFn = (
|
|
|
|
|
method: string,
|
|
|
|
|
params?: Record<string, unknown>,
|
|
|
|
|
sessionId?: string,
|
|
|
|
|
) => Promise<unknown>;
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-16 09:18:53 +00:00
|
|
|
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
|
2026-03-15 23:56:08 -07:00
|
|
|
const mergedHeaders = { ...headers };
|
2026-01-16 08:31:51 +00:00
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url);
|
2026-02-01 02:25:14 -08:00
|
|
|
const hasAuthHeader = Object.keys(mergedHeaders).some(
|
|
|
|
|
(key) => key.toLowerCase() === "authorization",
|
|
|
|
|
);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (hasAuthHeader) {
|
2026-02-01 02:25:14 -08:00
|
|
|
return mergedHeaders;
|
2026-01-31 16:19:20 +09:00
|
|
|
}
|
2026-01-16 08:31:51 +00:00
|
|
|
if (parsed.username || parsed.password) {
|
|
|
|
|
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
2026-02-01 02:25:14 -08:00
|
|
|
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
|
2026-01-16 08:31:51 +00:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-01 02:25:14 -08:00
|
|
|
return mergedHeaders;
|
2026-01-16 08:31:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function appendCdpPath(cdpUrl: string, path: string): string {
|
|
|
|
|
const url = new URL(cdpUrl);
|
|
|
|
|
const basePath = url.pathname.replace(/\/$/, "");
|
|
|
|
|
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
|
|
|
url.pathname = `${basePath}${suffix}`;
|
|
|
|
|
return url.toString();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 18:47:48 +00:00
|
|
|
export 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(/\/$/, "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 01:08:15 +00:00
|
|
|
function createCdpSender(ws: WebSocket) {
|
|
|
|
|
let nextId = 1;
|
|
|
|
|
const pending = new Map<number, Pending>();
|
|
|
|
|
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
const send: CdpSendFn = (
|
|
|
|
|
method: string,
|
|
|
|
|
params?: Record<string, unknown>,
|
|
|
|
|
sessionId?: string,
|
|
|
|
|
) => {
|
2026-01-14 01:08:15 +00:00
|
|
|
const id = nextId++;
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
const msg = { id, method, params, sessionId };
|
2026-01-14 01:08:15 +00:00
|
|
|
ws.send(JSON.stringify(msg));
|
|
|
|
|
return new Promise<unknown>((resolve, reject) => {
|
|
|
|
|
pending.set(id, { resolve, reject });
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const closeWithError = (err: Error) => {
|
2026-01-31 16:19:20 +09:00
|
|
|
for (const [, p] of pending) {
|
|
|
|
|
p.reject(err);
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
pending.clear();
|
|
|
|
|
try {
|
|
|
|
|
ws.close();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
ws.on("error", (err) => {
|
|
|
|
|
closeWithError(err instanceof Error ? err : new Error(String(err)));
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-14 01:08:15 +00:00
|
|
|
ws.on("message", (data) => {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
|
2026-01-31 16:19:20 +09:00
|
|
|
if (typeof parsed.id !== "number") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
const p = pending.get(parsed.id);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!p) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
pending.delete(parsed.id);
|
|
|
|
|
if (parsed.error?.message) {
|
|
|
|
|
p.reject(new Error(parsed.error.message));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
p.resolve(parsed.result);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.on("close", () => {
|
|
|
|
|
closeWithError(new Error("CDP socket closed"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { send, closeWithError };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:02:21 +00:00
|
|
|
export async function fetchJson<T>(
|
|
|
|
|
url: string,
|
|
|
|
|
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
|
|
|
|
init?: RequestInit,
|
|
|
|
|
): Promise<T> {
|
2026-03-02 15:41:58 +00:00
|
|
|
const res = await fetchCdpChecked(url, timeoutMs, init);
|
2026-02-18 18:33:40 +00:00
|
|
|
return (await res.json()) as T;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 15:41:58 +00:00
|
|
|
export async function fetchCdpChecked(
|
|
|
|
|
url: string,
|
2026-03-02 16:02:21 +00:00
|
|
|
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
2026-03-02 15:41:58 +00:00
|
|
|
init?: RequestInit,
|
|
|
|
|
): Promise<Response> {
|
2026-01-14 01:08:15 +00:00
|
|
|
const ctrl = new AbortController();
|
2026-02-06 20:30:29 -03:00
|
|
|
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
2026-01-14 01:08:15 +00:00
|
|
|
try {
|
2026-01-16 09:18:53 +00:00
|
|
|
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
2026-03-02 15:41:58 +00:00
|
|
|
const res = await withNoProxyForCdpUrl(url, () =>
|
2026-03-02 09:03:29 +01:00
|
|
|
fetch(url, { ...init, headers, signal: ctrl.signal }),
|
|
|
|
|
);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!res.ok) {
|
2026-03-10 14:49:31 -07:00
|
|
|
if (res.status === 429) {
|
|
|
|
|
// Do not reflect upstream response text into the error surface (log/agent injection risk)
|
|
|
|
|
throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`);
|
|
|
|
|
}
|
2026-01-31 16:19:20 +09:00
|
|
|
throw new Error(`HTTP ${res.status}`);
|
|
|
|
|
}
|
2026-02-18 18:33:40 +00:00
|
|
|
return res;
|
2026-01-14 01:08:15 +00:00
|
|
|
} finally {
|
|
|
|
|
clearTimeout(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:02:21 +00:00
|
|
|
export async function fetchOk(
|
|
|
|
|
url: string,
|
|
|
|
|
timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS,
|
|
|
|
|
init?: RequestInit,
|
|
|
|
|
): Promise<void> {
|
2026-03-02 15:41:58 +00:00
|
|
|
await fetchCdpChecked(url, timeoutMs, init);
|
2026-01-16 08:31:51 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 15:41:58 +00:00
|
|
|
export function openCdpWebSocket(
|
2026-01-14 01:08:15 +00:00
|
|
|
wsUrl: string,
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
|
2026-03-02 15:41:58 +00:00
|
|
|
): WebSocket {
|
2026-01-16 08:31:51 +00:00
|
|
|
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
const handshakeTimeoutMs =
|
|
|
|
|
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
|
|
|
|
|
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
|
2026-03-02 16:02:21 +00:00
|
|
|
: CDP_WS_HANDSHAKE_TIMEOUT_MS;
|
2026-03-02 09:03:29 +01:00
|
|
|
const agent = getDirectAgentForCdp(wsUrl);
|
2026-03-02 15:41:58 +00:00
|
|
|
return new WebSocket(wsUrl, {
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
handshakeTimeout: handshakeTimeoutMs,
|
2026-01-16 08:31:51 +00:00
|
|
|
...(Object.keys(headers).length ? { headers } : {}),
|
2026-03-02 09:03:29 +01:00
|
|
|
...(agent ? { agent } : {}),
|
2026-01-16 08:31:51 +00:00
|
|
|
});
|
2026-03-02 15:41:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function withCdpSocket<T>(
|
|
|
|
|
wsUrl: string,
|
|
|
|
|
fn: (send: CdpSendFn) => Promise<T>,
|
|
|
|
|
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const ws = openCdpWebSocket(wsUrl, opts);
|
2026-01-14 01:08:15 +00:00
|
|
|
const { send, closeWithError } = createCdpSender(ws);
|
|
|
|
|
|
|
|
|
|
const openPromise = new Promise<void>((resolve, reject) => {
|
|
|
|
|
ws.once("open", () => resolve());
|
|
|
|
|
ws.once("error", (err) => reject(err));
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
ws.once("close", () => reject(new Error("CDP socket closed")));
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
|
|
|
|
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* 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>
2026-02-11 07:54:48 +08:00
|
|
|
try {
|
|
|
|
|
await openPromise;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
closeWithError(err instanceof Error ? err : new Error(String(err)));
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await fn(send);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
closeWithError(err instanceof Error ? err : new Error(String(err)));
|
|
|
|
|
throw err;
|
|
|
|
|
} finally {
|
|
|
|
|
try {
|
|
|
|
|
ws.close();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|