[eric] canvas: browsers and apps share one 10-renderer ceiling, so the two caps can no longer stack to 14

This commit is contained in:
ciregenz
2026-08-07 20:10:59 -07:00
parent dfed363db7
commit 256e922886
3 changed files with 58 additions and 2 deletions
@@ -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--;
}
@@ -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');
});
+24 -1
View File
@@ -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;
}