fix: CDP mode hanging when using chrome://inspect remote debugging

- Add isCdpApiAvailable() to verify CDP HTTP JSON API before using endpoint
- Chrome's chrome://inspect#remote-debugging writes DevToolsActivePort but
  its CDP endpoint is incompatible with Playwright connectOverCDP (init
  succeeds but all tool calls hang silently)
- Add CDP readiness probe in connect() to catch unresponsive endpoints
- Skip tab cleanup in close() for CDP mode (no bridge tabs to clean)
- Add PlaywrightMCPMode tracking for extension vs CDP lifecycle policy
This commit is contained in:
jackwener committed 2026-03-16 02:28:28 +08:00
1 parent 61a62f80c4
commit 67d50191af
4 files changed
+81 -8

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@jackwener/opencli",
"version": "0.4.5",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "0.4.5",
"version": "0.4.6",
"license": "BSD-3-Clause",
"dependencies": {
"chalk": "^5.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "0.4.5",
"version": "0.4.6",
"publishConfig": {
"access": "public"
},
+10
View File
@@ -74,4 +74,14 @@ describe('PlaywrightMCP state', () => {
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
});
it('tracks backend mode for lifecycle policy', async () => {
const mcp = new PlaywrightMCP();
expect((mcp as any)._mode).toBeNull();
await mcp.close();
expect((mcp as any)._mode).toBeNull();
});
});
+68 -5
View File
@@ -5,6 +5,7 @@
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as http from 'node:http';
import * as net from 'node:net';
import { fileURLToPath } from 'node:url';
import * as fs from 'node:fs';
@@ -34,6 +35,33 @@ function isPortReachable(port: number, host = '127.0.0.1', timeoutMs = 800): Pro
});
}
/**
* Verify the CDP HTTP JSON API is functional.
* Chrome's chrome://inspect#remote-debugging mode writes DevToolsActivePort
* but doesn't expose the full CDP HTTP API (/json/version), which means
* Playwright's connectOverCDP won't work properly (init succeeds but
* all tool calls hang silently).
*/
export function isCdpApiAvailable(port: number, host = '127.0.0.1', timeoutMs = 2000): Promise<boolean> {
return new Promise(resolve => {
const req = http.get(`http://${host}:${port}/json/version`, { timeout: timeoutMs }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => {
try {
const data = JSON.parse(body);
// A valid CDP endpoint returns { Browser, ... } with a webSocketDebuggerUrl
resolve(!!data && typeof data === 'object' && !!data.Browser);
} catch {
resolve(false);
}
});
});
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
});
}
export async function discoverChromeEndpoint(): Promise<string | null> {
const candidates: string[] = [];
@@ -67,7 +95,12 @@ export async function discoverChromeEndpoint(): Promise<string | null> {
const endpoint = `ws://127.0.0.1:${port}${browserPath}`;
// Verify the port is actually reachable (Chrome may have closed, leaving a stale file)
if (await isPortReachable(port)) {
return endpoint;
// Verify CDP HTTP API is functional — chrome://inspect#remote-debugging
// writes DevToolsActivePort but doesn't expose the full CDP API,
// causing Playwright connectOverCDP to hang on all tool calls.
if (await isCdpApiAvailable(port)) {
return endpoint;
}
}
}
}
@@ -82,11 +115,14 @@ const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolv
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
const STDERR_BUFFER_LIMIT = 16 * 1024;
const INITIAL_TABS_TIMEOUT_MS = 1500;
const CDP_READINESS_PROBE_TIMEOUT_MS = 5000;
const TAB_CLEANUP_TIMEOUT_MS = 2000;
let _cachedMcpServerPath: string | null | undefined;
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
type PlaywrightMCPMode = 'extension' | 'cdp' | null;
type ConnectFailureInput = {
kind: ConnectFailureKind;
@@ -425,6 +461,7 @@ export class PlaywrightMCP {
private _initialTabIdentities: string[] = [];
private _closingPromise: Promise<void> | null = null;
private _state: PlaywrightMCPState = 'idle';
private _mode: PlaywrightMCPMode = null;
private _page: Page | null = null;
@@ -459,6 +496,7 @@ export class PlaywrightMCP {
this._page = null;
this._proc = null;
this._buffer = '';
this._mode = null;
this._initialTabIdentities = [];
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
PlaywrightMCP._activeInsts.delete(this);
@@ -500,6 +538,7 @@ export class PlaywrightMCP {
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
const mode: 'extension' | 'cdp' = cdpEndpoint ? 'cdp' : 'extension';
this._mode = mode;
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
const tokenFingerprint = getTokenFingerprint(extensionToken);
let stderrBuffer = '';
@@ -636,9 +675,31 @@ export class PlaywrightMCP {
debugLog(`SEND: ${initializedMsg.trim()}`);
this._proc?.stdin?.write(initializedMsg);
// Get initial tab count for cleanup (with timeout — CDP mode may hang on browser_tabs)
if (mode === 'cdp') {
// CDP readiness probe: verify tool calls actually work.
// Some CDP endpoints (e.g. chrome://inspect mode) accept WebSocket
// connections and respond to MCP init but silently drop tool calls.
debugLog('CDP readiness probe (tabs)...');
withTimeout(page.tabs(), CDP_READINESS_PROBE_TIMEOUT_MS, 'CDP readiness probe timed out')
.then(() => {
debugLog('CDP readiness probe succeeded');
settleSuccess(page);
})
.catch((err) => {
debugLog(`CDP readiness probe failed: ${err.message}`);
settleError('cdp-timeout', {
rawMessage: 'CDP endpoint connected but tool calls are unresponsive. ' +
'This usually means Chrome was opened with chrome://inspect#remote-debugging ' +
'which is not fully compatible. Launch Chrome with --remote-debugging-port=9222 instead, ' +
'or use the Playwright MCP Bridge extension (default mode).',
});
});
return;
}
// Extension mode uses tabs as a readiness probe and for tab cleanup bookkeeping.
debugLog('Fetching initial tabs count...');
withTimeout(page.tabs(), 5000, 'Timed out fetching initial tabs').then((tabs: any) => {
withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
this._initialTabIdentities = extractTabIdentities(tabs);
settleSuccess(page);
@@ -659,8 +720,8 @@ export class PlaywrightMCP {
this._state = 'closing';
this._closingPromise = (async () => {
try {
// Close tabs opened during this session (site tabs + extension tabs)
if (this._page && this._proc && !this._proc.killed) {
// Extension mode opens bridge/session tabs that we can clean up best-effort.
if (this._mode === 'extension' && this._page && this._proc && !this._proc.killed) {
try {
const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
const tabEntries = extractTabEntries(tabs);
@@ -690,6 +751,7 @@ export class PlaywrightMCP {
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
this._page = null;
this._proc = null;
this._mode = null;
this._state = 'closed';
PlaywrightMCP._activeInsts.delete(this);
}
@@ -782,6 +844,7 @@ export const __test__ = {
diffTabIndexes,
appendLimited,
withTimeout,
isCdpApiAvailable,
};
function findMcpServerPath(): string | null {