[eric] canvas: webview park and wake gain eviction margins and 10s cooldowns, ending the zoom-out reboot storm (ENG-228)

This commit is contained in:
ciregenz
2026-08-16 21:15:05 -07:00
parent ab4d7b2db7
commit 2db49cc0e8
3 changed files with 67 additions and 11 deletions
+34 -5
View File
@@ -34,15 +34,44 @@ test('denies a farther card once the cap is full', () => {
release([...keys, 'b-far']);
});
test('a closer card evicts the farthest, and the evicted one is then denied', () => {
// Cooldown + margin guard the evictions now; tests age the grants by shifting the module's clock.
function aged<T>(fn: () => T): T {
const realNow = Date.now;
(Date as unknown as { now: () => number }).now = () => realNow() + 60_000;
try { return fn(); } finally { (Date as unknown as { now: () => number }).now = realNow; }
}
test('a MEANINGFULLY closer card evicts the farthest once grants have aged', () => {
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');
aged(() => {
assert.equal(requestAppSlot('c-near', 1, false), true, 'a much closer card takes a slot by eviction');
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('anti-flap: fresh grants are cooldown-protected from eviction', () => {
const keys = fill('f', MAX, 100);
assert.equal(requestAppSlot('f-near', 1, false), false, 'even a closer card cannot boot a slot granted seconds ago');
release([...keys, 'f-near']);
});
test('anti-flap: near-equal priorities never steal the slot (the zoom-out storm)', () => {
const keys = fill('g', MAX, 100); // worst has priority 100+MAX-1
aged(() => {
// 10% closer is inside the 20% margin: denied, no reboot.
assert.equal(requestAppSlot('g-near', Math.round((100 + MAX - 1) * 0.9), false), false,
'a card only slightly closer must not reboot a live webview');
// 30% closer clears the margin: granted.
assert.equal(requestAppSlot('g-vnear', Math.round((100 + MAX - 1) * 0.7), false), true);
});
// Outside aged(): real clock, the winner's grant is seconds old, so a challenger CLOSER than the
// winner cannot steal it back (it is cooldown-protected and every older slot fails the margin).
assert.equal(requestAppSlot('g-steal', 1, false), false, 'no immediate counter-steal: the oscillation is dead');
release([...keys, 'g-near', 'g-vnear', 'g-steal']);
});
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');
+16 -2
View File
@@ -40,6 +40,13 @@ interface Slot {
const live = new Map<string, Slot>();
const listeners = new Set<() => void>();
// Anti-flap (the 2+ min zoom-out park/unpark storm): priorities are squared distances recomputed
// per tick, so near-equal cards stole the slot back and forth as the camera drifted, rebooting a
// webview on every steal. An eviction now needs BOTH a meaningfully closer challenger and a slot
// old enough to have been worth booting.
const grantedAt = new Map<string, number>();
export const EVICT_COOLDOWN_MS = 10_000;
export const EVICT_MARGIN = 0.8;
// Deferred: an eviction firing inside one card's render must not synchronously poke another card's state.
function notify(): void {
@@ -68,19 +75,25 @@ export function requestAppSlot(key: string, priority: number, pinned: boolean):
}
if (pinned || (evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS && guestBudgetHasRoom())) {
live.set(key, { priority, pinned });
grantedAt.set(key, Date.now());
return true;
}
let worstKey: string | null = null;
let worstPriority = -Infinity;
const now = Date.now();
for (const [k, s] of live) {
if (!s.pinned && s.priority > worstPriority) {
if (s.pinned) continue;
if (now - (grantedAt.get(k) ?? 0) < EVICT_COOLDOWN_MS) continue;
if (s.priority > worstPriority) {
worstPriority = s.priority;
worstKey = k;
}
}
if (worstKey !== null && priority < worstPriority) {
if (worstKey !== null && priority < worstPriority * EVICT_MARGIN) {
live.delete(worstKey);
grantedAt.delete(worstKey);
live.set(key, { priority, pinned });
grantedAt.set(key, now);
notify(); // the evicted card must re-evaluate and drop to a placeholder
return true;
}
@@ -89,6 +102,7 @@ export function requestAppSlot(key: string, priority: number, pinned: boolean):
/** A card that suspends or unmounts MUST release, or its slot leaks and a capped card never wakes. */
export function releaseAppSlot(key: string): void {
grantedAt.delete(key);
if (live.delete(key)) notify(); // a freed slot lets a capped card come alive
}