Skip to content

fix: Fix Firefox detatched-window memory leak by avoiding DocumentPictureInPicture instantiation in Snow hook cp-13.39.2 - #44352

Merged
HowardBraham merged 3 commits into
mainfrom
jongsun/test/snow-pip-prototype-hook
Jul 10, 2026
Merged

HowardBraham merged 3 commits into
mainfrom
jongsun/test/snow-pip-prototype-hook

Conversation

@MajorLift

@MajorLift MajorLift commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

On Firefox, every closed MetaMask window (popup, notification, home) is retained in memory (#42891 and recent internal reports).

  • Rate: ~70 detached window entries (~105MB, fresh test wallet) per popup open/close, linear with use. 30 cycles leave 3.56GB (13.37.0) / 3.31GB (13.38.1) of detached documents, surviving forced GC. State-heavy wallets multiply every retained document.
  • Mechanism (heap-graph traced, intervention-tested): Snow's picture-in-picture hook reads win?.documentPictureInPicture?.requestWindow in every window. The read lazily instantiates a per-window DocumentPictureInPicture, and Firefox's cycle collector fails to break the instance's preserved-wrapper cycle after window close, pinning the entire realm.
  • Why now: Firefox shipped the API in 151 (May 2026). The April-2023 hook was dormant until users auto-updated. The collection failure itself is a Gecko defect (upstream filing in progress; prior art: Bugzilla 1902438), but this fix does not depend on Mozilla.

Description

Applies the upstream Snow fix (LavaMoat/snow#171) as a Yarn patch: the hook moves from the per-window instance to DocumentPictureInPicture.prototype, reached through the interface object, which does not invoke the instance-creating getter.

  • Call semantics unchanged: instance lookup falls through to the patched prototype, this stays the instance, same wrapped native. Snow's PiP realm monitoring is preserved.
  • The patch covers snow.prod.js and snow.js in addition to src/request.js: the extension consumes Snow's prebuilt bundle as a LavaMoat static shim (LavamoatPlugin), and webpack never processes @lavamoat/snow/**.
  • Interim: remove this patch when @lavamoat/snow is bumped to a release containing the upstream fix. The bump is the removal trigger.

Cherry-pick candidacy

  • Affected population: every Firefox user on 151+ (effectively the whole Firefox base via auto-update). Reproduces from a fresh wallet in five popup opens. No workaround short of a browser restart.
  • Proposed: next RC, with a hotfix on the current production line worth considering.
  • Snow is security-team-owned. Their sign-off on shipping a patched Snow is part of this review.

Verification

Check Result
Intervention: official 13.37.0 artifact, only this hook changed, 30 cycles +2,100 docs (3.35GB) → -13 (1.6MB total). Hook is the sole delta, so this is also the causal proof
This PR's CI artifact (d4dd55f), probed directly Patched hook present; 5 cycles: +7 KB-scale entries (1.1MB) vs +345 docs (+534MB) stock
Field Original reporter confirms the leak is gone on this PR's CI build
Independent repro Manual steps + automated probe: https://github.com/MajorLift/snow/tree/verification/pip-realm-leak/verification

Related issues

Fixes: #42891

Manual testing steps

  1. Install this PR's Firefox build (metamaskbot comment) in a fresh profile via about:debugging → Load Temporary Add-on. Onboard with a test SRP.
  2. Confirm the fix is live: extension console → DocumentPictureInPicture.prototype.requestWindow.name → "open" (stock: "requestWindow").
  3. Open/close the popup five times, visiting activity or a swap view on a few opens.
  4. about:memory → Minimize memory usage → Measure → filter detached: no moz-extension UI-document cohorts (stock: ~70 entries / ~105MB per cycle).

Screenshots/Recordings

Heap-graph traces, memory reports, and retaining-path analysis: see the verification bundle above.

Pre-merge author checklist

CHANGELOG entry: Fixed a memory leak on Firefox where closed MetaMask windows were retained in memory, growing extension memory use with every popup open

@github-actions

Copy link
Copy Markdown
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

@metamask-ci metamask-ci Bot added INVALID-PR-TEMPLATE PR's body doesn't match template team-extension-platform Extension Platform team labels Jul 10, 2026
@MajorLift MajorLift changed the title [TEST — DO NOT MERGE] fix: patch @lavamoat/snow to avoid DocumentPictureInPicture instantiation (Firefox detached-document leak) test: patch @lavamoat/snow to avoid DocumentPictureInPicture instantiation (do not merge) Jul 10, 2026
@metamask-ci

metamask-ci Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor
Builds ready [01a9491]
Deprecated Browserify fallback builds
⚡ Performance Benchmarks (Total: 🟢 14 pass · 🟡 9 warn · 🔴 1 fail)

Baseline (latest main): 1716549 | Date: 6/29/2026 | Pipeline: 29099080774 | Baseline logs

Metricschrome-webpackfirefox-webpack
onboardingNewWallet
[Sentry log · main/release]
🟢 [CI log]🔴 [CI log]

Regressions (🔴 1 failure)

Interaction Benchmarks · Samples: 5
Benchmarkchrome-webpackfirefox-webpack
loadNewAccount
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
🔴 load_new_account
confirmTx
[Sentry log · main/release]
🟢 [CI log]🟢 [CI log]
bridgeUserActions
[Sentry log · main/release]
🟢 [CI log]🟢 [CI log]

📈 Results compared to the previous 5 runs on main

  • ↑ confirmTx/inp: +12%
  • ↑ bridgeUserActions/bridge_load_page: +35%
  • ↓ bridgeUserActions/longTaskCount: -17%
  • ↓ bridgeUserActions/longTaskTotalDuration: -12%
  • ↓ bridgeUserActions/tbt: -23%
  • ↑ loadNewAccount/load_new_account: +600%
  • ↑ loadNewAccount/total: +600%
  • ↑ loadNewAccount/inp: +39%
  • ↑ loadNewAccount/lcp: +1084%
  • ↑ confirmTx/confirm_tx: +12%
  • ↓ confirmTx/longTaskCount: -100%
  • ↓ confirmTx/longTaskTotalDuration: -100%
  • ↓ confirmTx/longTaskMaxDuration: -100%
  • ↓ confirmTx/tbt: -100%
  • ↑ confirmTx/total: +12%
  • ↓ confirmTx/fcp: -45%
  • ↑ confirmTx/lcp: +1169%
  • ↑ bridgeUserActions/bridge_load_page: +113%
  • ↑ bridgeUserActions/bridge_load_asset_picker: +72%
  • ↓ bridgeUserActions/longTaskCount: -100%
  • ↓ bridgeUserActions/longTaskTotalDuration: -100%
  • ↓ bridgeUserActions/longTaskMaxDuration: -100%
  • ↓ bridgeUserActions/tbt: -100%
  • ↑ bridgeUserActions/total: +13%
  • ↓ bridgeUserActions/inp: -22%
  • ↓ bridgeUserActions/fcp: -48%
  • ↑ bridgeUserActions/lcp: +1159%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 loadNewAccount/FCP: p75 1.9s
Startup Benchmarks · Samples: 100
Benchmarkchrome-webpackfirefox-webpack
startupStandardHome
[Sentry log · main/release]
🟢 [CI log]🟢 [CI log]
startupPowerUserHome
[Sentry log · main/release]
–🟡 [CI log]

📈 Results compared to the previous 5 runs on main

  • ↓ startupStandardHome/uiStartup: -15%
  • ↓ startupStandardHome/load: -16%
  • ↓ startupStandardHome/domContentLoaded: -16%
  • ↓ startupStandardHome/domInteractive: -14%
  • ↓ startupStandardHome/backgroundConnect: -18%
  • ↓ startupStandardHome/firstReactRender: -16%
  • ↓ startupStandardHome/initialActions: -33%
  • ↓ startupStandardHome/loadScripts: -16%
  • ↓ startupStandardHome/setupStore: -15%
  • ↓ startupStandardHome/longTaskCount: -33%
  • ↓ startupStandardHome/longTaskTotalDuration: -25%
  • ↓ startupStandardHome/longTaskMaxDuration: -20%
  • ↓ startupStandardHome/tbt: -24%
  • ↓ startupStandardHome/fcp: -17%
  • ↓ startupStandardHome/domInteractive: -19%
  • ↑ startupStandardHome/setupStore: +38%
  • ↓ startupStandardHome/fcp: -13%
  • ↓ startupPowerUserHome/backgroundConnect: -32%
  • ↑ startupPowerUserHome/inp: +17%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 startupPowerUserHome/INP: p75 256ms
  • 🟡 startupPowerUserHome/LCP: p75 3.6s
User Journey Benchmarks · Samples: 5 · mock API 🔴 1
Benchmarkchrome-webpackfirefox-webpack
onboardingImportWallet
[Sentry log · main/release]
🟢 [CI log]🟢 [CI log]
onboardingNewWallet
[Sentry log · main/release]
🟢 [CI log]🔴 [CI log]
🔴 total
assetDetails
[Sentry log · main/release]
🟡 [CI log]🟡 [CI log]
solanaAssetDetails
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
importSrpHome
[Sentry log · main/release]
🟡 [CI log]🟡 [CI log]
sendTransactions
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
swap
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]

📈 Results compared to the previous 5 runs on main

  • ↓ onboardingImportWallet/srpButtonToSrpForm: -17%
  • ↓ onboardingImportWallet/confirmSrpToPwForm: -19%
  • ↓ onboardingImportWallet/pwFormToMetricsScreen: -16%
  • ↓ onboardingImportWallet/metricsToWalletReadyScreen: -34%
  • ↓ onboardingImportWallet/doneButtonToHomeScreen: -86%
  • ↓ onboardingImportWallet/openAccountMenuToAccountListLoaded: -98%
  • ↓ onboardingImportWallet/longTaskCount: -100%
  • ↓ onboardingImportWallet/longTaskTotalDuration: -100%
  • ↓ onboardingImportWallet/longTaskMaxDuration: -100%
  • ↓ onboardingImportWallet/tbt: -100%
  • ↓ onboardingImportWallet/total: -85%
  • ↓ onboardingNewWallet/doneButtonToAssetList: -34%
  • ↓ onboardingNewWallet/longTaskCount: -50%
  • ↓ onboardingNewWallet/longTaskTotalDuration: -70%
  • ↓ onboardingNewWallet/longTaskMaxDuration: -48%
  • ↓ onboardingNewWallet/tbt: -100%
  • ↓ onboardingNewWallet/total: -29%
  • ↓ solanaAssetDetails/assetClickToPriceChart: -71%
  • ↓ solanaAssetDetails/total: -71%
  • ↓ solanaAssetDetails/inp: -31%
  • ↓ importSrpHome/openAccountMenuAfterLogin: -27%
  • ↓ importSrpHome/homeAfterImportWithNewWallet: -29%
  • ↓ importSrpHome/longTaskCount: -35%
  • ↓ importSrpHome/longTaskTotalDuration: -36%
  • ↓ importSrpHome/longTaskMaxDuration: -24%
  • ↓ importSrpHome/tbt: -40%
  • ↓ importSrpHome/total: -25%
  • ↓ importSrpHome/inp: -12%
  • ↓ importSrpHome/lcp: -58%
  • ↓ importSrpHome/cls: -47%
  • ↓ sendTransactions/openSendPageFromHome: -17%
  • ↓ sendTransactions/selectTokenToSendFormLoaded: -22%
  • ↓ sendTransactions/reviewTransactionToConfirmationPage: -33%
  • ↓ sendTransactions/longTaskCount: -20%
  • ↓ sendTransactions/longTaskTotalDuration: -26%
  • ↓ sendTransactions/longTaskMaxDuration: -10%
  • ↓ sendTransactions/tbt: -55%
  • ↓ sendTransactions/total: -32%
  • ↓ sendTransactions/inp: -23%
  • ↓ sendTransactions/fcp: -13%
  • ↓ sendTransactions/lcp: -13%
  • ↓ sendTransactions/cls: -40%
  • ↓ swap/openSwapPageFromHome: -31%
  • ↑ swap/fetchAndDisplaySwapQuotes: +154%
  • ↑ swap/longTaskTotalDuration: +33%
  • ↑ swap/longTaskMaxDuration: +52%
  • ↑ swap/tbt: +112%
  • ↑ swap/total: +151%
  • ↓ swap/cls: -63%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 assetDetails/INP: p75 208ms
  • 🟡 importSrpHome/INP: p75 232ms
  • 🟡 assetDetails/FCP: p75 1.8s
  • 🟡 solanaAssetDetails/FCP: p75 1.8s
  • 🟡 importSrpHome/FCP: p75 1.9s
  • 🟡 sendTransactions/FCP: p75 1.9s
  • 🔴 swap/INP: p75 744ms
  • 🟡 swap/FCP: p75 1.9s
Dapp Page Load Benchmarks · Samples: 100
Benchmarkchrome-webpack
dappPageLoad
[Sentry log · main/release]
🟢 [CI log]
Bundle sizes
  • background: 11.07 MiB
  • ui: 17.01 MiB
  • common: 0 Bytes
  • other: 998.07 KiB
  • contentScripts: 1.79 MiB
  • zip: 26.35 MiB
No matching bundle-size baseline was found in the history data, so diff values are omitted.

@MajorLift MajorLift added the DO-NOT-MERGE Pull requests that should not be merged label Jul 10, 2026
The extension consumes snow.prod.js as a LavaMoat static shim; patching
only the source file left the shipped bundle unchanged.
@MajorLift

Copy link
Copy Markdown
Contributor Author

@metamaskbot update-policies

@metamask-ci

metamask-ci Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

LavaMoat validation is still running. Please retry @metamaskbot update-policies after CI validation completes.

@metamask-ci

metamask-ci Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor
Builds ready [4ea9538]
Deprecated Browserify fallback builds
⚡ Performance Benchmarks (Total: 🟢 12 pass · 🟡 11 warn · 🔴 1 fail)

Baseline (latest main): 1716549 | Date: 6/29/2026 | Pipeline: 29102541316 | Baseline logs

Metricschrome-webpackfirefox-webpack
onboardingNewWallet
[Sentry log · main/release]
🟢 [CI log]🔴 [CI log]

Regressions (🔴 1 failure)

Interaction Benchmarks · Samples: 5
Benchmarkchrome-webpackfirefox-webpack
loadNewAccount
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
confirmTx
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
bridgeUserActions
[Sentry log · main/release]
🟡 [CI log]🟢 [CI log]

📈 Results compared to the previous 5 runs on main

  • ↓ loadNewAccount/load_new_account: -22%
  • ↓ loadNewAccount/total: -22%
  • ↓ bridgeUserActions/bridge_load_asset_picker: -13%
  • ↓ bridgeUserActions/longTaskCount: -17%
  • ↑ bridgeUserActions/longTaskMaxDuration: +13%
  • ↑ bridgeUserActions/tbt: +31%
  • ↓ bridgeUserActions/inp: -14%
  • ↑ loadNewAccount/load_new_account: +160%
  • ↑ loadNewAccount/total: +160%
  • ↑ loadNewAccount/inp: +39%
  • ↑ loadNewAccount/lcp: +1157%
  • ↑ confirmTx/confirm_tx: +14%
  • ↓ confirmTx/longTaskCount: -100%
  • ↓ confirmTx/longTaskTotalDuration: -100%
  • ↓ confirmTx/longTaskMaxDuration: -100%
  • ↓ confirmTx/tbt: -100%
  • ↑ confirmTx/total: +14%
  • ↑ confirmTx/inp: +12%
  • ↑ confirmTx/lcp: +1187%
  • ↑ bridgeUserActions/bridge_load_page: +150%
  • ↑ bridgeUserActions/bridge_load_asset_picker: +52%
  • ↓ bridgeUserActions/longTaskCount: -100%
  • ↓ bridgeUserActions/longTaskTotalDuration: -100%
  • ↓ bridgeUserActions/longTaskMaxDuration: -100%
  • ↓ bridgeUserActions/tbt: -100%
  • ↑ bridgeUserActions/total: +21%
  • ↓ bridgeUserActions/fcp: -43%
  • ↑ bridgeUserActions/lcp: +1319%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 bridgeUserActions/FCP: p75 1.8s
  • 🟡 loadNewAccount/FCP: p75 1.9s
  • 🟡 confirmTx/FCP: p75 1.9s
Startup Benchmarks · Samples: 100
Benchmarkchrome-webpackfirefox-webpack
startupStandardHome
[Sentry log · main/release]
🟢 [CI log]🟢 [CI log]
startupPowerUserHome
[Sentry log · main/release]
–🟡 [CI log]

📈 Results compared to the previous 5 runs on main

  • ↑ startupStandardHome/numNetworkReqs: +10%
  • ↓ startupStandardHome/uiStartup: -19%
  • ↓ startupStandardHome/load: -20%
  • ↓ startupStandardHome/domContentLoaded: -20%
  • ↓ startupStandardHome/domInteractive: -38%
  • ↓ startupStandardHome/backgroundConnect: -19%
  • ↓ startupStandardHome/firstReactRender: -14%
  • ↓ startupStandardHome/initialActions: -50%
  • ↓ startupStandardHome/loadScripts: -20%
  • ↓ startupStandardHome/fcp: -28%
  • ↓ startupStandardHome/lcp: -20%
  • ↓ startupPowerUserHome/domInteractive: -15%
  • ↓ startupPowerUserHome/backgroundConnect: -22%
  • ↑ startupPowerUserHome/firstReactRender: +11%
  • ↓ startupPowerUserHome/fcp: -20%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 startupPowerUserHome/INP: p75 216ms
  • 🟡 startupPowerUserHome/LCP: p75 3.5s
User Journey Benchmarks · Samples: 5 · mock API 🔴 1
Benchmarkchrome-webpackfirefox-webpack
onboardingImportWallet
[Sentry log · main/release]
🟢 [CI log]🟢 [CI log]
onboardingNewWallet
[Sentry log · main/release]
🟢 [CI log]🔴 [CI log]
🔴 total
assetDetails
[Sentry log · main/release]
🟡 [CI log]🟡 [CI log]
solanaAssetDetails
[Sentry log · main/release]
🟡 [CI log]🟡 [CI log]
importSrpHome
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
sendTransactions
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
swap
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]

