[Bug]: connectOverCDP: a tab whose main frame was swapped in by a prerender never initializes, and title()/evaluate() hang forever (default Chrome/Edge settings)
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 35/100
- Tipo di issue
- Bug
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- node.js, typescript
Direzione di ricerca
Start with _sessionForFrame and _framePosition/_onDialog in packages/playwright-core/src/server/chromium/crPage.ts, then follow _initialize where Page.getFrameTree is handled. Run the attached repro.js to see the tab reported with an empty URL and pending title. Done means the tab's title and evaluate resolve after a prerender activation, and the initialization error is logged rather than swallowed.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Version
1.65.0-next (main @ 3b3ac2576f). Also 1.64.0-alpha-1790635538000 and 1.65.0-alpha-2026-10-30 (bundled in @playwright/mcp 0.0.83 / 0.0.84)
Steps to reproduce
This is the scenario from #41397 and #42633, now with a reproducer that needs no Chrome feature flags, no Playwright patches and no CDP calls. #41397 was closed because its reproducer turned prerendering on with Chrome feature flags, and #42633 because there was no Playwright-only reproducer. Prerendering is on by default in Chrome and Edge. It is only "off" under Playwright because Chromium refuses to prerender in a tab that has any DevTools session attached (Preload.prerenderStatusUpdated reports PrerenderingDisabledByDevTools, even for a bare Target.attachToTarget). A tab that Playwright is not attached to prerenders normally. When such a tab activates a prerender, its main frame id can stop matching its target id. It did in tabs opened with Ctrl+T (Edge) and with /json/new (Chromium). It did not in Chromium's initial startup tab.
repro.js (only dependency: playwright). It spawns Chromium with no feature flags (only --headless=new, --remote-debugging-port, --user-data-dir, --no-first-run, plus --no-sandbox when running as root), opens a tab through PUT /json/new (as if the user pressed Ctrl+T), lets that page prerender and follow a same-origin link, and then connects:
// Only dependency: playwright (plus `npx playwright install chromium`). No Chrome feature flags, no CDP calls.
const http = require('http');
const os = require('os'), fs = require('fs'), path = require('path');
const { spawn } = require('child_process');
const { chromium } = require('playwright');
const server = http.createServer((req, res) => {
res.setHeader('content-type', 'text/html');
if (req.url === '/b.html')
return res.end('<title>Page B</title><script>window.wasPrerendering = document.prerendering</script>');
// Page A prerenders B (Speculation Rules, on by default in Chrome/Edge) and then follows the link.
res.end(`<script type="speculationrules">{"prerender":[{"source":"list","urls":["/b.html"]}]}</script>
<a id="go" href="/b.html">go</a><script>setTimeout(() => go.click(), 1500)</script>`);
}).listen(0, '127.0.0.1', async () => {
const url = `http://127.0.0.1:${server.address().port}/a.html`;
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-prerender-'));
const browserProcess = spawn(process.env.CHROME || chromium.executablePath(),
['--headless=new', '--remote-debugging-port=9333', `--user-data-dir=${userDataDir}`, '--no-first-run', ...(process.getuid?.() === 0 ? ['--no-sandbox'] : [])], { stdio: 'ignore' });
await new Promise(r => setTimeout(r, 2000));
// Open a tab the way a user would (Ctrl+T): nothing is attached to it, so Chrome is allowed to prerender.
await fetch(`http://127.0.0.1:9333/json/new?${url}`, { method: 'PUT' });
await new Promise(r => setTimeout(r, 4000));
const browser = await chromium.connectOverCDP('http://127.0.0.1:9333');
const within5s = p => Promise.race([p, new Promise(r => setTimeout(() => r('<still pending after 5s>'), 5000))]);
for (const page of browser.contexts()[0].pages()) {
console.log(JSON.stringify({
url: page.url(),
title: await within5s(page.title()),
wasPrerendering: await within5s(page.evaluate(() => window.wasPrerendering)),
}));
}
browserProcess.kill();
server.close();
process.exit(0);
});
node repro.js
Real-world sequence (Edge 156, Windows, "Preload pages" on, which is the default, @playwright/mcp with --cdp-endpoint):
- Playwright is connected and has opened some tabs.
- The user presses Ctrl+T. With "Preload your new tab page" on, Edge hands over a new tab page it had already loaded in the background. That
edge://newtab/tab isattached: falseinTarget.getTargetsand is not incontext.pages(). - The user searches from the address bar. Edge prerenders the results page and activates it in that tab. The target id stays
63B5F363…, the main frame becomes406A8897…, andactivationStartis 57.6 ms. - The next (re)connect, for example MCP
browser_closefollowed bybrowser_tabs list, hangs forever without the fix. The HTTP response has its headers sent and a 0-byte body.
Expected behavior
The tab is reported with its real URL (/b.html), page.title() resolves to "Page B", and the page is usable.
Actual behavior
{"url":"","title":"<still pending after 5s>","wasPrerendering":"<still pending after 5s>"}
{"url":"chrome://new-tab-page/","title":"New Tab"}
context.pages() returns the tab, but with url() === ''. Anything that needs the page's utility world is stuck: title() and evaluate() never resolve, and locator actions time out. Nothing is logged, even with DEBUG=pw:*. In the protocol log, Page.getFrameTree for that session returns normally, but Page.createIsolatedWorld is never sent for it.
In @playwright/mcp this makes browser_tabs with action = list hang forever, because it reads the title of every tab. The MCP log shows the title call for that frame never answered, while the other tab's title came back in about 20 ms. The HTTP response has its headers sent and a 0-byte body.
Additional context
Cause
CRPage assumes that a page's main frame id equals its target id (// Frame id equals target id. in _sessionForFrame). After a prerender activation the tab keeps its target id, but its main frame gets a new id. When Playwright attaches to such a tab, FrameSession._initialize handles Page.getFrameTree like this:
Page.getFrameTree callback
-> _handleFrameTree -> _onFrameAttached(<new main frame id>) // url still ''
-> _onFrameNavigated -> _eventBelongsToStaleFrame
-> CRPage._sessionForFrame(mainFrame)
walks up to a frame whose _id is a key of this._sessions (keyed by target id);
the main frame has no parent -> throw new Error('Frame has been detached.')
The throw aborts the callback before the Page.createIsolatedWorld loop, so the utility world is never created. _initialize() rejects, and the CRPage constructor passes the error to page.reportAsNew(opener, error), which only creates a dummy main frame and marks the page initialized. The error is never logged. This is the stack (abridged) captured with a logging-only patch:
pw:error Failed to initialize page for target AC58F53B...: Error: Frame has been detached.
at CRPage._sessionForFrame
at _FrameSession._eventBelongsToStaleFrame
at _FrameSession._onFrameNavigated
at _FrameSession._handleFrameTree
at ...Page.getFrameTree callback
at async Promise.all (index 1)
at async _FrameSession._initialize
FrameSession also looks up the main frame with frameManager.frame(this._targetId) in _framePosition(), _onDialog() and the worker addSession fallback. On such a tab that lookup returns null. So with only _sessionForFrame fixed, title() works, but boundingBox() returns null, click() times out and the dialog event never fires.
This is rarely seen under Playwright because Chromium won't prerender in a tab that has any DevTools session attached (PrerenderingDisabledByDevTools), and Playwright attaches to every tab it knows about. It does happen in tabs Playwright doesn't know about: new tab pages that Edge preloaded in the background are not auto-attached (see below), and before Playwright connects, nothing is attached to any tab.
Suggested fix
When the walk in _sessionForFrame reaches a parentless frame, return the main session if that frame is the page's main frame. Keep throwing for genuinely detached frames:
_sessionForFrame(frame: frames.Frame): FrameSession {
while (!this._sessions.has(frame._id)) {
const parent = frame.parentFrame();
if (!parent) {
if (frame === this._page.mainFrame())
return this._mainFrameSession;
throw new Error(`Frame has been detached.`);
}
frame = parent;
}
return this._sessions.get(frame._id)!;
}
Don't look up a session's own frame by target id. Add a helper, and use it in _framePosition(), _onDialog() and the worker addSession fallback:
private _sessionFrame(): frames.Frame | null {
return this._isMainFrame() ? this._page.mainFrame() : this._page.frameManager.frame(this._targetId);
}
Also stop swallowing initialization errors, so the next failure of this kind is visible:
this._mainFrameSession._initialize(bits.hasUIWindow).then(
() => this._page.reportAsNew(this._opener?._page, undefined),
error => {
debugLogger.log('error', `Failed to initialize page for target ${targetId}: ${error?.stack || error}`);
this._page.reportAsNew(this._opener?._page, error);
});
The same applies to frameSession._initialize(false).catch(e => e) for OOPIF sessions.
Full crPage.ts diff (against main @ 3b3ac2576f)
diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts
index 18a459b..134a914 100644
--- a/packages/playwright-core/src/server/chromium/crPage.ts
+++ b/packages/playwright-core/src/server/chromium/crPage.ts
@@ -18,6 +18,7 @@
import { assert } from '@isomorphic/assert';
import { rewriteErrorMessage } from '@utils/stackTrace';
import { eventsHelper } from '@utils/eventsHelper';
+import { debugLogger } from '@utils/debugLogger';
import * as dialog from '../dialog';
import * as dom from '../dom';
import * as frames from '../frames';
@@ -124,7 +125,11 @@ export class CRPage implements PageDelegate {
this._mainFrameSession._initialize(bits.hasUIWindow).then(
() => this._page.reportAsNew(this._opener?._page, undefined),
- error => this._page.reportAsNew(this._opener?._page, error));
+ error => {
+ // Initialization errors are otherwise invisible: the page is reported with a dummy main frame.
+ debugLogger.log('error', `Failed to initialize page for target ${targetId}: ${error?.stack || error}`);
+ this._page.reportAsNew(this._opener?._page, error);
+ });
}
private async _forAllFrameSessions(cb: (frame: FrameSession) => Promise<any>) {
@@ -142,11 +147,15 @@ export class CRPage implements PageDelegate {
}
_sessionForFrame(frame: frames.Frame): FrameSession {
- // Frame id equals target id.
+ // Frame id equals target id, except for the main frame after a prerender
+ // activation, where the tab keeps its target id but gets a new main frame id.
while (!this._sessions.has(frame._id)) {
const parent = frame.parentFrame();
- if (!parent)
+ if (!parent) {
+ if (frame === this._page.mainFrame())
+ return this._mainFrameSession;
throw new Error(`Frame has been detached.`);
+ }
frame = parent;
}
return this._sessions.get(frame._id)!;
@@ -439,6 +448,12 @@ class FrameSession {
return this._targetId === this._crPage._targetId;
}
+ // The frame this session is responsible for. Do not look the main frame up by target id:
+ // after a prerender activation the main frame id differs from the target id.
+ private _sessionFrame(): frames.Frame | null {
+ return this._isMainFrame() ? this._page.mainFrame() : this._page.frameManager.frame(this._targetId);
+ }
+
private _addRendererListeners() {
this._eventListeners.push(...[
eventsHelper.addEventListener(this._client, 'Log.entryAdded', event => this._onLogEntryAdded(event)),
@@ -785,7 +800,7 @@ class FrameSession {
}
const frameSession = new FrameSession(this._crPage, session, targetId, this);
this._crPage._sessions.set(targetId, frameSession);
- frameSession._initialize(false).catch(e => e);
+ frameSession._initialize(false).catch(e => debugLogger.log('error', `Failed to initialize frame session for target ${targetId}: ${e?.stack || e}`));
return;
}
@@ -825,7 +840,7 @@ class FrameSession {
const options = this._crPage._browserContext._options;
if (options.userAgent || options.locale)
this._updateUserAgent(session).catch(() => {});
- this._crPage._networkManager.addSession(session, this._page.frameManager.frame(event.targetInfo.parentFrameId ?? this._targetId) ?? undefined).catch(() => {});
+ this._crPage._networkManager.addSession(session, (event.targetInfo.parentFrameId ? this._page.frameManager.frame(event.targetInfo.parentFrameId) : this._sessionFrame()) ?? undefined).catch(() => {});
session._sendMayFail('Runtime.runIfWaitingForDebugger');
session._sendMayFail('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
session.on('Target.attachedToTarget', event => this._onAttachedToTarget(session, event));
@@ -918,7 +933,7 @@ class FrameSession {
}
_onDialog(event: Protocol.Page.javascriptDialogOpeningPayload) {
- if (!this._page.frameManager.frame(this._targetId))
+ if (!this._sessionFrame())
return; // Our frame/subtree may be gone already.
this._page.browserContext.dialogManager.dialogDidOpen(new dialog.Dialog(
this._page,
@@ -1184,7 +1199,7 @@ class FrameSession {
}
private async _framePosition(): Promise<types.Point | null> {
- const frame = this._page.frameManager.frame(this._targetId);
+ const frame = this._sessionFrame();
if (!frame)
return null;
if (frame === this._page.mainFrame())
Regression test (fails before the fix, passes after)
diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts
index 87df27f..45be4b6 100644
--- a/tests/library/chromium/connect-over-cdp.spec.ts
+++ b/tests/library/chromium/connect-over-cdp.spec.ts
@@ -16,6 +16,7 @@
*/
import { playwrightTest as test, expect } from '../../config/browserTest';
+import childProcess from 'child_process';
import http from 'http';
import fs from 'fs';
import path from 'path';
@@ -961,3 +962,49 @@ test.fixme('CD-053 should connect when another client inspects a terminated shar
await browser.close();
}
});
+
+test('CD-054 should connect to a page whose main frame was swapped in by a prerender activation', async ({ browserType, mode, server, createUserDataDir }, testInfo) => {
+ test.skip(mode !== 'default', 'Spawns a browser directly');
+ server.setRoute('/a.html', (req, res) => {
+ res.setHeader('content-type', 'text/html');
+ res.end(`<script type="speculationrules">{"prerender":[{"source":"list","urls":["/b.html"]}]}</script>
+ <a id="go" href="/b.html">go</a>
+ <script>setTimeout(() => document.getElementById('go').click(), 1500)</script>`);
+ });
+ server.setRoute('/b.html', (req, res) => {
+ res.setHeader('content-type', 'text/html');
+ res.end(`<title>Page B</title><script>window.wasPrerendering = document.prerendering</script>`);
+ });
+ // Chromium does not prerender in a tab that has a DevTools session attached, and
+ // Playwright attaches to every tab of a browser it launched, so spawn the browser directly.
+ const userDataDir = await createUserDataDir();
+ const executablePath = (browserType as any)._playwright._defaultLaunchOptions?.executablePath || browserType.executablePath();
+ const browserProcess = childProcess.spawn(executablePath, ['--headless=new', '--no-sandbox', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, '--no-first-run', 'about:blank'], { stdio: 'ignore' });
+ try {
+ let port = 0;
+ await expect.poll(() => {
+ try {
+ port = +fs.readFileSync(path.join(userDataDir, 'DevToolsActivePort'), 'utf8').split('\n')[0];
+ } catch {
+ }
+ return port;
+ }).toBeTruthy();
+ // Open the tab without attaching to it, and let it activate the prerendered page.
+ await fetch(`http://127.0.0.1:${port}/json/new?${server.PREFIX}/a.html`, { method: 'PUT' });
+ await expect.poll(async () => {
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
+ return targets.some(t => t.type === 'page' && t.url === server.PREFIX + '/b.html');
+ }).toBe(true);
+
+ const cdpBrowser = await browserType.connectOverCDP(`http://127.0.0.1:${port}/`);
+ const page = cdpBrowser.contexts()[0].pages().find(p => p.url() === server.PREFIX + '/b.html');
+ expect(page).toBeTruthy();
+ expect(await page!.evaluate(() => (window as any).wasPrerendering)).toBe(true);
+ expect(await page!.title()).toBe('Page B');
+ await page!.goto(server.EMPTY_PAGE);
+ expect(page!.url()).toBe(server.EMPTY_PAGE);
+ await cdpBrowser.close();
+ } finally {
+ browserProcess.kill();
+ }
+});
Verification
- Chromium 153 in a container:
repro.jsand the new test fail before the fix and pass after it. Theconnect-over-cdp,oopifandbfcachespecs, plus the pagedialog,frame-*,goto,click,bounding-boxandworkersspecs, pass with the patch (66 + 241 tests). - Real Edge 156 on Windows, with the same change applied to @playwright/mcp 0.0.83 (playwright-core 1.64.0-alpha): both the address-bar sequence above and a speculation-rules activation reattach immediately, with the correct URL and title. On the speculation-rules tab,
evaluate,boundingBox,clickand thedialogevent were also checked, and all work.
Related observations, not addressed by this patch
- While Playwright is connected, a tab that Edge preloaded in the background ("Preload your new tab page") is never auto-attached and never appears in
context.pages(), even after it navigates (step 2 above). This is why it can prerender at all. Checked on Edge 156: with the setting off, Ctrl+T tabs were attached. With it on, the first Ctrl+T after enabling it (nothing preloaded yet) was attached, and the next one (the preloaded tab) wasattached: falseinTarget.getTargetsand missing fromcontext.pages(). This looks like a separate auto-attach gap for preloaded tabs. - Over
connectOverCDP,page.goBack()to an entry restored from bfcache times out, with or without this patch (checked on Chromium 153). Playwright launches with--disable-back-forward-cache, but a user's browser doesn't.
I'm happy to open a PR with the diff and test above.
Environment
Container (repro): Linux x64, Node.js 22.23.4, Chrome for Testing 153.0.8010.12 (--headless=new), playwright-core from main @ 3b3ac2576f and 1.64.0-alpha-1790635538000
Real-world: Windows x64, Microsoft Edge 156 ("Preload pages for faster browsing and searching" and "Preload your new tab page" both on), @playwright/mcp 0.0.83 with this change applied, via --cdp-endpoint (HTTP transport, --port 8931)
- Lingua principale
- TypeScript
- Stelle
- 99.1k
- Fork
- 6.8k
- Merge medio
- 15h 37m
- PR unite (30g)
- 329
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di microsoft/playwright
-
[Feature]: Option to make all locators ignore hidden elements by defaultForse già presa Una pull request collegata a questa issue è aperta o già unita. Aperta
Difficoltà 4/5 3-5 giorni Idoneità per principianti 18/100
microsoft/playwright#43855 · 4 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
[Feature]: Configure web servers per projectForse già presa @microsoft-playwright-automation l’ha presa 1 giorno fa. Aperta
Difficoltà 4/5 3-5 giorni Idoneità per principianti 25/100
microsoft/playwright#43853 · 4 commenti · 2 reazioni ·
I maintainer di solito rispondono entro 1 giorno
-
[Feature]: Extend isVisible/toBeVisible API to include overlap detectionForse già presa @microsoft-playwright-automation l’ha presa 1 giorno fa. Aperta
Difficoltà 5/5 Più di una settimana Idoneità per principianti 15/100
microsoft/playwright#43849 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
[Feature]: `Update snapshot` sticky header in UI Mode Attachments panelForse già presa @microsoft-playwright-automation l’ha presa 2 giorni fa. Apertaneeds-triage
Difficoltà 4/5 3-5 giorni Idoneità per principianti 32/100
microsoft/playwright#43833 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
[Feature]: Let an MCP client label its own extension tab groupForse già presa @microsoft-playwright-automation l’ha presa 3 giorni fa. Apertamilestone needs-triage
Difficoltà 4/5 3-5 giorni Idoneità per principianti 15/100
microsoft/playwright#43823 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di microsoft/playwright
Issue simili
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
-
bug needs triage
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
DSpace/dspace-angular#6511 ·
I maintainer di solito rispondono entro 1 giorno
-
area: frontend area:byok bug severity:low
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
I maintainer di solito rispondono entro 1 giorno
-
Phone app shows Unknown for a remote machine whose key changedForse già presa @janicduplessis l’ha presa oggi. Apertaarea: mobile priority: P3 type: bug
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 78/100
appandflow/stim#5006 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Area - Local to Cloud Bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 66/100
microsoft/vscode-mssql#23209 ·
I maintainer di solito rispondono entro 1 giorno