mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] dashboard: cap live app webviews (closest-to-center wins) to bound memory
This commit is contained in:
@@ -18,6 +18,7 @@ import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded';
|
||||
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
|
||||
import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeViewCardCleanly } from '@/shared/viewTeardown';
|
||||
import { requestAppSlot, releaseAppSlot, subscribeAppBudget } from '@/shared/appWebviewBudget';
|
||||
import { expandSession } from '@/shared/state/agentsSlice';
|
||||
import WindowControls from './WindowControls';
|
||||
import { openCardContextMenu } from '../desktop/CardContextMenu';
|
||||
@@ -56,6 +57,7 @@ const MIN_H = 200;
|
||||
// has no login/scroll state worth keeping, so we just unmount the webview when the card is off-screen or
|
||||
// too small to read, and remount (reload) on return. Asymmetric: resume instantly, suspend after a beat
|
||||
// so panning past a card doesn't reload it.
|
||||
const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron');
|
||||
const APP_PREVIEW_MIN_PX = 260; // below this on-screen width the live page is indistinguishable from a still
|
||||
const APP_PREVIEW_MARGIN_PX = 400; // resume once the card is within this of the viewport
|
||||
const APP_SUSPEND_SETTLE_MS = 1200;
|
||||
@@ -224,23 +226,37 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
if (previewDeferred) {
|
||||
want = false; // reveal-parked: never boot until the first click clears the defer
|
||||
} else if (alwaysLive) {
|
||||
// Actively used (selected, interacting, agent-driven, tiled, fullscreen): pinned, never capped.
|
||||
if (isElectron) requestAppSlot(cardKey, 0, true);
|
||||
want = true;
|
||||
} else {
|
||||
const now = getCanvasState();
|
||||
const vpEl = document.querySelector('[data-canvas-viewport]');
|
||||
const vpW = vpEl ? (vpEl as HTMLElement).clientWidth : window.innerWidth;
|
||||
const vpH = vpEl ? (vpEl as HTMLElement).clientHeight : window.innerHeight;
|
||||
let onscreen: boolean;
|
||||
if (cardWidth * now.zoom < APP_PREVIEW_MIN_PX) {
|
||||
want = false;
|
||||
onscreen = false;
|
||||
} else {
|
||||
const m = APP_PREVIEW_MARGIN_PX / now.zoom;
|
||||
const vx = -now.panX / now.zoom - m;
|
||||
const vy = -now.panY / now.zoom - m;
|
||||
const vw = vpW / now.zoom + 2 * m;
|
||||
const vh = vpH / now.zoom + 2 * m;
|
||||
want = cardX < vx + vw && cardX + cardWidth > vx && cardY < vy + vh && cardY + cardHeight > vy;
|
||||
onscreen = cardX < vx + vw && cardX + cardWidth > vx && cardY < vy + vh && cardY + cardHeight > vy;
|
||||
}
|
||||
if (!onscreen || !isElectron) {
|
||||
want = onscreen; // non-Electron previews are cheap iframes, no renderer to cap
|
||||
} else {
|
||||
// On-screen but passive: go live only if the hard cap has a slot; closest-to-center wins it.
|
||||
const cx = (-now.panX + vpW / 2) / now.zoom;
|
||||
const cy = (-now.panY + vpH / 2) / now.zoom;
|
||||
const ddx = cardX + cardWidth / 2 - cx;
|
||||
const ddy = cardY + cardHeight / 2 - cy;
|
||||
want = requestAppSlot(cardKey, ddx * ddx + ddy * ddy, false);
|
||||
}
|
||||
}
|
||||
if (!want) releaseAppSlot(cardKey);
|
||||
if (want === previewLiveRef.current) return;
|
||||
if (want) {
|
||||
if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; }
|
||||
@@ -265,14 +281,19 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
}
|
||||
};
|
||||
evaluate();
|
||||
const unsubBudget = subscribeAppBudget(evaluate); // an eviction or a freed slot re-runs this card's decision
|
||||
window.addEventListener('openswarm:canvas-pan-changed', evaluate);
|
||||
window.addEventListener('resize', evaluate);
|
||||
return () => {
|
||||
unsubBudget();
|
||||
window.removeEventListener('openswarm:canvas-pan-changed', evaluate);
|
||||
window.removeEventListener('resize', evaluate);
|
||||
if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; }
|
||||
};
|
||||
}, [alwaysLive, previewDeferred, cardX, cardY, cardWidth, cardHeight, getCanvasState]);
|
||||
}, [alwaysLive, previewDeferred, cardX, cardY, cardWidth, cardHeight, getCanvasState, cardKey]);
|
||||
|
||||
// Free the cap slot on unmount (card deleted, dashboard switch) so a slot is never leaked.
|
||||
useEffect(() => () => releaseAppSlot(cardKey), [cardKey]);
|
||||
|
||||
// Deselecting the card exits interact mode (click anywhere else on canvas).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user