📈 Results compared to the previous 5 runs on main

  • ↓ onboardingImportWallet/metricsToWalletReadyScreen: -14%
  • ↓ onboardingImportWallet/doneButtonToHomeScreen: -86%
  • ↓ onboardingImportWallet/openAccountMenuToAccountListLoaded: -50%
  • ↓ onboardingImportWallet/longTaskCount: -80%
  • ↓ onboardingImportWallet/longTaskTotalDuration: -94%
  • ↓ onboardingImportWallet/longTaskMaxDuration: -91%
  • ↓ onboardingImportWallet/tbt: -99%
  • ↓ onboardingImportWallet/total: -83%
  • ↑ onboardingNewWallet/agreeButtonToOnboardingSuccess: +17%
  • ↓ onboardingNewWallet/doneButtonToAssetList: -38%
  • ↓ onboardingNewWallet/longTaskCount: -100%
  • ↓ onboardingNewWallet/longTaskTotalDuration: -100%
  • ↓ onboardingNewWallet/longTaskMaxDuration: -100%
  • ↓ onboardingNewWallet/tbt: -100%
  • ↓ onboardingNewWallet/total: -32%
  • ↓ solanaAssetDetails/assetClickToPriceChart: -64%
  • ↓ solanaAssetDetails/total: -64%
  • ↓ solanaAssetDetails/inp: -17%
  • ↓ importSrpHome/loginToHomeScreen: -13%
  • ↓ importSrpHome/longTaskCount: -35%
  • ↓ importSrpHome/longTaskTotalDuration: -41%
  • ↓ importSrpHome/longTaskMaxDuration: -19%
  • ↓ importSrpHome/tbt: -39%
  • ↓ importSrpHome/inp: -27%
  • ↓ importSrpHome/lcp: -48%
  • ↓ importSrpHome/cls: -47%
  • ↓ sendTransactions/openSendPageFromHome: -30%
  • ↓ sendTransactions/selectTokenToSendFormLoaded: -57%
  • ↓ sendTransactions/reviewTransactionToConfirmationPage: -48%
  • ↓ sendTransactions/longTaskCount: -20%
  • ↓ sendTransactions/longTaskTotalDuration: -27%
  • ↓ sendTransactions/longTaskMaxDuration: -12%
  • ↓ sendTransactions/tbt: -62%
  • ↓ sendTransactions/total: -48%
  • ↓ sendTransactions/inp: -19%
  • ↓ sendTransactions/fcp: -11%
  • ↓ sendTransactions/cls: -40%
  • ↓ swap/openSwapPageFromHome: -25%
  • ↑ swap/fetchAndDisplaySwapQuotes: +122%
  • ↓ swap/longTaskTotalDuration: -14%
  • ↓ swap/tbt: -100%
  • ↑ swap/total: +120%
  • ↓ swap/cls: -63%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 assetDetails/FCP: p75 1.8s
  • 🟡 solanaAssetDetails/FCP: p75 1.9s
  • 🟡 assetDetails/FCP: p75 1.9s
  • 🟡 solanaAssetDetails/FCP: p75 1.9s
  • 🟡 importSrpHome/FCP: p75 1.9s
  • 🟡 sendTransactions/FCP: p75 2.0s
  • 🟡 swap/FCP: p75 2.0s
