From 2db49cc0e8da4c47cdd29fb68c11cd2a3497fd65 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 16 Aug 2026 21:15:05 -0700 Subject: [PATCH] [eric] canvas: webview park and wake gain eviction margins and 10s cooldowns, ending the zoom-out reboot storm (ENG-228) --- .../hooks/interaction/useWebviewSuspend.ts | 21 ++++++++-- frontend/src/shared/appWebviewBudget.test.ts | 39 ++++++++++++++++--- frontend/src/shared/appWebviewBudget.ts | 18 ++++++++- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts index ca551a10..2a485010 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts @@ -44,6 +44,13 @@ const MAX_LIVE_WEBVIEWS = 8; // Grace after terminal so an agent whose status blips completed->running between back-to-back turns can't lose its browser in the gap. const WORKING_GRACE_MS = 20_000; const lastWorkingAt = new Map(); +// Anti-flap twin of the app budget's cooldown: a card geometry-parked seconds ago must not be +// passively re-woken, and a card just woken must not be geometry/cap-parked, or boundary jitter +// reboots webviews in a loop (the zoom-out storm). Explicit intent (un-minimize, working agents, +// hide-the-app parks) bypasses both stamps on purpose. +const FLAP_COOLDOWN_MS = 10_000; +const parkedAt = new Map(); +const wokeAt = new Map(); function sessionIsWorking(s: { id?: string; status?: string } | undefined): boolean { if (!s) return false; @@ -194,7 +201,9 @@ export function useWebviewSuspend( const bigEnough = card.width * zoom >= RESUME_MIN_CARD_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()) { + if (bigEnough && cardIntersectsViewport(card, vpRef.current, RESUME_MARGIN_PX) && guestBudgetHasRoom() + && Date.now() - (parkedAt.get(id) ?? 0) > FLAP_COOLDOWN_MS) { + wokeAt.set(id, Date.now()); dispatch(resumeBrowserCard(id)); budget--; } @@ -203,13 +212,14 @@ export function useWebviewSuspend( const timer = setTimeout(async () => { const isSuspended = (id: string) => !!store.getState().dashboardLayout.suspendedBrowserCards[id]; // Read live, not off the effect's closure: this re-runs after an await, and the user can restore a card mid-capture. + const outsideWakeCooldown = (id: string) => Date.now() - (wokeAt.get(id) ?? 0) > FLAP_COOLDOWN_MS; const wantsPark = (id: string, card: BrowserCardPosition): boolean => appHidden || isMinimized(id) // Read zoom off the ref, never the closure: this fires 800ms after the effect ran, and // zooming out is exactly the gesture that should be parking these. - || (!withinRestoreGrace(id) && card.width * vpRef.current.zoom < SUSPEND_MAX_CARD_PX) - || (!withinRestoreGrace(id) && !cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX)); + || (!withinRestoreGrace(id) && outsideWakeCooldown(id) && card.width * vpRef.current.zoom < SUSPEND_MAX_CARD_PX) + || (!withinRestoreGrace(id) && outsideWakeCooldown(id) && !cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX)); await refreshVisibleFrames(browserCards, isSuspended, vpRef.current); for (const [id, card] of Object.entries(browserCards)) { if (isSuspended(id)) continue; @@ -219,18 +229,21 @@ export function useWebviewSuspend( const dataUrl = await captureForSuspend(id, card); // The capture await yielded; conditions may have changed under us. if (!wantsPark(id, card) || mustStayLive(id, card)) continue; + // Hide/minimize parks skip the stamp: returning to the app must wake in-view cards instantly. + if (!appHidden && !isMinimized(id)) parkedAt.set(id, Date.now()); dispatch(suspendBrowserCard({ browserId: id, dataUrl })); } const countLive = () => Object.keys(browserCards).filter((id) => !isSuspended(id)).length; if (countLive() > MAX_LIVE_WEBVIEWS) { const candidates = Object.entries(browserCards) - .filter(([id, card]) => !isSuspended(id) && !mustStayLive(id, card)) + .filter(([id, card]) => !isSuspended(id) && !mustStayLive(id, card) && outsideWakeCooldown(id)) .sort((a, b) => distFromCenter(b[1], vpRef.current) - distFromCenter(a[1], vpRef.current)); for (const [id, card] of candidates) { if (countLive() <= MAX_LIVE_WEBVIEWS) break; const dataUrl = await captureForSuspend(id, card); if (mustStayLive(id, card)) continue; + parkedAt.set(id, Date.now()); dispatch(suspendBrowserCard({ browserId: id, dataUrl })); } } diff --git a/frontend/src/shared/appWebviewBudget.test.ts b/frontend/src/shared/appWebviewBudget.test.ts index c61c2972..4bcd489b 100644 --- a/frontend/src/shared/appWebviewBudget.test.ts +++ b/frontend/src/shared/appWebviewBudget.test.ts @@ -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(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'); diff --git a/frontend/src/shared/appWebviewBudget.ts b/frontend/src/shared/appWebviewBudget.ts index db435181..a18bc92a 100644 --- a/frontend/src/shared/appWebviewBudget.ts +++ b/frontend/src/shared/appWebviewBudget.ts @@ -40,6 +40,13 @@ interface Slot { const live = new Map(); 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(); +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 }