Version
1.63.0
Steps to reproduce
npm i playwright@1.63.0 && npx playwright install chromium
- Save this as
repro.mjs:
import { chromium } from 'playwright';
// A page that repaints on every frame, so screencast frames keep arriving.
const html = '<p id=n></p><script>let i = 0; (function tick() { n.textContent = i++; requestAnimationFrame(tick); })();</script>';
const browser = await chromium.launch();
console.log(`Chromium ${browser.version()}`);
for (let i = 1; i <= 50; i++) {
const context = await browser.newContext();
await context.tracing.start({ screenshots: true }); // or: browser.newContext({ recordVideo: { dir } })
const pages = await Promise.all(Array.from({ length: 8 }, () => context.newPage()));
await Promise.all(pages.map(page => page.setContent(html)));
await pages[0].waitForTimeout(100);
await Promise.all(pages.map(async page => {
const crashed = new Promise(resolve => page.once('crash', resolve));
page.goto('chrome://crash').catch(() => {});
await crashed;
}));
await context.tracing.stop();
await context.close();
console.log(`iteration ${i}: no assertion`);
}
await browser.close();
console.log('Not reproduced');
node repro.mjs
The Node process dies within the first four iterations: 10 of 10 runs (5 on Node 22.17.0, 5 on Node 23.10.0).
The same thing in Playwright Test, with one page per test:
// playwright.config.mjs
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: { trace: 'retain-on-failure' },
});
// crash.spec.mjs
import { test } from '@playwright/test';
test('a page that crashes while it is being recorded', async ({ page }) => {
// A page that repaints on every frame, so screencast frames keep arriving.
await page.setContent('<p id=n></p><script>let i = 0; (function tick() { n.textContent = i++; requestAnimationFrame(tick); })();</script>');
await page.waitForTimeout(100);
const crashed = page.waitForEvent('crash');
page.goto('chrome://crash').catch(() => {});
await crashed;
});
npx playwright test --repeat-each=40 --workers=1: in two runs, 6 and 9 of 40 failed with trace: 'retain-on-failure', and 2 and 3 of 40 with use: { video: 'on' } instead (Node 22.17.0).
Expected behavior
The page emits crash and the script or test carries on, as it does when nothing is being recorded (0 assertion errors in 240 crashes, see the table below).
Actual behavior
An uncaught exception from Playwright's CDP client ends the Node process:
node_modules/playwright-core/lib/coreBundle.js:838
throw new Error(message || "Assertion error");
^
Error: Assertion error
at assert (node_modules/playwright-core/lib/coreBundle.js:838:11)
at _CRSession._onMessage (node_modules/playwright-core/lib/coreBundle.js:35014:11)
at CRConnection._onMessage (node_modules/playwright-core/lib/coreBundle.js:34951:20)
at Immediate.<anonymous> (node_modules/playwright-core/lib/coreBundle.js:39453:30)
at process.processImmediate (node:internal/timers:485:21)
at process.callbackTrampoline (node:internal/async_hooks:130:17)
Node.js v22.17.0
In Playwright Test the test fails with nothing but this, with no stack and no location, so it looks like an ordinary flaky test:
1) crash.spec.mjs:3:1 › a page that crashes while it is being recorded
Error: Assertion error
Additional context
Cause. From DEBUG=pw:protocol (session ids shortened):
SEND ► {"id":432,"method":"Page.screencastFrameAck","params":{"sessionId":1},"sessionId":"D13E…"}
◀ RECV {"method":"Inspector.targetCrashed","params":{},"sessionId":"D13E…"}
◀ RECV {"id":432,"result":{},"sessionId":"D13E…"}
- While trace screenshots or video are recorded, every screencast frame is acknowledged with
Page.screencastFrameAck (crPage.ts#L952-L962).
- The renderer crashes while an ack is in flight.
Inspector.targetCrashed leads to CRSession._markAsCrashed() (crPage.ts#L908-L911, crConnection.ts#L127-L130), which rejects and clears every pending callback (crConnection.ts#L194-L202).
- Chromium still answers the ack, after the crash event. In
CRSession._onMessage (crConnection.ts#L154-L174) the id is no longer known, and the reply carries no error, so it is not the ignored -32001 case either. It falls through to assert(!object.id, …), which throws from the transport's setImmediate callback, where no user code can catch it.
When it happens. Measured with a variant of the script above, 240 renderer crashes per row (30 contexts × 8 pages), Node 23.10.0:
| Recording |
Page |
Crash |
Assertion errors |
| nothing |
repainting |
Page.crash |
0 |
| nothing |
static |
Page.crash |
0 |
trace, snapshots only |
repainting |
Page.crash |
0 |
trace, screenshots |
static |
Page.crash |
0 |
trace, screenshots |
repainting |
Page.crash |
19 |
trace, screenshots |
repainting |
chrome://crash |
31 |
trace, screenshots + snapshots |
repainting |
Page.crash |
12, 26, 29, 39 (four runs) |
recordVideo |
repainting |
Page.crash |
28 |
recordVideo |
repainting |
chrome://crash |
49 |
It takes a screencast with frames flowing; how the renderer dies does not matter. Note that trace: 'retain-on-failure' records screenshots during every test, so every test in which a page crashes is exposed, even though the trace is thrown away when the test passes.
A possible fix. Ignore a reply that arrives after the session was marked as crashed, the same way as the -32001 case:
} else if (object.id && (this._crashed || object.error?.code === -32001)) {
// A reply to a command whose callback was already rejected because the
// page crashed, or a message to a closed session: ignore it.
} else {
With this change applied to a local copy of playwright-core 1.63.0, the script above ran all 50 iterations (400 crashes) in 3 of 3 runs; with the change reverted in the same copy, 2 of 2 runs died within five iterations. The screencast ack is simply the command that is almost always in flight: any reply that arrives after Inspector.targetCrashed takes the same path.
How we ran into it. A test that crashes the renderer on purpose, to check that data already written to IndexedDB survives, failed about one run in eight with only Error: Assertion error, and about one in three under CPU load. It ran with trace: 'retain-on-failure'; PWDEBUGIMPL=1 revealed the stack. We now kill the browser process from outside instead, which closes the connection with nothing in flight.
The code involved is unchanged on main (b9a34ac).
Environment
System:
OS: macOS 26.7
CPU: (11) arm64 Apple M3 Pro
Memory: 18.00 GB
Binaries:
Node: 22.17.0 (also reproduced on 23.10.0)
npmPackages:
playwright: 1.63.0
@playwright/test: 1.63.0
Browsers:
Chromium 153.0.8010.12 (bundled with 1.63.0), headless
Version
1.63.0
Steps to reproduce
npm i playwright@1.63.0 && npx playwright install chromiumrepro.mjs:node repro.mjsThe Node process dies within the first four iterations: 10 of 10 runs (5 on Node 22.17.0, 5 on Node 23.10.0).
The same thing in Playwright Test, with one page per test:
npx playwright test --repeat-each=40 --workers=1: in two runs, 6 and 9 of 40 failed withtrace: 'retain-on-failure', and 2 and 3 of 40 withuse: { video: 'on' }instead (Node 22.17.0).Expected behavior
The page emits
crashand the script or test carries on, as it does when nothing is being recorded (0 assertion errors in 240 crashes, see the table below).Actual behavior
An uncaught exception from Playwright's CDP client ends the Node process:
In Playwright Test the test fails with nothing but this, with no stack and no location, so it looks like an ordinary flaky test:
Additional context
Cause. From
DEBUG=pw:protocol(session ids shortened):Page.screencastFrameAck(crPage.ts#L952-L962).Inspector.targetCrashedleads toCRSession._markAsCrashed()(crPage.ts#L908-L911, crConnection.ts#L127-L130), which rejects and clears every pending callback (crConnection.ts#L194-L202).CRSession._onMessage(crConnection.ts#L154-L174) the id is no longer known, and the reply carries no error, so it is not the ignored-32001case either. It falls through toassert(!object.id, …), which throws from the transport'ssetImmediatecallback, where no user code can catch it.When it happens. Measured with a variant of the script above, 240 renderer crashes per row (30 contexts × 8 pages), Node 23.10.0:
Page.crashPage.crashsnapshotsonlyPage.crashscreenshotsPage.crashscreenshotsPage.crashscreenshotschrome://crashscreenshots+snapshotsPage.crashrecordVideoPage.crashrecordVideochrome://crashIt takes a screencast with frames flowing; how the renderer dies does not matter. Note that
trace: 'retain-on-failure'records screenshots during every test, so every test in which a page crashes is exposed, even though the trace is thrown away when the test passes.A possible fix. Ignore a reply that arrives after the session was marked as crashed, the same way as the
-32001case:With this change applied to a local copy of
playwright-core1.63.0, the script above ran all 50 iterations (400 crashes) in 3 of 3 runs; with the change reverted in the same copy, 2 of 2 runs died within five iterations. The screencast ack is simply the command that is almost always in flight: any reply that arrives afterInspector.targetCrashedtakes the same path.How we ran into it. A test that crashes the renderer on purpose, to check that data already written to IndexedDB survives, failed about one run in eight with only
Error: Assertion error, and about one in three under CPU load. It ran withtrace: 'retain-on-failure';PWDEBUGIMPL=1revealed the stack. We now kill the browser process from outside instead, which closes the connection with nothing in flight.The code involved is unchanged on
main(b9a34ac).Environment