Dapp Page Load Benchmarks · Samples: 100
Benchmarkchrome-webpack
dappPageLoad
[Sentry log · main/release]
🟢 [CI log]
Bundle sizes
  • background: 11.07 MiB
  • ui: 17.01 MiB
  • common: 0 Bytes
  • other: 998.07 KiB
  • contentScripts: 1.79 MiB
  • zip: 26.35 MiB
No matching bundle-size baseline was found in the history data, so diff values are omitted.

The lockfile still pinned hash=693c32 from the source-only patch, so
installs resolved the stale patched artifact and the shipped bundle kept
the instance-form hook.
@sonarqubecloud

Copy link
Copy Markdown

@metamask-ci

metamask-ci Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor
Builds ready [d4dd55f]
Deprecated Browserify fallback builds
⚡ Performance Benchmarks (Total: 🟢 14 pass · 🟡 10 warn · 🔴 0 fail)

Baseline (latest main): 1716549 | Date: 6/29/2026 | Pipeline: 29104394786 | Baseline logs

Interaction Benchmarks · Samples: 5
Benchmarkchrome-webpackfirefox-webpack
loadNewAccount
[Sentry log · main/release]
🟡 [CI log]🟢 [CI log]
confirmTx
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
bridgeUserActions
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
🟡 bridge_search_token

