[Bug]: connectOverCDP: a tab whose main frame was swapped in by a prerender never initializes, and title()/evaluate() hang forever (default Chrome/Edge settings)

Aperta
#43,842 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@dgozman ci sta già lavorando.

Dal 2/11/2026.

  • #43854 di @dgozman — aperta

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
Ambito
devtools, testing

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):

  1. Playwright is connected and has opened some tabs.
  2. 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 is attached: false in Target.getTargets and is not in context.pages().
  3. 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 becomes 406A8897…, and activationStart is 57.6 ms.
  4. The next (re)connect, for example MCP browser_close followed by browser_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.js and the new test fail before the fix and pass after it. The connect-over-cdp, oopif and bfcache specs, plus the page dialog, frame-*, goto, click, bounding-box and workers specs, 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, click and the dialog event 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) was attached: false in Target.getTargets and missing from context.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

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di microsoft/playwright

Tutte le issue di microsoft/playwright

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.