From 256e9228861b3ff480243e1e7a3c395f114ee127 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 7 Aug 2026 20:10:59 -0700 Subject: [PATCH] [eric] canvas: browsers and apps share one 10-renderer ceiling, so the two caps can no longer stack to 14 --- .../hooks/interaction/useWebviewSuspend.ts | 11 +++++++- frontend/src/shared/appWebviewBudget.test.ts | 24 ++++++++++++++++++ frontend/src/shared/appWebviewBudget.ts | 25 ++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts index 75c6d674..e2d83ab1 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts @@ -11,11 +11,18 @@ import { getActivity, isAnyBrowserBusy } from '@/shared/browserCommandHandler'; import { isKeepAliveBrowser } from '@/shared/browserFocus'; import { captureTabCapsule } from '@/shared/browserStateCapsule'; import { getMinimizedShot } from '../../desktop/minimizedShots'; +import { guestBudgetHasRoom, wireBrowserLiveCounter } from '@/shared/appWebviewBudget'; import { useAppHidden } from './useAppHidden'; import { cardIntersectsViewport, distFromCenter, type Viewport } from './suspendGeometry'; const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron'); +// Feed the global guest budget the live-browser count, so apps and browsers share ONE ceiling. +wireBrowserLiveCounter(() => { + const dl = store.getState().dashboardLayout; + return Object.keys(dl.browserCards).filter((id) => !dl.suspendedBrowserCards[id]).length; +}); + const SETTLE_MS = 800; // Hysteresis: suspend only well past the edge, resume just past it, so a card sitting on the boundary never flaps between webview and snapshot. const SUSPEND_MARGIN_PX = 320; @@ -184,7 +191,9 @@ export function useWebviewSuspend( continue; } const bigEnough = card.width * zoom >= RESUME_MIN_CARD_PX; - if (bigEnough && cardIntersectsViewport(card, vpRef.current, RESUME_MARGIN_PX)) { + // Passive wake also asks the GLOBAL budget: a free browser slot means nothing if apps already + // hold the machine at its ceiling. Explicit restores and working agents above never ask. + if (bigEnough && cardIntersectsViewport(card, vpRef.current, RESUME_MARGIN_PX) && guestBudgetHasRoom()) { dispatch(resumeBrowserCard(id)); budget--; } diff --git a/frontend/src/shared/appWebviewBudget.test.ts b/frontend/src/shared/appWebviewBudget.test.ts index 3497ea93..c61c2972 100644 --- a/frontend/src/shared/appWebviewBudget.test.ts +++ b/frontend/src/shared/appWebviewBudget.test.ts @@ -4,6 +4,8 @@ import assert from 'node:assert/strict'; import { requestAppSlot, releaseAppSlot, + wireBrowserLiveCounter, + totalLiveGuests, MAX_LIVE_APP_WEBVIEWS as MAX, } from './appWebviewBudget.ts'; @@ -65,3 +67,25 @@ test('re-requesting an already-live card just updates it, no extra slot', () => assert.equal(requestAppSlot('f-far', 9999, false), false, 'still full after a re-request'); release([...keys, 'f-far']); }); + +test('the global ceiling counts browsers and apps together', () => { + // 8 live browsers reported: only 2 of the 6 app slots may actually go live. + wireBrowserLiveCounter(() => 8); + const keys: string[] = []; + let granted = 0; + for (let i = 0; i < 6; i++) { + const k = `g${i}`; + if (requestAppSlot(k, i, false)) { granted++; keys.push(k); } + } + assert.equal(granted, 2, `apps must stop at the global ceiling, granted ${granted}`); + assert.equal(totalLiveGuests(), 10); + wireBrowserLiveCounter(() => 0); + keys.forEach(releaseAppSlot); +}); + +test('pinned cards ignore the global ceiling, a working agent is never throttled', () => { + wireBrowserLiveCounter(() => 99); + assert.equal(requestAppSlot('pinned-work', 0, true), true); + wireBrowserLiveCounter(() => 0); + releaseAppSlot('pinned-work'); +}); diff --git a/frontend/src/shared/appWebviewBudget.ts b/frontend/src/shared/appWebviewBudget.ts index 299976fc..db435181 100644 --- a/frontend/src/shared/appWebviewBudget.ts +++ b/frontend/src/shared/appWebviewBudget.ts @@ -10,6 +10,29 @@ */ export const MAX_LIVE_APP_WEBVIEWS = 6; +// The GLOBAL ceiling across every guest renderer, browsers and apps together. Each side already had +// its own cap (browsers 8, apps 6) but neither knew the other existed, so a busy canvas could still +// stack 14 live renderers, and renderer memory pressure is exactly what evicts the wash (the +// background band) and, at the limit, what OOMs the app. Pinned/working cards stay exempt: a +// throttle must never blind an agent mid-task. +export const MAX_LIVE_GUESTS = 10; + +// Browsers live in redux, not in this map; the suspend hook wires in a counter so this module stays +// store-free (and its tests stay pure). +let p_browserLiveCount: () => number = () => 0; + +export function wireBrowserLiveCounter(fn: () => number): void { + p_browserLiveCount = fn; +} + +export function totalLiveGuests(): number { + return live.size + p_browserLiveCount(); +} + +export function guestBudgetHasRoom(): boolean { + return totalLiveGuests() < MAX_LIVE_GUESTS; +} + interface Slot { priority: number; // squared distance from viewport center; smaller = closer = kept when slots are scarce pinned: boolean; // actively used: never counted against the cap, never evicted @@ -43,7 +66,7 @@ export function requestAppSlot(key: string, priority: number, pinned: boolean): existing.pinned = pinned; return true; } - if (pinned || evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS) { + if (pinned || (evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS && guestBudgetHasRoom())) { live.set(key, { priority, pinned }); return true; }