📈 Results compared to the previous 5 runs on main

  • ↓ loadNewAccount/load_new_account: -20%
  • ↓ loadNewAccount/total: -20%
  • ↓ loadNewAccount/inp: -18%
  • ↑ loadNewAccount/fcp: +14%
  • ↓ bridgeUserActions/bridge_load_page: -20%
  • ↑ bridgeUserActions/bridge_load_asset_picker: +16%
  • ↓ bridgeUserActions/longTaskCount: -17%
  • ↓ bridgeUserActions/longTaskTotalDuration: -13%
  • ↓ bridgeUserActions/inp: -14%
  • ↑ loadNewAccount/load_new_account: +46%
  • ↑ loadNewAccount/total: +46%
  • ↓ loadNewAccount/inp: -26%
  • ↓ loadNewAccount/fcp: -50%
  • ↑ loadNewAccount/lcp: +1128%
  • ↑ confirmTx/confirm_tx: +13%
  • ↓ confirmTx/longTaskCount: -100%
  • ↓ confirmTx/longTaskTotalDuration: -100%
  • ↓ confirmTx/longTaskMaxDuration: -100%
  • ↓ confirmTx/tbt: -100%
  • ↑ confirmTx/total: +13%
  • ↑ confirmTx/fcp: +10%
  • ↑ confirmTx/lcp: +1140%
  • ↑ bridgeUserActions/bridge_load_page: +85%
  • ↑ bridgeUserActions/bridge_load_asset_picker: +67%
  • ↑ bridgeUserActions/bridge_search_token: +217%
  • ↓ bridgeUserActions/longTaskCount: -100%
  • ↓ bridgeUserActions/longTaskTotalDuration: -100%
  • ↓ bridgeUserActions/longTaskMaxDuration: -100%
  • ↓ bridgeUserActions/tbt: -100%
  • ↑ bridgeUserActions/total: +178%
  • ↓ bridgeUserActions/inp: -14%
  • ↑ bridgeUserActions/lcp: +1179%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 loadNewAccount/FCP: p75 2.1s
  • 🟡 confirmTx/FCP: p75 1.9s
  • 🟡 bridgeUserActions/FCP: p75 1.8s
