Version
1.63.0 (Firefox 155.0, revision 1543). The same code is on main at b9a34ac.
Steps to reproduce
Repro: https://gist.github.com/coffeegrind123/1c4da4dffbc8b230c1c5c22416f4f806 (one script; the only dependency is playwright). The same script is inline below.
npm init -y && npm install playwright@1.63.0 && npx playwright install firefox
- Save the script below as
repro.mjs (Linux only, because it reads /proc).
node repro.mjs
The script runs 10 context cycles with no crash, then 10 with a crash each, and prints how much the Firefox parent process grew in each phase. It crashes a page by SIGKILLing the browser's own web content processes, found by walking its process tree. That's what the OOM killer does to a page.
// Firefox parent-process RSS across context cycles, with and without a content
// process crash in each. Linux only (reads /proc). Run: node repro.mjs
import { firefox } from 'playwright';
import fs from 'node:fs';
const CYCLES = 10;
const rssMB = pid => Number(fs.readFileSync(`/proc/${pid}/status`, 'utf8').match(/VmRSS:\s+(\d+)/)[1]) / 1024;
function descendants(root) {
const children = new Map();
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry))
continue;
try {
const stat = fs.readFileSync(`/proc/${entry}/stat`, 'utf8');
const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
children.set(ppid, [...(children.get(ppid) || []), Number(entry)]);
} catch {}
}
const result = [];
const queue = [root];
while (queue.length) {
for (const child of children.get(queue.shift()) || []) {
result.push(child);
queue.push(child);
}
}
return result;
}
// SIGKILL every web content process, the way the OOM killer takes one out.
// Only one page is open at a time, so this crashes exactly that page.
function killWebContent(browserPid) {
for (const pid of descendants(browserPid)) {
try {
const name = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim();
if (name === 'Web Content' || name === 'Isolated Web Co')
process.kill(pid, 'SIGKILL');
} catch {}
}
}
async function cycle(browser, browserPid, crash) {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('about:blank');
if (crash) {
const crashed = page.waitForEvent('crash');
killWebContent(browserPid);
await crashed;
}
await context.close();
}
const server = await firefox.launchServer();
const browserPid = server.process().pid;
const browser = await firefox.connect(server.wsEndpoint());
// The first context cycles grow the parent with or without a crash.
for (let i = 0; i < 2; i++)
await cycle(browser, browserPid, false);
for (const crash of [false, true]) {
const before = rssMB(browserPid);
for (let i = 0; i < CYCLES; i++)
await cycle(browser, browserPid, crash);
await new Promise(f => setTimeout(f, 2000));
const delta = rssMB(browserPid) - before;
console.log(`${crash ? 'with a crash' : 'no crash '}: parent RSS ${delta >= 0 ? '+' : ''}${delta.toFixed(0)} MB over ${CYCLES} context cycles`);
}
await browser.close();
await server.close();
Expected behavior
A context cycle whose page crashed costs the parent about the same as one that didn't. context.close() frees the crashed page's window.
Actual behavior
Every crashed page keeps its browser window, and the window's about:tabcrashed document, alive in the parent until the browser exits. context.close() doesn't close it.
no crash : parent RSS +1 MB over 10 context cycles
with a crash: parent RSS +192 MB over 10 context cycles
Across 3 runs, the crash phase was +188..+192 MB and the no-crash phase was -4..+1 MB: about 19 MB per crash, unbounded.
An about:memory dump of the parent (kill -SIGRTMIN <pid>) after the 10 crash cycles, with every context closed, lists these top-level windows:
chrome://browser/content/browser.xhtml 10
about:tabcrashed?e=tabcrashed&u=about%3A 10
chrome://extensions/content/dummy.xhtml 1
Additional context
Cause. The oop-frameloader-crashed observer in browser_patches/firefox/juggler/TargetRegistry.js (lines 139-150 at b9a34ac) emits Crashed and calls target.dispose(), and that's all it does. PageTarget.dispose() (line 874) removes the page from browserContext.pages and from the registry's maps. After that, nothing can close the tab:
BrowserContext.destroy() (lines 1008-1023) closes only this.pages, which no longer contains the crashed page.
- The default context never closes pages.
onTabCloseListener finds no target for the tab.
Proposed fix (+43 lines, TargetRegistry.js and components/Juggler.js):
- Close the crashed tab on the next tick after
dispose().
- In a persistent launch there's no
-silent, so closing the browser's last window would quit Firefox. There, a crashed page that is the last tab is kept until another tab opens. Juggler.js already reads -silent (line 83). It now passes lastWindowQuits: !this._silent to the registry.
diff --git a/browser_patches/firefox/juggler/TargetRegistry.js b/browser_patches/firefox/juggler/TargetRegistry.js
--- a/browser_patches/firefox/juggler/TargetRegistry.js
+++ b/browser_patches/firefox/juggler/TargetRegistry.js
@@ -7,6 +7,7 @@
const {ContextualIdentityService} = ChromeUtils.importESModule("moz-src:///toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs");
const {NetUtil} = ChromeUtils.importESModule('resource://gre/modules/NetUtil.sys.mjs');
const {AppConstants} = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+const {setTimeout} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
const Cr = Components.results;
@@ -112,10 +113,16 @@
return TargetRegistry._instance || null;
}
- constructor() {
+ constructor({ lastWindowQuits = true } = {}) {
helper.decorateAsEventEmitter(this);
TargetRegistry._instance = this;
+ // False when Juggler holds the last-window-closing survival area (-silent,
+ // every non-persistent launch); a persistent launch quits with its last window.
+ this._lastWindowQuits = lastWindowQuits;
+ // Crashed pages whose tab is the browser's last; see _closeCrashedTab.
+ this._retainedCrashedTargets = new Set();
+
this._browserContextIdToBrowserContext = new Map();
this._userContextIdToBrowserContext = new Map();
this._browserToTarget = new Map();
@@ -146,6 +153,13 @@
return;
target.emit(PageTarget.Events.Crashed);
target.dispose();
+ // dispose() detaches the page from every client and drops it from its
+ // context, so nothing could close this tab afterwards: context.close()
+ // only closes the pages it still tracks, and the default context closes
+ // none. Each crashed page kept its window alive until the browser
+ // exited. Deferred so the tab is not torn down inside Gecko's own
+ // crash notification.
+ setTimeout(() => this._closeCrashedTab(target), 0);
}
}, 'oop-frameloader-crashed');
@@ -170,6 +184,11 @@
target.updateOverridesForBrowsingContext(tab.linkedBrowser.browsingContext);
if (!hasExplicitSize)
target.updateViewportSize();
+
+ // Another tab exists now, so a crashed page kept as the last one can go.
+ for (const crashed of this._retainedCrashedTargets)
+ setTimeout(() => this._closeCrashedTab(crashed), 0);
+ this._retainedCrashedTargets.clear();
};
const onTabCloseListener = event => {
@@ -333,6 +352,16 @@
return this._userContextIdToBrowserContext.get(userContextId);
}
+ _closeCrashedTab(target) {
+ if (this._lastWindowQuits && target.isLastTab()) {
+ // Closing it would quit a persistent-context browser under the client.
+ // Keep it until another tab opens; onTabOpenListener comes back for it.
+ this._retainedCrashedTargets.add(target);
+ return;
+ }
+ target.closeCrashedTab();
+ }
+
async newPage({browserContextId}) {
// When creating the very first page, we cannot create multiple in parallel.
// See https://github.com/microsoft/playwright/issues/34586.
@@ -762,6 +791,23 @@
});
}
+ closeCrashedTab() {
+ // Its window or context may have closed it first.
+ if (!this._tab.isConnected || this._tab.closing)
+ return;
+ this.close();
+ }
+
+ isLastTab() {
+ if (this._gBrowser.tabs.length > 1)
+ return false;
+ for (const win of Services.wm.getEnumerator('navigator:browser')) {
+ if (win !== this._window && !win.closed)
+ return false;
+ }
+ return true;
+ }
+
channel() {
return this._channel;
}
diff --git a/browser_patches/firefox/juggler/components/Juggler.js b/browser_patches/firefox/juggler/components/Juggler.js
--- a/browser_patches/firefox/juggler/components/Juggler.js
+++ b/browser_patches/firefox/juggler/components/Juggler.js
@@ -94,7 +94,7 @@
case "final-ui-startup":
Services.obs.removeObserver(this, topic);
- const targetRegistry = new TargetRegistry();
+ const targetRegistry = new TargetRegistry({ lastWindowQuits: !this._silent });
new NetworkObserver(targetRegistry);
const loadStyleSheet = () => {
Verification. I applied this diff to the 1.63.0 Firefox build (TargetRegistry.js and Juggler.js replaced in omni.ja, nothing else changed):
- The repro gives +17..+20 MB for the crash phase against -4..+7 MB without crashes (3 runs), down from +188..+192 MB.
- The
about:memory dump lists only dummy.xhtml.
- With
launchPersistentContext: after the only page crashes, the browser stays up, three new pages open and evaluate, and context.close() returns.
Camoufox vendors this Juggler and has the same bug. The same change fixed it there (daijro/camoufox#795), where the leak measured 15-60 MB of parent RSS per crash.
I found no existing issue for this. I searched for firefox crash window/tab/memory leak, tabcrashed and oop-frameloader-crashed. The nearest are #42659 (Firefox keeping navigated documents alive, a different retention path) and #42641 (slow browserContext.close() from minimizeMemoryUsage).
Environment
System:
OS: Linux 6.18 Debian GNU/Linux 12 (bookworm)
CPU: (16) x64 AMD Ryzen 9 3900X 12-Core Processor
Memory: 21.50 GB
Container: Yes
Binaries:
Node: 22.23.2
npm: 10.9.8
npmPackages:
playwright: 1.63.0
Version
1.63.0 (Firefox 155.0, revision 1543). The same code is on
mainat b9a34ac.Steps to reproduce
Repro: https://gist.github.com/coffeegrind123/1c4da4dffbc8b230c1c5c22416f4f806 (one script; the only dependency is
playwright). The same script is inline below.npm init -y && npm install playwright@1.63.0 && npx playwright install firefoxrepro.mjs(Linux only, because it reads/proc).node repro.mjsThe script runs 10 context cycles with no crash, then 10 with a crash each, and prints how much the Firefox parent process grew in each phase. It crashes a page by SIGKILLing the browser's own web content processes, found by walking its process tree. That's what the OOM killer does to a page.
Expected behavior
A context cycle whose page crashed costs the parent about the same as one that didn't.
context.close()frees the crashed page's window.Actual behavior
Every crashed page keeps its browser window, and the window's
about:tabcrasheddocument, alive in the parent until the browser exits.context.close()doesn't close it.Across 3 runs, the crash phase was +188..+192 MB and the no-crash phase was -4..+1 MB: about 19 MB per crash, unbounded.
An
about:memorydump of the parent (kill -SIGRTMIN <pid>) after the 10 crash cycles, with every context closed, lists these top-level windows:Additional context
Cause. The
oop-frameloader-crashedobserver inbrowser_patches/firefox/juggler/TargetRegistry.js(lines 139-150 at b9a34ac) emitsCrashedand callstarget.dispose(), and that's all it does.PageTarget.dispose()(line 874) removes the page frombrowserContext.pagesand from the registry's maps. After that, nothing can close the tab:BrowserContext.destroy()(lines 1008-1023) closes onlythis.pages, which no longer contains the crashed page.onTabCloseListenerfinds no target for the tab.Proposed fix (+43 lines,
TargetRegistry.jsandcomponents/Juggler.js):dispose().-silent, so closing the browser's last window would quit Firefox. There, a crashed page that is the last tab is kept until another tab opens.Juggler.jsalready reads-silent(line 83). It now passeslastWindowQuits: !this._silentto the registry.Verification. I applied this diff to the 1.63.0 Firefox build (
TargetRegistry.jsandJuggler.jsreplaced inomni.ja, nothing else changed):about:memorydump lists onlydummy.xhtml.launchPersistentContext: after the only page crashes, the browser stays up, three new pages open and evaluate, andcontext.close()returns.Camoufox vendors this Juggler and has the same bug. The same change fixed it there (daijro/camoufox#795), where the leak measured 15-60 MB of parent RSS per crash.
I found no existing issue for this. I searched for firefox crash window/tab/memory leak,
tabcrashedandoop-frameloader-crashed. The nearest are #42659 (Firefox keeping navigated documents alive, a different retention path) and #42641 (slowbrowserContext.close()fromminimizeMemoryUsage).Environment