mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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<string, number>();
|
||||
// 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<string, number>();
|
||||
const wokeAt = new Map<string, number>();
|
||||
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user