Startup Benchmarks · Samples: 100
Benchmarkchrome-webpackfirefox-webpack
startupStandardHome
[Sentry log · main/release]
🟢 [CI log]🟢 [CI log]
startupPowerUserHome
[Sentry log · main/release]
–🟡 [CI log]

📈 Results compared to the previous 5 runs on main

  • ↑ startupStandardHome/numNetworkReqs: +10%
  • ↑ startupStandardHome/domInteractive: +16%
  • ↑ startupStandardHome/setupStore: +38%
  • ↑ startupStandardHome/fcp: +19%
  • ↓ startupPowerUserHome/domInteractive: -37%
  • ↓ startupPowerUserHome/backgroundConnect: -34%
  • ↓ startupPowerUserHome/fcp: -37%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 startupPowerUserHome/INP: p75 216ms
  • 🟡 startupPowerUserHome/LCP: p75 3.3s
User Journey Benchmarks · Samples: 5 · mock API
Benchmarkchrome-webpackfirefox-webpack
onboardingImportWallet
[Sentry log · main/release]
🟢 [CI log]🟢 [CI log]
onboardingNewWallet
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
🟡 total
assetDetails
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
solanaAssetDetails
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
importSrpHome
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
sendTransactions
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]
swap
[Sentry log · main/release]
🟢 [CI log]🟡 [CI log]

