From 526624f57da17a6c1041ba43462ec25c3c1664c6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 08:55:51 +0300 Subject: [PATCH 1/5] [feat] 26-B: one poke per frame, an ingest queue that yields, and a windowed object list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 26 Stage 0 (hardening audit M6, M1, M2). The receive path ran an unbounded amount of work per incoming object; this bounds all three places it did so. WHAT - `pokeScene()` in sceneStore replaces all 117 `objectsGroup.update((v) => v)` call sites across 37 files. Eighteen subscribers hang off that store and several traverse the whole tree, so a 1,000-object handshake was ~8M node visits, synchronously, on the receive path. One flush per microtask normally; one per ~16ms while an ingest batch is open (`beginSceneBatch`/`endSceneBatch`, refcounted). The seam lives IN the store because all 37 files already import from it, so it costs no import edge — which matters when the pokers include peerHandler, flowRuntime, autosave and history. A microtask, not rAF: it lands before paint AND still runs in a hidden tab. Batch mode uses a timer for the same reason. `sceneRevision` is bumped on every flush, for a subscriber that wants to cache a traversal (and for 26-A's meter). - `createObject` is a QUEUE with an 8ms slice (`INGEST_SLICE_MS`). The dispatcher never awaited it, so N object messages started N overlapping main-thread GLTF parses in one task and their completion order was accidental. Objects now apply strictly in arrival order, the drainer yields a MACROtask every 8ms (a microtask chain never returns to the browser), and the whole drain holds a scene batch. `clearSceneLocal` drops the queue — roadmap section 5's "the ingest queue drops on clear". - Toasts' "Receiving objects" reconciliation was `getObjectByProperty` — a full tree walk — TWICE per outstanding uuid on EVERY poke. One traversal into a Set, then lookups: O(objects + outstanding) instead of O(both). - The object list VIRTUALISES above 500 visible rows: the same `Objects.svelte` row component in a new `flat` mode over the flattened `visibleObjectRows` — the one array the keyboard walker, Ctrl+A and the type-ahead already read their order from — with two spacer divs for what is off screen and a keyboard follow that moves the window. Below the threshold the recursive tree is unchanged. The scroller is found by real SCROLLABILITY (it is flowbite's Listgroup, whose element we do not own) and the row height is MEASURED, never assumed, because the spacers are in pixels. - audit M1: `sendObjects` builds its uuid list PER CALL (it was a module-level array that `countObjects` pushed onto and only the timer emptied, so two approvals 400ms apart cross-contaminated both joiners' `loading` lists and `count` was a running total), and resolves its connection INSIDE the 500ms timer, bailing when it is gone — `peer.connections[peerId]` is undefined mid-dial and closed when the joiner gave up, and both used to throw inside a timer where nothing catches it. - audit M2: a `loading` batch records its SENDER (local only — the message is unchanged), and is cleared by that peer's teardown, by a parse that rejects (`noteLoadFailed`), by a scene clear, and by a 60s stall. Nothing could clear it before: the only writer removed a uuid when its object APPEARED, and an object that never arrives never appears. COUNTERFACTUALS (suite `scene-poke`, 32 checks, one page) - Measured IN THE SAME RUN: the bare `objectsGroup.update((v) => v)` this replaced notifies 500 times for 500 calls where `pokeScene` notifies once. - Measured IN THE SAME RUN on the same payloads: the old ingest shape (parse + add + bare poke, 400 objects, one task) holds the main thread for 215ms and renders 2 frames; through the queue the worst hitch is 86ms and 7 frames render. - Broken then restored: `handleDisconnected`'s batch clear removed -> "the sender disconnecting clears the batch" red. `VIRTUAL_MIN` raised to 5000 -> "the list draws a WINDOW" reads 720 rows in the DOM, the spacers and the mode attribute go red too (4 checks). Both restored, suite green again. GATES - svelte-check 352 errors / 47 warnings, DOWN from the 357/47 floor — typing `loading` and `loadingcount` in appStore (they were written as arrays through a splice-in-place and inferred `never[]`) removed 5 pre-existing errors. `check-baseline.json` ratcheted with `--update`. - `npm run build` green with the dev server stopped. - Held suites at or above base: object-list-keys, objectlist-search, object-search, object-delete, selection-extras, clear-scene, inspector, dispose all green. `multi-select` ("member transforms replicated to B") and `undo` ("the placed position replicates") are red — and reproduce IDENTICALLY at base 7646fc2 with src reverted, so they are pre-existing and not this diff. Co-Authored-By: Claude Opus 5 --- check-baseline.json | 4 +- src/components/menu/Controls.svelte | 97 +++++++- src/components/menu/Inspector.svelte | 15 +- src/components/menu/Objects.svelte | 11 +- src/components/menu/Toasts.svelte | 25 +- src/lib/ai/tools.js | 4 +- src/lib/animatedImports.js | 10 +- src/lib/audioDevices.js | 10 +- src/lib/autosave.js | 6 +- src/lib/cameraObjects.js | 8 +- src/lib/cameraPreview.js | 4 +- src/lib/colliderEdit.js | 4 +- src/lib/commandsHandler.svelte.js | 211 ++++++++++++++--- src/lib/drawMode.js | 4 +- src/lib/environment.js | 6 +- src/lib/faceEdit.js | 10 +- src/lib/fileHandler.svelte.js | 10 +- src/lib/geometries.svelte.js | 14 +- src/lib/geometryEdit.js | 6 +- src/lib/history.js | 10 +- src/lib/materialsHandler.js | 18 +- src/lib/meshEdit.js | 5 +- src/lib/moveSmoothing.js | 6 +- src/lib/multiTransform.js | 6 +- src/lib/objectActions.js | 27 ++- src/lib/objectListNav.js | 6 +- src/lib/objectOrigin.js | 4 +- src/lib/objectPermissions.js | 4 +- src/lib/particleActions.js | 4 +- src/lib/peerHandler.svelte.js | 8 +- src/lib/physics.js | 12 +- src/lib/playInteract.js | 4 +- src/lib/prefabs.js | 4 +- src/lib/sessions.js | 8 +- src/lib/shaderGraph.js | 6 +- src/lib/splineTool.js | 6 +- src/lib/terrainSculpt.js | 4 +- src/lib/transientObjects.js | 6 +- src/lib/uvEditor.js | 20 +- src/lib/vrControls.js | 15 +- src/lib/vrSleeve.js | 7 +- src/stores/appStore.js | 9 +- src/stores/sceneStore.js | 97 ++++++++ tests/e2e/scene-poke.test.cjs | 330 +++++++++++++++++++++++++++ 44 files changed, 882 insertions(+), 203 deletions(-) create mode 100644 tests/e2e/scene-poke.test.cjs diff --git a/check-baseline.json b/check-baseline.json index 222d02fc..aaeba533 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 357, + "errors": 352, "warnings": 47, - "measured": "2026-09-12" + "measured": "2026-09-16" } diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 4e48ad99..46e07e28 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -411,6 +411,93 @@ }; } + // --- 26-B: LIST VIRTUALISATION (roadmap 26 Stage 0, audit M6) ------------------- + // The tree rendered EVERY visible row, recursively, and re-reconciled all of them on + // every scene poke. At 3,000 objects that is 3,000 component instances each carrying + // nine handlers and a Tooltip — the object list alone was several hundred ms of the + // reported freeze, and it is why deleting one object in a big scene felt worse than + // the delete itself. + // + // Above the threshold the SAME row component renders in `flat` mode over the + // flattened `visibleObjectRows` — the one array the keyboard walker, Ctrl+A and the + // type-ahead already read their order from — with two spacer divs standing in for + // what is off screen. Sharing that array is what keeps the arrows and the window + // agreeing by construction; deriving a second order would be a copy guaranteed to + // drift (the Explorer's `gridEntries` ruling, one panel over). + // + // Below the threshold NOTHING changes: the recursive tree renders exactly as it did, + // indent borders and all, so the common case is byte-identical. + const VIRTUAL_MIN = 500; + /** rows drawn beyond each edge, so a fast flick does not show blank space */ + const OVERSCAN = 12; + let rowH = $state(24); + let scrollTop = $state(0); + let viewportH = $state(0); + let treeScroller: HTMLElement | null = null; + const treeRows = $derived(viewMode ? [] : visibleObjectRows($objectsGroup, $expandedObjects, $objectFilter as any)); + const virtualising = $derived(treeRows.length > VIRTUAL_MIN); + const windowStart = $derived(virtualising ? Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN) : 0); + const windowEnd = $derived( + virtualising + ? Math.min(treeRows.length, Math.ceil((scrollTop + (viewportH || 400)) / rowH) + OVERSCAN) + : 0 + ); + const windowRows = $derived(virtualising ? treeRows.slice(windowStart, windowEnd) : []); + + /** Find the real scrolling ancestor by SCROLLABILITY, never by class name — the + * scroller is flowbite's `Listgroup`, whose element we do not own (the deep-link + * ruling in Section.svelte, same reason). */ + function trackTreeScroll(node: HTMLElement) { + let ro: any = null; + const read = () => { + if (!treeScroller) return; + scrollTop = treeScroller.scrollTop; + viewportH = treeScroller.clientHeight; + // measure ONE real row rather than trusting a constant: the spacers are in + // pixels, so a wrong height makes the window drift away from the scrollbar + const first = node.querySelector('[role="treeitem"] > div') as HTMLElement | null; + const h = first?.offsetHeight ?? 0; + if (h > 8 && Math.abs(h - rowH) > 0.5) rowH = h; + }; + let el: HTMLElement | null = node.parentElement; + while (el) { + const style = getComputedStyle(el); + if (/(auto|scroll)/.test(style.overflowY)) break; + el = el.parentElement; + } + treeScroller = el; + if (treeScroller) { + treeScroller.addEventListener('scroll', read, { passive: true }); + ro = new ResizeObserver(read); + ro.observe(treeScroller); + ro.observe(node); + } + read(); + return { + destroy() { + treeScroller?.removeEventListener('scroll', read); + ro?.disconnect(); + treeScroller = null; + } + }; + } + + // keyboard follow: in the window a selected row that is off screen has no element to + // scroll itself into view, so the WINDOW moves instead (the arrows would otherwise + // walk silently into nothing). + let lastFollowed = ''; + $effect(() => { + const uuid = $selectedObjects.length ? $selectedObjects[$selectedObjects.length - 1] : ''; + if (!virtualising || !uuid || uuid === lastFollowed) { lastFollowed = uuid; return; } + lastFollowed = uuid; + const index = treeRows.findIndex((r) => r.uuid === uuid); + if (index < 0 || !treeScroller) return; + const top = index * rowH; + const view = treeScroller.clientHeight; + if (top < treeScroller.scrollTop) treeScroller.scrollTop = top; + else if (top + rowH > treeScroller.scrollTop + view) treeScroller.scrollTop = top + rowH - view; + }); + // bottom status line: totals across the whole tree (N objects · M hidden) let objectCount = $state(0); let hiddenCount = $state(0); @@ -2199,8 +2286,14 @@ {#if $objectsGroup} -
- {#if $objectsGroup.children.length > 0} +
+ {#if virtualising} + + {#each windowRows as row (row.uuid)} + + {/each} + + {:else if $objectsGroup.children.length > 0} {#each $objectsGroup.children.filter((/** @type {any} */ c) => !c.userData?.__localOnly) as element} {/each} diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 238d2b3e..e01083d0 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -165,8 +165,7 @@ backgroundColor, globalCamera, viewMode, - showGrid - } from '../../stores/sceneStore'; + showGrid, pokeScene } from '../../stores/sceneStore'; // 16-P3: grid + snapping prefs (LOCAL, like the clip planes) import { gridSettings, setGrid, resetGrid, effectiveCell } from '$lib/gridSettings'; import { snapEnabled, snapSettings, surfaceSnap, snapTargets } from '$lib/snapping'; @@ -499,7 +498,7 @@ setShaderGraphFor(object.uuid, null); detachFrom(object); } - objectsGroup.update((v) => v); + pokeScene(); showToast(own.length === 1 ? 'Shader removed from this object' : 'Shader removed from ' + own.length + ' objects'); } @@ -1185,7 +1184,7 @@ } } selectedObject.update((s) => s); - objectsGroup.update((v) => v); + pokeScene(); } /** A picked image file → every selected material, decoded once. @param {File} file */ @@ -1199,7 +1198,7 @@ if (uuids.length > 1) endHistoryBatch(`Texture (${uuids.length})`); } selectedObject.update((s) => s); - objectsGroup.update((v) => v); + pokeScene(); } /** CL-A A4: which material preset matches the current values (else 'custom') @param {any} p */ @@ -1221,7 +1220,7 @@ } function sendName() { - objectsGroup.update((value) => value); // refresh the object list + pokeScene(); // refresh the object list $peers.send({ type: 'name', name: $selectedObject.name, uuid: $selectedObject.uuid }); } @@ -2578,7 +2577,7 @@ $selectedObject.uuid, selected?.name === 'Level Up' ? 'up' : val ); - objectsGroup.update((v) => v); + pokeScene(); rerenderSelectGroup = !rerenderSelectGroup; }} /> @@ -3353,7 +3352,7 @@ object.material.needsUpdate = true; $peers.send({ type: 'color', uuid: object.uuid, color: c.hex }); } - objectsGroup.update((v) => v); + pokeScene(); }} /> {/if} diff --git a/src/components/menu/Objects.svelte b/src/components/menu/Objects.svelte index d30407a3..a80328f7 100644 --- a/src/components/menu/Objects.svelte +++ b/src/components/menu/Objects.svelte @@ -1,7 +1,11 @@ + +{#if $statsOpen} +
+
+
+ +
+

+ Judged against the {profile === 'vr' ? 'VR / mobile' : 'desktop'} budget. + Nothing here leaves this device. +

+ + + + {#each rows as row (row.key)} + + + + + + {/each} + +
+ + {row.label} + {num(row.value)}{row.unit} + {num(row.green)} / {num(row.amber)} +
+ +

Frame

+
+ p50 / p95 / p99 + + {num($sceneMetrics.frameP50)} / {num($sceneMetrics.frameP95)} / {num($sceneMetrics.frameP99)} ms + + Long tasks (1 min) + + {#if $sceneMetrics.longTasksAvailable} + {num($sceneMetrics.longTasks)} · worst {num($sceneMetrics.longestTask)} ms + {:else} + not available in this browser + {/if} + + JS heap + {$sceneMetrics.heap == null ? 'not available' : mb($sceneMetrics.heap)} + Meshes / hidden + {num($sceneMetrics.meshes)} / {num($sceneMetrics.hidden)} + {#if $sceneMetrics.ingestBacklog} + Objects still arriving + {num($sceneMetrics.ingestBacklog)} + {/if} +
+ +

+ Wire, last {Math.round(wire.seconds)}s +

+ {#if wire.rows.length === 0} +

Nothing sent or received yet.

+ {:else} + + + {#each wire.rows.slice(0, 10) as row (row.type)} + + + + + + + + {/each} + +
{row.type}{num(row.in)} in{num(row.out)} out{row.perSecond.toFixed(1)}/s{row.bytes == null ? '' : '≈' + num(row.bytes) + 'B'}
+ {/if} +
+
+{/if} + + diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index 21095bbb..80ad8e18 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -28,6 +28,9 @@ import { peers, userdata } from '../stores/appStore'; // the departing object was using, and never what the rest of the scene still holds. import { disposeTree, keepSet } from '$lib/disposeTree'; import { safeStorage } from './safeStorage'; +// 26-A: the backlog is a reading the Statistics panel wants and sceneBudget cannot +// reach — it REGISTERS rather than importing us, the registerDiagnosticsSection shape. +import { registerMetricSource } from './sceneBudget'; //Access scene Store let scene = $state(); @@ -672,6 +675,7 @@ export function dropIngestQueue() { export function ingestBacklog() { return ingestQueue.length; } +registerMetricSource('ingestBacklog', ingestBacklog); /** * @param {any} object @param {string[]|null} uuid @param {boolean} [override] diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index df552289..db242582 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -27,6 +27,7 @@ import { canApply, getAuthProvider, dispatchCloudMessage, rolesInfo } from '$lib // dispatcher can reject a malformed message before any applier sees it. import { validateWireMessage } from '$lib/wireValidate'; import { noteWireError } from '$lib/wireErrors'; +import { noteWire } from '$lib/sceneBudget'; import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; import { applyPing } from '$lib/ping'; import { applyAssetFile, answerAssetRequest, applyAssetThumb, answerAssetThumbRequest, applyAssetStart, applyAssetChunk, applyAssetMissing } from '$lib/assetShare'; @@ -1049,6 +1050,10 @@ export class PeerConnection { noteWireError(conn.peer, 'shape', typeof data); return; } + // 26-A (roadmap 26 section 3, audit H7): WHICH STREAM IS CHATTY. Counted per + // type here and in `broadcast`; local only, never replicated, and the byte + // figure is a 1-in-16 sample so the measurement cannot become the cost. + noteWire('in', data); // …then the shape its own type implies, so an applier cannot throw halfway // through applying half a message. A type absent from the table is ALLOWED, // which is what keeps a newer peer's messages working. @@ -1517,6 +1522,7 @@ export class PeerConnection { // conn can't throw mid-loop and starve the rest of the mesh (172). /** @param {any} payload */ broadcast(payload) { + noteWire('out', payload); // TWO REASONS TO WITHHOLD, and they are different arguments about the same peer. // // P2b, BANDWIDTH: pose streams (`camera`, `vrhands`) are bytes nobody in another diff --git a/src/lib/sceneBudget.js b/src/lib/sceneBudget.js new file mode 100644 index 00000000..fb873d49 --- /dev/null +++ b/src/lib/sceneBudget.js @@ -0,0 +1,438 @@ +import { writable, get } from 'svelte/store'; +import { objectsGroup, globalRenderer } from '../stores/sceneStore'; +import { coarsePointer } from './inputDevice'; + +// 26-A (roadmap 26 sections 2 and 3) — WHAT THE SCENE COSTS, AND WHETHER THAT IS A LOT. +// +// THE FINDING: there was no scene-level budget anywhere. No object, triangle, draw-call +// or texture ceiling, and `renderer.info` was read by exactly one thing — the VR stats +// plate. So the answer to "how big can a scene be" was nobody's, the answer to "why did +// it get slow" was a guess, and a diagnostics bundle carried no numbers at all. +// +// TIERS WITH ACTIONS, NOT WALLS. Green does nothing, amber shows the meter, red is what +// the ingest fork (26-C) and the auto-stops (26-G) read. Nothing here refuses anything: +// a budget that stops you working is a budget people turn off. +// +// TWO PROFILES, because the same scene is fine on a desktop and fatal on a headset: a +// mobile GPU at 72-90Hz has a third of the frame time and a fraction of the memory, and +// the tab is KILLED rather than slowed when it runs out. +// +// A LEAF: svelte/store, the scene store and `inputDevice` (itself import-free). That is +// deliberate — peerHandler counts wire traffic through here, commandsHandler publishes +// its ingest backlog, and both sit inside the documented import cycles. Anything that +// cannot be reached without an edge REGISTERS instead (`registerMetricSource`). +// +// EVERYTHING HERE IS LOCAL. Not one number replicates, saves or undoes: a budget is a +// fact about THIS machine's GPU and this tab's main thread, and two peers on different +// hardware must be allowed to disagree about it. + +/** + * @typedef {'green'|'amber'|'red'|'unknown'} Tier + * @typedef {{key: string, label: string, unit: string, desktop: [number, number], vr: [number, number], why: string}} Budget + */ + +/** + * The section-2 table as DATA, so the meter, the overlay and the gateway read ONE + * source (the `hudKinds` / `SAVE_AS_FORMATS` shape). Each pair is [green ceiling, + * amber ceiling]; above the second number is red. + * @type {Budget[]} + */ +export const BUDGETS = [ + { + key: 'objects', + label: 'Objects', + unit: '', + desktop: [1000, 3000], + vr: [500, 1500], + why: 'every object is at least one draw call, one wire message per joiner, one row in the tree and one node in every traversal' + }, + { + key: 'triangles', + label: 'Triangles / frame', + unit: '', + desktop: [1000000, 3000000], + vr: [300000, 600000], + why: 'vertex and fill cost, at 60Hz on a desktop against 72-90Hz on a headset' + }, + { + key: 'calls', + label: 'Draw calls / frame', + unit: '', + desktop: [1000, 2000], + vr: [300, 500], + why: 'there is no instancing or batching in core, so every call is CPU time' + }, + { + key: 'textures', + label: 'Textures', + unit: '', + desktop: [300, 600], + vr: [150, 300], + why: 'a proxy for GPU bytes: the tab is killed on mobile and the context is lost on desktop' + }, + { + key: 'geometries', + label: 'Geometries', + unit: '', + desktop: [1500, 4000], + vr: [700, 2000], + why: 'buffers held on the GPU; a leak shows here first (a delete that never disposed)' + }, + { + key: 'frameP95', + label: 'Frame time p95', + unit: 'ms', + desktop: [20, 33], + vr: [11, 13.9], + why: 'FPS averages a stutter away; p95 is the frame you actually feel' + }, + { + key: 'longTasks', + label: 'Long tasks / min', + unit: '', + desktop: [2, 12], + vr: [1, 6], + why: 'the direct measure of "the window froze" — a task over 50ms blocks input' + } +]; + +/** @type {Map} */ +const byKey = new Map(BUDGETS.map((b) => [b.key, b])); + +/** + * Which profile this device is judged against. `renderer.xr.isPresenting` is the true + * answer while a headset is on; a coarse pointer is the standing one for a phone. + * @param {any} [renderer] + * @returns {'desktop'|'vr'} + */ +export function profileFor(renderer) { + try { + if (renderer?.xr?.isPresenting) return 'vr'; + } catch { + /* a disposed renderer */ + } + return coarsePointer() ? 'vr' : 'desktop'; +} + +/** + * The tier one reading falls in. PURE — this is the part that has to be right, and it + * is testable with no browser and no GPU. + * @param {string} key @param {number | null | undefined} value @param {'desktop'|'vr'} profile + * @returns {Tier} + */ +export function tierOf(key, value, profile) { + const budget = byKey.get(key); + if (!budget || value == null || !Number.isFinite(value)) return 'unknown'; + const [green, amber] = profile === 'vr' ? budget.vr : budget.desktop; + if (value <= green) return 'green'; + if (value <= amber) return 'amber'; + return 'red'; +} + +const ORDER = { unknown: 0, green: 1, amber: 2, red: 3 }; + +/** + * The meter's single dot: the worst tier across everything we can read. An UNKNOWN + * never darkens the dot — "we have not measured it" is not "it is fine", but it is + * certainly not a warning either. + * @param {Record} metrics @param {'desktop'|'vr'} profile @returns {Tier} + */ +export function worstTier(metrics, profile) { + /** @type {Tier} */ + let worst = 'unknown'; + for (const budget of BUDGETS) { + const tier = tierOf(budget.key, metrics?.[budget.key], profile); + if (ORDER[tier] > ORDER[worst]) worst = tier; + } + return worst; +} + +/** + * Every budget with its current reading and tier — what the overlay renders and what a + * diagnostics bundle carries. + * @param {Record} metrics @param {'desktop'|'vr'} profile + */ +export function budgetRows(metrics, profile) { + return BUDGETS.map((budget) => { + const value = metrics?.[budget.key]; + const [green, amber] = profile === 'vr' ? budget.vr : budget.desktop; + return { ...budget, value: value ?? null, green, amber, tier: tierOf(budget.key, value, profile) }; + }); +} + +// --- frame times ------------------------------------------------------------------ +// A RING, not an average. p95 is the whole point: a scene that renders 58 of every 60 +// frames in 8ms and two in 300ms reads as 60fps and feels broken. + +const FRAME_RING = 240; +/** @type {number[]} */ +const frames = []; + +/** @param {number} ms */ +export function noteFrame(ms) { + if (!Number.isFinite(ms) || ms <= 0) return; + frames.push(ms); + if (frames.length > FRAME_RING) frames.shift(); +} + +/** @param {number[]} sorted @param {number} q */ +function percentile(sorted, q) { + if (!sorted.length) return null; + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1)); + return sorted[index]; +} + +/** p50 / p95 / p99 over the ring. PURE given the ring. */ +export function frameStats() { + const sorted = [...frames].sort((a, b) => a - b); + return { + n: sorted.length, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + p99: percentile(sorted, 0.99) + }; +} + +// --- long tasks ------------------------------------------------------------------- +// `PerformanceObserver('longtask')` is the browser telling us, in its own words, that +// the main thread was blocked past 50ms. Nothing else in this app can say that. + +/** @type {{at: number, ms: number}[]} */ +let longTasks = []; +/** @type {any} */ +let longTaskObserver = null; + +/** @param {number} ms */ +export function noteLongTask(ms) { + const now = Date.now(); + longTasks.push({ at: now, ms }); + // a rolling minute, which is what the budget is stated in + longTasks = longTasks.filter((t) => now - t.at < 60000); +} + +/** Count in the last minute plus the worst one. */ +export function longTaskStats() { + const now = Date.now(); + const recent = longTasks.filter((t) => now - t.at < 60000); + return { perMinute: recent.length, longest: recent.reduce((m, t) => Math.max(m, t.ms), 0) }; +} + +export function startLongTasks() { + if (longTaskObserver || typeof PerformanceObserver === 'undefined') return false; + try { + longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) noteLongTask(entry.duration); + }); + longTaskObserver.observe({ entryTypes: ['longtask'] }); + return true; + } catch { + // Safari and Firefox do not implement it. The rest of the panel still works, + // and the row says "not available" rather than lying with a zero. + longTaskObserver = null; + return false; + } +} + +export function stopLongTasks() { + try { + longTaskObserver?.disconnect(); + } catch { + /* already gone */ + } + longTaskObserver = null; +} + +// --- wire traffic per type (audit H7's measurement) --------------------------------- +// WHICH STREAM IS CHATTY is the question, and the answer is a COUNT — exact, and free. +// BYTES are sampled: `JSON.stringify` on every message would itself become the cost +// being measured, so one in SAMPLE_EVERY is measured and scaled, and the UI says "≈". + +const SAMPLE_EVERY = 16; +/** @type {Map} */ +const wire = new Map(); +let wireSince = Date.now(); +let wireTick = 0; + +/** @param {'in'|'out'} dir @param {any} payload */ +export function noteWire(dir, payload) { + const type = typeof payload?.type === 'string' ? payload.type : 'unknown'; + let row = wire.get(type); + if (!row) wire.set(type, (row = { in: 0, out: 0, bytes: 0, sampled: 0 })); + row[dir]++; + if (++wireTick % SAMPLE_EVERY === 0) { + try { + row.bytes += JSON.stringify(payload).length; + row.sampled++; + } catch { + // a payload holding an ArrayBuffer (the raw-bytes channels) — count the + // message, skip the estimate rather than pretend + } + } +} + +/** Per-type rows, busiest first, with a per-second rate over the window since the + * last reset. `bytes` is an ESTIMATE and is labelled as one wherever it is shown. */ +export function wireStats() { + const seconds = Math.max(1, (Date.now() - wireSince) / 1000); + const rows = [...wire.entries()] + .map(([type, row]) => ({ + type, + in: row.in, + out: row.out, + perSecond: (row.in + row.out) / seconds, + bytes: row.sampled ? Math.round((row.bytes / row.sampled) * (row.in + row.out)) : null + })) + .sort((a, b) => b.in + b.out - (a.in + a.out)); + return { seconds, rows }; +} + +export function resetWireStats() { + wire.clear(); + wireSince = Date.now(); + wireTick = 0; +} + +// --- extra sources, registered rather than imported --------------------------------- + +/** @type {Map any>} */ +const sources = new Map(); + +/** + * Contribute a reading without this module importing you. The `registerDiagnosticsSection` + * seam, one domain over — commandsHandler publishes its ingest backlog this way, and + * physics can publish its body count without sceneBudget reaching into the cycle family. + * @param {string} key @param {() => any} read @returns {() => void} unregister + */ +export function registerMetricSource(key, read) { + sources.set(key, read); + return () => sources.delete(key); +} + +// --- the sampler -------------------------------------------------------------------- + +/** The last sample. Written ~2x/s, never per frame — the panel is DOM. */ +/** @type {import('svelte/store').Writable>} */ +export const sceneMetrics = writable({ at: 0, profile: 'desktop' }); + +/** The desktop Statistics overlay's open state. LOCAL. */ +export const statsOpen = writable(false); + +/** How often the reading is recomputed. Anything faster is unreadable and the walk is + * O(objects); anything slower misses the hitch you opened the panel to find. */ +const SAMPLE_MS = 500; + +let running = false; +/** @type {any} */ +let rafId = null; +let lastFrameAt = 0; +let lastSampleAt = 0; + +function walkScene() { + const group = get(objectsGroup); + let objects = 0; + let meshes = 0; + let hidden = 0; + group?.traverse?.((/** @type {any} */ o) => { + if (o === group) return; + objects++; + if (o.isMesh) meshes++; + if (o.visible === false) hidden++; + }); + return { objects, meshes, hidden }; +} + +function sample() { + /** @type {any} */ + const renderer = get(globalRenderer); + const info = renderer?.info; + const profile = profileFor(renderer); + const scene = walkScene(); + const fps = frameStats(); + const tasks = longTaskStats(); + /** @type {any} */ + const perf = typeof performance !== 'undefined' ? performance : null; + const heap = perf?.memory?.usedJSHeapSize ?? null; + /** @type {Record} */ + const extra = {}; + for (const [key, read] of sources) { + try { + extra[key] = read(); + } catch { + extra[key] = null; + } + } + const metrics = { + at: Date.now(), + profile, + objects: scene.objects, + meshes: scene.meshes, + hidden: scene.hidden, + triangles: info?.render?.triangles ?? null, + calls: info?.render?.calls ?? null, + geometries: info?.memory?.geometries ?? null, + textures: info?.memory?.textures ?? null, + frameP50: fps.p50, + frameP95: fps.p95, + frameP99: fps.p99, + frameSamples: fps.n, + fps: fps.p50 ? Math.round(1000 / fps.p50) : null, + longTasks: tasks.perMinute, + longestTask: Math.round(tasks.longest), + longTasksAvailable: !!longTaskObserver, + heap, + ...extra + }; + sceneMetrics.set(metrics); +} + +function loop() { + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + if (lastFrameAt) noteFrame(now - lastFrameAt); + lastFrameAt = now; + if (now - lastSampleAt >= SAMPLE_MS) { + lastSampleAt = now; + sample(); + } + if (running) rafId = requestAnimationFrame(loop); +} + +/** + * Start sampling. The rAF loop is OUR OWN rather than threlte's task graph, on purpose: + * frame time measured from the browser's own callback cadence is exactly the quantity + * "did the window freeze" is asking about, and it keeps this a leaf that Scene.svelte + * does not have to know exists. + */ +export function startSceneMetrics() { + if (running || typeof requestAnimationFrame === 'undefined') return; + running = true; + lastFrameAt = 0; + lastSampleAt = 0; + startLongTasks(); + rafId = requestAnimationFrame(loop); +} + +export function stopSceneMetrics() { + running = false; + if (rafId != null) cancelAnimationFrame(rafId); + rafId = null; + stopLongTasks(); +} + +/** Force a reading now — the overlay opening, and the suite. */ +export function sampleSceneMetrics() { + sample(); + return get(sceneMetrics); +} + +/** One line per budget, for the diagnostics bundle (audit H4). */ +export function budgetSummary() { + const metrics = get(sceneMetrics); + const profile = metrics.profile === 'vr' ? 'vr' : 'desktop'; + return { + profile, + tier: worstTier(metrics, profile), + metrics, + budgets: budgetRows(metrics, profile).map((r) => ({ key: r.key, value: r.value, tier: r.tier })), + wire: wireStats().rows.slice(0, 12) + }; +} diff --git a/tests/e2e/scene-budget.test.cjs b/tests/e2e/scene-budget.test.cjs new file mode 100644 index 00000000..2e0f7a91 --- /dev/null +++ b/tests/e2e/scene-budget.test.cjs @@ -0,0 +1,188 @@ +// 26-A — the scene budget, the meter and the desktop Statistics panel +// (roadmap 26 sections 2 and 3). +// +// THE FINDING: there was no scene-level budget at all, and `renderer.info` had exactly +// ONE reader in the whole app — the VR stats plate. On a desktop, where every heavy +// scene is built, there was no way to see draw calls, triangles, GPU object counts or a +// single frame-time number, and a diagnostics bundle carried none of them. +// +// What is asserted, in the order it matters: +// 1. the tier arithmetic, which is the part that has to be right and needs no browser; +// 2. the sampler actually reads the live renderer and the live scene; +// 3. the meter's dot changes tier when the scene crosses a budget — driven by REAL +// objects, not by writing the store; +// 4. the panel opens from the burger menu (the real entry point) and renders the rows; +// 5. the wire counters count per type, and the numbers reach the diagnostics bundle. +const h = require('./helpers.cjs'); + +h.run(async () => { + // GPU args: section 2 asserts a frame-time percentile over real frames, and a + // SwiftShader page runs at ~2.5fps where "p95" is noise (the e2e skill's rule). + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- 1. the pure part --------------------------------------------------- + const pure = await A.page.evaluate(() => { + const b = window.__stores.sceneBudget; + return { + budgets: b.BUDGETS.length, + // objects: desktop 1000 / 3000, vr 500 / 1500 + green: b.tierOf('objects', 900, 'desktop'), + amber: b.tierOf('objects', 2000, 'desktop'), + red: b.tierOf('objects', 4000, 'desktop'), + // the SAME count is judged harder on a headset — that is the whole reason + // there are two columns + vrAmber: b.tierOf('objects', 900, 'vr'), + vrRed: b.tierOf('objects', 2000, 'vr'), + boundaryGreen: b.tierOf('objects', 1000, 'desktop'), + boundaryAmber: b.tierOf('objects', 3000, 'desktop'), + unknownKey: b.tierOf('not-a-budget', 5, 'desktop'), + unknownValue: b.tierOf('objects', null, 'desktop'), + nan: b.tierOf('objects', NaN, 'desktop'), + // the meter takes the WORST, and an unmeasured reading never darkens it + worstOfGreen: b.worstTier({ objects: 10, triangles: 10, calls: 1 }, 'desktop'), + worstOfMixed: b.worstTier({ objects: 10, triangles: 10, calls: 5000 }, 'desktop'), + worstOfNothing: b.worstTier({}, 'desktop'), + rows: b.budgetRows({ objects: 4000 }, 'desktop').find((r) => r.key === 'objects') + }; + }); + h.check(pure.budgets >= 7, `the budget table is data (${pure.budgets} rows)`); + h.check(pure.green === 'green' && pure.amber === 'amber' && pure.red === 'red', 'the three tiers read as written'); + h.check(pure.vrAmber === 'amber' && pure.vrRed === 'red', '…and the VR column judges the same count harder'); + h.check( + pure.boundaryGreen === 'green' && pure.boundaryAmber === 'amber', + 'a reading EXACTLY on a ceiling stays in the lower tier' + ); + h.check( + pure.unknownKey === 'unknown' && pure.unknownValue === 'unknown' && pure.nan === 'unknown', + 'an unknown budget, a missing reading and a NaN are all "unknown", never a tier' + ); + h.check(pure.worstOfGreen === 'green' && pure.worstOfMixed === 'red', 'the meter takes the worst reading'); + h.check(pure.worstOfNothing === 'unknown', '…and nothing measured is not a warning'); + h.check(pure.rows?.tier === 'red' && pure.rows?.green === 1000, 'budgetRows carries the reading, the ceilings and the tier'); + + // ---- 2. the sampler reads the LIVE renderer and scene -------------------- + const live = await A.page.evaluate(async () => { + const { sceneBudget, THREE, objectsGroup, pokeScene } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + group.clear(); + const geo = new THREE.BoxGeometry(1, 1, 1); + const mat = new THREE.MeshStandardMaterial(); + for (let i = 0; i < 24; i++) group.add(new THREE.Mesh(geo, mat)); + pokeScene(); + await new Promise((r) => setTimeout(r, 900)); + return sceneBudget.sampleSceneMetrics(); + }); + h.check(live.objects === 24, `the sampler walks the live scene (${live.objects} objects)`); + h.check(live.meshes === 24, `…and counts meshes (${live.meshes})`); + h.check( + typeof live.triangles === 'number' && live.triangles > 0, + `renderer.info reaches the desktop at last (${live.triangles} triangles, ${live.calls} draw calls)` + ); + h.check(typeof live.geometries === 'number', `GPU object counts are read (${live.geometries} geometries, ${live.textures} textures)`); + h.check( + live.frameSamples > 10 && live.frameP95 != null && live.frameP95 >= live.frameP50, + `frame percentiles come from real frames (${live.frameSamples} samples, p50 ${live.frameP50}, p95 ${live.frameP95})` + ); + h.check(live.profile === 'desktop', `a desktop context is judged against the desktop budget (${live.profile})`); + h.check(typeof live.ingestBacklog === 'number', 'a registered source (the ingest backlog) reaches the sample'); + + // ---- 3. the meter's dot moves with the scene ---------------------------- + await A.page.waitForTimeout(700); + const greenDot = await A.page.getAttribute('#object-budget-dot', 'data-tier'); + h.check(greenDot === 'green', `24 objects reads green in the status line (${greenDot})`); + + await A.page.evaluate(async () => { + const { THREE, objectsGroup, pokeScene } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + const geo = new THREE.BoxGeometry(1, 1, 1); + const mat = new THREE.MeshStandardMaterial(); + // past the desktop AMBER ceiling for objects (3000) + for (let i = 0; i < 3200; i++) group.add(new THREE.Mesh(geo, mat)); + pokeScene(); + }); + await h.eventually( + () => A.page.getAttribute('#object-budget-dot', 'data-tier'), + (t) => t === 'red', + 'the status-line dot goes RED when the object budget is exceeded' + ); + const title = await A.page.getAttribute('#object-count', 'title'); + h.check( + /over on/.test(String(title)) && /object/i.test(String(title)), + `…and the tooltip names WHAT is over budget (${title})` + ); + + // ---- 4. the panel, through its real entry point -------------------------- + await A.page.evaluate(() => { + const { THREE, objectsGroup, pokeScene } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + group.clear(); + group.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())); + pokeScene(); + }); + h.check((await A.page.locator('#stats-window').count()) === 0, 'the Statistics window starts closed (premise)'); + // 94: the logo IS the menu button + await A.page.locator('#logo-menu').click(); + await A.page.waitForTimeout(400); + const menuRow = A.page.locator('#open-stats'); + if ((await menuRow.count()) === 0) { + // the burger opener differs across layouts; fall back to the store, and SAY SO + h.check(false, 'the burger menu offers a Statistics row (#open-stats not reachable — check the opener)'); + await A.page.evaluate(() => window.__stores.sceneBudget.statsOpen.set(true)); + } else { + await menuRow.click(); + h.check(true, 'the burger menu offers a Statistics row and it opens the window'); + } + await A.page.waitForSelector('#stats-window', { timeout: 8000 }); + h.check(true, 'the Statistics window is open'); + const panel = await A.page.evaluate(() => { + const rows = [...document.querySelectorAll('#stats-budgets tr[data-budget]')]; + return { + rows: rows.length, + keys: rows.map((r) => r.getAttribute('data-budget')), + tiers: rows.map((r) => r.getAttribute('data-tier')), + frame: document.querySelector('#stats-frame')?.textContent ?? '', + overall: document.querySelector('#stats-overall')?.getAttribute('data-tier') + }; + }); + h.check(panel.rows >= 7, `every budget gets a row (${panel.rows})`); + h.check(panel.keys.includes('triangles') && panel.keys.includes('calls'), 'including the two renderer.info readings the desktop never had'); + h.check(panel.tiers.every((t) => ['green', 'amber', 'red', 'unknown'].includes(String(t))), 'each row carries a tier'); + h.check(/p50/.test(panel.frame) && /ms/.test(panel.frame), 'the frame block shows the percentiles'); + h.check(['green', 'amber', 'red', 'unknown'].includes(String(panel.overall)), `the header carries the overall tier (${panel.overall})`); + + // ---- 5. wire counters + the diagnostics bundle --------------------------- + const wire = await A.page.evaluate(() => { + const b = window.__stores.sceneBudget; + b.resetWireStats(); + for (let i = 0; i < 40; i++) b.noteWire('out', { type: 'camera', pos: [i, 0, 0] }); + for (let i = 0; i < 5; i++) b.noteWire('in', { type: 'move', uuid: 'x' }); + b.noteWire('in', null); // a malformed message still counts, as 'unknown' + const stats = b.wireStats(); + return { + busiest: stats.rows[0], + second: stats.rows[1], + types: stats.rows.map((r) => r.type), + seconds: stats.seconds + }; + }); + h.check(wire.busiest?.type === 'camera' && wire.busiest?.out === 40, `the busiest type is named and counted (${wire.busiest?.type} x${wire.busiest?.out})`); + h.check(wire.second?.type === 'move' && wire.second?.in === 5, 'and the next one, by direction'); + h.check(wire.types.includes('unknown'), 'a message with no type counts as "unknown" rather than being dropped'); + h.check(typeof wire.busiest?.bytes === 'number', `bytes are estimated from a sample (≈${wire.busiest?.bytes})`); + + const bundle = await A.page.evaluate(() => { + const text = window.__stores.diagnostics.bundleText(); + return { hasSection: /scene-budget/.test(text), hasTriangles: /triangles/.test(text) }; + }); + h.check(bundle.hasSection, 'the diagnostics bundle carries a scene-budget section'); + h.check(bundle.hasTriangles, '…with the numbers in it — a report can carry them now'); + + // the panel closes from its own button + await A.page.locator('#stats-close').click(); + await A.page.waitForTimeout(250); + h.check((await A.page.locator('#stats-window').count()) === 0, 'the window closes from its own X'); + + h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`); + await h.finish(browser); +}); From 9be2f00860831f19d486795536c5f855afb6041b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 10:50:38 +0300 Subject: [PATCH 3/5] [feat] 26-C: an oversized scene asks before it arrives, and a big file says so before it opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 26 section 4, Stage 2 (the ingest gate). A scene arriving over the wire announces itself FIRST — `{type:'loading', count, uuids}` — and only then sends the objects, so there is exactly one moment where its size is known and nothing has been applied. Past that moment a 4,000-object scene is simply happening to you. WHAT - `ingestVerdict(current, incoming, profile)` in sceneBudget, PURE: the total, the tier, the object-budget limit, and `allowed` — how many fit before the scene crosses into red. What is ALREADY in the scene counts (2,900 here plus 500 asks). Amber warns and does not ask; red asks — the tiers-with-actions rule 26-A set up. - THE GATE lives in `createLoader`, the one place a `loading` announcement lands. On a red verdict it HOLDS the ingest queue that 26-B built, so every object that arrives after it is parked — parsed or not, with no second code path and nothing to unwind. The 60s stall timer is disarmed while the question is open: the objects are parked, not missing, and clearing the progress bar under an open fork would be a lie. - THE FORK, three ways, as a sticky card mirrored from an `ingestGate` store (the `restoreAvailable` idiom, so commandsHandler never imports the UI), with `noClose` — an X would leave the transfer stalled with nothing left to resume it: Load all · Load the first N · Cancel. "The first N" caps the drainer; everything past the cap is DROPPED and counted as arrived, so the bar does not wait out the stall for something that is never coming. Cancel drops the queue and clears the bar. - LOCAL ONLY. Nothing is sent. The peer is not told we declined — that is a fact about THIS device's budget and there is nothing for them to do about it. They see us with fewer objects, which is what happened. - THE FILE HALF: `requestLoadPayload` — the one entry point a PERSON reaches by opening a .tpscene or pressing Load in Sessions — counts the payload (`countPayloadObjects`, nested children included, the unit the budget is stated in) and asks before replacing the scene. Deliberately NOT in `applySession`: travel, a peer's proposal, an autosave restore and rejoin all go through that, and a replicated hop must never stop at a dialog nobody is standing at (the travel-node rule). The file gets TWO ways out, not three: "load the first N objects of this file" makes a scene nobody saved, which the user would then re-save over their own file silently truncated. A stream is divisible; a document is not. The file is compared against the budget ALONE, because it replaces the scene rather than adding to it. COUNTERFACTUALS (suite `ingest-gate`, 29 checks, one page) - `createLoader`'s gate disabled -> 6 red: nothing parks, the scene is touched while the question should be open, the card never appears, and the rest of the run cannot find the fork's buttons. - The file-open ask removed from `requestLoadPayload` -> the dialog never appears and the run cannot answer it. Both restored; suite green again (29/29). GATES - svelte-check 352 errors / 47 warnings — at the floor this branch ratcheted to. - `npm run build` green with the dev server stopped. - Held green: scene-poke (32/32), scene-budget (34/34), sessions, tpscene, clear-scene, and the two dedicated handshake suites object-sync and net-handshake — which cover exactly the late-joiner receive path this gate sits on ("a late joiner receives EVERY object", "every message left over an OPEN connection"). - `scene-levels` is red on nine two-peer checks and reproduces IDENTICALLY at base 7646fc2 with src reverted (same nine names, 206s vs 205s). Pre-existing, not this diff. Co-Authored-By: Claude Opus 5 --- src/components/menu/Toasts.svelte | 23 ++++ src/lib/commandsHandler.svelte.js | 113 ++++++++++++++++- src/lib/sceneBudget.js | 36 ++++++ src/lib/sessions.js | 63 ++++++++++ tests/e2e/ingest-gate.test.cjs | 196 ++++++++++++++++++++++++++++++ 5 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/ingest-gate.test.cjs diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index 738320f0..ed57123f 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -19,6 +19,7 @@ import { armExplorerSceneSave, explorerClose } from '../../stores/appStore' import { peers, loading, loadingcount, pendingApprovals, waitingForApproval, userdata, toastStore, fixLight, showSidebar, specatorMode, restorePanels, appNotice, connectDrawerOpen, connectDrawerTab, toastsInDrawerOnly, showInfoToast, dismissToastById } from '../../stores/appStore' import { restoreAvailable, restoreSnapshot, dismissRestore } from '$lib/autosave' + import { ingestGate, resolveIngestGate } from '$lib/commandsHandler.svelte' import { cancelOutboundRequest } from '$lib/peerApproval' // 27-B: the ONE sticky card for an uncaught error. This file already mirrors // state stores into sticky toasts (restoreAvailable below); diagnostics.js stays a @@ -260,6 +261,28 @@ $effect(() => { else dismissToastById('diagnostics-error'); }); +// 26-C (roadmap 26 Stage 2): A SCENE BIGGER THAN THIS DEVICE'S BUDGET IS ARRIVING. +// The objects are PARKED in the ingest queue, not applied, so this card is the only +// thing between them and the scene — hence `noClose`: dismissing it with an X would +// leave the transfer stalled with nothing left to resume it. The state store is the +// seam (the restoreAvailable idiom), so commandsHandler never imports the UI. +$effect(() => { + const gate = $ingestGate; + if (gate) + showInfoToast( + 'ingest-gate', + `This scene has ${gate.count} objects — that would take this device to ${gate.total}, above the ${gate.limit} recommended here.`, + [ + { label: 'Load all', action: () => resolveIngestGate('all') }, + { label: `Load the first ${gate.allowed}`, action: () => resolveIngestGate('some') }, + { label: 'Cancel', action: () => resolveIngestGate('cancel') } + ], + undefined, + true + ); + else dismissToastById('ingest-gate'); +}); + $effect(() => { const snap = $restoreAvailable; if (snap) diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index 80ad8e18..a4d8a51c 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -20,7 +20,7 @@ import { stripEditOverlays } from '$lib/editOverlays' import { runSceneClearHandlers } from '$lib/moduleSDK' import { annotations } from '$lib/annotationsHandler' import { isViewer, warnViewerReadOnly } from '$lib/objectPermissions' -import { get } from 'svelte/store' +import { get, writable } from 'svelte/store' import { addMessage, loading, loadingcount, showToast, fixLight, specatorMode } from '../stores/appStore'; import { dropWireErrors } from './wireErrors'; import { peers, userdata } from '../stores/appStore'; @@ -30,7 +30,8 @@ import { disposeTree, keepSet } from '$lib/disposeTree'; import { safeStorage } from './safeStorage'; // 26-A: the backlog is a reading the Statistics panel wants and sceneBudget cannot // reach — it REGISTERS rather than importing us, the registerDiagnosticsSection shape. -import { registerMetricSource } from './sceneBudget'; +import { registerMetricSource, ingestVerdict, profileFor } from './sceneBudget'; +import { globalRenderer } from '../stores/sceneStore.js'; //Access scene Store let scene = $state(); @@ -452,6 +453,24 @@ export async function createLoader(count, uuids, senderId) { loading.set(Array.isArray(uuids) ? uuids : []); loadingcount.set(count); loadingSender = senderId ?? null; + // 26-C: THE ONE MOMENT the size is known and nothing has been applied. Past it a + // 4,000-object scene is simply happening to you. + const verdict = ingestVerdict(liveObjectCount(), count, profileFor(get(globalRenderer))); + if (verdict.gate) { + ingestHeld = true; + ingestGate.set({ + count: verdict.incoming, + allowed: verdict.allowed, + total: verdict.total, + limit: verdict.limit, + sender: loadingSender + }); + // the stall timer must NOT run while the question is open — the objects are + // parked, not missing, and clearing the bar under an open fork would be a lie + clearTimeout(loadingStallTimer); + loadingStallTimer = null; + return; + } armLoadingStall(); } @@ -622,6 +641,78 @@ const INGEST_SLICE_MS = 8; let ingestQueue = []; let ingestDraining = false; +// --------------------------------------------------------------------------- +// 26-C (roadmap 26 Stage 2) — THE INGEST GATE. +// +// A scene arriving over the wire announces itself first (`{type:'loading', count, +// uuids}`) and only then sends the objects, so there is exactly one moment where the +// size is known and nothing has been applied yet. Past that moment a 4,000-object scene +// is simply happening to you. +// +// The queue built in 26-B is already the parking mechanism: HOLDING it parks every +// object that arrives, parsed or not, with no second code path and nothing to unwind. +// The fork is three-way because a stream is divisible — half a room's scenery is a +// usable scene, and the alternative to "load the first N" is all-or-nothing on somebody +// else's content. +// +// LOCAL ONLY. Nothing here is sent: the peer is not told we declined, because that is a +// fact about THIS device's budget and there is nothing for them to do about it. They +// see us with fewer objects, which is what actually happened. +// --------------------------------------------------------------------------- + +/** The open question, or null. Toasts.svelte MIRRORS this into one sticky card (the + * `restoreAvailable` idiom) rather than this module importing the UI. */ +/** @type {import('svelte/store').Writable<{count: number, allowed: number, total: number, limit: number, sender: string | null} | null>} */ +export const ingestGate = writable(null); + +let ingestHeld = false; +/** How many more objects this drain may apply before dropping the rest. Infinity = no + * cap, which is every path that never met a gate. */ +let ingestCap = Infinity; + +/** How many objects the scene already holds — the walk the verdict is measured against. */ +function liveObjectCount() { + let n = 0; + sceneObjects?.traverse?.((/** @type {any} */ o) => { + if (o !== sceneObjects) n++; + }); + return n; +} + +/** + * Answer the fork. 'all' releases everything, 'some' applies up to the budget and drops + * the rest, 'cancel' drops the lot. + * @param {'all'|'some'|'cancel'} answer + */ +export function resolveIngestGate(answer) { + const open = get(ingestGate); + if (!open) return 0; + ingestGate.set(null); + ingestHeld = false; + if (answer === 'cancel') { + const dropped = dropIngestQueue(); + clearLoadingBatch(); + showToast('Cancelled — ' + open.count + ' objects were not loaded.'); + return dropped; + } + ingestCap = answer === 'some' ? open.allowed : Infinity; + // the stall timer was parked while the question was open; the transfer resumes now + armLoadingStall(); + if (!ingestDraining && ingestQueue.length) { + ingestDraining = true; + beginSceneBatch(); + void drainIngest(); + } + if (answer === 'some') + showToast('Loading the first ' + open.allowed + ' of ' + open.count + ' objects.'); + return ingestQueue.length; +} + +/** Is a fork open? Read by the suite. */ +export function ingestGateOpen() { + return ingestHeld; +} + /** @param {any[]} args */ function enqueueIngest(args) { return new Promise((resolve, reject) => { @@ -636,14 +727,24 @@ function enqueueIngest(args) { async function drainIngest() { try { - while (ingestQueue.length) { + while (ingestQueue.length && !ingestHeld) { const started = performance.now(); while (ingestQueue.length && performance.now() - started < INGEST_SLICE_MS) { const job = ingestQueue.shift(); if (!job) break; + if (ingestCap <= 0) { + // over the budget the user agreed to: the object is DROPPED, and its + // uuid is counted as arrived so the progress bar does not wait out + // the full stall for something that is never coming + const uuid = job.args[1]; + noteLoadFailed(Array.isArray(uuid) ? uuid : []); + job.resolve(undefined); + continue; + } try { // @ts-ignore - spread of a fixed-length arg tuple job.resolve(await applyCreateObject(...job.args)); + if (Number.isFinite(ingestCap)) ingestCap--; } catch (error) { // a parse that rejects is still an ARRIVAL as far as the progress bar // is concerned, or the batch waits out the full 60s stall @@ -653,17 +754,21 @@ async function drainIngest() { job.reject(error); } } - if (ingestQueue.length) await new Promise((r) => setTimeout(r, 0)); + if (ingestQueue.length && !ingestHeld) await new Promise((r) => setTimeout(r, 0)); } } finally { ingestDraining = false; endSceneBatch(); + if (!ingestQueue.length) ingestCap = Infinity; } } /** A peer wiped the scene, or we did: whatever is still parked is about to be wrong. * (Roadmap 26 section 5 — "the ingest queue drops on clear".) */ export function dropIngestQueue() { + ingestHeld = false; + ingestCap = Infinity; + ingestGate.set(null); if (!ingestQueue.length) return 0; const dropped = ingestQueue.length; for (const job of ingestQueue) job.resolve(undefined); diff --git a/src/lib/sceneBudget.js b/src/lib/sceneBudget.js index fb873d49..faae7885 100644 --- a/src/lib/sceneBudget.js +++ b/src/lib/sceneBudget.js @@ -160,6 +160,42 @@ export function budgetRows(metrics, profile) { }); } +/** + * 26-C (roadmap 26 Stage 2) — SHOULD THIS MANY MORE OBJECTS BE LET IN? + * + * The one question the ingest gate and the file-open ask both need, and it is PURE, so + * it is answerable with no scene, no wire and no browser. + * + * `allowed` is how many of `incoming` fit before the scene crosses into red — the + * number the "load the first N" fork offers. It is measured against the AMBER ceiling + * because that is where red begins; offering to fill the scene exactly to the edge of + * red is the most that can be let in without asking again. + * + * @param {number} current objects already in the scene + * @param {number} incoming objects announced + * @param {'desktop'|'vr'} profile + * @returns {{tier: Tier, total: number, current: number, incoming: number, limit: number, allowed: number, gate: boolean}} + */ +export function ingestVerdict(current, incoming, profile) { + const now = Math.max(0, Number(current) || 0); + const more = Math.max(0, Number(incoming) || 0); + const total = now + more; + const budget = byKey.get('objects'); + const limit = budget ? (profile === 'vr' ? budget.vr[1] : budget.desktop[1]) : Infinity; + const tier = tierOf('objects', total, profile); + return { + tier, + total, + current: now, + incoming: more, + limit, + allowed: Math.max(0, Math.min(more, limit - now)), + // nothing to ask about when the arrival is empty, and nothing to ask about + // below red — amber warns, red asks (the tiers-with-actions rule) + gate: more > 0 && tier === 'red' + }; +} + // --- frame times ------------------------------------------------------------------ // A RING, not an average. p95 is the whole point: a scene that renders 58 of every 60 // frames in 8ms and two in 300ms reads as 60fps and feels broken. diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 5ca59a3c..4dfc1f4c 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -1426,8 +1426,71 @@ export async function requestLoadSession(id) { * @returns {Promise} true when the load APPLIED NOW, false when it became a * proposal (or there was nothing to load) */ + +/** Objects in a SERIALIZED payload, counting nested children — the same unit the + * budget is stated in (`objectsGroup` tree nodes), not the top-level array length. + * @param {any} payload */ +export function countPayloadObjects(payload) { + let n = 0; + /** @param {any} node */ + const walk = (node) => { + if (!node) return; + n++; + for (const kid of node.children ?? []) walk(kid); + }; + for (const element of payload?.objects ?? []) { + // a serialized element is `{object: {...}, geometries, materials}` (toJSON) or the + // bare node; both shapes appear in saved payloads + walk(element?.object ?? element); + } + return n; +} + +/** + * Ask when a file would take this device past its object budget. True = go ahead. + * @param {any} payload + */ +async function confirmSceneSize(payload) { + try { + const [{ ingestVerdict, profileFor }, { showChoice }] = await Promise.all([ + import('./sceneBudget'), + import('./confirmDialog') + ]); + const group = get(objectsGroup); + // the file REPLACES the scene, so the comparison is the file against the budget + // and not the file plus what is already here + const verdict = ingestVerdict(0, countPayloadObjects(payload), profileFor(get(globalRenderer))); + if (!verdict.gate) return true; + const answer = await showChoice({ + title: 'This scene is large', + message: + '"' + (payload?.name ?? 'This scene') + '" has ' + verdict.incoming + + ' objects — above the ' + verdict.limit + + ' recommended for this device. It may be slow, and on a phone or headset the tab can be closed by the browser.', + choices: [{ value: 'open', label: 'Open anyway' }], + cancelLabel: 'Cancel' + }); + return answer === 'open'; + } catch { + // the ask is a courtesy; never let it stop a load it could not evaluate + return true; + } +} + +/** @param {any} payload @returns {Promise} see the block comment above */ export async function requestLoadPayload(payload) { if (!payload) return false; + // 26-C (roadmap 26 Stage 2, last bullet): SAY HOW BIG IT IS BEFORE REPLACING THE + // SCENE. This is the file half of the ingest gate, and it sits HERE rather than in + // `applySession` on purpose: travel, a peer's proposal, an autosave restore and the + // rejoin path all go through applySession, and a replicated hop must never stop at a + // dialog nobody is standing at (the travel-node rule). This function is the one + // entry point a PERSON reaches by opening a file or pressing Load. + // + // TWO ways out, not the wire's three. "Load the first N objects of this file" makes + // a scene nobody saved, which the user would then re-save over their own file + // silently truncated — a stream is divisible, a document is not. + if (!(await confirmSceneSize(payload))) return false; /** @type {any} */ const peer = get(peers); let connected = Object.keys(peer?.connections ?? {}); diff --git a/tests/e2e/ingest-gate.test.cjs b/tests/e2e/ingest-gate.test.cjs new file mode 100644 index 00000000..693e9e6c --- /dev/null +++ b/tests/e2e/ingest-gate.test.cjs @@ -0,0 +1,196 @@ +// 26-C — Stage 2: the ingest gate (roadmap 26 section 4). +// +// A scene arriving over the wire announces itself FIRST (`{type:'loading', count, +// uuids}`) and only then sends the objects, so there is exactly one moment where the +// size is known and nothing has been applied yet. Past that moment a 4,000-object scene +// is simply happening to you — which is what the freeze reports describe. +// +// What is asserted, in the order it matters: +// 1. the verdict, which is pure and decides everything downstream; +// 2. an over-budget announcement PARKS the objects instead of applying them, and the +// progress bar does not quietly give up while the question is open; +// 3. each of the three answers does what it says — including "the first N", which is +// the only one with arithmetic in it; +// 4. a scene FILE asks too, with two ways out rather than three, and Cancel really +// leaves the scene alone. +const h = require('./helpers.cjs'); + +const objectCount = (page) => + page.evaluate(() => { + let n = 0; + const g = window.__stores.objectsGroup; + let group; + const s = g.subscribe((/** @type {any} */ v) => (group = v)); + s(); + group?.traverse?.((/** @type {any} */ o) => { if (o !== group) n++; }); + return n; + }); + +/** N object messages, exactly as the wire delivers them, without draining them. */ +const feed = (page, n, prefix) => + page.evaluate( + ({ n, prefix }) => { + const { THREE, commandsHandler } = window.__stores; + const geo = new THREE.BoxGeometry(1, 1, 1); + const mat = new THREE.MeshStandardMaterial(); + const uuids = []; + for (let i = 0; i < n; i++) { + const mesh = new THREE.Mesh(geo, mat); + mesh.name = prefix + i; + uuids.push(mesh.uuid); + commandsHandler.createObject({ element: mesh.toJSON() }, null); + } + return uuids; + }, + { n, prefix } + ); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- 1. the verdict ------------------------------------------------------ + const verdicts = await A.page.evaluate(() => { + const { ingestVerdict } = window.__stores.sceneBudget; + return { + // desktop objects: green <= 1000, amber <= 3000, red above + small: ingestVerdict(0, 50, 'desktop'), + amber: ingestVerdict(0, 2000, 'desktop'), + red: ingestVerdict(0, 4200, 'desktop'), + // the CURRENT scene counts: 2,900 here plus 500 more crosses it + topUp: ingestVerdict(2900, 500, 'desktop'), + // …and a headset crosses far sooner on the same numbers + vr: ingestVerdict(0, 2000, 'vr'), + empty: ingestVerdict(0, 0, 'desktop'), + alreadyOver: ingestVerdict(5000, 100, 'desktop'), + negative: ingestVerdict(-5, -5, 'desktop') + }; + }); + h.check(verdicts.small.gate === false && verdicts.amber.gate === false, 'green and AMBER do not ask — amber warns, red asks'); + h.check(verdicts.red.gate === true && verdicts.red.allowed === 3000, `red asks, and offers the first ${verdicts.red.allowed}`); + h.check( + verdicts.topUp.gate === true && verdicts.topUp.allowed === 100, + `what is ALREADY here counts: 2900 + 500 asks, and only ${verdicts.topUp.allowed} fit` + ); + h.check(verdicts.vr.gate === true, 'the same 2,000 objects ask on a headset and not on a desktop'); + h.check(verdicts.empty.gate === false, 'an empty arrival never asks'); + h.check(verdicts.alreadyOver.allowed === 0, 'a scene already past the budget offers zero, not a negative number'); + h.check(verdicts.negative.total === 0 && verdicts.negative.gate === false, 'nonsense input answers 0, never NaN'); + + // ---- 2. an over-budget arrival PARKS ------------------------------------ + const before = await objectCount(A.page); + const armed = await A.page.evaluate((before) => { + const { commandsHandler } = window.__stores; + // announce more than the desktop budget can take + commandsHandler.createLoader(4200, ['a', 'b', 'c'], 'peer-sending'); + return { open: commandsHandler.ingestGateOpen(), before }; + }, before); + h.check(armed.open, 'an over-budget announcement opens the gate'); + await feed(A.page, 30, 'parked-'); + await A.page.waitForTimeout(700); + const parked = await A.page.evaluate(() => ({ + backlog: window.__stores.commandsHandler.ingestBacklog(), + gate: (() => { let v; const s = window.__stores.commandsHandler.ingestGate.subscribe((/** @type {any} */ x) => (v = x)); s(); return v; })() + })); + h.check(parked.backlog >= 29, `the objects are PARKED, not applied (${parked.backlog} in the queue)`); + h.check((await objectCount(A.page)) === before, 'the scene is untouched while the question is open'); + h.check(parked.gate?.count === 4200 && parked.gate?.limit === 3000, `the card is told the real numbers (${parked.gate?.count} of ${parked.gate?.limit})`); + const card = await A.page.locator('.tp-toast', { hasText: 'This scene has 4200 objects' }); + h.check((await card.count()) > 0, 'the fork is on screen'); + h.check( + (await A.page.getByRole('button', { name: /Load the first/ }).count()) > 0, + '…offering "Load the first N" beside Load all and Cancel' + ); + + // ---- 3a. Cancel -------------------------------------------------------- + await A.page.getByRole('button', { name: 'Cancel', exact: true }).first().click(); + await A.page.waitForTimeout(500); + const cancelled = await A.page.evaluate(() => ({ + backlog: window.__stores.commandsHandler.ingestBacklog(), + open: window.__stores.commandsHandler.ingestGateOpen(), + loading: (() => { let v; const s = window.__stores.loading.subscribe((/** @type {any} */ x) => (v = x)); s(); return v.length; })() + })); + h.check(cancelled.backlog === 0 && !cancelled.open, 'Cancel drops the parked queue and closes the gate'); + h.check((await objectCount(A.page)) === before, '…and not one of them reached the scene'); + h.check(cancelled.loading === 0, '…and the progress bar is cleared rather than left stuck'); + + // ---- 3b. "Load the first N" -------------------------------------------- + const capBase = await objectCount(A.page); + await A.page.evaluate(() => window.__stores.commandsHandler.createLoader(4200, [], 'peer-sending')); + await feed(A.page, 40, 'capped-'); + await A.page.waitForTimeout(400); + // force a small allowance so the arithmetic is observable in a headless scene + await A.page.evaluate(() => { + window.__stores.commandsHandler.ingestGate.update((/** @type {any} */ g) => ({ ...g, allowed: 12 })); + }); + await A.page.waitForTimeout(200); + await A.page.getByRole('button', { name: /Load the first 12/ }).first().click(); + await h.eventually( + () => A.page.evaluate(() => window.__stores.commandsHandler.ingestBacklog()), + (n) => n === 0, + 'the queue drains after the answer' + ); + const capped = (await objectCount(A.page)) - capBase; + h.check(capped === 12, `exactly the allowance was applied and the rest dropped (${capped} of 40)`); + h.check( + (await A.page.evaluate(() => { let v; const s = window.__stores.loading.subscribe((/** @type {any} */ x) => (v = x)); s(); return v.length; })) === 0, + 'the dropped objects count as arrived, so the bar does not wait out the stall' + ); + + // ---- 3c. Load all ------------------------------------------------------- + await A.page.evaluate(() => { + const { objectsGroup, pokeScene } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + group.clear(); + pokeScene(); + }); + await A.page.waitForTimeout(400); + const allBase = await objectCount(A.page); + await A.page.evaluate(() => window.__stores.commandsHandler.createLoader(4200, [], 'peer-sending')); + await feed(A.page, 25, 'all-'); + await A.page.waitForTimeout(300); + await A.page.getByRole('button', { name: 'Load all', exact: true }).first().click(); + await h.eventually( + () => objectCount(A.page), + (n) => n - allBase === 25, + 'Load all applies every parked object' + ); + h.check(!(await A.page.evaluate(() => window.__stores.commandsHandler.ingestGateOpen())), 'and the gate closes behind it'); + + // ---- 4. a scene FILE asks too ------------------------------------------ + const payload = await A.page.evaluate(() => { + const { THREE, sessions } = window.__stores; + const objects = []; + for (let i = 0; i < 3500; i++) { + const m = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshBasicMaterial()); + m.name = 'file-' + i; + objects.push({ object: { uuid: m.uuid, name: m.name, type: 'Mesh', children: [] } }); + } + return { count: sessions.countPayloadObjects({ objects }), nested: sessions.countPayloadObjects({ objects: [{ object: { children: [{ children: [{}] }] } }] }) }; + }); + h.check(payload.count === 3500, `a payload's objects are counted (${payload.count})`); + h.check(payload.nested === 3, `…including nested children, the unit the budget is stated in (${payload.nested})`); + + const sceneBefore = await objectCount(A.page); + await A.page.evaluate(() => { + const objects = []; + for (let i = 0; i < 3500; i++) + objects.push({ object: { uuid: 'file-uuid-' + i, name: 'file-' + i, type: 'Mesh', children: [] } }); + // requestLoadPayload is what a file open and the Sessions manager's Load both + // reach; the travel node and a peer proposal deliberately do NOT + window.__tpLoad = window.__stores.sessions.requestLoadPayload({ name: 'Huge', objects }); + }); + await A.page.waitForSelector('dialog', { timeout: 8000 }); + const ask = await A.page.evaluate(() => document.querySelector('dialog')?.textContent ?? ''); + h.check(/3500 objects/.test(ask), `the ask names the count (${ask.slice(0, 90)})`); + h.check(/3000 recommended/.test(ask), '…against the budget for this device'); + h.check(!/first \d/.test(ask), 'a FILE gets two ways out, not three — half a document is not a scene'); + await A.page.getByRole('button', { name: /Cancel/i }).first().click(); + const answered = await A.page.evaluate(() => window.__tpLoad); + h.check(answered === false, 'Cancel refuses the load'); + await A.page.waitForTimeout(400); + h.check((await objectCount(A.page)) === sceneBefore, '…and the current scene is untouched — it was not cleared first'); + + h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`); + await h.finish(browser); +}); From d322e7abad26d462c02beebb0423cf2f5396b7f8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 11:47:51 +0300 Subject: [PATCH 4/5] [feat] 26-G: a simulation that cannot keep up stops once, and a frozen window pauses drawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 26 section 4, Stages 3 and 4. Stages 0-2 stop the app freezing on the way IN; this is what happens once a heavy scene is already here and the device cannot keep up. The roadmap's principle holds throughout: every stop is ONCE per streak, REVERSIBLE, and SAYS SO — an automatic action the user cannot see and cannot undo is just a different kind of broken. WHAT ALREADY EXISTED, AND WAS WIRED TO RATHER THAN REBUILT - 27-C put a try/catch around the physics step (audit M7). A step that THROWS already stops the run once. Nothing caught a step that is merely too SLOW. - 27-D shipped the per-node script budget (audit C1) — Stage 3's second bullet. Done. - 27-G shipped the context-loss half of Stage 4 (`ContextLostOverlay`, the canvas listeners, the recompile sweep). The new overlay stands DOWN whenever that one is up; two cards describing two different failures at once would be two cards arguing. WHAT - `src/lib/overloadGuard.js`, a LEAF (stores + sceneBudget). `createStreakWatch` is PURE: N bad samples IN A ROW, firing exactly once per streak. Consecutive, never cumulative — one 300ms hitch while a texture uploads is not a scene too heavy to run, and a trigger that fired on it would stop somebody's simulation because they imported a picture. - STAGE 3, PHYSICS: `step()` times `stepInner`; 30 consecutive steps over 24ms (a 24ms step on a 16.7ms frame makes every frame late before rendering starts) stop the run ONCE with a toast that names the reason and carries Resume, so the stop is never a dead end. The real step and the test hook share ONE stop path so they cannot drift. - STAGE 4, THE FREEZE: sceneBudget's frame loop (26-A) feeds a streak of 10 frames over 250ms — 2.5 seconds of a window that has stopped answering — through a new `registerFrameObserver` seam, so the budget module keeps knowing nothing about pausing. Three things are NOT a frozen scene and never trip it: a backgrounded tab (the browser throttles rAF to ~1Hz on purpose), the first frame after the tab returns (its delta spans the whole absence), and a 3s grace after Resume (the first composer frame recompiles). - THE PAUSE IS REAL: `Outline.svelte`'s render task — the one place a frame is drawn — returns early while `renderPaused` holds, so a device that cannot keep up does no GPU work at all. NEVER in a headset: the XR compositor needs frames, and a paused session is a frozen world strapped to the user's face with no overlay (DOM is invisible in VR). - THE OVERLAY (`RenderPausedOverlay.svelte`): Save now · Reduce · Resume, saying that nothing is lost and that autosave keeps running. - REDUCE sets the NEWEST top-level objects aside until what is still drawn fits the object budget — and does it with a render LAYER, NOT `visible = false`. That is the design's load-bearing decision: autosave exports through GLTFExporter with no options, and `onlyVisible` DEFAULTS TO TRUE, so a hidden object is silently DROPPED from the recovery snapshot. Reducing a scene would quietly delete its newest objects from the one copy meant to survive a crash, while the overlay promised autosave carries on. A layer is invisible to every serializer, never replicates, and is honoured by the camera cull and the raycaster alike: a reduced object is still in the scene, the save, the wire and the undo stack — only not drawn or picked HERE. Original masks live in a WeakMap, never on userData, so they cannot leak into a file. "Show them again" undoes. - THE RESTORE PROMPT names the snapshot's object count against this device's budget before restoring (Stage 4's last bullet) — a phone that died restoring a big scene comes back to exactly that prompt, and the count is the reason. It reads the same `ingestVerdict` the 26-C gate does. COUNTERFACTUALS (suite `overload-guard`, 34 checks, one page) - Measured IN THE SAME EXPORT: a layer-reduced object is in a default GLTFExporter output while a `visible = false` twin is dropped — the hazard, proven rather than asserted. - The Outline render gate disabled -> "no frame is drawn while paused" red (684 frames in 600ms instead of 0). - The hidden-tab guard disabled -> "thirty 1-second frames in a HIDDEN tab never pause" red: every tab switch would have paused the scene. Both restored; suite green again. GATES - svelte-check 352 errors / 47 warnings — at the floor this branch ratcheted to. - `npm run build` green with the dev server stopped. - Held green: scene-poke, scene-budget, ingest-gate, ai-flow-physics, flow-physics-collider, physics-ground-bounds. - The slow-step stop NEVER fired in any physics suite across two full runs (no "too slow" toast anywhere in the logs), so it cannot be behind their reds. Those are `physics-discoverability`, `flow-physics-nodes` and `physics-kinematic` — the standing pre-existing reds CLAUDE.md's 21-B entry already names as A/B'd against base — and suites that died at `h.connect` ("could not press Connect" / "could not approve"), i.e. signaling on a saturated box before any physics code ran. - The first battery run died once inside `overload-guard` with Playwright's "Resulting promise was garbage collected" after 3,200 real meshes on a swap-full box. The Reduce fixture now uses empty Groups (the budget counts tree NODES, so a Group counts exactly like a mesh) and the suite is green standalone. Co-Authored-By: Claude Opus 5 --- src/App.svelte | 11 +- src/components/Outline.svelte | 12 +- src/components/RenderPausedOverlay.svelte | 106 +++++++++ src/components/menu/Toasts.svelte | 13 ++ src/lib/overloadGuard.js | 218 ++++++++++++++++++ src/lib/physics.js | 35 +++ src/lib/sceneBudget.js | 26 ++- tests/e2e/overload-guard.test.cjs | 266 ++++++++++++++++++++++ 8 files changed, 682 insertions(+), 5 deletions(-) create mode 100644 src/components/RenderPausedOverlay.svelte create mode 100644 src/lib/overloadGuard.js create mode 100644 tests/e2e/overload-guard.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 9e2d505c..a52b9cec 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -41,6 +41,9 @@ // 27-G: the one overlay that must sit above everything, because nothing else on // screen is usable while the graphics context is gone. import ContextLostOverlay from './components/ContextLostOverlay.svelte' + // 26-G: the frame-freeze half of Stage 4 (the context-loss half is the overlay above) + import RenderPausedOverlay from './components/RenderPausedOverlay.svelte' + import './lib/overloadGuard' // 27-D: safe mode pauses the runtime BEFORE it is started, so a scene whose scripts // hang on load can still be opened and edited. import { flowPaused } from './stores/flowStore' @@ -450,9 +453,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/wireValidate'), import('./lib/wireErrors'), import('./lib/safeStorage'), - import('./lib/sceneBudget') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib } + import('./lib/sceneBudget'), + import('./lib/overloadGuard') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib } }) } }) @@ -512,6 +516,7 @@ import { startMusicToolbox } from './lib/musicToolbox' {/if} + diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index cf052ac0..2abcba88 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -36,7 +36,8 @@ OutlineEffect, RenderPass } from 'postprocessing'; - import { onMount, untrack } from 'svelte'; + import { onMount, onDestroy, untrack } from 'svelte'; + import { renderPaused } from '$lib/overloadGuard'; // 16-Q4: the camera preview window renders as an inset viewport of THIS renderer import { pipRect, pipTarget, glRect } from '$lib/cameraPip'; import { buildCamera } from '$lib/cameraObjects'; @@ -321,8 +322,17 @@ autoRender.set(before); }; }); + // 26-G (roadmap 26 Stage 4): THE ONE PLACE A FRAME IS DRAWN, so the one place a + // pause can be real — no GPU work at all while it holds, which is what a device that + // cannot keep up needs. Read through a subscription, never get() per frame. NEVER in + // a headset: the XR compositor needs frames, and a paused XR session shows the user a + // frozen world strapped to their face with no overlay (DOM is invisible in VR). + let renderIsPaused = false; + const stopPauseWatch = renderPaused.subscribe((value) => (renderIsPaused = !!value)); + onDestroy(stopPauseWatch); useTask( (delta) => { + if (renderIsPaused && !renderer.xr.isPresenting) return; // In WebXR the EffectComposer can't be used: its passes render to canvas-sized // targets, not the XR framebuffer, so blitting them mismatches sizes // (GL_INVALID_FRAMEBUFFER_OPERATION) and nothing reaches the headset (dark diff --git a/src/components/RenderPausedOverlay.svelte b/src/components/RenderPausedOverlay.svelte new file mode 100644 index 00000000..cbc55786 --- /dev/null +++ b/src/components/RenderPausedOverlay.svelte @@ -0,0 +1,106 @@ + + +{#if $renderPaused && !$contextLost} +
+
+

Rendering paused

+

+ The scene is too heavy for this device — the last frames each took longer than a + quarter of a second, so drawing has stopped to give the window back. + Nothing is lost, and autosave keeps running while this is open. +

+ {#if $reducedObjects} +

{$reducedObjects} object{$reducedObjects === 1 ? ' is' : 's are'} already set aside on this device.

+ {/if} +
+ + + +
+
+
+{/if} + + diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index ed57123f..0426788e 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -20,6 +20,7 @@ import { peers, loading, loadingcount, pendingApprovals, waitingForApproval, userdata, toastStore, fixLight, showSidebar, specatorMode, restorePanels, appNotice, connectDrawerOpen, connectDrawerTab, toastsInDrawerOnly, showInfoToast, dismissToastById } from '../../stores/appStore' import { restoreAvailable, restoreSnapshot, dismissRestore } from '$lib/autosave' import { ingestGate, resolveIngestGate } from '$lib/commandsHandler.svelte' + import { ingestVerdict, profileFor } from '$lib/sceneBudget' import { cancelOutboundRequest } from '$lib/peerApproval' // 27-B: the ONE sticky card for an uncaught error. This file already mirrors // state stores into sticky toasts (restoreAvailable below); diagnostics.js stays a @@ -261,6 +262,14 @@ $effect(() => { else dismissToastById('diagnostics-error'); }); +/** 26-G: the restore prompt's budget line reads the same verdict the ingest gate does. */ +function restoreLimit() { + return ingestVerdict(0, 1, profileFor(null)).limit; +} +function restoreOverBudget(objects: number) { + return ingestVerdict(0, Number(objects) || 0, profileFor(null)).gate; +} + // 26-C (roadmap 26 Stage 2): A SCENE BIGGER THAN THIS DEVICE'S BUDGET IS ARRIVING. // The objects are PARKED in the ingest queue, not applied, so this card is the only // thing between them and the scene — hence `noClose`: dismissing it with an X would @@ -289,6 +298,10 @@ $effect(() => { showInfoToast( 'restore-session', `Restore previous session? ${snap.objects} objects, saved ${new Date(snap.ts).toLocaleTimeString()}` + + // 26-G (roadmap 26 Stage 4, last bullet): say how the snapshot compares with + // this device's budget BEFORE restoring it. A phone that died restoring a + // 50MB scene comes back to this exact prompt, and the count is the reason. + (restoreOverBudget(snap.objects) ? ` — above the ${restoreLimit()} recommended for this device.` : '') + // 27-D: `risky` means the last attempt to restore THIS snapshot never // reached a clean flow tick. Auto-restore is already skipped for it; say // why, so pressing Restore again is a choice rather than a surprise. diff --git a/src/lib/overloadGuard.js b/src/lib/overloadGuard.js new file mode 100644 index 00000000..98d7dc50 --- /dev/null +++ b/src/lib/overloadGuard.js @@ -0,0 +1,218 @@ +import { writable, get } from 'svelte/store'; +import { objectsGroup, globalRenderer, pokeScene } from '../stores/sceneStore'; +import { BUDGETS, profileFor, registerFrameObserver } from './sceneBudget'; + +// 26-G (roadmap 26 section 4, Stages 3 and 4) — WHEN THE SCENE IS TOO HEAVY TO RUN. +// +// Stages 0-2 stop the app freezing on the way IN. This is what happens once a heavy +// scene is already here and the device cannot keep up: a simulation that takes longer +// to step than a frame lasts, or a render loop so slow the window stops answering. +// +// THE PRINCIPLE, the roadmap's: the main thread must never run an unbounded loop in +// response to input it did not schedule. Every stop here is ONCE per streak, REVERSIBLE, +// and SAYS SO — an automatic action the user cannot see and cannot undo is just a +// different kind of broken. +// +// The context-loss half of Stage 4 already shipped in 27-G (`ContextLostOverlay`, the +// canvas listeners, the recompile sweep). This does not rebuild it: a lost context is +// shown by that overlay, and the paused overlay stands down whenever it is up. +// +// A LEAF over svelte/store, the scene store and sceneBudget. physics.js imports the +// streak watch from here, which is why nothing here may reach the history family. + +/** + * A "N bad samples IN A ROW" detector that fires ONCE per streak. PURE given its inputs, + * so the rule is provable with no GPU and no physics world. + * + * Consecutive, not cumulative: one 300ms hitch while a texture uploads is not a scene + * that is too heavy, and a trigger that fired on it would stop somebody's simulation + * because they imported a picture. + * @param {{overMs: number, count: number}} opts + */ +export function createStreakWatch({ overMs, count }) { + let streak = 0; + let fired = false; + return { + /** @param {number} ms @returns {boolean} true exactly once, on the sample that completes a streak */ + note(ms) { + if (!(ms > overMs)) { + streak = 0; + fired = false; + return false; + } + streak++; + if (streak >= count && !fired) { + fired = true; + return true; + } + return false; + }, + reset() { + streak = 0; + fired = false; + }, + streak: () => streak + }; +} + +// --- Stage 3: the physics budget ------------------------------------------------- + +/** Step time past which a simulation is not keeping up: a 24ms step on a 16.7ms frame + * means every frame is late before rendering even starts. */ +export const PHYSICS_SLOW_MS = 24; +/** …for this many steps in a row (half a second at 60Hz). */ +export const PHYSICS_SLOW_STEPS = 30; + +// --- Stage 4: the render freeze --------------------------------------------------- + +/** A frame that takes this long is the window visibly not answering. */ +export const FREEZE_FRAME_MS = 250; +/** …for this many frames in a row, i.e. at least 2.5 seconds of a frozen tab. */ +export const FREEZE_FRAMES = 10; + +/** The pause, or null. `reason` says which trigger fired. LOCAL — a pause is about THIS + * device's GPU, so it never replicates. */ +/** @type {import('svelte/store').Writable<{reason: string, at: number} | null>} */ +export const renderPaused = writable(null); + +const freezeWatch = createStreakWatch({ overMs: FREEZE_FRAME_MS, count: FREEZE_FRAMES }); +/** After a resume the next few frames are expected to be slow (the first composer frame + * recompiles) — a grace window stops Resume from immediately re-pausing. */ +const RESUME_GRACE_MS = 3000; +let graceUntil = 0; +let wasHidden = false; + +/** + * Fed every frame by sceneBudget's loop. Three things are NOT a frozen scene and must + * never trip it: a backgrounded tab (the browser throttles rAF to ~1Hz on purpose), the + * first frame after the tab comes back (its delta spans the whole absence), and the + * seconds straight after a resume. + * @param {number} ms + */ +export function noteFrameForFreeze(ms) { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + wasHidden = true; + freezeWatch.reset(); + return false; + } + if (wasHidden) { + wasHidden = false; + freezeWatch.reset(); + return false; + } + if (get(renderPaused) || Date.now() < graceUntil) return false; + if (freezeWatch.note(ms)) { + pauseRendering('frozen'); + return true; + } + return false; +} + +/** @param {string} reason */ +export function pauseRendering(reason) { + if (get(renderPaused)) return; + renderPaused.set({ reason, at: Date.now() }); +} + +export function resumeRendering() { + renderPaused.set(null); + freezeWatch.reset(); + graceUntil = Date.now() + RESUME_GRACE_MS; +} + +// --- Reduce: take the scene down to the budget, LOCALLY ---------------------------- +// +// "Hiding the newest objects" — but NOT with `visible = false`. Autosave exports the +// scene through GLTFExporter with no options, and `onlyVisible` DEFAULTS TO TRUE, so a +// hidden object is silently DROPPED from the recovery snapshot. Reducing a scene would +// then quietly delete its newest objects from the one copy meant to survive a crash — +// and the overlay promises autosave keeps running. +// +// A render LAYER is invisible to every serializer (GLTFExporter never reads layers, +// toJSON writes the mask but nothing reads it back as visibility), never replicates, and +// is honoured by the camera's cull and the raycaster alike. So a reduced object is still +// in the scene, in the save, on the wire and in the undo stack — it is simply not drawn +// or picked HERE. The original masks live in a WeakMap, never on userData, so they cannot +// leak into a file. + +/** The layer reduced objects are moved to. 31 is the last of three's 32 layers and + * nothing in this app enables it on a camera. */ +export const REDUCED_LAYER = 31; + +/** @type {Map>} per reduced ROOT uuid, each node's mask */ +const reduced = new Map(); +export const reducedObjects = writable(0); + +/** @param {any} node */ +function countNodes(node) { + let n = 0; + node.traverse((/** @type {any} */ o) => { + n++; + }); + return n; +} + +/** + * Stop drawing the newest top-level objects until what is still drawn is inside the + * object budget for this device. Returns how many were set aside. + * @param {'desktop'|'vr'} [profile] + */ +export function reduceScene(profile) { + const group = get(objectsGroup); + if (!group) return 0; + const which = profile ?? profileFor(get(globalRenderer)); + const budget = BUDGETS.find((b) => b.key === 'objects'); + const limit = budget ? (which === 'vr' ? budget.vr[1] : budget.desktop[1]) : Infinity; + let drawn = 0; + for (const child of group.children) if (!reduced.has(child.uuid)) drawn += countNodes(child); + let setAside = 0; + // NEWEST FIRST: children are in append order, so the end of the list is what arrived + // last — most likely whatever tipped the scene over + for (let i = group.children.length - 1; i >= 0 && drawn > limit; i--) { + const root = group.children[i]; + if (reduced.has(root.uuid)) continue; + /** @type {WeakMap} */ + const masks = new WeakMap(); + root.traverse((/** @type {any} */ node) => { + masks.set(node, node.layers.mask); + node.layers.set(REDUCED_LAYER); + }); + reduced.set(root.uuid, masks); + drawn -= countNodes(root); + setAside++; + } + reducedObjects.set(reduced.size); + if (setAside) pokeScene(); + return setAside; +} + +/** Draw everything `reduceScene` set aside again, exactly as it was. */ +export function restoreReduced() { + const group = get(objectsGroup); + let restored = 0; + for (const [uuid, masks] of reduced) { + const root = group?.getObjectByProperty?.('uuid', uuid); + if (root) { + root.traverse((/** @type {any} */ node) => { + const mask = masks.get(node); + // a node added under a reduced root since (a child attached later) had no + // saved mask — give it the default layer rather than leaving it stranded + node.layers.mask = mask ?? 1; + }); + restored++; + } + } + reduced.clear(); + reducedObjects.set(0); + if (restored) pokeScene(); + return restored; +} + +/** Is this object set aside? For the suite and any list that wants to say so. @param {string} uuid */ +export function isReduced(uuid) { + return reduced.has(uuid); +} + +// The frame observer. Registered here, not imported by sceneBudget, so the budget +// module stays a leaf that knows nothing about pausing. +registerFrameObserver(noteFrameForFreeze); diff --git a/src/lib/physics.js b/src/lib/physics.js index 5df993db..cc27ca41 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -1,4 +1,6 @@ import * as THREE from 'three'; +// 26-G: the streak watch is a pure leaf (stores + sceneBudget) — no edge into history. +import { createStreakWatch, PHYSICS_SLOW_MS, PHYSICS_SLOW_STEPS } from './overloadGuard'; import { writable, get } from 'svelte/store'; import { flowGraphs, allNodes, allEdges, SCENE_GRAPH } from '../stores/flowStore'; import { objectsGroup, lockedObjects, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore'; @@ -1251,7 +1253,15 @@ const MAX_SUBSTEPS = 8; /** @param {number} now */ function step(now) { try { + const started = performance.now(); stepInner(now); + // 26-G (roadmap 26 Stage 3): A SIMULATION THAT CANNOT KEEP UP. 27-C catches a step + // that THROWS; nothing caught one that simply takes longer than the frame it runs + // in, which turns every frame late before rendering starts and reads as the app + // freezing. Streak-based and ONCE per streak (a single slow step while a big body + // is built is not a scene too heavy to simulate), and the toast carries Resume so + // the stop is never a dead end. + if (slowStepWatch.note(performance.now() - started)) stopForSlowSteps(); } catch (error) { console.warn('physics step failed, stopping the simulation', error); // stopSimulation clears the post-tick hook itself, so this cannot re-enter. @@ -1265,6 +1275,31 @@ function step(now) { } } +const slowStepWatch = createStreakWatch({ overMs: PHYSICS_SLOW_MS, count: PHYSICS_SLOW_STEPS }); + +/** ONE stop path for the slow-step streak, shared by the real step and the test hook so + * the two cannot drift apart. */ +function stopForSlowSteps() { + slowStepWatch.reset(); + stopSimulation({ reason: 'too slow' }); + showToast('Physics stopped — the simulation was too slow for this device (over ' + PHYSICS_SLOW_MS + 'ms a step). The scene is intact.', [ + { label: 'Resume', action: () => { void toggleSimulation(); } } + ]); +} + +/** TEST-ONLY: feed `n` step durations of `ms` through the SAME watch the real step uses, + * so the slow-step stop is provable without building a scene slow enough on the CI box. */ +export function noteSlowStepsForTest(/** @type {number} */ n, /** @type {number} */ ms) { + let fired = false; + for (let i = 0; i < n; i++) { + if (slowStepWatch.note(ms)) { + fired = true; + stopForSlowSteps(); + } + } + return fired; +} + /** TEST-ONLY: force the next step to throw, so the guard around it is provable. */ let throwOnNextStep = false; export function throwOnNextStepForTest() { diff --git a/src/lib/sceneBudget.js b/src/lib/sceneBudget.js index faae7885..cc446420 100644 --- a/src/lib/sceneBudget.js +++ b/src/lib/sceneBudget.js @@ -421,9 +421,33 @@ function sample() { sceneMetrics.set(metrics); } +/** @type {Set<(ms: number) => void>} */ +const frameObservers = new Set(); + +/** + * Hear every frame's duration. 26-G's freeze detector is the reader; it registers rather + * than being imported so this module keeps knowing nothing about pausing. An observer + * that throws is isolated — one bad observer must not end the sampler for everyone. + * @param {(ms: number) => void} fn @returns {() => void} unregister + */ +export function registerFrameObserver(fn) { + frameObservers.add(fn); + return () => frameObservers.delete(fn); +} + function loop() { const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); - if (lastFrameAt) noteFrame(now - lastFrameAt); + if (lastFrameAt) { + const ms = now - lastFrameAt; + noteFrame(ms); + for (const fn of frameObservers) { + try { + fn(ms); + } catch { + /* isolated — see registerFrameObserver */ + } + } + } lastFrameAt = now; if (now - lastSampleAt >= SAMPLE_MS) { lastSampleAt = now; diff --git a/tests/e2e/overload-guard.test.cjs b/tests/e2e/overload-guard.test.cjs new file mode 100644 index 00000000..e4c2d50d --- /dev/null +++ b/tests/e2e/overload-guard.test.cjs @@ -0,0 +1,266 @@ +// 26-G — Stages 3 and 4: the runtime auto-stops and the paused overlay +// (roadmap 26 section 4). +// +// Stages 0-2 stop the app freezing on the way IN. This is what happens once a heavy +// scene is already here and the device cannot keep up. +// +// What is asserted, in the order it matters: +// 1. the streak rule — consecutive, once per streak — because a single hitch must never +// stop anybody's simulation; +// 2. a simulation too slow to keep up is stopped ONCE, says so, and offers Resume; +// 3. a frozen render loop pauses drawing, but a backgrounded tab never does; +// 4. Reduce sets the newest objects aside ON THIS DEVICE ONLY, and — the hazard this +// design exists for — they STAY in the autosave export; +// 5. the restore prompt says how the snapshot compares with this device's budget. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- 1. the streak rule -------------------------------------------------- + const streak = await A.page.evaluate(() => { + const { createStreakWatch } = window.__stores.overloadGuard; + const w = createStreakWatch({ overMs: 24, count: 5 }); + const fires = []; + // four slow, one fast: the streak BREAKS and nothing fires + for (const ms of [30, 30, 30, 30, 10]) fires.push(w.note(ms)); + const brokenStreak = fires.every((f) => !f); + // five slow in a row fires exactly once, on the fifth + const run = [30, 30, 30, 30, 30, 30, 30].map((ms) => w.note(ms)); + const firedOnFifth = run[4] === true && run.filter(Boolean).length === 1; + // a sample exactly ON the threshold is not over it + const w2 = createStreakWatch({ overMs: 24, count: 2 }); + const onThreshold = [24, 24, 24].map((ms) => w2.note(ms)).some(Boolean); + // after a fast sample the watch re-arms and can fire again + w.note(10); + const again = [30, 30, 30, 30, 30].map((ms) => w.note(ms)).filter(Boolean).length === 1; + return { brokenStreak, firedOnFifth, onThreshold, again }; + }); + h.check(streak.brokenStreak, 'a streak broken by one fast sample fires nothing — a single hitch is not a heavy scene'); + h.check(streak.firedOnFifth, 'N slow samples in a row fire exactly once, and not again while the streak continues'); + h.check(!streak.onThreshold, 'a sample exactly on the threshold is not over it'); + h.check(streak.again, 'a fast sample re-arms the watch'); + + // ---- 2. physics: a simulation too slow to keep up ------------------------- + await A.page.evaluate(async () => { + const { commandsHandler } = window.__stores; + commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 400)); + }); + const started = await A.page.evaluate(async () => { + const { physics } = window.__stores; + // prewarm rapier (lazy wasm), then run + await physics.toggleSimulation(); + await new Promise((r) => setTimeout(r, 1500)); + let sim; const s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s(); + return sim; + }); + h.check(started === true, 'the simulation is running (premise)'); + await A.page.evaluate(() => window.__stores.toastStore.set([])); + const slow = await A.page.evaluate(async () => { + const { physics, overloadGuard } = window.__stores; + // one short of the streak must NOT stop it + const early = physics.noteSlowStepsForTest(overloadGuard.PHYSICS_SLOW_STEPS - 1, 40); + let sim; let s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s(); + const stillRunning = sim; + // a fast step breaks that streak, then a full one stops the run + physics.noteSlowStepsForTest(1, 5); + const fired = physics.noteSlowStepsForTest(overloadGuard.PHYSICS_SLOW_STEPS, 40); + s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s(); + return { early, stillRunning, fired, stopped: sim === false }; + }); + h.check(!slow.early && slow.stillRunning, 'one step short of the streak leaves the simulation running'); + h.check(slow.fired && slow.stopped, 'a full streak of slow steps STOPS the simulation'); + await h.eventually( + () => A.page.locator('.tp-toast', { hasText: 'too slow for this device' }).count(), + (n) => n > 0, + '…and says so, naming the reason' + ); + const resume = A.page.locator('.tp-toast', { hasText: 'too slow for this device' }).getByRole('button', { name: 'Resume' }); + h.check((await resume.count()) > 0, '…with a Resume button, so the stop is never a dead end'); + await resume.first().click(); + await h.eventually( + () => A.page.evaluate(() => { let v; const s = window.__stores.physics.simulating.subscribe((/** @type {any} */ x) => (v = x)); s(); return v; }), + (v) => v === true, + 'Resume starts the simulation again' + ); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + + // ---- 3. the render freeze ------------------------------------------------- + h.check((await A.page.locator('#render-paused').count()) === 0, 'the paused overlay starts hidden (premise)'); + const freeze = await A.page.evaluate(() => { + const g = window.__stores.overloadGuard; + g.resumeRendering(); + // resumeRendering starts a grace window; step past it for the test + const realNow = Date.now; + Date.now = () => realNow() + 10000; + const short = []; + for (let i = 0; i < g.FREEZE_FRAMES - 1; i++) short.push(g.noteFrameForFreeze(400)); + const notYet = !short.some(Boolean); + g.noteFrameForFreeze(16); // breaks it + let paused = false; + for (let i = 0; i < g.FREEZE_FRAMES; i++) paused = g.noteFrameForFreeze(400) || paused; + Date.now = realNow; + let state; const s = g.renderPaused.subscribe((/** @type {any} */ v) => (state = v)); s(); + return { notYet, paused, reason: state?.reason }; + }); + h.check(freeze.notYet, 'nine frozen frames do not pause — the rule is ten in a row'); + h.check(freeze.paused && freeze.reason === 'frozen', `ten frames over 250ms PAUSE drawing (reason: ${freeze.reason})`); + await A.page.waitForSelector('#render-paused', { timeout: 5000 }); + h.check(true, 'the "Rendering paused" overlay appears'); + const card = await A.page.locator('#render-paused').textContent(); + h.check(/Nothing is lost/.test(String(card)) && /autosave keeps running/.test(String(card)), 'it says nothing is lost and autosave carries on'); + + // the render loop really stops: renderer.info.render.frame stops advancing + const frozenFrames = await A.page.evaluate(async () => { + let renderer; const s = window.__stores.globalRenderer.subscribe((/** @type {any} */ r) => (renderer = r)); s(); + const a = renderer.info.render.frame; + await new Promise((r) => setTimeout(r, 600)); + return renderer.info.render.frame - a; + }); + h.check(frozenFrames === 0, `no frame is drawn while paused (${frozenFrames} frames in 600ms)`); + + await A.page.locator('#render-paused-resume').click(); + await A.page.waitForTimeout(700); + const liveFrames = await A.page.evaluate(async () => { + let renderer; const s = window.__stores.globalRenderer.subscribe((/** @type {any} */ r) => (renderer = r)); s(); + const a = renderer.info.render.frame; + await new Promise((r) => setTimeout(r, 600)); + return renderer.info.render.frame - a; + }); + h.check((await A.page.locator('#render-paused').count()) === 0, 'Resume closes the overlay'); + h.check(liveFrames > 5, `…and drawing starts again (${liveFrames} frames in 600ms)`); + + // a BACKGROUNDED tab throttles rAF to ~1Hz on purpose — that must never pause + const hidden = await A.page.evaluate(() => { + const g = window.__stores.overloadGuard; + g.resumeRendering(); + const realNow = Date.now; + Date.now = () => realNow() + 10000; + const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'visibilityState'); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + let paused = false; + for (let i = 0; i < 30; i++) paused = g.noteFrameForFreeze(1000) || paused; + // …and the FIRST frame back spans the whole absence + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); + const firstBack = g.noteFrameForFreeze(60000); + delete document.visibilityState; + if (desc) Object.defineProperty(Document.prototype, 'visibilityState', desc); + Date.now = realNow; + return { paused, firstBack }; + }); + h.check(!hidden.paused, 'thirty 1-second frames in a HIDDEN tab never pause — that is the browser throttling, not a heavy scene'); + h.check(!hidden.firstBack, 'the first frame after coming back is ignored — its delta is the whole absence'); + + // ---- 4. Reduce: newest set aside, LOCALLY, and still in the autosave ------ + const reduce = await A.page.evaluate(async () => { + const { THREE, objectsGroup, pokeScene, overloadGuard, autosave } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + group.clear(); + // 3,200 top-level objects: 200 past the desktop object budget (3,000). EMPTY GROUPS, + // not meshes: the budget counts tree NODES, so a Group counts exactly like a mesh, + // and it costs this memory-starved box no geometry and no GPU upload (a run with + // 3,200 real meshes died here with "Resulting promise was garbage collected"). + for (let i = 0; i < 3200; i++) { + const m = new THREE.Group(); + m.name = 'r' + i; + group.add(m); + } + pokeScene(); + await new Promise((r) => setTimeout(r, 300)); + const n = overloadGuard.reduceScene('desktop'); + const newest = group.children[group.children.length - 1]; + const oldest = group.children[0]; + // the camera draws layer 0; a reduced object is on the reduced layer + const cam = new THREE.PerspectiveCamera(); + return { + n, + newestReduced: overloadGuard.isReduced(newest.uuid) && !cam.layers.test(newest.layers), + oldestDrawn: !overloadGuard.isReduced(oldest.uuid) && cam.layers.test(oldest.layers), + stillVisibleFlag: newest.visible === true, + stillInScene: group.children.length, + hasExport: typeof autosave.exportScene === 'function' || typeof autosave.snapshotScene === 'function' + }; + }); + h.check(reduce.n === 200, `Reduce sets aside exactly the overflow, newest first (${reduce.n})`); + h.check(reduce.newestReduced, 'the NEWEST object is set aside and no longer drawn'); + h.check(reduce.oldestDrawn, 'the oldest is untouched'); + h.check(reduce.stillInScene === 3200, `nothing was removed from the scene (${reduce.stillInScene})`); + h.check( + reduce.stillVisibleFlag, + '`visible` is NOT touched — GLTFExporter drops invisible objects from autosave, and this must not' + ); + + // the hazard, proven: a GLTF export (autosave's serializer, no options) still carries + // a reduced object, where a `visible = false` hide would have dropped it + const exported = await A.page.evaluate(async () => { + const { THREE, GLTFExporterModule, objectsGroup, overloadGuard } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + const probe = new THREE.Group(); + const reducedOne = group.children[group.children.length - 1].clone(); + reducedOne.name = 'probe-reduced'; + reducedOne.layers.set(overloadGuard.REDUCED_LAYER); + const hiddenOne = group.children[0].clone(); + hiddenOne.name = 'probe-hidden'; + hiddenOne.visible = false; + probe.add(reducedOne, hiddenOne); + const json = await new Promise((resolve) => + new GLTFExporterModule.GLTFExporter().parse(probe, resolve, () => resolve(null)) + ); + const names = (json?.nodes ?? []).map((/** @type {any} */ n) => n.name); + return { reduced: names.includes('probe-reduced'), hidden: names.includes('probe-hidden') }; + }); + h.check(exported.reduced, 'a REDUCED object is still in a default GLTF export — autosave keeps it'); + h.check(!exported.hidden, '…while a `visible = false` one is dropped: the counterfactual, measured in the same export'); + + const restored = await A.page.evaluate(() => { + const { objectsGroup, overloadGuard } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + const n = overloadGuard.restoreReduced(); + const newest = group.children[group.children.length - 1]; + return { n, back: !overloadGuard.isReduced(newest.uuid) && newest.layers.mask === 1 }; + }); + h.check(restored.n === 200 && restored.back, `restoring puts every set-aside object back on its original layer (${restored.n})`); + + // the overlay's own Reduce button, end to end + await A.page.evaluate(() => { + const g = window.__stores.overloadGuard; + g.pauseRendering('frozen'); + window.__stores.toastStore.set([]); + }); + await A.page.waitForSelector('#render-paused-reduce', { timeout: 5000 }); + await A.page.locator('#render-paused-reduce').click(); + await A.page.waitForTimeout(400); + h.check((await A.page.locator('#render-paused').count()) === 0, 'Reduce resumes drawing'); + h.check( + (await A.page.locator('.tp-toast', { hasText: 'nothing was deleted' }).count()) > 0, + '…and says what it did, and that nothing was deleted' + ); + h.check( + (await A.page.locator('.tp-toast').getByRole('button', { name: 'Show them again' }).count()) > 0, + '…with a way to undo it' + ); + + // ---- 5. the restore prompt names the budget ------------------------------ + await A.page.evaluate(() => { + window.__stores.overloadGuard.restoreReduced(); + window.__stores.toastStore.set([]); + window.__stores.autosave.restoreAvailable.set({ objects: 4200, ts: Date.now() }); + }); + await h.eventually( + () => A.page.locator('.tp-toast', { hasText: 'Restore previous session?' }).textContent().catch(() => ''), + (t) => /4200 objects/.test(String(t)) && /above the 3000 recommended/.test(String(t)), + 'the restore prompt says the snapshot is above this device\'s budget' + ); + await A.page.evaluate(() => window.__stores.autosave.restoreAvailable.set({ objects: 40, ts: Date.now() })); + await h.eventually( + () => A.page.locator('.tp-toast', { hasText: 'Restore previous session?' }).textContent().catch(() => ''), + (t) => /40 objects/.test(String(t)) && !/recommended/.test(String(t)), + '…and says nothing about the budget for a small one' + ); + await A.page.evaluate(() => window.__stores.autosave.restoreAvailable.set(null)); + + h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`); + await h.finish(browser); +}); From ec9da2e904ff3c1b86b19e4b94cb460864d86eea Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 11:55:02 +0300 Subject: [PATCH 5/5] [fix] 26-G: a slow machine drawing a light scene is not an overloaded one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freeze trigger in d322e7a paused rendering on ANY ten consecutive frames over 250ms. A software-rendered page lives at ~2.5fps — 400ms frames, permanently — so the "Rendering paused" overlay appeared during ORDINARY non-GPU e2e suites and covered their clicks: `#render-paused intercepts pointer events` 23 times in one battery, turning physics-colliders red on a click timeout and adding one to physics-discoverability. The new suite could not see it, because it ran on a GPU page and drove the trigger directly — found by reading the held-suite logs. The same fault reaches a real user: a weak GPU drawing a scene of twelve boxes. Pausing that helps nothing. There is nothing heavy to set aside, Reduce would reduce nothing, and the person loses a window that was slow but still ANSWERING. WHAT - `sceneIsHeavy()`: objects, triangles or draw calls at amber or worse for this profile, read from sceneBudget's `sceneMetrics`. The streak only counts while it is true, so a light scene cannot build one at all. The scene-size axes only, never frame time — that would make the rule circular. An `unknown` reading (nothing sampled yet) is not heavy, so a freshly booted page can never be paused before the first sample. - This is also simply what the overlay already SAYS: "the scene is too heavy for this device". The trigger now agrees with its own copy. SUITE `overload-guard` 34 -> 36 checks - New guard: "forty 400ms frames on a LIGHT scene never pause" (+ its premise). - The freeze and hidden-tab checks now set a HEAVY reading first. The hidden-tab one would otherwise pass VACUOUSLY, since a light scene never pauses whatever the tab does. - The suite leaves a light scene behind after Reduce: 3,200 objects is heavy by definition, and on a saturated box the real frame loop is entitled to pause over it. COUNTERFACTUAL - `sceneIsHeavy` forced to always answer true -> "forty 400ms frames on a LIGHT scene never pause" and its premise go red. Restored; 36/36 green. REGRESSION CHECK (same battery that found it) - `render-paused intercepts pointer events`: 23 -> 0. - physics-colliders: red -> ALL PASS. - physics-discoverability: now runs to completion with no click timeout; its remaining FAILs are the standing pre-existing reds CLAUDE.md's 21-B entry names as A/B'd against base. Neither 26-G trigger fired anywhere in that run (0 "too slow" toasts, 0 pauses), so they cannot come from this lane. GATES: svelte-check 352/47 (the floor); `npm run build` green with the server stopped. Co-Authored-By: Claude Opus 5 --- src/lib/overloadGuard.js | 28 ++++++++++++++++++++++- tests/e2e/overload-guard.test.cjs | 37 ++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/lib/overloadGuard.js b/src/lib/overloadGuard.js index 98d7dc50..e226a3cf 100644 --- a/src/lib/overloadGuard.js +++ b/src/lib/overloadGuard.js @@ -1,6 +1,6 @@ import { writable, get } from 'svelte/store'; import { objectsGroup, globalRenderer, pokeScene } from '../stores/sceneStore'; -import { BUDGETS, profileFor, registerFrameObserver } from './sceneBudget'; +import { BUDGETS, profileFor, registerFrameObserver, sceneMetrics, tierOf } from './sceneBudget'; // 26-G (roadmap 26 section 4, Stages 3 and 4) — WHEN THE SCENE IS TOO HEAVY TO RUN. // @@ -101,6 +101,17 @@ export function noteFrameForFreeze(ms) { return false; } if (get(renderPaused) || Date.now() < graceUntil) return false; + // A SLOW MACHINE IS NOT AN OVERLOADED SCENE. A software-rendered page lives at + // ~2.5fps — 400ms frames, forever — and a real user on a weak GPU can too, drawing a + // scene of twelve boxes. Pausing that helps nothing: there is nothing heavy to set + // aside, Reduce would reduce nothing, and the person loses a window that was slow but + // ANSWERING. The first version of this trigger did exactly that and covered every + // non-GPU e2e suite with the overlay. So the streak only counts while the SCENE is + // heavy by its own measure; a light scene cannot build one at all. + if (!sceneIsHeavy()) { + freezeWatch.reset(); + return false; + } if (freezeWatch.note(ms)) { pauseRendering('frozen'); return true; @@ -108,6 +119,21 @@ export function noteFrameForFreeze(ms) { return false; } +/** The scene-size axes only — never frame time itself, which would make the rule + * circular. `unknown` (nothing sampled yet) is NOT heavy, so a freshly booted page can + * never be paused before the first reading. */ +const HEAVY_AXES = ['objects', 'triangles', 'calls']; + +/** Is the scene big enough that pausing it and setting part of it aside could help? */ +export function sceneIsHeavy() { + const metrics = get(sceneMetrics); + const profile = metrics?.profile === 'vr' ? 'vr' : 'desktop'; + return HEAVY_AXES.some((key) => { + const tier = tierOf(key, metrics?.[key], profile); + return tier === 'amber' || tier === 'red'; + }); +} + /** @param {string} reason */ export function pauseRendering(reason) { if (get(renderPaused)) return; diff --git a/tests/e2e/overload-guard.test.cjs b/tests/e2e/overload-guard.test.cjs index e4c2d50d..dd24d41f 100644 --- a/tests/e2e/overload-guard.test.cjs +++ b/tests/e2e/overload-guard.test.cjs @@ -89,8 +89,32 @@ h.run(async () => { // ---- 3. the render freeze ------------------------------------------------- h.check((await A.page.locator('#render-paused').count()) === 0, 'the paused overlay starts hidden (premise)'); + + // THE REGRESSION THIS RULE WAS REWRITTEN FOR: a SLOW MACHINE drawing a LIGHT scene. A + // software-rendered page lives at ~2.5fps — 400ms frames, forever — and the first + // version of this trigger paused it, covering every non-GPU e2e suite with the overlay + // ("#render-paused intercepts pointer events", 23 times in one battery). Pausing a + // light scene helps nothing: there is nothing heavy to set aside. + const light = await A.page.evaluate(() => { + const g = window.__stores.overloadGuard; + const b = window.__stores.sceneBudget; + g.resumeRendering(); + const realNow = Date.now; + Date.now = () => realNow() + 10000; + b.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 12, triangles: 144, calls: 12 }); + const heavy = g.sceneIsHeavy(); + let paused = false; + for (let i = 0; i < 40; i++) paused = g.noteFrameForFreeze(400) || paused; + Date.now = realNow; + return { heavy, paused }; + }); + h.check(!light.heavy, 'twelve boxes are not a heavy scene (premise)'); + h.check(!light.paused, 'forty 400ms frames on a LIGHT scene never pause — a slow machine is not an overloaded scene'); + const freeze = await A.page.evaluate(() => { const g = window.__stores.overloadGuard; + // a HEAVY reading: past the desktop object budget, so pausing could actually help + window.__stores.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 4200, triangles: 900000, calls: 2600 }); g.resumeRendering(); // resumeRendering starts a grace window; step past it for the test const realNow = Date.now; @@ -135,6 +159,8 @@ h.run(async () => { // a BACKGROUNDED tab throttles rAF to ~1Hz on purpose — that must never pause const hidden = await A.page.evaluate(() => { const g = window.__stores.overloadGuard; + // HEAVY, or this check passes vacuously — a light scene never pauses anyway + window.__stores.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 4200, triangles: 900000, calls: 2600 }); g.resumeRendering(); const realNow = Date.now; Date.now = () => realNow() + 10000; @@ -241,10 +267,19 @@ h.run(async () => { (await A.page.locator('.tp-toast').getByRole('button', { name: 'Show them again' }).count()) > 0, '…with a way to undo it' ); + // leave a LIGHT scene behind: 3,200 objects is heavy by definition, and the real frame + // loop would be entitled to pause over it on a saturated box while section 5 runs + await A.page.evaluate(() => { + const { objectsGroup, pokeScene, overloadGuard } = window.__stores; + overloadGuard.restoreReduced(); + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + group.clear(); + pokeScene(); + overloadGuard.resumeRendering(); + }); // ---- 5. the restore prompt names the budget ------------------------------ await A.page.evaluate(() => { - window.__stores.overloadGuard.restoreReduced(); window.__stores.toastStore.set([]); window.__stores.autosave.restoreAvailable.set({ objects: 4200, ts: Date.now() }); });