feat(desktop): unload hidden browser panes by memory budget, keeping pages Chrome would keep
Follows #9497 (merged). Rebased onto main.
Summary
- Replaces the fixed hidden-webview caps (3 user, 6 agent) with a memory budget. Each group may use up to 6% of system RAM, so a 128 GB machine keeps many hidden pages and an 8 GB machine keeps few.
- Like Chrome's tab discarding, a page is never unloaded while it plays audio, holds unsaved form input, or has an agent attached.
- Agent pages nobody has used for 30 minutes are unloaded even under budget.
Why / Context
#9497 made unloading safe for agents, because the next command rebuilds the page. But each rebuild still loses in-page state, so the policy should unload rarely and choose well. A fixed count does neither: it ignores how much RAM the machine has and how much each page uses. Chrome does not cap tabs. It unloads background tabs under memory pressure, oldest first, and skips tabs whose state would be lost.
How It Works
selectWebviewsToUnloadis a pure function. It works on one group's hidden pages, oldest first.- It unloads idle pages, then the oldest pages until the rest fit the budget and the count is at most 20.
- It always keeps the newest 3. Kept pages still count toward the budget.
browser.getUnloadInfo(desktop main) returns, for each pane:- Its memory: the guest process's
workingSetSizefromapp.getAppMetrics(), split between the panes when a process serves several. - Whether it plays audio:
isCurrentlyAudible(). - Whether it holds unsaved form input: a changed field, checked in an isolated world with a 500 ms timeout.
- It also returns the total system RAM.
- Its memory: the guest process's
- The registry sweep is now async. It runs on the same triggers as before and also once a minute. It skips a page that was shown or used while the query was in flight. If main does not answer, the budget is unlimited, and the count limit and idle rule still apply.
lastUsedAtis now a time in ms (kept strictly increasing) instead of a counter, so the idle rule can work.
| User pages | Agent pages | |
|---|---|---|
| Budget | 6% of RAM | 6% of RAM |
| Always kept | newest 3 | newest 3 |
| At most | 20 | 20 |
| Idle limit | none | 30 min |
Manual QA
Dev app on a 128 GB machine (budget 7.7 GB per group), driven with the CLI:
- 12 agent pages across 3 workspaces all stay loaded. #9497's cap kept 6.
-
getUnloadInforeports the real memory of each guest process (about 93 MB per test page, each in its own process). - Page 1 got a half-typed input and page 2 played audio, then the agents opened 12 more pages (24 in total). 20 stay loaded. Pages 3–6 are unloaded, and pages 1 and 2 stay loaded.
- A command on unloaded page 3 rebuilds it, and page 7 is unloaded in its place. Page 1 still holds "half-typed".
In-app CDP suite (11/11, no renderer errors)
- 4 hidden user tabs (opened through the UI) and 12 hidden agent pages all stay loaded.
- Past 20 agent pages, pages 4–7 are unloaded. The form page, the audio page and the page with a live CDP session stay loaded, and the user's 4 pages are untouched. The form still holds its typed value.
- Idle rule: I set one agent page and one user page to 31 minutes since last use over CDP. The periodic sweep unloads the agent page within 60 s, and the user page stays.
- Budget limit: I wrapped
getUnloadInfoover CDP to report a 5 GB machine (a budget of about 307 MB per group). Agent pages drop to the 3 protected plus the newest 3, and user pages to the newest 3. - An unloaded page still rebuilds on the next command.
Testing
bun run typecheck(apps/desktop)bunx biome checkon the changed filesbun test src/main/lib/browser src/main/lib/pane-layout src/renderer/routes/_authenticated src/renderer/lib/terminal: 6788 pass- New tests:
selectWebviewsToUnload.test.ts: under budget nothing is unloaded; the oldest go first but never the newest 3; kept pages count toward the budget; idle pages go even under budget.browserRuntimeRegistry.test.ts: pages an agent drives, that play audio, or hold unsaved input stay loaded over budget; agent pages have their own budget and agent use keeps a page; a page shown while the sweep waits on main stays loaded.
Design Decisions
- Each group gets its own budget. One shared budget would let agents in other workspaces unload the user's pages again.
- Unsaved input is checked in an isolated world. The page's own scripts cannot see the check or change its result.
- Memory comes from
getAppMetrics, not OS memory-pressure events. Electron does not expose Chromium's memory-pressure signal.
Review fixes
- Unsaved input: the check now records trusted edits in an isolated world instead of comparing values with their defaults. An untouched
<select>orrange/colorinput, or a field already saved in place, no longer pins a page.contenteditableeditors are covered too. Checked in the app with real typing (8/8): untouched controls are not protected; typing, a slider move and an editor edit are; a submit or a navigation clears the flag; a value set by script does not count. - Newest pages: the newest 3 pages kept over budget now include protected ones.
- Stuck sweep: the sweep stops waiting on main after 5 s and logs the failure.
- CDP backlog: counted in bytes (a #9497 review note).
Known Limitations
- Memory counts only each page's main guest process. Out-of-process iframes are not counted.
- A page whose save goes through
fetchwith no form submit stays protected until it navigates.
Summary by cubic
Replaces the fixed caps on hidden browser panes (3 user, 6 agent) with a memory budget: each group may keep hidden pages totaling up to 6% of system RAM, so a large machine keeps many pages and a small one keeps few.
- Pages are never unloaded while they play audio, hold unsaved form input, or have an agent attached; agent pages idle for 30 minutes go even under budget.
- The newest 3 pages always stay (protected pages count toward those), and no group keeps more than 20 pages.
- Unsaved input is tracked by recording real edits in an isolated world, so untouched selects and range inputs don't block unloading; a submit clears the record.
- Memory comes from
app.getAppMetrics(). Sweeps run on the previous triggers plus once a minute; a page shown or used while the query is in flight is skipped. - If main doesn't answer within 5 seconds, the sweep gives up and only the count and idle rules apply.
- Also changes queued CDP message balancing to count bytes, not UTF-16 code units, so non-ASCII frames can't exceed the limit unnoticed.
Summary by CodeRabbit
-
New Features
- Browser panes now unload based on memory use and inactivity rather than fixed count caps. Active agent panes, audible pages, pages with unsaved input, and recently used or visible panes are protected.
- Unloading considers ordinary and agent panes separately, with safeguards to keep a minimum number of pages loaded.
-
Bug Fixes
- Browser message-size limits now account for UTF-8 bytes, improving handling of non-ASCII content.