📈 Results compared to the previous 5 runs on main

  • ↓ onboardingImportWallet/metricsToWalletReadyScreen: -16%
  • ↓ onboardingImportWallet/doneButtonToHomeScreen: -86%
  • ↓ onboardingImportWallet/openAccountMenuToAccountListLoaded: -96%
  • ↓ onboardingImportWallet/longTaskCount: -80%
  • ↓ onboardingImportWallet/longTaskTotalDuration: -93%
  • ↓ onboardingImportWallet/longTaskMaxDuration: -91%
  • ↓ onboardingImportWallet/tbt: -99%
  • ↓ onboardingImportWallet/total: -85%
  • ↓ onboardingNewWallet/doneButtonToAssetList: -19%
  • ↓ onboardingNewWallet/longTaskCount: -50%
  • ↓ onboardingNewWallet/longTaskTotalDuration: -71%
  • ↓ onboardingNewWallet/longTaskMaxDuration: -50%
  • ↓ onboardingNewWallet/tbt: -100%
  • ↓ onboardingNewWallet/total: -17%
  • ↓ solanaAssetDetails/assetClickToPriceChart: -63%
  • ↓ solanaAssetDetails/total: -63%
  • ↓ solanaAssetDetails/inp: -38%
  • ↓ importSrpHome/loginToHomeScreen: -12%
  • ↓ importSrpHome/longTaskCount: -19%
  • ↓ importSrpHome/longTaskTotalDuration: -37%
  • ↓ importSrpHome/longTaskMaxDuration: -15%
  • ↓ importSrpHome/tbt: -35%
  • ↓ importSrpHome/inp: -24%
  • ↓ importSrpHome/lcp: -57%
  • ↓ importSrpHome/cls: -47%
  • ↓ sendTransactions/openSendPageFromHome: -26%
  • ↓ sendTransactions/selectTokenToSendFormLoaded: -50%
  • ↓ sendTransactions/reviewTransactionToConfirmationPage: -23%
  • ↓ sendTransactions/longTaskCount: -20%
  • ↓ sendTransactions/longTaskTotalDuration: -29%
  • ↓ sendTransactions/longTaskMaxDuration: -14%
  • ↓ sendTransactions/tbt: -70%
  • ↓ sendTransactions/total: -23%
  • ↓ sendTransactions/inp: -28%
  • ↓ sendTransactions/cls: -40%
  • ↓ swap/openSwapPageFromHome: -21%
  • ↑ swap/fetchAndDisplaySwapQuotes: +144%
  • ↑ swap/longTaskTotalDuration: +50%
  • ↑ swap/longTaskMaxDuration: +51%
  • ↑ swap/tbt: +107%
  • ↑ swap/total: +142%
  • ↓ swap/cls: -63%

🌐 Core Web Vitals — 🟢 good · 🟡 needs improvement · 🔴 poor (web.dev thresholds)

  • 🟡 assetDetails/FCP: p75 1.8s
  • 🟡 solanaAssetDetails/FCP: p75 1.8s
  • 🟡 importSrpHome/FCP: p75 1.8s
  • 🟡 sendTransactions/FCP: p75 1.8s
  • 🟡 swap/FCP: p75 1.9s
Dapp Page Load Benchmarks · Samples: 100
Benchmarkchrome-webpack
dappPageLoad
[Sentry log · main/release]
🟢 [CI log]
Bundle sizes
  • background: 11.07 MiB
  • ui: 17.01 MiB
  • common: 0 Bytes
  • other: 998.07 KiB
  • contentScripts: 1.79 MiB
  • zip: 26.35 MiB
No matching bundle-size baseline was found in the history data, so diff values are omitted.

@MajorLift MajorLift removed the DO-NOT-MERGE Pull requests that should not be merged label Jul 10, 2026
@MajorLift MajorLift changed the title test: patch @lavamoat/snow to avoid DocumentPictureInPicture instantiation (do not merge) fix: avoid DocumentPictureInPicture instantiation in Snow hook (Firefox detached-window leak) Jul 10, 2026
@MajorLift
MajorLift marked this pull request as ready for review July 10, 2026 16:46
@MajorLift
MajorLift requested a review from a team as a code owner July 10, 2026 16:46
@MajorLift MajorLift changed the title fix: avoid DocumentPictureInPicture instantiation in Snow hook (Firefox detached-window leak) fix: Fix Firefox detatched-window memory leak by avoiding DocumentPictureInPicture instantiation in Snow hook cp-13.39.1 Jul 10, 2026

@MajorLift MajorLift left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reviewers:

