[eric] dashboard: cap live app webviews (closest-to-center wins) to bound memory

This commit is contained in:
ciregenz
2026-07-29 11:22:27 -07:00
parent cc24f5adaf
commit 2ee25f80fc
3 changed files with 169 additions and 3 deletions
@@ -0,0 +1,67 @@
// Run: node --test frontend/src/shared/appWebviewBudget.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
requestAppSlot,
releaseAppSlot,
MAX_LIVE_APP_WEBVIEWS as MAX,
} from './appWebviewBudget.ts';
// The module holds shared state, so every test fills, asserts, then releases its own keys to leave a clean slate.
function fill(prefix: string, n: number, basePriority: number): string[] {
const keys: string[] = [];
for (let i = 0; i < n; i++) {
const k = `${prefix}${i}`;
assert.equal(requestAppSlot(k, basePriority + i, false), true, `slot ${i} should be granted below cap`);
keys.push(k);
}
return keys;
}
function release(keys: string[]): void {
for (const k of keys) releaseAppSlot(k);
}
test('grants every request below the cap', () => {
const keys = fill('a', MAX, 10);
release(keys);
});
test('denies a farther card once the cap is full', () => {
const keys = fill('b', MAX, 10);
assert.equal(requestAppSlot('b-far', 9999, false), false, 'a card farther than all live cards is denied');
release([...keys, 'b-far']);
});
test('a closer card evicts the farthest, and the evicted one is then denied', () => {
const keys = fill('c', MAX, 100); // priorities 100..100+MAX-1; farthest is the last
assert.equal(requestAppSlot('c-near', 1, false), true, 'a closer card takes a slot by eviction');
// The farthest original (highest priority) was evicted; re-requesting it now fails (still full, still farthest).
const evicted = `c${MAX - 1}`;
assert.equal(requestAppSlot(evicted, 100 + MAX - 1, false), false, 'the evicted farthest card cannot re-enter');
release([...keys, 'c-near']);
});
test('pinned cards bypass the cap and are never evicted', () => {
const keys = fill('d', MAX, 10);
assert.equal(requestAppSlot('d-pin', 0, true), true, 'pinned card is admitted past a full cap');
// A pinned card does not consume an evictable slot, so an unpinned farther card is still denied.
assert.equal(requestAppSlot('d-far', 9999, false), false, 'unpinned farther card still denied with a pin present');
// Another pin also admitted.
assert.equal(requestAppSlot('d-pin2', 0, true), true, 'second pinned card also admitted');
release([...keys, 'd-pin', 'd-pin2', 'd-far']);
});
test('releasing a slot lets a previously-denied card in', () => {
const keys = fill('e', MAX, 10);
assert.equal(requestAppSlot('e-wait', 9999, false), false, 'denied while full');
releaseAppSlot(keys[0]);
assert.equal(requestAppSlot('e-wait', 9999, false), true, 'admitted after a slot frees');
release([keys[1], keys[2], keys[3], keys[4], keys[5], 'e-wait'].filter(Boolean));
});
test('re-requesting an already-live card just updates it, no extra slot', () => {
const keys = fill('f', MAX, 10);
assert.equal(requestAppSlot(keys[0], 5, false), true, 'existing card re-request is idempotent');
assert.equal(requestAppSlot('f-far', 9999, false), false, 'still full after a re-request');
release([...keys, 'f-far']);
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Hard ceiling on simultaneously-live app (View) webviews. An app preview is the heaviest surface on
* the canvas (a full renderer running a live frontend), and unlike browser cards these had NO global
* cap, so a pile of big app cards left in view could stack renderers until the whole app OOMs. This is
* admission control: a passive on-screen app goes live only if a slot is free, else it stays a
* placeholder; the cards closest to the viewport center win the scarce slots. A card the user is
* actively using (selected, interacting, agent-driven, tiled, fullscreen) is "pinned" and bypasses the
* cap, the same way a browser card's mustStayLive rule exempts it. Below the cap this is a no-op, so
* everyday behavior is unchanged; it only bites when too many previews want to be live at once.
*/
export const MAX_LIVE_APP_WEBVIEWS = 6;
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
}
const live = new Map<string, Slot>();
const listeners = new Set<() => void>();
// Deferred: an eviction firing inside one card's render must not synchronously poke another card's state.
function notify(): void {
queueMicrotask(() => {
for (const fn of [...listeners]) fn();
});
}
function evictableLiveCount(): number {
let n = 0;
for (const s of live.values()) if (!s.pinned) n++;
return n;
}
/**
* Whether this card may be live now. Idempotent (safe to call every pan/resize tick). A pinned card is
* always granted; an unpinned one is granted if a slot is free, or if it is closer to center than the
* farthest currently-live unpinned card, which it then evicts.
*/
export function requestAppSlot(key: string, priority: number, pinned: boolean): boolean {
const existing = live.get(key);
if (existing) {
existing.priority = priority;
existing.pinned = pinned;
return true;
}
if (pinned || evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS) {
live.set(key, { priority, pinned });
return true;
}
let worstKey: string | null = null;
let worstPriority = -Infinity;
for (const [k, s] of live) {
if (!s.pinned && s.priority > worstPriority) {
worstPriority = s.priority;
worstKey = k;
}
}
if (worstKey !== null && priority < worstPriority) {
live.delete(worstKey);
live.set(key, { priority, pinned });
notify(); // the evicted card must re-evaluate and drop to a placeholder
return true;
}
return false;
}
/** A card that suspends or unmounts MUST release, or its slot leaks and a capped card never wakes. */
export function releaseAppSlot(key: string): void {
if (live.delete(key)) notify(); // a freed slot lets a capped card come alive
}
/** Re-run a card's live/suspend decision whenever the budget changes (an eviction or a freed slot). */
export function subscribeAppBudget(fn: () => void): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}