[Bug] initializeServer hangs 30s when any background tab is discarded by Chrome Memory Saver (--cdp-endpoint on a real profile)
Package: MCP (@playwright/mcp 0.0.81) · Component: CDP connection / --cdp-endpoint
Summary
When --cdp-endpoint points at a long-lived real Chrome profile, the MCP server never finishes initializing if any pre-existing tab has been discarded/frozen by Chrome's Memory Saver. Every tool call fails identically:
### Error
TimeoutError: async initializeServer: Timeout 30000ms exceeded.
Call log:
- <ws connecting> ws://127.0.0.1:9222/devtools/browser
- <ws connected> ws://127.0.0.1:9222/devtools/browserThe browser itself is perfectly healthy — a raw CDP client gets answers in single-digit milliseconds on the exact same URL. connectOverCDP() is blocked by one zombie target, so from the MCP side this is unrecoverable: no tool ever runs, and the error message points at MCP startup rather than at the real cause.
Environment
| OS | Windows 11 (64-bit) |
| Chrome | 153.0.8010.48, default user-data-dir |
| Debug route | chrome://inspect → "Allow remote debugging for this browser instance" |
| @playwright/mcp | 0.0.81 (bundled playwright-core 1.64.0-alpha-2026-09-14) |
| Launch line | npx -y @playwright/mcp@latest --cdp-endpoint ws://127.0.0.1:9222/devtools/browser |
Steps to reproduce
- Use your normal Chrome profile with 3–4 tabs open, then leave them in the background until Memory Saver discards them (
chrome://discardsshows Discarded; ~10 min idle was enough here). - Enable CDP through the
chrome://inspecttoggle.<profile>/DevToolsActivePortthen contains9222+/devtools/browser/<uuid>. - Start MCP with
--cdp-endpoint ws://127.0.0.1:9222/devtools/browser. - Call any tool (
browser_snapshot,browser_tabs list, …).
Actual vs expected
Actual: 30 s timeout on every call, forever. Closing/reopening the MCP session does not help — the discarded tab is still there.
Expected: connect succeeds; targets whose renderer never answers are reported as degraded (or skipped after a bounded wait) instead of blocking initialization indefinitely.
Evidence
Three measurements against one endpoint:
| client | result |
|---|---|
raw WebSocket + Browser.getVersion |
3–9 ms, replies Chrome/153.0.8010.48 |
chromium.connectOverCDP('ws://…/devtools/browser') |
timeout (20 s, and 30 s via MCP) |
chromium.connectOverCDP('ws://…/devtools/browser/<uuid>') (path read from DevToolsActivePort) |
timeout — so the missing UUID is not the issue |
Per-target probe (Target.setAutoAttach {flatten:true}, then Runtime.enable per session):
alive page http://127.0.0.1:3080/ <- only non-discarded tab
DEAD page https://github.com/dream-num/dsh-univer-office/releases
DEAD page https://mp.weixin.qq.com/s/p30gUreI4HxF7Uvs8GDV4g
DEAD page https://github.com/deepseek-ai/deepseek-harness/releases
ok browser_ui / background_page / service_worker (extensions, omnibox, tab-search)Note that Target.attachToTarget succeeds for a discarded tab and hands back a sessionId; every session-bound command on it (Runtime.enable, Page.enable) is then silently unanswered — no error, no Target.targetDestroyed, nothing. That silence is what makes this so hard to diagnose from the outside.
Causal proof — wake the tabs, retry the connect in the same process/endpoint:
=== phase 1: wake every page target ===
awake http://127.0.0.1:3080/
awake https://github.com/dream-num/dsh-univer-office/releases
awake https://mp.weixin.qq.com/s/p30gUreI4HxF7Uvs8GDV4g
awake https://github.com/deepseek-ai/deepseek-harness/releases
=== phase 2: Playwright connectOverCDP, same endpoint as @playwright/mcp ===
CONNECTED in 2708 ms | version=153.0.8010.48
pages=4 (all four titles readable, incl. the two GitHub ones)One subtlety worth flagging: focus was handed back to the original tab before phase 2, so those three tabs were still in the background and Playwright connected fine. The deciding factor is renderer liveness, not foreground/background — which means "just skip hidden tabs" would not be a correct fix.
Relation to existing reports
- #41093 (Edge 148,
connect_over_cdphangs right after<ws connected>) is closed via PR #41128 — but that PR was never merged; a Playwright maintainer describes it as "the fix that we ended up not landing" over in #41714. On1.64.0-alpha-2026-09-14the connect-time hang is still fully reproducible; here the trigger is a Memory-Saver discard rather than a first navigation that never commits. - #41714 reports the same root cause from
@playwright/cli(one discarded tab wedges every command, becauseTab.headerSnapshot()awaitspage.title()with no timeout). Its proposed fix PR #41733 is also closed without merging (merged: false). That path would not unblock me either way: in the MCP case the code never gets that far, since I cannot even obtain aBrowserobject.
Suggested fixes
- Bound per-target initialization during connect (
Promise.raceagainst the existing timeout) so one unresponsive renderer cannot blockconnectOverCDP; expose such pages as degraded. - Fail with an actionable message. The only symptom today is
async initializeServer: Timeout 30000ms exceeded. If the client noticed "attached to N targets, M never answeredRuntime.enable: , ", a 30-second mystery becomes a one-line diagnosis. - Secondary — HTTP discovery fallback. On the
chrome://inspecttoggle route Chrome serves WebSocket only:/json,/json/versionand/json/listall return 404, soconnectOverCDP('http://127.0.0.1:9222')dies in ~10 ms withUnexpected status 404 when connecting to http://127.0.0.1:9222/json/version/. Falling back to/json/list, and/or reading<user-data-dir>/DevToolsActivePort, would cover it — same finding as vercel-labs/agent-browser#628.
Workarounds (currently needed to use MCP at all)
- Disable Memory Saver in
chrome://settings/performance, or add the sites to "Keep these sites active". - Use a dedicated profile:
chrome.exe --user-data-dir=D:\chrome-dsh --remote-debugging-port=9222. That route serves/json/version200, needs no per-connection approval, and is unaffected by Chrome ≥136 refusing the flag on the default profile dir (official note). Target.activateTargetthe tabs before connecting (works, but you have to know this already).
Minimal repro
Node 24 (global WebSocket), no MCP required — run it once with discarded tabs, then again after clicking each tab:
// npm i [email protected]
// node repro.mjs ws://127.0.0.1:9222/devtools/browser
const { chromium } = require('playwright-core');
const ENDPOINT = process.argv[2];
async function rawProbe() {
const ws = new WebSocket(ENDPOINT);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
const pending = new Map(); let id = 0;
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } };
const call = (method, params, sessionId, wait = 2500) => new Promise((res) => {
const i = ++id; const t = setTimeout(() => { pending.delete(i); res({ timeout: true }); }, wait);
pending.set(i, (m) => { clearTimeout(t); res(m); });
ws.send(JSON.stringify(sessionId ? { id: i, sessionId, method, params } : { id: i, method, params }));
});
const v = await call('Browser.getVersion');
console.log('raw Browser.getVersion:', v.timeout ? 'NO REPLY' : v.result.product);
const attached = [];
ws.onmessage = (e) => { const m = JSON.parse(e.data);
if (m.method === 'Target.attachedToTarget') attached.push(m.params);
else if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } };
await call('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
await new Promise(r => setTimeout(r, 1500));
for (const a of attached) {
const r = await call('Runtime.enable', {}, a.sessionId);
console.log(` ${(r.timeout ? 'DEAD' : r.error ? 'err' : 'alive').padEnd(6)} ${a.targetInfo.type.padEnd(13)} ${a.targetInfo.url.slice(0, 64)}`);
}
ws.close();
}
(async () => {
await rawProbe();
const t0 = Date.now();
try {
const b = await chromium.connectOverCDP(ENDPOINT, { timeout: 20000 });
console.log(`connectOverCDP: OK in ${Date.now() - t0} ms, pages=${b.contexts().flatMap(c => c.pages()).length}`);
await b.close();
} catch (e) {
console.log(`connectOverCDP: FAILED in ${Date.now() - t0} ms — ${String(e.message).split('\n')[0]}`);
}
})();Output on the affected profile, verbatim:
raw Browser.getVersion: Chrome/153.0.8010.48
alive page http://127.0.0.1:3080/
DEAD page https://github.com/dream-num/dsh-univer-office/releases
DEAD page https://mp.weixin.qq.com/s/p30gUreI4HxF7Uvs8GDV4g
DEAD page https://github.com/deepseek-ai/deepseek-harness/releases
connectOverCDP: FAILED in 20011 ms — browserType.connectOverCDP: Timeout 20000ms exceeded.Source: microsoft/playwright-mcp