Comment on lines +36 to +50
- if (!win?.documentPictureInPicture?.requestWindow) {
+ // Reach requestWindow through the interface object's prototype rather
+ // than win.documentPictureInPicture: the attribute getter lazily
+ // instantiates a per-window DocumentPictureInPicture, and on Firefox
+ // (which shipped the API in 151) the instance's preserved wrapper pins
+ // the entire window realm after the window closes, leaking every
+ // protected window's document. Prototype access does not instantiate,
+ // and instance method lookup falls through to the patched prototype.
+ // Upstream: https://github.com/LavaMoat/snow/pull/171
+ const proto = win?.DocumentPictureInPicture?.prototype;
+ if (!proto?.requestWindow) {
return;
}
- win.documentPictureInPicture.requestWindow = hook(win, win.documentPictureInPicture.requestWindow, hookDocumentPictureInPicture);
+ proto.requestWindow = hook(win, proto.requestWindow, hookDocumentPictureInPicture);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key fix is just the three LoC here.

Comment on lines +9 to +15
- if (!win?.documentPictureInPicture?.requestWindow) {
+ const proto = win?.DocumentPictureInPicture?.prototype;
+ if (!proto?.requestWindow) {
return;
}
- win.documentPictureInPicture.requestWindow = hook(win, win.documentPictureInPicture.requestWindow, hookDocumentPictureInPicture);
+ proto.requestWindow = hook(win, proto.requestWindow, hookDocumentPictureInPicture);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change as above

Comment on lines +19 to +22
diff --git a/snow.prod.js b/snow.prod.js
index 62108e44f9a71377a777caa6d8757582e0c6b657..1193c272c39595f9cefccf16ffa4cdfa9bb3d5d1 100644
--- a/snow.prod.js
+++ b/snow.prod.js

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just the newly emitted build.

Comment thread package.json
"@keystonehq/bc-ur-registry-eth": "^0.22.1",
"@lavamoat/lavadome-react": "0.0.20",
"@lavamoat/snow": "^2.0.4",
"@lavamoat/snow": "patch:@lavamoat/snow@npm%3A2.0.4#~/.yarn/patches/@lavamoat-snow-npm-2.0.4-6af84c19b4.patch",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix applied as yarn patch.

@MajorLift MajorLift self-assigned this Jul 10, 2026
@MajorLift MajorLift changed the title fix: Fix Firefox detatched-window memory leak by avoiding DocumentPictureInPicture instantiation in Snow hook cp-13.39.1 fix: Fix Firefox detatched-window memory leak by avoiding DocumentPictureInPicture instantiation in Snow hook cp-13.39.2 Jul 10, 2026
@HowardBraham
HowardBraham added this pull request to the merge queue Jul 10, 2026
@HowardBraham HowardBraham added the retry-ci Tells GitHub Actions to retry failed jobs, label removed automatically before the retry label Jul 10, 2026
Merged via the queue into main with commit 942441b Jul 10, 2026
226 of 227 checks passed
@HowardBraham
HowardBraham deleted the jongsun/test/snow-pip-prototype-hook branch July 10, 2026 18:58
@github-actions github-actions Bot removed the retry-ci Tells GitHub Actions to retry failed jobs, label removed automatically before the retry label Jul 10, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 10, 2026
@metamaskbot metamaskbot added the release-13.41.0 Issue or pull request that will be included in release 13.41.0 label Jul 10, 2026
@metamask-ci metamask-ci Bot mentioned this pull request Jul 11, 2026
18 tasks
@metamaskbot metamaskbot added release-13.40.0 Issue or pull request that will be included in release 13.40.0 and removed release-13.41.0 Issue or pull request that will be included in release 13.41.0 labels Jul 13, 2026
@gauthierpetetin

Copy link
Copy Markdown
Contributor

Missing release label release-13.40.0 on PR. Adding release label release-13.40.0 on PR and removing other release labels(release-13.41.0), as PR was added to branch 13.40.0 when release was cut.

@MajorLift

MajorLift commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor Author

🧪 Validation Run

Verdict: ✅ reproduced — Claim: the snow hook's per-instance accessor assignment retains closed windows, and moving it to the prototype releases them. Both halves reproduce against the patch as written: 8 of 8 windows retained before, 0 of 8 after, in every one of 8 replicates. Evidence: 6 replicates, clean box, controls valid in every run · five instruments, one run · Firefox 153.0 headless · captured retroactively for the pr-validate showcase, not at review time

Note

Trial run of the MetaMask evidence skills —
feedback welcome, on the finding or on whether this format is useful to a reviewer.
Not a review verdict; nothing here blocks the PR.

Warning

This comment previously reported the fix arm at 0 retained and a verdict of proven. That was wrong and is corrected here. Those numbers came from three runs on a loaded, disk-constrained machine. Six replicates on a clean isolated box return B2 = 6/8 every time — see below (that arm, B2, is the synthetic one; the arm transcribing the patch is B3 below).

The leak is in none of the changed lines — it lives in a native window's lifecycle a pre-existing hook triggers, so no acquire/release pair in the diff accounts for it. Reading can't settle it, so the mechanism was run against controls.

replicate A2 — assign on instance B2 — assign on prototype P — positive control Z — negative control
1–6 (all identical) 8 / 8 6 / 8 8 ✓ 0 ✓

Six consecutive replicates, each a fresh browser launch, every one VALID on controls. Full log.

Instrument battery, four arms. Canary reports A2 8/8 and B2 6/8 with positive control 8/8 and negative control 0/8, run marked VALID. The canary line reads DISCRIMINATES, A2 retains more. The memory reporter and heap census lines both read wrong direction, noise not signal.

Re-run on a second isolated box after the labels were fixed; the figures reproduce byte-for-byte. B2 = 6/8 is the number that retracts the earlier claim, and the two byte instruments now label themselves rather than leaving the reader to spot the sign.

What this establishes: the pre-fix pattern retains every closed window — A2 8/8 against a baseline that reclaims all of them (Z 0/8), with the canary shown live by P 8/8. That part is solid and reproducible.

What the synthetic pair could not establish: that the prototype assignment fixes it. B2 retains 6 of 8, not 0 — but B2 installs a function that closes over the window, and the patch installs one that does not. Varying the assignment target on its own gives 8/8 → 0/8.

Note

The synthetic arms model something the patch does not do. A2 and B2 install function () { return cw; }, which closes over the window. snow 2.0.4's hook(win, native, cb) does not: win is read only in hook's synchronous body, and the function it installs closes over native. So B2 = 6/8 is a property of the stand-in — the extra window reference it carries and the patch does not — and the arms below drop it.

The assignment target, isolated

native is one object on both sides of the patch: win.documentPictureInPicture.requestWindow is inherited from DocumentPictureInPicture.prototype, so the two branches read the same function and differ only in where they put it back. Measured, not assumed — nativeSame=true in every replicate. That leaves the assignment target as the only variable the patch changes, so it can be varied on its own, against arms that transcribe hook() rather than stand in for it.

rep  load1   A2    B2    A3    B3    C     D     P     Z     delivery   controls
--------------------------------------------------------------------------------------------------------
1    1.35    8     6     8     0     0     8     8     0     8/8 all    VALID
2    1.46    8     6     8     0     0     8     8     0     8/8 all    VALID
3    1.49    8     6     8     0     0     8     8     0     8/8 all    VALID
4    1.42    8     6     8     0     0     8     8     0     8/8 all    VALID
5    1.14    8     6     8     0     0     8     8     0     8/8 all    VALID
6    1.05    8     6     8     0     0     8     8     0     8/8 all    VALID
7    1.00    8     6     8     0     0     8     8     0     8/8 all    VALID
8    0.79    8     6     8     0     0     8     8     0     8/8 all    VALID
--------------------------------------------------------------------------------------------------------
all          8     6     8     0     0     8     8     0                8/8 valid
                     captures win      captures native      captures nothing
                     (synthetic)       (snow's real hook)   (control)
     instance          A2 8.00           A3 8.00              D  8.00 
     prototype         B2 6.00           B3 0.00              C  0.00 
     target effect     A2-B2 +2.00       A3-B3 +8.00          D-C +8.00

environment probe (what the target objects actually are here): inst=object inst.rW=function ctor=function proto.rW=function nativeSame=true
  • A3 8/8 → B3 0/8 — the pre-fix body retains every closed window; the post-fix body reclaims every one. Eight replicates, identical, each a fresh browser launch.
  • D 8/8 → C 0/8 — the same target contrast with an installed function that captures nothing at all lands in the same place, so the target carries it without help from anything the function holds.
  • A2 8/8 → B2 6/8 reproduces the earlier pass byte-for-byte, which places the 6 where it belongs: on the window the stand-in closes over, not on the prototype it assigns to.
  • Every arm installed on all 8 windows in all 8 replicates, so no arm reached 0 by failing to apply. P 8/8 and Z 0/8 in all 8.
  • The byte instruments read A3 − B3 = 0 B — blind here as they were on the earlier pair, which is consistent with the retained thing being a wrapper rather than document byte-mass.
  • Load on the measuring host spanned 0.79–2.09 across the eight replicates; the gate holds at replicate start and the in-run figure went above it in two of them. The 8/8 → 0/8 split is identical in all eight. Contention is the variable that produced the correction above, and across this range it did not move the result.

The A3, B3, C and D arms come from a later revision of instruments.py, with driver job4.sh and aggregator report.py, which is not yet hosted; the harness linked below carries A2,B2,P,Z only and does not produce the figures in this section.

Left unmeasured: this varies the target inside a chrome-context harness, where the installed functions are built in the harness's realm rather than the page's. That is constant across every arm and so cannot produce the A3/B3 split, but it is not the page's own realm, and a reader who wants the mechanism nailed to the extension's real flow would want the hook exercised from in-page snow rather than transcribed.

Instrument battery — what could see it and what could not

instrument A2 vs B2
weak-reference canary 8 vs 6 separates the arms
nsIMemoryReporterManager, detached window bytes A2 − B2 = −708,576 B wrong direction — noise
saveHeapSnapshot → takeCensus A2 − B2 = −37,392 B wrong direction — noise
computeShortestPaths to a retained document returns the harness's own stack frame cannot attribute
cycle-collector dump, nsIMemoryInfoDumper allTraces parent graphs match across arms blind

Collected because a memory investigation collects the battery regardless of which member is expected to move — the joint pattern is what localizes the retention. Here it says the retained thing is a wrapper and a closure, not document byte-mass: layout and style are already torn down at close(), so byte-aggregating instruments read noise.

Note

An earlier version of this harness printed discriminates for any inequality, which is how it labelled that −708,576 B as a result. It now distinguishes "A2 retains more" from "wrong direction". A difference with the wrong sign is not weak evidence, it is the absence of evidence.

Reproducing it — one chrome-context call per arm, nothing persists between calls
FF_BIN=<firefox> SNAP=off N=8 ARMS=A2,B2,P,Z python3 instruments.py

Harness: instruments.py · replicate driver: job2.sh, which runs the four arms N times on a disposable box and prints every replicate.

Each arm opens N windows, applies its treatment, closes them, forces 15× GC+CC, and counts surviving weak references — all inside a single marionette call. That matters: a page global written in one marionette call is undefined in the next, so a harness that opens in one call and counts in another reads an empty ref list and reports 0 for every arm regardless of what happened.

Run it on an isolated machine. The discrepancy that produced the retracted numbers above came from measuring on a contended host; the same harness on a clean box is stable to the replicate.

The fix under test — .yarn/patches/@lavamoat-snow · hookRequest:

 function hookRequest(win) {
-  if (!win?.documentPictureInPicture?.requestWindow) {
+  const proto = win?.DocumentPictureInPicture?.prototype;
+  if (!proto?.requestWindow) {
     return;
   }
-  win.documentPictureInPicture.requestWindow = hook(win, win.documentPictureInPicture.requestWindow, hookDocumentPictureInPicture);
+  proto.requestWindow = hook(win, proto.requestWindow, hookDocumentPictureInPicture);
 }

@MetaMask MetaMask locked as resolved and limited conversation to collaborators Jul 30, 2026

This branch was previously deployed

1 inactive deployment
pr-comment — d4dd55f3 Deployed Jul 10, 2026 by MajorLift via identify-codeowners #70650
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

INVALID-PR-TEMPLATE PR's body doesn't match template release-13.40.0 Issue or pull request that will be included in release 13.40.0 risk:medium size-S team-extension-platform Extension Platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Memory leak in Firefox

5 participants