Repository navigation
Description
Version
1.62.1
Steps to reproduce
Run the self-contained script below (node repro.js, only dependency is playwright). It serves two local pages, one of which client-side redirects immediately via <meta http-equiv="refresh" content="0; URL='/target.html'"> (the W3C ACT-Rules bc659a "passed" fixtures are real-world examples of such pages), then loops: goto → evaluate (destroyed by the refresh navigation — expected) → page.close().
const http = require('http');
const {chromium} = require('playwright');
const pages = {
'/a.html': `<!DOCTYPE html><html lang="en"><head><title>a</title>
<meta http-equiv="refresh" content="0; URL='/target.html'"></head>
<body><p>redirecting</p></body></html>`,
'/target.html': `<!DOCTYPE html><html lang="en"><head><title>t</title></head>
<body><p>arrived</p></body></html>`
};
const settles = (promise, ms) => Promise.race([
promise.then(() => 'resolved').catch(() => 'rejected'),
new Promise(resolve => setTimeout(() => resolve('NEVER SETTLED'), ms))
]);
(async () => {
const server = http.createServer((req, res) => {
res.setHeader('content-type', 'text/html');
res.end(pages[req.url] || pages['/target.html']);
});
await new Promise(resolve => server.listen(0, resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch();
const context = await browser.newContext();
let hangs = 0;
for (let round = 0; round < 10; round++) {
const page = await context.newPage();
await page.goto(`${base}/a.html`, {waitUntil: 'load'});
await page.evaluate(
() => new Promise(resolve => setTimeout(resolve, 100))
).catch(() => {});
const outcome = await settles(page.close(), 10000);
console.log(`round ${round}: page.close() ${outcome}`);
if (outcome === 'NEVER SETTLED') hangs++;
}
console.log(`${hangs}/10 rounds hung`);
process.exit(0);
})();Expected behavior
page.close() always settles — resolves once the page is gone, or rejects with an error.
Actual behavior
On 7–8 of 10 rounds (Chromium only), the page.close() promise never settles — no resolution, no rejection, no timeout. .catch() never fires, so any loop awaiting close() freezes permanently and silently. In our accessibility-scanning harness this froze two long captures for 35+ minutes each on real W3C fixture pages before diagnosis.
Root cause (from a DEBUG=pw:protocol trace of a hanging round)
- Playwright sends
Target.closeTarget {targetId}. - The browser replies
{"result":{"success":true}}. - The target does not actually close: the meta-refresh navigation was mid-commit, and the same session then emits the redirect destination's full lifecycle —
Page.frameNavigated,DOMContentLoaded,load, paint events,networkIdle— all after the successful-lookingcloseTargetresponse. Target.targetDestroyedis never emitted;page.close()waits for it indefinitely.
The browser-side half reproduces without Playwright: a raw-CDP client (plain WebSocket) gets closeTarget → success:true with no targetDestroyed ever emitted and the target still listed in Target.getTargets (~3/10 rounds, ~45–60 ms commit window). We are filing a Chromium bug for that half and will cross-link it here. Playwright's half is that page.close() trusts the acknowledgment with no deadline or retry.
Additional context — matrix (10 rounds per cell, same repro)
close() |
close({runBeforeUnload:true}) |
context.close() |
|
|---|---|---|---|
| Chromium | 8/10 hang | 0/10 | 0/10 |
| Firefox | 0/10 | 0/10 | 0/10 |
| WebKit | 0/10 | 0/10 | 0/10 |
After a hang, context.close() still settles and destroys the page (8/8 recoveries) — the context teardown path is unaffected. Verified workarounds: page.close({runBeforeUnload: true}), or racing close() against a deadline and calling context.close() on expiry.
Possibly related: #33806 (hangs while waiting for pending navigations) — different in that no dialog is involved and the acknowledged-but-not-performed Target.closeTarget is visible in the protocol trace.
Environment
- Operating System: Linux (kernel 7.0.0-30-generic)
- Browser: Chromium (bundled, HeadlessChrome/151.0.7922.34)
- Node.js: 24.14.1
Activity
wittjeff commentedon Aug 22, 2026
The Chromium half is already tracked — and root-caused at the C++ level with a pending code change — in https://issues.chromium.org/issues/536385539 (
Target.closeTargetis lost when a navigation commit swaps the main RenderFrameHost right afterClosePage; the pendingClosePagecallback dies with the old RFH and is never re-issued). Our meta-refresh(0) scenario above is an additional real-world trigger of the same race; corroborating evidence and workaround data posted there as comment 2.RenderDocumentintroducespage.close()hang #42068