diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..f8e9394f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,107 @@
+# 27-I (hardening audit H5) — CI ON PULL REQUESTS.
+#
+# Until now the only workflow was release.yml, which runs on a v* TAG. So CONTRIBUTING's
+# rule ("npm run build should pass and npm run check should not add new errors") was
+# enforced by whoever remembered to run it, and a broken main was discovered at release
+# time. Everything below is what a maintainer already does by hand.
+#
+# WHY e2e-smoke IS NOT REQUIRED YET: it renders WebGL through SwiftShader on a GPU-less
+# runner, and this project's own notes measure ~4.5 fps there plus a documented family of
+# timing-sensitive suites. Blocking every PR on that before it has been observed green on
+# GitHub would train people to ignore a red tick, which is worse than not having it. It
+# reports, it does not gate, and the comment below says when to flip it.
+#
+# Two-peer suites stay a MANUAL gate: they meet on the self-hosted signaling box, which a
+# public runner cannot reach and should not be pointed at.
+name: ci
+on:
+ pull_request:
+ push:
+ branches: [main, release/next]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ - run: npm run build
+
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ # ONE source of truth for the baseline: check-baseline.json, read by the same
+ # script release.yml uses. The number used to live in a shell block here and went
+ # stale by three.
+ - name: svelte-check baseline gate
+ run: node scripts/check-ratchet.cjs
+ # 27-H (audit M4): 507 bare `localStorage` calls were routed through
+ # $lib/safeStorage in one pass. Without a gate that codemod decays on the next
+ # feature, because the file you are editing still shows you ninety-three examples
+ # of the old way. `setItem` throws in Safari private mode and on a full quota, and
+ # most of these sit inside $effects and store subscribers, where the throw kills
+ # the subscriber for the session.
+ - name: no bare localStorage
+ run: node scripts/check-storage.cjs
+
+ unit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ - run: npm run test:unit
+
+ e2e-smoke:
+ runs-on: ubuntu-latest
+ # NOT a gate yet — see the header. Flip this to false once it has been green on a few
+ # PRs in a row; until then a red here is a signal to read, not a block.
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ - run: npx playwright install --with-deps chromium
+ # WebXR and getUserMedia need HTTPS, so the dev server serves TLS from certs/ —
+ # gitignored, generated per clone.
+ - run: npm run certs
+ - name: start the dev server
+ run: |
+ npm run dev -- --port 5173 --strictPort --host localhost > /tmp/dev.log 2>&1 &
+ for i in $(seq 1 60); do
+ curl -sk -o /dev/null https://localhost:5173/ && break
+ sleep 1
+ done
+ curl -sk -o /dev/null -w 'dev server: %{http_code}\n' https://localhost:5173/
+ - name: single-peer suites
+ env:
+ APP_URL: https://localhost:5173/
+ run: |
+ # 27-D/27-E/27-G added three more SINGLE-PEER suites, so they belong here.
+ # Deliberately NOT added: net-stress and signaling-reconnect are multi-peer and
+ # meet on the self-hosted signaling box, which a public runner cannot reach and
+ # should not be pointed at (see the header) — they stay a manual gate.
+ for s in net-backoff net-mesh wire-hardening runtime-resilience diagnostics mesh-budget script-guard dispose approval-timeout; do
+ echo "::group::$s"
+ npm run e2e -- "$s" || echo "SUITE FAILED: $s"
+ echo "::endgroup::"
+ done
+ - name: dev server log on failure
+ if: failure()
+ run: tail -40 /tmp/dev.log
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6d0529a9..36cf3373 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,24 +30,12 @@ jobs:
# -> 384/47 with the controls/dock rework: warnings 62 -> 47 when the six
# hand-written pill cells became one {#each} template, errors -1 net from the
# applyLook fold in PointerLockControls)
+ # 27-I: ONE source of truth. The counts used to be hardcoded in this block and went
+ # stale (362 here while the tree measured 359); check-baseline.json is the floor now
+ # and ci.yml's gate runs the very same script.
- name: svelte-check baseline gate
- run: |
- npm run check 2>&1 | tee check.log || true
- # svelte-check prints "N ERRORS N WARNINGS" (machine) or
- # "found N errors and N warnings" (human) depending on the TTY — parse both
- LINE=$(grep -Eo '[0-9]+ ERRORS [0-9]+ WARNINGS' check.log | tail -1)
- ERRORS=$(echo "$LINE" | awk '{print $1}')
- WARNINGS=$(echo "$LINE" | awk '{print $3}')
- if [ -z "$ERRORS" ]; then
- LINE=$(grep -Eo 'found [0-9]+ errors and [0-9]+ warnings' check.log | tail -1)
- ERRORS=$(echo "$LINE" | awk '{print $2}')
- WARNINGS=$(echo "$LINE" | awk '{print $5}')
- fi
- echo "svelte-check: $ERRORS errors / $WARNINGS warnings (baseline 362/47)"
- if [ -z "$ERRORS" ]; then echo "could not parse svelte-check output"; exit 1; fi
- if [ "$ERRORS" -gt 362 ] || [ "$WARNINGS" -gt 47 ]; then
- echo "baseline exceeded"; exit 1
- fi
+ run: node scripts/check-ratchet.cjs
+
- name: zip the build
run: cd build && zip -r "../theprototype-${GITHUB_REF_NAME}.zip" .
- uses: softprops/action-gh-release@v2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59accb45..73225935 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,105 @@
per release, newest first. HTML comments like this one are stripped before
rendering, so maintainer notes stay out of the user-facing window. -->
+## 1.12.0 — Hold together 🛡️
+
+### 🛡️ Connections that recover, and sessions with a size (roadmap #27 + #25)
+
+- ⏱️ **A connection request now ends.** The pill counts down while you wait and the
+ host's card shows how long someone has been waiting. After 90 seconds the request
+ cancels itself and offers **Try again**, instead of sitting on *Requesting* for
+ ever. Dialling someone who is not online ends the request too, rather than leaving
+ it up beside a toast saying they are unreachable.
+- 👥 **A session has a size.** Settings ▸ Connection ▸ **Session size** says how many
+ people you expect. Past it an approval still works but warns you, and at 16 the
+ approve buttons say the session is full — everyone connects to everyone, so one
+ more person costs every other person bandwidth. Waiting requests are capped, and
+ expired cards are dropped before live ones.
+- 🔌 **The signaling link stops giving up.** Reconnection retries with a jittered
+ backoff and no attempt limit, a closed peer is rebuilt rather than abandoned, and
+ coming back online or returning to the tab retries immediately. The Connect pill
+ shows a chip while it is retrying, so a dead link no longer looks like a dead app.
+- 🧱 **One bad message can no longer kill a connection.** Everything arriving from a
+ peer is shape-checked before it reaches the code that applies it, and anything
+ malformed is counted and dropped instead of throwing. A peer sending repeated
+ rubbish is reported once, not once per message.
+- 🔁 **The editor survives a bad frame.** A throw inside the flow runtime or the
+ physics step no longer ends the session: the frame is skipped, the failure is rate
+ limited so one broken node cannot flood you, and the runtime can be resumed.
+- 🩺 **Diagnostics you can copy.** Settings ▸ About ▸ **Copy diagnostics** puts a
+ bundle on the clipboard — recent log entries, the last uncaught error and session
+ details — so a problem can be reported with something in it.
+- 🔁 **A runaway script no longer takes the room with it.** Script nodes run on every
+ peer, every frame, so a `while (true)` in one node used to freeze everybody's tab,
+ not just its author's. Every loop a script contains is now counted, and one that
+ runs away stops with a *Script loop limit* badge on the node while the scene keeps
+ running. A node that is merely slow — rather than infinite — is paused after it has
+ spent too long in too many frames in a row, and editing its code starts it again.
+- 🧯 **Safe mode.** Adding `#safe` to the app's address opens a scene with the flow
+ runtime paused, so a scene whose scripts misbehave on load can still be opened,
+ repaired and resumed. A restore that never completed a frame is also remembered: the
+ next start offers the prompt with a warning instead of silently loading it again.
+- 🧹 **Deleting gives the memory back.** Removing an object used to drop it from the
+ scene and leave its geometry, materials and textures sitting on the graphics card
+ until the page was closed, so a session that imported and deleted the same model ten
+ times paid for ten copies. Deleting, clearing a scene and replacing an object now free
+ what only that object was using — and never what something else still draws with,
+ which matters because duplicates, clones and a material shared across a selection all
+ point at the same resources.
+- 🖥️ **A lost graphics context now says so.** When the browser takes the 3D context
+ away — a driver update, a graphics reset, a phone under memory pressure — the viewport
+ used to freeze silently while the rest of the app carried on answering, which reads as
+ the whole thing having crashed. You get a panel explaining what happened, a button to
+ save the scene (which is still intact, because it lives in the page rather than on the
+ graphics card), and the view restores itself when the browser hands the context back.
+
+### 💾 Storage that keeps your work (roadmap #27, wave 2)
+
+- 💾 **Saving can no longer hang forever.** A database operation that is aborted or
+ stops answering now fails and says so, instead of leaving the app waiting on it.
+- 🪶 **Autosave stopped stuttering on big scenes.** It measures what saving actually
+ costs and spaces itself out accordingly, it can no longer start a second save on top
+ of the one already running, and when it cannot save at all it tells you rather than
+ going quiet.
+- 🕶️ **Settings keep working in Safari private mode and on a full disk.** Every
+ preference now goes through one place that falls back to memory for the keys it
+ cannot write, so a browser that refuses storage costs you the setting, not the app.
+- 🎙️ **The microphone is given back.** Turning voice off, leaving voice mode or leaving
+ a session now releases the device, so your operating system stops showing the
+ recording indicator. Push-to-talk holds the device for a few seconds between presses
+ on purpose — reacquiring it costs a renegotiation with every peer.
+
+### 🚦 A scene that will not freeze you out (roadmap #26)
+
+- 🚦 **A big scene stays responsive while it arrives.** Objects are created in slices
+ rather than all at once, the viewport is refreshed once a frame instead of once per
+ object, and an object list of thousands of rows draws only the rows you can see.
+- 📊 **A Statistics panel, and a budget you can see.** The burger menu ▸ **Statistics**
+ opens frame timings, draw calls, triangles, memory and per-message network counters.
+ The object count in the status line carries a coloured dot that names whatever is over
+ budget, and the numbers ride along in a diagnostics bundle.
+- 🛑 **An oversized scene asks before it arrives.** A scene big enough to hurt is held at
+ the door with **Load all / Load the first N / Cancel** instead of arriving and
+ wedging the tab, and opening an oversized file warns you first.
+- ⏸️ **A window that cannot keep up pauses instead of freezing.** A simulation that
+ falls too far behind stops once, with a Resume button, and a viewport that has stopped
+ drawing offers **Save now**, **Reduce** and **Resume** rather than appearing to have
+ crashed.
+
+### ⏱️ One clock, and a joiner that is told (roadmap #25 + #26, wave 3)
+
+- ⏱️ **Everyone in a session now shares one clock.** Peers used to stamp events with
+ their own machine's time, so anything comparing ages or ordering across peers was
+ wrong by the difference between two computers. The session now runs on the host's
+ clock, and a machine whose clock is minutes out is corrected on its first exchange.
+- 🚪 **A refused join is told it was refused.** Being declined, and arriving at a session
+ that is already full, now say so plainly instead of leaving you watching a request
+ that never resolves.
+- 🎚️ **A heavy scene gives up shadows before it gives up frames.** When a scene is too
+ much for the machine, quality is reduced automatically and reversibly — starting with
+ the passes that cost the most and show the least — so the room keeps moving. The
+ budgets it steers by are measured on real scenes rather than estimated.
+
## 1.11.0 — Muscle memory ⌨️
### ⌨️ Input parity (roadmap #24, batch A)
@@ -359,7 +458,6 @@ select more than one thing — including on a phone.
- 🎨 Modules can add their own post-processing effects and shader compilers.
- 🧹 Settings descriptions read as sentences again instead of one word per line.
-
## 1.5.0 — Move it, properly 🎬
Animation you can trust: movements play from where the object actually is, pause
diff --git a/RELEASING.md b/RELEASING.md
index 9b55f557..751e89cc 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -16,13 +16,31 @@ git push origin main --follow-tags
After that: `npm version minor` (features) or `npm version patch` (fixes), then the
same push. The tag triggers `.github/workflows/release.yml`, which builds, gates on
-the svelte-check baseline (the error/warning counts live in the workflow — update
-them when the baseline moves), zips `build/`, and publishes a GitHub Release with
-generated notes.
+the svelte-check baseline (27-I moved the error/warning counts OUT of the workflow
+and into `check-baseline.json` at the repo root, read only by
+`scripts/check-ratchet.cjs`, which `release.yml` and `ci.yml` both call — ratchet it
+DOWN with `node scripts/check-ratchet.cjs --update` whenever a change legitimately
+removes errors, and never hardcode the number anywhere again), zips `build/`, and
+publishes a GitHub Release with generated notes.
MAJOR = a breaking file-format or wire-protocol change (`SESSION_FORMAT` /
`MODULE_FORMAT` bumps, incompatible peer messages).
+## Recreate `release/next` after the tag push
+
+The repo has `delete_branch_on_merge: true`, so merging the release PR DELETES
+`release/next` on origin — and GitHub then silently retargets every open PR that was
+based on it to `main`, which would let an ungated batch land straight on main. This
+bit the 1.11.0 release (PR #206 was retargeted). After pushing the tag:
+
+```sh
+git push origin main:release/next # recreate it at the released commit
+gh pr list --base main # anything retargeted goes back to release/next
+```
+
+Recreating it from main also keeps `release/next:package.json` in step with the
+released version instead of drifting (it carried a stale 1.8.0 before 1.11.0).
+
## After tagging
- Update `CHANGELOG.md` (the in-app What's new window renders it).
diff --git a/check-baseline.json b/check-baseline.json
new file mode 100644
index 00000000..f39d4b38
--- /dev/null
+++ b/check-baseline.json
@@ -0,0 +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": 341,
+ "warnings": 47,
+ "measured": "2026-09-17"
+}
diff --git a/package-lock.json b/package-lock.json
index b677c544..8fe9c92e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,7 +45,8 @@
"svelte-check": "^4.7.6",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
- "vite": "^8.2.2"
+ "vite": "^8.2.2",
+ "vitest": "^5.0.0"
},
"engines": {
"node": ">=24"
@@ -211,9 +212,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -1240,6 +1241,17 @@
"@types/node": "*"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -1306,6 +1318,13 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1462,6 +1481,64 @@
"@types/node": "*"
}
},
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
+ "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.0",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz",
+ "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
+ "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@xyflow/svelte": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.6.5.tgz",
@@ -1577,6 +1654,16 @@
"node": ">=8"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -1694,6 +1781,16 @@
"three": ">=0.126.1"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -2145,6 +2242,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -2231,6 +2335,16 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -3663,9 +3777,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4264,6 +4378,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -4299,6 +4420,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4309,6 +4437,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -4560,6 +4695,26 @@
"three": ">=0.162.0 <1.0.0"
}
},
+ "node_modules/tinybench": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
+ "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -5123,6 +5278,99 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
+ "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.0",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "6.1.4",
+ "tinyexec": "1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.0",
+ "@vitest/browser-preview": "5.0.0",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.0",
+ "@vitest/coverage-v8": "5.0.0",
+ "@vitest/ui": "5.0.0",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz",
+ "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -5158,6 +5406,23 @@
"npm": ">=3.10.0"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/package.json b/package.json
index 3557a294..91c76024 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,9 @@
"lint": "prettier --check .",
"e2e": "node tests/e2e/run.cjs",
"deps:check": "node scripts/deps-check.cjs",
- "sync-llms": "node scripts/sync-llms.cjs"
+ "sync-llms": "node scripts/sync-llms.cjs",
+ "test:unit": "vitest run",
+ "check:storage": "node scripts/check-storage.cjs"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
@@ -38,7 +40,8 @@
"svelte-check": "^4.7.6",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
- "vite": "^8.2.2"
+ "vite": "^8.2.2",
+ "vitest": "^5.0.0"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.5",
diff --git a/scripts/check-ratchet.cjs b/scripts/check-ratchet.cjs
new file mode 100644
index 00000000..c640b373
--- /dev/null
+++ b/scripts/check-ratchet.cjs
@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+// 27-I (hardening audit H5) — THE SVELTE-CHECK BASELINE, IN ONE PLACE.
+//
+// The counts were hardcoded in a shell block inside release.yml, which meant the number
+// lived in a file nobody edits while the baseline moved with almost every batch: the
+// comment there records 435 -> 421 -> 419 -> 417 -> 391 -> 388 -> 387 -> 386 -> 385, and
+// the gate said 362 while this worktree measures 359. A gate whose number is stale is a
+// gate that either blocks honest work or waves through a regression.
+//
+// So the baseline is DATA (check-baseline.json), this script is the only reader, and both
+// workflows call it. Ratcheting DOWN is the project's own convention when a change
+// legitimately removes errors, and `--update` writes the new floor so that is one command
+// rather than an edit in two places.
+//
+// node scripts/check-ratchet.cjs # run npm run check, compare, exit 1 if worse
+// node scripts/check-ratchet.cjs --update # …and rewrite the baseline when it improves
+// node scripts/check-ratchet.cjs --file x # compare an existing log instead of running
+//
+// It parses BOTH output shapes on purpose: svelte-check prints "N ERRORS N WARNINGS" to a
+// pipe and "found N errors and N warnings" to a TTY, and CI has been bitten by that
+// before — the release workflow already carries both branches.
+
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const ROOT = path.resolve(__dirname, '..');
+const BASELINE = path.join(ROOT, 'check-baseline.json');
+
+/** @param {string} text @returns {{errors: number, warnings: number} | null} */
+function parseCounts(text) {
+ const machine = [...text.matchAll(/(\d+)\s+ERRORS\s+(\d+)\s+WARNINGS/g)].pop();
+ if (machine) return { errors: Number(machine[1]), warnings: Number(machine[2]) };
+ const human = [...text.matchAll(/found (\d+) errors? and (\d+) warnings?/g)].pop();
+ if (human) return { errors: Number(human[1]), warnings: Number(human[2]) };
+ return null;
+}
+
+function readBaseline() {
+ try {
+ const raw = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
+ if (typeof raw.errors !== 'number' || typeof raw.warnings !== 'number') throw new Error('shape');
+ return raw;
+ } catch (error) {
+ console.error('check-ratchet: cannot read ' + BASELINE + ' (' + error.message + ')');
+ process.exit(2);
+ }
+}
+
+function main() {
+ const args = process.argv.slice(2);
+ const update = args.includes('--update');
+ const fileAt = args.indexOf('--file');
+ const baseline = readBaseline();
+
+ let output;
+ if (fileAt >= 0 && args[fileAt + 1]) {
+ output = fs.readFileSync(args[fileAt + 1], 'utf8');
+ } else {
+ try {
+ output = execSync('npm run check', { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
+ } catch (error) {
+ // svelte-check exits non-zero AT the legacy baseline, which is the normal case
+ // here — the counts on stdout are what decides, never the exit code.
+ output = (error.stdout || '') + (error.stderr || '');
+ }
+ }
+
+ const counts = parseCounts(output);
+ if (!counts) {
+ console.error('check-ratchet: could not parse svelte-check output');
+ console.error(output.split('\n').slice(-5).join('\n'));
+ process.exit(1);
+ }
+
+ const worseErrors = counts.errors > baseline.errors;
+ const worseWarnings = counts.warnings > baseline.warnings;
+ const better = counts.errors < baseline.errors || counts.warnings < baseline.warnings;
+ console.log(
+ 'svelte-check: ' + counts.errors + ' errors / ' + counts.warnings + ' warnings' +
+ ' (baseline ' + baseline.errors + '/' + baseline.warnings + ')'
+ );
+
+ if (worseErrors || worseWarnings) {
+ console.error(
+ 'BASELINE EXCEEDED by ' + Math.max(0, counts.errors - baseline.errors) + ' error(s) and ' +
+ Math.max(0, counts.warnings - baseline.warnings) + ' warning(s).'
+ );
+ console.error('Fix them, or if they are genuinely pre-existing, say so in the PR and move the baseline.');
+ process.exit(1);
+ }
+
+ if (better) {
+ if (update) {
+ fs.writeFileSync(
+ BASELINE,
+ JSON.stringify({ ...baseline, errors: counts.errors, warnings: counts.warnings, measured: new Date().toISOString().slice(0, 10) }, null, '\t') + '\n'
+ );
+ console.log('ratcheted the baseline down to ' + counts.errors + '/' + counts.warnings);
+ } else {
+ console.log('IMPROVED — run with --update to ratchet the baseline down (the project convention).');
+ }
+ }
+ process.exit(0);
+}
+
+main();
diff --git a/scripts/check-storage.cjs b/scripts/check-storage.cjs
new file mode 100644
index 00000000..37ad90a8
--- /dev/null
+++ b/scripts/check-storage.cjs
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+// 27-H (hardening audit M4) — THE GUARD THAT KEEPS THE CODEMOD FROM DECAYING.
+//
+// 507 bare `localStorage` calls across 94 files were routed through `$lib/safeStorage` in
+// one pass. Without something failing on the next bare one, that lasts exactly until the
+// next feature: nobody grepping for "how do I persist a setting" finds the wrapper, they
+// find ninety-three examples of `localStorage.setItem` in the file they are editing.
+//
+// This is the whole rule. `localStorage.setItem` THROWS in Safari private mode and on a
+// full quota, and most of these sit inside `$effect`s and store subscribers, where a
+// throw kills the subscriber for the session — the setting stops persisting AND the UI it
+// drives stops updating, with nothing pointing at storage.
+//
+// Exits 1 on a violation; prints the file, the line and what to write instead. Wired into
+// ci.yml's `check` job beside the svelte-check ratchet.
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..');
+const SRC = path.join(ROOT, 'src');
+
+// `localStorage.x(` for the four methods, and the one enumeration form the codebase used
+const CALL = /\blocalStorage\.(getItem|setItem|removeItem|clear)\s*\(/;
+const KEYS = /\bObject\.keys\(\s*localStorage\s*\)/;
+
+/**
+ * The only files allowed to touch it directly, each for a stated reason. Adding to this
+ * list is a decision, which is the point of it being a list.
+ */
+const ALLOWED = new Map([
+ ['src/lib/safeStorage.js', 'it IS the wrapper'],
+ [
+ 'src/app.html',
+ 'an inline
+
+{#if $contextLost}
+
+
+
The 3D view has stopped
+
+ The browser took back this page's graphics context. That is usually temporary, and
+ it often comes back by itself. Your scene is still here — it lives
+ in the page, not on the graphics card — so you can save it right now.
+
+
+
+
+
+
+
+{/if}
+
+
diff --git a/src/components/ContextMenu.svelte b/src/components/ContextMenu.svelte
index 746ca6c7..7b64f219 100644
--- a/src/components/ContextMenu.svelte
+++ b/src/components/ContextMenu.svelte
@@ -4,6 +4,7 @@
import Icon from './ui/Icon.svelte';
import { collectLeaves, rankMatches } from '$lib/menuFilter';
import { autofocusOk, typeToFocus } from '$lib/inputDevice';
+ import { safeStorage } from '$lib/safeStorage';
// Generic context menu. items: [{ label, action?, disabled?, tooltip?, danger?,
// icon?, hint?, checked?, keepOpen?, rowActions?, children?: items[] } |
@@ -116,12 +117,12 @@
/** @param {number} value */
function rememberHeight(value: number) {
try {
- localStorage.setItem(heightStore(), String(Math.round(value)));
+ safeStorage.setItem(heightStore(), String(Math.round(value)));
} catch {}
}
function storedHeight(): number | null {
try {
- const raw = parseInt(localStorage.getItem(heightStore()) ?? "", 10);
+ const raw = parseInt(safeStorage.getItem(heightStore()) ?? "", 10);
return Number.isFinite(raw) && raw >= MIN_LIST_HEIGHT ? raw : null;
} catch {
return null;
diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte
index 5dbd8e05..e8ffbf83 100644
--- a/src/components/Flow.svelte
+++ b/src/components/Flow.svelte
@@ -20,6 +20,7 @@
import { bottomDockable } from '$lib/bottomDockDrop';
import { dockAddItems } from '$lib/dockMenu';
import { fly } from 'svelte/transition';
+ import { safeStorage } from '$lib/safeStorage';
const clampH = (h: number) => Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8));
// 18-B: floating-window size limits, shared with the clamp helpers
@@ -29,7 +30,7 @@
// mirrors Nodes' palette-open (bound below) so the docked content only insets above
// the Controls HUD when the node palette is actually shown (overlapping the HUD)
let paletteOpen = $state(
- typeof localStorage !== 'undefined' ? localStorage.getItem('flowPaletteOpen') !== 'false' : true
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('flowPaletteOpen') !== 'false' : true
);
let winW = $state(760);
let winH = $state(480);
@@ -42,9 +43,9 @@
winH = Math.min(fit.h, Math.round(window.innerHeight * 0.9));
}
if (typeof localStorage !== 'undefined') {
- docked = localStorage.getItem('flowDocked') !== 'false';
- winW = parseInt(localStorage.getItem('flowWinW') ?? '760') || 760;
- winH = parseInt(localStorage.getItem('flowWinH') ?? '480') || 480;
+ docked = safeStorage.getItem('flowDocked') !== 'false';
+ winW = parseInt(safeStorage.getItem('flowWinW') ?? '760') || 760;
+ winH = parseInt(safeStorage.getItem('flowWinH') ?? '480') || 480;
clampWin();
}
// touch / limited-width: keep the editor docked (no room to float; undock hidden),
@@ -64,7 +65,7 @@
function setDocked(v: boolean) {
docked = v;
- localStorage.setItem('flowDocked', String(v));
+ safeStorage.setItem('flowDocked', String(v));
if (v) activateDock('flow'); // re-docking makes it the visible tab
else forgetDockTab('flow'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip
}
@@ -151,15 +152,15 @@
winW = fit.w;
winH = fit.h;
resizeGroup('flow', winW, winH);
- localStorage.setItem('flowWinW', String(winW));
- localStorage.setItem('flowWinH', String(winH));
+ safeStorage.setItem('flowWinW', String(winW));
+ safeStorage.setItem('flowWinH', String(winH));
}
function endWinResize(e: any) {
if (!winResizing) return;
winResizing = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
- localStorage.setItem('flowWinW', String(winW));
- localStorage.setItem('flowWinH', String(winH));
+ safeStorage.setItem('flowWinW', String(winW));
+ safeStorage.setItem('flowWinH', String(winH));
}
diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte
index 8f305979..d60fda87 100644
--- a/src/components/Outline.svelte
+++ b/src/components/Outline.svelte
@@ -36,15 +36,18 @@
OutlineEffect,
RenderPass
} from 'postprocessing';
- import { onMount, untrack } from 'svelte';
+ import { onMount, onDestroy, untrack } from 'svelte';
+ import { renderPaused } from '$lib/overloadGuard';
+ import { qualityOverrides, ingestDrawGap } from '$lib/qualityGovernor';
// 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';
+ import { safeStorage } from '$lib/safeStorage';
let outlineEffectSelected: OutlineEffect | null = null;
let outlineEffectLocked: OutlineEffect | null = null;
- const { scene, renderer, camera, size, autoRender, renderStage } = useThrelte();
+ const { scene, renderer, camera, size, autoRender, renderStage, dpr } = useThrelte();
const composer = new EffectComposer(renderer);
composer.removeAllPasses();
const renderPass = new RenderPass(scene, camera.current);
@@ -213,10 +216,13 @@
// displays, so its output was upsampled and read as a shifted "ghost" of the
// shading offset from the objects. The per-kind `resize` hook carries that
// lesson in the registry rather than hardcoded here.
- const dpr = renderer.getPixelRatio ? renderer.getPixelRatio() : 1;
+ // 26-D: the governor changes the pixel ratio WITHOUT changing the CSS size, so the
+ // composer has to follow the dpr too or its targets stay at the old resolution
+ void $dpr;
+ const pixelRatio = renderer.getPixelRatio ? renderer.getPixelRatio() : 1;
composer.setSize($size.width, $size.height);
for (const instance of stackInstances)
- instance.def?.resize?.(instance.object, $size.width, $size.height, dpr);
+ instance.def?.resize?.(instance.object, $size.width, $size.height, pixelRatio);
});
// L4: the capability gate now covers the WHOLE stack, not just AO (see
// viewMode.postSupported for the three-r185 + Chromium<=150 story, why the
@@ -251,14 +257,18 @@
// changes (measured: setting a camera to No files replaced rendered nothing new).
void $postStacks;
void $lookOverride;
+ // 26-D: the quality governor's post steps — AO first (the personal chip reads as plain
+ // shaded, an authored AO entry is dropped), then the whole stack. LOCAL overrides: the
+ // authored document is never touched, so a peer's look is unchanged
+ const reduced = $qualityOverrides;
const entries = effectivePostStack({
stack: resolvedDoc(POST_SCENE_KEY),
cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera) : null),
- mode: $viewMode,
- localEnabled: $postEnabledLocal,
+ mode: reduced.aoOff && $viewMode === 'shaded-ao' ? 'shaded' : $viewMode,
+ localEnabled: $postEnabledLocal && !reduced.postOff,
postOk,
postWarm
- });
+ }).filter((entry) => !(reduced.aoOff && entry.kind === 'ao'));
const signature = postStackSignature(entries);
if (signature === stackSignature) return;
stackSignature = signature;
@@ -320,8 +330,30 @@
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);
+ // 26-D THE INGEST DRAW GAP (26-E's finding): while a big received scene drains through
+ // slow frames, draw at most one frame per gap — every object's parse waits for a frame to
+ // pass, so a joiner redrawing a 2,000-object scene 30 times a second was starving its own
+ // receive queue (3,000 objects: ~180s drawing, 5.6s not). Never in XR, like the pause.
+ let drawGapMs = 0;
+ let lastDrawAt = 0;
+ const stopGapWatch = ingestDrawGap.subscribe((value) => (drawGapMs = value));
+ onDestroy(stopGapWatch);
useTask(
(delta) => {
+ if (renderIsPaused && !renderer.xr.isPresenting) return;
+ if (drawGapMs > 0 && !renderer.xr.isPresenting) {
+ const drawNow = performance.now();
+ if (drawNow - lastDrawAt < drawGapMs) return;
+ lastDrawAt = drawNow;
+ }
// 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
@@ -428,7 +460,7 @@
});
// e2e hook (debugStores opt-in): the effects live in this component only
onMount(() => {
- if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores'))
+ if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores'))
(window as any).__outlineDebug = () => ({
selected: outlineEffectSelected?.selection.size ?? -1,
locked: outlineEffectLocked?.selection.size ?? -1,
@@ -439,7 +471,7 @@
// L1: the compiled chain lives in this component only, and its ORDER is the
// thing worth asserting — so the hook names each pass by identity rather than
// by constructor (minified in a build) and reports the merge plan.
- if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores'))
+ if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores'))
(window as any).__postDebug = () => ({
chain: ((composer as any).passes ?? []).map((pass: any) => {
if (pass === renderPass) return 'render';
@@ -449,6 +481,8 @@
return index >= 0 ? 'stack:' + (stackPlan[index]?.kinds ?? []).join('+') : 'other';
}),
composerPasses: ((composer as any).passes ?? []).length,
+ // 26-D: the composer's own buffer, which must follow a governor dpr change
+ composerBufferWidth: (composer as any).inputBuffer?.width ?? null,
outlinedSelected: outlineEffectSelected?.selection.size ?? 0,
outlinedLocked: outlineEffectLocked?.selection.size ?? 0,
stackPasses: stackPasses.length,
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/Scene.svelte b/src/components/Scene.svelte
index 44a07b66..f47c9396 100644
--- a/src/components/Scene.svelte
+++ b/src/components/Scene.svelte
@@ -1,6 +1,7 @@
diff --git a/src/components/editors/HudEditor.svelte b/src/components/editors/HudEditor.svelte
index 9978e794..8840d17f 100644
--- a/src/components/editors/HudEditor.svelte
+++ b/src/components/editors/HudEditor.svelte
@@ -68,6 +68,7 @@
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock';
import { bottomDockable } from '$lib/bottomDockDrop';
+ import { safeStorage } from '$lib/safeStorage';
// 21-D5: WHICH document is being authored. `hudDocs` was already keyed
// `'scene' | objectUuid`, so "attach this HUD to a camera" is simply authoring the
@@ -116,10 +117,10 @@
let winW = $state(680);
let winH = $state(480);
if (typeof localStorage !== 'undefined') {
- docked = localStorage.getItem('hudDocked') !== 'false';
+ docked = safeStorage.getItem('hudDocked') !== 'false';
const saved = clampWinSize(
- parseInt(localStorage.getItem('hudWinW') ?? '680') || 680,
- parseInt(localStorage.getItem('hudWinH') ?? '480') || 480,
+ parseInt(safeStorage.getItem('hudWinW') ?? '680') || 680,
+ parseInt(safeStorage.getItem('hudWinH') ?? '480') || 480,
WIN_MIN
);
winW = saved.w;
@@ -127,7 +128,7 @@
}
function setDocked(/** @type {boolean} */ v) {
docked = v;
- localStorage.setItem('hudDocked', String(v));
+ safeStorage.setItem('hudDocked', String(v));
if (v) activateDock('hud');
else forgetDockTab('hud'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip
}
@@ -181,7 +182,7 @@
const SCREENS_RESERVE = 148;
let paneH = $state(0);
let screensH = $state(
- parseInt((typeof localStorage !== 'undefined' && localStorage.getItem('hudScreens:h')) || '132') || 132
+ parseInt((typeof localStorage !== 'undefined' && safeStorage.getItem('hudScreens:h')) || '132') || 132
);
let screensResizing = $state(false);
const screensMax = $derived(Math.max(56, (paneH || 320) - SCREENS_RESERVE));
@@ -203,7 +204,7 @@
screensResizing = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
try {
- localStorage.setItem('hudScreens:h', String(screensH));
+ safeStorage.setItem('hudScreens:h', String(screensH));
} catch {}
}
@@ -275,20 +276,20 @@
/** @param {string} key @param {number} fallback */
function snapPref(key, fallback) {
if (typeof localStorage === 'undefined') return fallback;
- const raw = localStorage.getItem(key);
+ const raw = safeStorage.getItem(key);
const n = raw === null ? NaN : parseFloat(raw);
return Number.isFinite(n) ? n : fallback;
}
let snapOn = $state(
- typeof localStorage === 'undefined' ? SNAP_DEFAULTS.on : localStorage.getItem('hud:snapOn') !== 'false'
+ typeof localStorage === 'undefined' ? SNAP_DEFAULTS.on : safeStorage.getItem('hud:snapOn') !== 'false'
);
let snapGrid = $state(Math.max(1, snapPref('hud:snapGrid', SNAP_DEFAULTS.grid)));
let snapThreshold = $state(Math.max(0, snapPref('hud:snapThreshold', SNAP_DEFAULTS.threshold)));
$effect(() => {
try {
- localStorage.setItem('hud:snapOn', String(snapOn));
- localStorage.setItem('hud:snapGrid', String(snapGrid));
- localStorage.setItem('hud:snapThreshold', String(snapThreshold));
+ safeStorage.setItem('hud:snapOn', String(snapOn));
+ safeStorage.setItem('hud:snapGrid', String(snapGrid));
+ safeStorage.setItem('hud:snapThreshold', String(snapThreshold));
} catch {}
});
// the lines the LIVE gesture is actually sitting on, drawn as 1px overlays. Cleared
@@ -948,8 +949,8 @@
saveWinSize();
}
function saveWinSize() {
- localStorage.setItem('hudWinW', String(winW));
- localStorage.setItem('hudWinH', String(winH));
+ safeStorage.setItem('hudWinW', String(winW));
+ safeStorage.setItem('hudWinH', String(winH));
}
function resetWinSize() {
const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN);
diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte
index 875be0b4..c7ec646a 100644
--- a/src/components/editors/Nodes.svelte
+++ b/src/components/editors/Nodes.svelte
@@ -71,6 +71,7 @@
import { isValidFlowConnection, typeColor, replaceableInputEdges } from '$lib/flowSockets';
import { moduleNodeGroups, moduleNodeComponents } from '$lib/moduleSDK';
import { peers, username, modulesOpen, flowFocus } from '../../stores/appStore';
+ import { safeStorage } from '$lib/safeStorage';
// 21-D7: DEEP LINK — 'show me the node that drives this HUD element'. A write-once
// request that we act on and CLEAR, the inspectorScrollTo shape, so it cannot re-fire
@@ -277,7 +278,7 @@
// 3775px for a 200px gesture. A test that needs to press a field needs this.
$effect(() => {
if (typeof window === 'undefined' || typeof localStorage === 'undefined') return;
- if (localStorage.getItem('debugStores') !== 'true') return;
+ if (safeStorage.getItem('debugStores') !== 'true') return;
// TS syntax, not a JSDoc cast: this file is lang="ts", where JSDoc @type is IGNORED
(window as any).__flowViewport = { setViewport, fitView };
// A6.4: which types this MOUNTED pane can actually render, plus the snapshot it
@@ -300,10 +301,10 @@
// inset its content above the Controls HUD only when the palette is actually shown.
let {
paletteOpen = $bindable(
- typeof localStorage === 'undefined' || localStorage.getItem('flowPaletteOpen') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('flowPaletteOpen') !== 'false'
)
}: { paletteOpen?: boolean } = $props();
- let paletteSide = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('flowPaletteSide') ?? 'left' : 'left');
+ let paletteSide = $state(typeof localStorage !== 'undefined' ? safeStorage.getItem('flowPaletteSide') ?? 'left' : 'left');
// #20 P7: the left column's own height, measured — the graph tree's resize ceiling
let paletteColH = $state(0);
@@ -687,7 +688,7 @@
title={paletteOpen ? 'Hide the node palette' : 'Show the node palette'}
onclick={() => {
paletteOpen = !paletteOpen;
- localStorage.setItem('flowPaletteOpen', String(paletteOpen));
+ safeStorage.setItem('flowPaletteOpen', String(paletteOpen));
}}
>
{paletteOpen ? (paletteSide === 'right' ? '▸' : '◂') : paletteSide === 'right' ? '◂' : '▸'}
@@ -699,7 +700,7 @@
title="Move the palette to the other side"
onclick={() => {
paletteSide = paletteSide === 'right' ? 'left' : 'right';
- localStorage.setItem('flowPaletteSide', paletteSide);
+ safeStorage.setItem('flowPaletteSide', paletteSide);
}}
>
⇄
diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte
index 2cffb660..abee349a 100644
--- a/src/components/editors/ShaderEditor.svelte
+++ b/src/components/editors/ShaderEditor.svelte
@@ -61,6 +61,7 @@
import ShaderTexturePicker from './nodes/ShaderTexturePicker.svelte';
import ShaderVectorInput from './nodes/ShaderVectorInput.svelte';
import DragRow from '../ui/DragRow.svelte';
+ import { safeStorage } from '$lib/safeStorage';
const nodeTypes = Object.fromEntries(shaderNodeDefs().map((def) => [def.key, ShaderNode]));
const catalog = shaderNodeDefs().filter((def) => def.key !== SURFACE_NODE);
@@ -375,12 +376,12 @@
let winW = $state(720);
let winH = $state(480);
if (typeof localStorage !== 'undefined') {
- docked = localStorage.getItem('shaderDocked') !== 'false';
+ docked = safeStorage.getItem('shaderDocked') !== 'false';
// 18-B: a size saved on a bigger screen must not come back oversized. Fitted
// BEFORE the assignment so nothing reads $state during init.
const savedWin = clampWinSize(
- parseInt(localStorage.getItem('shaderWinW') ?? '720') || 720,
- parseInt(localStorage.getItem('shaderWinH') ?? '480') || 480,
+ parseInt(safeStorage.getItem('shaderWinW') ?? '720') || 720,
+ parseInt(safeStorage.getItem('shaderWinH') ?? '480') || 480,
WIN_MIN
);
winW = savedWin.w;
@@ -397,7 +398,7 @@
function setDocked(/** @type {boolean} */ v) {
docked = v;
- localStorage.setItem('shaderDocked', String(v));
+ safeStorage.setItem('shaderDocked', String(v));
if (v) activateDock('shader'); // re-docking makes it the visible tab
else forgetDockTab('shader'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip
}
@@ -479,8 +480,8 @@
saveWinSize();
}
function saveWinSize() {
- localStorage.setItem('shaderWinW', String(winW));
- localStorage.setItem('shaderWinH', String(winH));
+ safeStorage.setItem('shaderWinW', String(winW));
+ safeStorage.setItem('shaderWinH', String(winH));
}
function resetWinSize() {
const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN);
diff --git a/src/components/editors/UvEditor.svelte b/src/components/editors/UvEditor.svelte
index b6799246..912cdb56 100644
--- a/src/components/editors/UvEditor.svelte
+++ b/src/components/editors/UvEditor.svelte
@@ -47,6 +47,7 @@
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock';
import { bottomDockable } from '$lib/bottomDockDrop';
+ import { safeStorage } from '$lib/safeStorage';
/** the armed transform modes, in 1/2/3 order */
const MODES = /** @type {['move'|'rotate'|'scale', string, string][]} */ ([
@@ -128,12 +129,12 @@
let winW = $state(640);
let winH = $state(460);
if (typeof localStorage !== 'undefined') {
- docked = localStorage.getItem('uvDocked') !== 'false';
+ docked = safeStorage.getItem('uvDocked') !== 'false';
// 18-B: a size saved on a bigger screen must not come back oversized.
// Fitted before the assignment so nothing reads $state during init.
const savedWin = clampWinSize(
- parseInt(localStorage.getItem('uvWinW') ?? '640') || 640,
- parseInt(localStorage.getItem('uvWinH') ?? '460') || 460,
+ parseInt(safeStorage.getItem('uvWinW') ?? '640') || 640,
+ parseInt(safeStorage.getItem('uvWinH') ?? '460') || 460,
WIN_MIN
);
winW = savedWin.w;
@@ -141,7 +142,7 @@
}
function setDocked(/** @type {boolean} */ v) {
docked = v;
- localStorage.setItem('uvDocked', String(v));
+ safeStorage.setItem('uvDocked', String(v));
if (v) activateDock('uv');
else forgetDockTab('uv'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip
}
@@ -1543,8 +1544,8 @@
saveWinSize();
}
function saveWinSize() {
- localStorage.setItem('uvWinW', String(winW));
- localStorage.setItem('uvWinH', String(winH));
+ safeStorage.setItem('uvWinW', String(winW));
+ safeStorage.setItem('uvWinH', String(winH));
}
/** 18-B: double-click the grip — back to the default size, position kept */
function resetWinSize() {
diff --git a/src/components/menu/CharacterModal.svelte b/src/components/menu/CharacterModal.svelte
index db2712d2..479eda41 100644
--- a/src/components/menu/CharacterModal.svelte
+++ b/src/components/menu/CharacterModal.svelte
@@ -3,6 +3,7 @@
import ThemedSelect from '../ui/ThemedSelect.svelte';
import { characterModalOpen, avatarConfig, userdata, peers } from '../../stores/appStore.js';
import { FACE_SHAPES, resolveAvatar } from '$lib/avatarModel';
+ import { safeStorage } from '$lib/safeStorage';
// resolve so shape/showLabel have defaults even for older stored configs
$: cfg = resolveAvatar($avatarConfig);
@@ -29,7 +30,7 @@
function update(partial: any) {
const next = { ...$avatarConfig, ...partial };
$avatarConfig = next;
- localStorage.setItem('avatarConfig', JSON.stringify(next));
+ safeStorage.setItem('avatarConfig', JSON.stringify(next));
// update our own userdata row and broadcast
$userdata.forEach((element) => {
if (element[0] === $peers.peer.id) element[5] = next;
diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte
index 8945c74f..103dacbc 100644
--- a/src/components/menu/Connect.svelte
+++ b/src/components/menu/Connect.svelte
@@ -5,11 +5,14 @@
import { onMount, tick } from 'svelte';
import { createPeer, PeerConnection } from '$lib/peerHandler.svelte';
import { peerServerStatus, inviteServerParam } from '$lib/peerServer';
+ // 27-F: the signaling link's retry state (audit H2). A chip, not a toast per attempt.
+ import { signalingRetry, approvalStartedAt, approvalRemaining, APPROVAL_WINDOW_MS, joinRefusal, clearJoinRefusal, HARD_PEER_CAP } from '$lib/connectionState';
import { cancelOutboundRequest, requestConnect } from '$lib/peerApproval';
import { sessionHost } from '$lib/connectionState';
import { connectSlot, drawerSlot } from '$lib/cloudHooks';
import CloudSlot from '../CloudSlot.svelte';
import ConnectInfoDrawer from './ConnectInfoDrawer.svelte';
+ import { safeStorage } from '$lib/safeStorage';
let peerIdToConnect = $state('');
let displayid = $state('Generating...');
@@ -39,6 +42,45 @@
// from $userdata.length: the roster is populated optimistically at DIAL time.
const remoteOpen = $derived($peers ? [...$peers.openedPeers] : []);
const pendingOut = $derived($waitingForApproval.filter((w) => w[1] === 'pending'));
+
+ // 25-F: the host's answer, when it was no. A chip beside the idle pill for a while,
+ // because the toast that also says it can be missed or routed into the drawer — and
+ // "declined" and "full" call for different next moves.
+ const REFUSAL_CHIP_MS = 20000;
+ const refusalText = $derived(
+ $joinRefusal
+ ? String($joinRefusal.peerId).slice(0, 6).toUpperCase() +
+ ($joinRefusal.result === 'full' ? "'s session is full (" + HARD_PEER_CAP + ')' : ' declined')
+ : ''
+ );
+ $effect(() => {
+ const at = $joinRefusal?.at;
+ if (!at) return;
+ const t = setTimeout(() => {
+ if ($joinRefusal?.at === at) clearJoinRefusal();
+ }, REFUSAL_CHIP_MS);
+ return () => clearTimeout(t);
+ });
+ // 27-E: the pill COUNTS DOWN. A request that hangs with no end is the worst of the
+ // three states a dial can be in — a refusal at least finishes — so the wait is visible
+ // and bounded. One 1s tick only while something is pending; the clock itself lives in
+ // connectionState so the host's card age cannot disagree with it.
+ let nowTick = $state(Date.now());
+ $effect(() => {
+ if (!pendingOut.length) return;
+ const t = setInterval(() => (nowTick = Date.now()), 1000);
+ return () => clearInterval(t);
+ });
+ const pendingLeft = $derived.by(() => {
+ void nowTick;
+ const id = pendingOut[0]?.[0];
+ if (!id) return 0;
+ const started = $approvalStartedAt[id];
+ // No stamp means no clock, and a fabricated full window is worse than none: it
+ // paints a confident 1:30 that never decrements, and it disagrees with the host's
+ // card, which ages from the same map and would read zero. Show nothing instead.
+ return started ? Math.ceil(approvalRemaining(started) / 1000) : 0;
+ });
const connState = $derived(
remoteOpen.length > 0 ? 'connected' : pendingOut.length > 0 ? 'pending' : 'idle'
);
@@ -146,10 +188,10 @@
// is fine for a quick try but not recommended for real use. Shown once.
try {
const isLocalVersion = !/(\.io|\.app)$/i.test(location.hostname);
- const firstRun = !localStorage.getItem('peerServerConfig');
- const seen = localStorage.getItem('localPeerNoticeSeen');
+ const firstRun = !safeStorage.getItem('peerServerConfig');
+ const seen = safeStorage.getItem('localPeerNoticeSeen');
if (isLocalVersion && firstRun && !seen) {
- localStorage.setItem('localPeerNoticeSeen', '1');
+ safeStorage.setItem('localPeerNoticeSeen', '1');
showToast(
'It looks like you are running a local build of theprototype. Configure a peer signaling server in Settings for reliable connections — the public PeerJS cloud is not recommended for real use.',
[
@@ -259,7 +301,15 @@
{:else if connState === 'pending'}
+{/if}
+
+
diff --git a/src/components/menu/StorageModal.svelte b/src/components/menu/StorageModal.svelte
index ee496091..5f0ee57e 100644
--- a/src/components/menu/StorageModal.svelte
+++ b/src/components/menu/StorageModal.svelte
@@ -22,6 +22,11 @@
import { HardDrive, RefreshCw, Trash2, Info, ChevronRight } from '@lucide/svelte';
import { showConfirm } from '$lib/confirmDialog';
import { showToast } from '../../stores/appStore';
+ // 27-H (audit M5): autosave backs its own cadence off when an export gets expensive,
+ // and a save cadence that quietly moved from 30s to 5 minutes should be visible
+ // somewhere rather than guessed at. This panel is already where "what is this app
+ // doing to my disk" is answered.
+ import { autosaveStatus, autosaveEnabled } from '$lib/autosave';
import {
storageModalOpen,
storageScan,
@@ -162,6 +167,14 @@
}
}
+ /** "every 30 seconds" / "every 2 minutes" — the cadence in words. @param {number} ms */
+ function fmtCadence(ms) {
+ const seconds = Math.round(ms / 1000);
+ if (seconds < 90) return seconds + ' seconds';
+ const minutes = Math.round(seconds / 60);
+ return minutes + (minutes === 1 ? ' minute' : ' minutes');
+ }
+
/** the fill of the used/quota bar, as a percentage @param {any} s */
function usedPct(s) {
if (!s?.estimate?.quota) return 0;
@@ -234,6 +247,26 @@
{:else}
Reading the store…
{/if}
+
+ {#if !$autosaveEnabled}
+ Autosave is off, so nothing here is crash recovery.
+ {:else if $autosaveStatus.lastError}
+ Autosave is failing — the last snapshot
+ could not be written, so there is nothing to recover from a crash.
+ {:else}
+ Autosave writes a snapshot
+ {fmtCadence($autosaveStatus.debounceMs)}
+ after a change{#if $autosaveStatus.lastExportMs}, and the last one took
+ {Math.round($autosaveStatus.lastExportMs)}ms
+ to prepare{/if}.
+ {#if $autosaveStatus.debounceMs > 30_000}
+ It has slowed itself down because this scene is expensive to export; a shorter
+ interval would stutter while you work.
+ {/if}
+ {/if}
+
{#if groups.length}
diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte
index c35d1511..f82dad58 100644
--- a/src/components/menu/Toasts.svelte
+++ b/src/components/menu/Toasts.svelte
@@ -19,7 +19,16 @@
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 { cancelOutboundRequest } from '$lib/peerApproval'
+ import { ingestGate, resolveIngestGate } from '$lib/commandsHandler.svelte'
+ import { ingestVerdict, profileFor } from '$lib/sceneBudget'
+ import { cancelOutboundRequest, denyPeer } 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
+ // leaf by publishing a store instead of importing the toast pipeline itself.
+ import { lastUncaught, copyDiagnostics } from '$lib/diagnostics'
+ // 27-E: the card ages against the SAME clock the joiner's pill counts down from, and
+ // the caps decide whether approving is even offered.
+ import { approvalStartedAt, APPROVAL_WINDOW_MS, softPeerCap, HARD_PEER_CAP, sessionSize, roomIsFull } from '$lib/connectionState'
import { rolesInfo } from '$lib/cloudHooks'
import { sceneCommand } from '$lib/commandsHandler.svelte';
import { objectsGroup, camSave, globalCamera, globalScene } from '../../stores/sceneStore.js';
@@ -31,6 +40,7 @@
import { peerScenes, elsewhereThan, PRIVATE_SCENE } from '$lib/peerScenes';
import { currentLevel } from '$lib/levels';
import { showToast } from '../../stores/appStore';
+ import { safeStorage } from '$lib/safeStorage';
/**
* Stop watching and give the camera back. EXTRACTED from the banner button so the
@@ -116,23 +126,44 @@ const MAX_REQUESTS = 3;
// evicted by a burst of ordinary toasts — only the transient ones are capped,
// and the "+N more" count reflects just those. Sticky cards render LAST so they
// hold a stable spot while transients come and go above them.
+// 27-E: one 1s tick, and only while a request is actually pending.
+let approvalTick = $state(Date.now());
+$effect(() => {
+ if (!$pendingApprovals.length) return;
+ const t = setInterval(() => (approvalTick = Date.now()), 1000);
+ return () => clearInterval(t);
+});
+function approvalAge(peerId: string) {
+ void approvalTick;
+ const started = $approvalStartedAt[peerId];
+ return started ? Date.now() - started : 0;
+}
+// A pending row comes from a store declared `writable([])`, which TypeScript infers as
+// `never[]` — so reading `.peerId` off the row is an error at every use. Narrow ONCE
+// here rather than casting at each read in the card.
+const rowAge = (approval: any) => approvalAge(approval?.peerId);
+const roomFull = $derived(roomIsFull($peers));
+
const stickyToasts = $derived($toastStore.filter((t: any) => t?.sticky));
const transientToasts = $derived($toastStore.filter((t: any) => !t?.sticky));
const hiddenCount = $derived(Math.max(0, transientToasts.length - MAX_TOASTS));
const visibleToasts = $derived([...transientToasts.slice(-MAX_TOASTS), ...stickyToasts]);
+// 26-B (audit M6): ONE traversal, then set lookups. This ran
+// `getObjectByProperty` — a full tree walk — TWICE per outstanding uuid, on every
+// scene poke: with 1,000 objects still to arrive over a 1,000-object scene that is
+// two million node visits per poke, and the receive path poked once per object. It
+// was the single most expensive consumer of the poke and a large part of the
+// reported freeze. Same verdict, O(objects + outstanding) instead of O(both).
$effect(() => {
- if($loading.length > 0)
- if($objectsGroup)
- // Remove loaded UUIDs from the loading array
- // once their corresponding objects are available
- $loading.forEach((uuid) => {
- $objectsGroup.getObjectByProperty('uuid', uuid)
- if ($objectsGroup.getObjectByProperty('uuid', uuid)) {
- $loading.splice($loading.indexOf(uuid, 0), 1);
- $loading = $loading // Trigger reactivity
- }
- })
+ const group = $objectsGroup;
+ const outstanding = $loading;
+ if (!group || !outstanding.length) return;
+ /** @type {Set} */
+ const present = new Set();
+ group.traverse((/** @type {any} */ o) => present.add(o.uuid));
+ const left = outstanding.filter((/** @type {string} */ uuid) => !present.has(uuid));
+ if (left.length !== outstanding.length) loading.set(left);
});
// 15-P2: "Receiving objects" visibility. The old machinery (showToast +
@@ -157,6 +188,18 @@ $effect(() => {
// joiner a role right away — "Approve + edit" makes them an editor instead of the
// default viewer. A 'retry' request just re-establishes an existing whitelisted conn.
function approvePeer(approval, role) {
+ // 27-E: the mesh is FULL — every peer holds N-1 connections and every mutation fans
+ // out N-1 times — so past the hard cap approving degrades the session for everyone,
+ // not just for the person joining. The button is disabled with the reason; this is
+ // the backstop for any other path in. Counted off the OPEN
+ // connections: userdata is the whitelist, written at DIAL time, so it counts every
+ // person ever invited — including those who never arrived and those who have left.
+ if (roomIsFull($peers)) {
+ showToast('This session is full (' + HARD_PEER_CAP + ' people). Ask someone to leave first.');
+ return;
+ }
+ if (sessionSize($peers) >= $softPeerCap)
+ showToast('That is ' + (sessionSize($peers) + 1) + ' people — voice and live gestures may lag on slower devices.');
$pendingApprovals = $pendingApprovals.filter((p) => p.peerId !== approval.peerId);
if (approval.status === 'retry') {
try { $peers.connections[approval.peerId]?.close(); } catch {}
@@ -164,12 +207,15 @@ function approvePeer(approval, role) {
$userdata.push([approval.peerId, '', '']);
}
$peers.send({ type: 'userdata', userdata: $userdata });
- $peers.connectToPeer(approval.peerId, true);
+ // 25-F: an approval dial-back says it is one (a retry is a re-dial, not an approval)
+ if (approval.status !== 'retry' && typeof $peers.approveDialBack === 'function') $peers.approveDialBack(approval.peerId);
+ else $peers.connectToPeer(approval.peerId, true);
if (role && $rolesInfo?.setRole) $rolesInfo.setRole(approval.peerId, role);
}
-function rejectPeer(approval) {
- $pendingApprovals = $pendingApprovals.filter((p) => p.peerId !== approval.peerId);
- try { $peers.connections[approval.peerId]?.close?.(); } catch {}
+// 25-F: through the shared deny, so the joiner HEARS it (and VR and the card agree)
+const hearsNo = (approval: any) => !!approval?.hearsNo;
+function rejectPeer(approval, result: 'denied' | 'full' = 'denied') {
+ denyPeer(approval.peerId, result);
}
// professional toast card: manual close (✕) + auto-dismiss timer (kept from before)
@@ -198,12 +244,73 @@ let libraryPromptDone = false;
/** the ask announced by `explorer-share-ask`, so one batch cannot toast twice */
let announcedAsk = '';
+$effect(() => {
+ const err = $lastUncaught;
+ if (err)
+ showInfoToast(
+ 'diagnostics-error',
+ `Something went wrong: ${err.message}`,
+ [
+ {
+ label: 'Copy diagnostics',
+ keepOpen: true,
+ action: async () => {
+ const ok = await copyDiagnostics();
+ showToast(ok ? 'Diagnostics copied to the clipboard' : 'Could not copy the diagnostics');
+ }
+ }
+ ],
+ () => lastUncaught.set(null)
+ );
+ 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
+// 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)
showInfoToast(
'restore-session',
- `Restore previous session? ${snap.objects} objects, saved ${new Date(snap.ts).toLocaleTimeString()}`,
+ `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.
+ (snap.risky
+ ? ' Warning: the last attempt to restore this scene never finished a frame, so it may be what stopped the app.'
+ : ''),
[
{ label: 'Restore', action: () => restoreSnapshot() },
{ label: 'Dismiss', action: () => dismissRestore() }
@@ -325,9 +432,9 @@ $effect(() => {
$effect(() => {
const notice = $appNotice;
- const seen = typeof localStorage !== 'undefined' && !!localStorage.getItem('hasSeenDisclaimer');
+ const seen = typeof localStorage !== 'undefined' && !!safeStorage.getItem('hasSeenDisclaimer');
const markSeen = () => {
- try { localStorage.setItem('hasSeenDisclaimer', 'true'); } catch {}
+ try { safeStorage.setItem('hasSeenDisclaimer', 'true'); } catch {}
};
if (notice && !seen)
showInfoToast(
@@ -418,16 +525,33 @@ style="z-index: var(--z-toast); pointer-events: none;"
APPROVAL_WINDOW_MS}>
+ {#if rowAge(approval) > APPROVAL_WINDOW_MS}
+ asked {Math.round(rowAge(approval) / 1000)}s ago — they may have given up
+ {:else}
+ asked {Math.max(1, Math.round(rowAge(approval) / 1000))}s ago
+ {/if}
+ {#if roomFull}· this session is full ({HARD_PEER_CAP}){/if}
+
{#if $rolesInfo}
- approvePeer(approval, null)} title="Approve as a view-only viewer">View only
+ approvePeer(approval, null)} title={roomFull ? 'This session is full (' + HARD_PEER_CAP + ')' : 'Approve as a view-only viewer'}>View only
{#if approval.status !== 'retry'}
- approvePeer(approval, 'editor')} title="Approve and grant edit access">Editor access
+ approvePeer(approval, 'editor')} title={roomFull ? 'This session is full (' + HARD_PEER_CAP + ')' : 'Approve and grant edit access'}>Editor access
{/if}
{:else}
- approvePeer(approval, null)}>Approve
+ approvePeer(approval, null)} title={roomFull ? 'This session is full (' + HARD_PEER_CAP + ')' : 'Approve this request'}>Approve
{/if}
- rejectPeer(approval)} title="Decline">Reject
+
+ {#if roomFull && hearsNo(approval)}
+ rejectPeer(approval, 'full')} title={'Tell them this session is full (' + HARD_PEER_CAP + ')'}>Tell them it's full
+ {/if}
+ rejectPeer(approval)} title={hearsNo(approval) ? 'Decline — they are told' : 'Decline'}>Reject
@@ -593,13 +717,27 @@ style="z-index: var(--z-toast-low); pointer-events: none;"
with every other toast); only the role-coloured buttons + the peer-id chip
remain bespoke (viewer=gray, editor=blue, reject=outlined red). */
.cxreq-id { font-size: 11px; color: #9ca3af; font-family: ui-monospace, monospace; }
- .cxreq-btn { font-size: 11px; padding: 4px 10px; border-radius: 7px; border: 0; cursor: pointer; color: #fff; white-space: nowrap; }
+ .cxreq-age {
+ margin-top: 2px;
+ font-size: 11px;
+ opacity: 0.65;
+}
+.cxreq-age.expired {
+ opacity: 0.9;
+ color: #fbbf24;
+}
+.cxreq-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+.cxreq-btn { font-size: 11px; padding: 4px 10px; border-radius: 7px; border: 0; cursor: pointer; color: #fff; white-space: nowrap; }
.cxreq-view { background: #6b7280; }
.cxreq-view:hover { background: #7b8494; }
.cxreq-editor { background: #2563eb; }
.cxreq-editor:hover { background: #1d4ed8; }
.cxreq-reject { background: transparent; border: 1px solid rgb(248 113 113 / 0.4); color: #f87171; }
.cxreq-reject:hover { background: rgb(220 38 38 / 0.15); }
+ .cxreq-full { background: #b45309; }
/* professional notification toast (replaces the flowbite green toast) */
.tp-toast {
pointer-events: auto;
diff --git a/src/components/menu/Users.svelte b/src/components/menu/Users.svelte
index 1ff4e389..50a29047 100644
--- a/src/components/menu/Users.svelte
+++ b/src/components/menu/Users.svelte
@@ -106,6 +106,7 @@
import NotificationCenter from './NotificationCenter.svelte';
import CloudSlot from '../CloudSlot.svelte';
import { usersSlot, profileSlot, rolesInfo, scenePresence } from '$lib/cloudHooks';
+ import { safeStorage } from '$lib/safeStorage';
// N3: latency-band dot color for a peer's network-quality indicator
const qColor = (level: string) =>
@@ -145,7 +146,7 @@
const reader = new FileReader();
reader.onload = function(fileLoadedEvent) {
avatarImage = fileLoadedEvent.target.result;
- localStorage.setItem('avatar', avatarImage);
+ safeStorage.setItem('avatar', avatarImage);
//find and update, same for image
$userdata.forEach(element => {
@@ -159,7 +160,7 @@
};
reader.readAsDataURL(avatarFile);
// an uploaded image is a CUSTOM avatar
- try { localStorage.removeItem('avatarReset'); } catch {}
+ try { safeStorage.removeItem('avatarReset'); } catch {}
}
}
@@ -168,7 +169,7 @@
// (pushed by the plugin via cloudApi.setAccountIdentity -> $cloudIdentity) UNLESS
// the user set a custom one. "Custom username" = the usernameCustom flag; "custom
// avatar" = an uploaded image in localStorage.avatar.
- const ls = (k: string) => (typeof localStorage !== 'undefined' ? localStorage.getItem(k) : null);
+ const ls = (k: string) => (typeof localStorage !== 'undefined' ? safeStorage.getItem(k) : null);
const usernameIsCustom = () => ls('usernameCustom') === '1';
const cid = $derived($cloudIdentity);
/** what the header/button/peers show */
@@ -196,7 +197,7 @@
/** @param {string} v */
function setPeersView(v: string) {
peersView = v;
- try { localStorage.setItem('peers:view', v); } catch {}
+ try { safeStorage.setItem('peers:view', v); } catch {}
}
/** WHO AM I in the roster. The flat list has always taken index 0 as self (userdata
* is built that way), so the fallback is not a guess — it is the same rule, reached
@@ -443,15 +444,15 @@
function onUsernameEdited() {
try {
- localStorage.setItem('username', $username || '');
- localStorage.setItem('usernameCustom', ($username || '').trim() ? '1' : '0');
+ safeStorage.setItem('username', $username || '');
+ safeStorage.setItem('usernameCustom', ($username || '').trim() ? '1' : '0');
} catch {}
broadcastUserdata();
}
function resetAvatarToDefault() {
avatarImage = '';
- try { localStorage.removeItem('avatar'); } catch {}
+ try { safeStorage.removeItem('avatar'); } catch {}
broadcastUserdata(); // falls back to the cloud-account avatar (or default)
}
@@ -985,7 +986,7 @@
>
{/if}
- {#if avatarImage || (typeof localStorage !== 'undefined' && localStorage.getItem('avatar'))}
+ {#if avatarImage || (typeof localStorage !== 'undefined' && safeStorage.getItem('avatar'))}
Reset to {cid?.avatar ? 'account picture' : 'default'}
{/if}
diff --git a/src/components/menu/ViewportMenu.svelte b/src/components/menu/ViewportMenu.svelte
index a0f84745..67a611c9 100644
--- a/src/components/menu/ViewportMenu.svelte
+++ b/src/components/menu/ViewportMenu.svelte
@@ -18,6 +18,7 @@
import { togglePanel } from '$lib/panelToggles';
import { trackpadMode } from '$lib/trackpadNav';
import { helpersInPlay } from '$lib/helperLayer';
+ import { safeStorage } from '$lib/safeStorage';
// Scene.svelte routes right-TAPS here (77): empty viewport → this menu with
// the clicked ground point; an object under the cursor → its own context
@@ -264,8 +265,8 @@
checked: !!$showGrid,
action: () => {
showGrid.update((v) => !v);
- if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid');
- else localStorage.setItem('showGrid', 'false');
+ if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid');
+ else safeStorage.setItem('showGrid', 'false');
}
},
{
diff --git a/src/components/play/PlayReticle.svelte b/src/components/play/PlayReticle.svelte
index 2edf1f7f..993276a6 100644
--- a/src/components/play/PlayReticle.svelte
+++ b/src/components/play/PlayReticle.svelte
@@ -5,11 +5,12 @@
// playInteract.js.
import { isLocked, isVRMode } from '../../stores/sceneStore';
import { playInteractState } from '$lib/playInteract';
+ import { safeStorage } from '$lib/safeStorage';
// the scroll hint is worth exactly one showing, so it is a LOCAL pref and
// never touches the wire
let hintSeen = $state(
- typeof localStorage !== 'undefined' && localStorage.getItem('playCarryHintSeen') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('playCarryHintSeen') === 'true'
);
const reticle = $derived($playInteractState);
@@ -20,7 +21,7 @@
if (!carrying || hintSeen) return;
hintSeen = true;
try {
- localStorage.setItem('playCarryHintSeen', 'true');
+ safeStorage.setItem('playCarryHintSeen', 'true');
} catch {}
});
diff --git a/src/components/ui/ToolboxSection.svelte b/src/components/ui/ToolboxSection.svelte
index 7e70c8f9..7270b89e 100644
--- a/src/components/ui/ToolboxSection.svelte
+++ b/src/components/ui/ToolboxSection.svelte
@@ -11,6 +11,7 @@
// The open/closed state is a LOCAL preference (localStorage, per section
// key): which sections a user keeps open is workflow, not scene data.
import { ChevronRight } from '@lucide/svelte';
+ import { safeStorage } from '$lib/safeStorage';
/** @type {{ key: string, label: string, open?: boolean, forceOpen?: boolean,
* id?: string, children: any }} */
@@ -23,14 +24,14 @@
const isOpen = $derived.by(() => {
if (forceOpen) return true;
const saved =
- override ?? (typeof localStorage !== 'undefined' ? localStorage.getItem(storeKey) : null);
+ override ?? (typeof localStorage !== 'undefined' ? safeStorage.getItem(storeKey) : null);
return saved === null ? open : saved === 'open';
});
function toggle() {
override = isOpen ? 'closed' : 'open';
try {
- localStorage.setItem(storeKey, override);
+ safeStorage.setItem(storeKey, override);
} catch {}
}
diff --git a/src/components/ui/ToolboxWindow.svelte b/src/components/ui/ToolboxWindow.svelte
index 9019d906..8fb2c5c4 100644
--- a/src/components/ui/ToolboxWindow.svelte
+++ b/src/components/ui/ToolboxWindow.svelte
@@ -47,6 +47,7 @@
import { dragWindow } from '$lib/dragWindow';
import { focusStack } from '$lib/windowFocus';
import { notesDrawerOpen, inspectorClose } from '../../stores/appStore';
+ import { safeStorage } from '$lib/safeStorage';
/** @type {{ id: string, title: string, key: string,
* defaultRect?: { left?: number, top?: number, right?: number, bottom?: number },
@@ -89,7 +90,7 @@
const sheetKey = $derived('tbxSheetH:' + key);
$effect(() => {
if (sheetH || typeof window === 'undefined') return;
- const saved = parseInt(localStorage.getItem(sheetKey) || '');
+ const saved = parseInt(safeStorage.getItem(sheetKey) || '');
sheetH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.4) : saved;
});
let sheetResizing = $state(false);
@@ -121,7 +122,7 @@
/** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId);
} catch {}
try {
- localStorage.setItem(sheetKey, String(sheetH));
+ safeStorage.setItem(sheetKey, String(sheetH));
} catch {}
}
diff --git a/src/lib/ai/meshProviders.js b/src/lib/ai/meshProviders.js
index 79a1e6b9..30cdb179 100644
--- a/src/lib/ai/meshProviders.js
+++ b/src/lib/ai/meshProviders.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from '../safeStorage';
// Text/image -> 3D mesh generation providers (roadmap #11, G1). Mirrors
// ai/providers.js (the LLM providers) but for mesh backends: a self-hosted ComfyUI
@@ -53,7 +54,7 @@ const ENABLED_KEY = 'meshGenEnabled';
/** @returns {MeshProviderConfig[]} */
function loadProviders() {
try {
- const raw = localStorage.getItem(PROVIDERS_KEY);
+ const raw = safeStorage.getItem(PROVIDERS_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return Array.isArray(parsed) ? parsed : [];
} catch {
@@ -64,7 +65,7 @@ function loadProviders() {
/** @param {MeshProviderConfig[]} list */
function persist(list) {
try {
- localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list));
+ safeStorage.setItem(PROVIDERS_KEY, JSON.stringify(list));
} catch {}
}
@@ -75,7 +76,7 @@ export const meshProviders = writable(loadProviders());
export const meshActiveProvider = writable(
(() => {
try {
- return localStorage.getItem(ACTIVE_KEY) || null;
+ return safeStorage.getItem(ACTIVE_KEY) || null;
} catch {
return null;
}
@@ -86,7 +87,7 @@ export const meshActiveProvider = writable(
export const meshGenEnabled = writable(
(() => {
try {
- return localStorage.getItem(ENABLED_KEY) === 'true';
+ return safeStorage.getItem(ENABLED_KEY) === 'true';
} catch {
return false;
}
@@ -160,8 +161,8 @@ export function removeMeshProvider(id) {
export function setMeshActiveProvider(id) {
meshActiveProvider.set(id);
try {
- if (id) localStorage.setItem(ACTIVE_KEY, id);
- else localStorage.removeItem(ACTIVE_KEY);
+ if (id) safeStorage.setItem(ACTIVE_KEY, id);
+ else safeStorage.removeItem(ACTIVE_KEY);
} catch {}
}
@@ -169,7 +170,7 @@ export function setMeshActiveProvider(id) {
export function setMeshGenEnabled(on) {
meshGenEnabled.set(!!on);
try {
- localStorage.setItem(ENABLED_KEY, String(!!on));
+ safeStorage.setItem(ENABLED_KEY, String(!!on));
} catch {}
}
diff --git a/src/lib/ai/providers.js b/src/lib/ai/providers.js
index de2adda2..754dbfc7 100644
--- a/src/lib/ai/providers.js
+++ b/src/lib/ai/providers.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from '../safeStorage';
// AI provider settings (roadmap #10, A1). A LOCAL per-device preference — the
// only credentials the app stores. Keys live in PLAINTEXT localStorage (there is
@@ -89,7 +90,7 @@ const ENABLED_KEY = 'aiEnabled';
/** @returns {AiProviderConfig[]} */
function loadProviders() {
try {
- const raw = localStorage.getItem(PROVIDERS_KEY);
+ const raw = safeStorage.getItem(PROVIDERS_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return Array.isArray(parsed) ? parsed : [];
} catch {
@@ -100,7 +101,7 @@ function loadProviders() {
/** @param {AiProviderConfig[]} list */
function persistProviders(list) {
try {
- localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list));
+ safeStorage.setItem(PROVIDERS_KEY, JSON.stringify(list));
} catch {}
}
@@ -113,7 +114,7 @@ export const aiProviders = writable(loadProviders());
export const aiActiveProvider = writable(
(() => {
try {
- return localStorage.getItem(ACTIVE_KEY) || null;
+ return safeStorage.getItem(ACTIVE_KEY) || null;
} catch {
return null;
}
@@ -124,7 +125,7 @@ export const aiActiveProvider = writable(
export const aiEnabled = writable(
(() => {
try {
- return localStorage.getItem(ENABLED_KEY) === 'true';
+ return safeStorage.getItem(ENABLED_KEY) === 'true';
} catch {
return false;
}
@@ -203,8 +204,8 @@ export function removeAiProvider(id) {
export function setAiActiveProvider(id) {
aiActiveProvider.set(id);
try {
- if (id) localStorage.setItem(ACTIVE_KEY, id);
- else localStorage.removeItem(ACTIVE_KEY);
+ if (id) safeStorage.setItem(ACTIVE_KEY, id);
+ else safeStorage.removeItem(ACTIVE_KEY);
} catch {}
}
@@ -212,7 +213,7 @@ export function setAiActiveProvider(id) {
export function setAiEnabled(on) {
aiEnabled.set(!!on);
try {
- localStorage.setItem(ENABLED_KEY, String(!!on));
+ safeStorage.setItem(ENABLED_KEY, String(!!on));
} catch {}
}
diff --git a/src/lib/ai/tools.js b/src/lib/ai/tools.js
index 3f5fc5ac..5178503d 100644
--- a/src/lib/ai/tools.js
+++ b/src/lib/ai/tools.js
@@ -1,5 +1,5 @@
import { get } from 'svelte/store';
-import { objectsGroup, lockedObjects } from '../../stores/sceneStore.js';
+import { objectsGroup, lockedObjects, pokeScene } from '../../stores/sceneStore.js';
import { peers } from '../../stores/appStore.js';
import { createGeometry, createLight, createGroup } from '$lib/geometries.svelte.js';
import { recordObjectPresence, recordTransform } from '$lib/history';
@@ -245,7 +245,7 @@ function applyAiTransform(object, t) {
scale: object.scale.toArray()
};
notifyExternalMove(object.uuid);
- objectsGroup.update((v) => v);
+ pokeScene();
broadcast({ type: 'move', uuid: object.uuid, pos: after.pos, rot: after.rot, scale: after.scale });
recordTransform({ uuid: object.uuid, before, after });
}
diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js
index f7b4114b..31bb6077 100644
--- a/src/lib/animatedImports.js
+++ b/src/lib/animatedImports.js
@@ -4,7 +4,7 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { registerHistoryKind, recordEntry } from './history';
import { runtimeNow } from './moduleSDK';
@@ -178,7 +178,7 @@ export async function applyObjectFile(data) {
if (data.pos) held.position.fromArray(data.pos);
if (data.rot) held.rotation.set(data.rot[0], data.rot[1], data.rot[2]);
if (data.scale) held.scale.fromArray(data.scale);
- objectsGroup.update((value) => value);
+ pokeScene();
if (data.anim) setAnimationState(data.uuid, data.anim, false);
return;
}
@@ -192,7 +192,7 @@ export async function applyObjectFile(data) {
if (data.rot) root.rotation.set(data.rot[0], data.rot[1], data.rot[2]);
if (data.scale) root.scale.fromArray(data.scale);
group.add(root);
- objectsGroup.update((value) => value);
+ pokeScene();
registerAnimatedImport(root, animations, bytes, data.kind === 'fbx' ? 'fbx' : 'gltf');
if (data.anim) setAnimationState(data.uuid, data.anim, false);
} catch (error) {
@@ -329,7 +329,7 @@ export async function animatedImportsRestore(entries, replicate = true) {
console.log('animated import restore failed', error);
}
}
- if (restored) objectsGroup.update((value) => value);
+ if (restored) pokeScene();
return restored;
}
@@ -383,7 +383,7 @@ registerHistoryKind('animimport', (entry, state) => {
return false;
}
existing.parent?.remove(existing);
- objectsGroup.update((value) => value);
+ pokeScene();
if (peer) peer.send({ type: 'delete', uuid: entry.uuid, peerId: peer.peer.id });
return true;
});
diff --git a/src/lib/animationPreview.js b/src/lib/animationPreview.js
index e214ba02..1cfa2e9d 100644
--- a/src/lib/animationPreview.js
+++ b/src/lib/animationPreview.js
@@ -1,4 +1,5 @@
import * as THREE from 'three';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { writable, get } from 'svelte/store';
import { objectsGroup } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
@@ -122,7 +123,7 @@ function newId() {
* flowRuntime's syncedNow / moduleSDK's runtimeNow (neither is exported; both
* read this store). Wall clock wrapped daily to keep float precision. */
function syncedNow() {
- return get(syncedAnimations) ? (Date.now() % 86400000) / 1000 : performance.now() / 1000;
+ return get(syncedAnimations) ? (sessionNow() % 86400000) / 1000 : performance.now() / 1000;
}
/** @param {string} uuid */
@@ -1040,7 +1041,7 @@ registerHistoryKind('anim', (entry, state) => {
if (!set) stop(entry.uuid);
animations.update((map) => {
const next = { ...map };
- if (set) next[entry.uuid] = { ...set, changedAt: Date.now() };
+ if (set) next[entry.uuid] = { ...set, changedAt: sessionNow() };
else delete next[entry.uuid];
return next;
});
@@ -1061,7 +1062,7 @@ function editSet(uuid, fn) {
const next = fn(structuredClone(set));
if (!next) return map;
changed = true;
- return { ...map, [uuid]: { ...next, changedAt: Date.now() } };
+ return { ...map, [uuid]: { ...next, changedAt: sessionNow() } };
});
if (!changed || gesture) return;
recordAnimEntry(uuid, before, get(animations)[uuid]);
@@ -2082,7 +2083,7 @@ export function copyAnimationsFrom(set, toUuid) {
// clip ids are per object, so they can stay as they are
const copy = normalizeAnimSet(structuredClone(source));
if (!copy) return false;
- animations.update((map) => ({ ...map, [toUuid]: { ...copy, changedAt: Date.now() } }));
+ animations.update((map) => ({ ...map, [toUuid]: { ...copy, changedAt: sessionNow() } }));
broadcastAnim(toUuid);
return true;
}
@@ -2174,7 +2175,7 @@ function parkedPosition(clip, p) {
function setPlay(uuid, patch, replicate = false) {
playback.update((map) => ({
...map,
- [uuid]: { ...playOf(uuid), ...patch, changedAt: patch.changedAt ?? Date.now() }
+ [uuid]: { ...playOf(uuid), ...patch, changedAt: patch.changedAt ?? sessionNow() }
}));
if (replicate) broadcastPlay(uuid);
}
diff --git a/src/lib/annotationsHandler.js b/src/lib/annotationsHandler.js
index a8b30b8b..49749832 100644
--- a/src/lib/annotationsHandler.js
+++ b/src/lib/annotationsHandler.js
@@ -21,6 +21,7 @@ import {
} from '../stores/appStore';
import { registerAnnotationsPersistence, markAnnotationsDirty } from './autosave';
import { flyTo } from './objectActions';
+import { safeStorage } from './safeStorage';
// Synced note pins on objects. Offsets are object-local so pins follow their
// object; one note per pin. Replication mirrors the flow-graph pattern:
@@ -49,10 +50,10 @@ export const noteMarkers = writable([]);
/** H3: LOCAL pref — pins visible in the viewport (not replicated) */
export const showNotePins = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('showNotePins') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('showNotePins') !== 'false'
);
if (typeof localStorage !== 'undefined')
- showNotePins.subscribe((value) => localStorage.setItem('showNotePins', String(value)));
+ showNotePins.subscribe((value) => safeStorage.setItem('showNotePins', String(value)));
/** H9: pin shapes (replicated per note; 'round' = the historical pin) */
export const NOTE_SHAPES = ['round', 'star', 'square'];
@@ -172,9 +173,9 @@ let authorKeyCache = '';
export function myAuthorKey() {
if (authorKeyCache) return authorKeyCache;
try {
- const stored = localStorage.getItem(AUTHOR_KEY);
+ const stored = safeStorage.getItem(AUTHOR_KEY);
authorKeyCache = stored || crypto.randomUUID();
- if (!stored) localStorage.setItem(AUTHOR_KEY, authorKeyCache);
+ if (!stored) safeStorage.setItem(AUTHOR_KEY, authorKeyCache);
} catch {
authorKeyCache = 'local';
}
diff --git a/src/lib/arProbe.js b/src/lib/arProbe.js
index 39ceb61b..ef741fae 100644
--- a/src/lib/arProbe.js
+++ b/src/lib/arProbe.js
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// CO0 — the on-device WebXR capability probe.
//
@@ -47,7 +48,7 @@ const RESTORE_DEADLINE = 45;
function loadFindings() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(FINDINGS_KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(FINDINGS_KEY) : null;
const stored = raw ? JSON.parse(raw) : null;
return Array.isArray(stored) ? stored : [];
} catch {
@@ -67,7 +68,7 @@ export const probeRunning = writable(false);
/** @param {any} list */
function persistFindings(list) {
try {
- if (typeof localStorage !== 'undefined') localStorage.setItem(FINDINGS_KEY, JSON.stringify(list));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(FINDINGS_KEY, JSON.stringify(list));
} catch {
// private mode / quota: the on-screen report still works for this run
}
@@ -143,7 +144,7 @@ function ago(ms) {
function readStoredAnchor() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(ANCHOR_KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(ANCHOR_KEY) : null;
const stored = raw ? JSON.parse(raw) : null;
return stored && typeof stored.handle === 'string' && stored.handle ? stored : null;
} catch {
@@ -154,7 +155,7 @@ function readStoredAnchor() {
/** @param {any} record */
function writeStoredAnchor(record) {
try {
- if (typeof localStorage !== 'undefined') localStorage.setItem(ANCHOR_KEY, JSON.stringify(record));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(ANCHOR_KEY, JSON.stringify(record));
return true;
} catch {
return false;
@@ -599,8 +600,8 @@ export async function clearProbeState() {
resetFindings();
try {
if (typeof localStorage !== 'undefined') {
- localStorage.removeItem(ANCHOR_KEY);
- localStorage.removeItem(FINDINGS_KEY);
+ safeStorage.removeItem(ANCHOR_KEY);
+ safeStorage.removeItem(FINDINGS_KEY);
}
} catch {
// nothing to do — the store is already reset
diff --git a/src/lib/audioDevices.js b/src/lib/audioDevices.js
index ee9cdb13..f694628b 100644
--- a/src/lib/audioDevices.js
+++ b/src/lib/audioDevices.js
@@ -1,6 +1,7 @@
import * as THREE from 'three';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { writable, get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
// The device write path rides the existing 'props' history kind (objectActions.js
// owns the replay), so this module needs history only to RECORD — a static edge
@@ -322,7 +323,7 @@ export function setDeviceFor(uuid, patch, opts = {}) {
// a knob wants its sound NOW, not after the debounce — apply locally at once; the
// reconcile below is the backstop for everything that did not come through here
applyParams(object);
- objectsGroup.update((value) => value);
+ pokeScene();
return next ? structuredClone(next) : null;
}
@@ -346,7 +347,7 @@ export function previewDeviceParams(uuid, params, opts = {}) {
const peer = get(peers);
if (peer) peer.send({ type: 'objectParameters', parameter: 'device', uuid, device: next });
}
- if (opts.poke !== false) objectsGroup.update((value) => value);
+ if (opts.poke !== false) pokeScene();
return structuredClone(next);
}
@@ -364,7 +365,7 @@ export function applyRemoteDevice(data) {
if (data.device && typeof data.device === 'object') object.userData.device = normalizeDevice(data.device);
else delete object.userData.device;
applyParams(object);
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
}
@@ -404,7 +405,7 @@ export function addDevice(kind, opts = {}) {
// this the wire copy carries an identity matrix and every peer places the device at
// the origin (found by C1's flight: A held the speaker at [-10,10,14], B at [0,0,0])
object.updateMatrix();
- objectsGroup.update((value) => value);
+ pokeScene();
recordObjectPresence('create', object);
/** @type {any} */
const peer = get(peers);
@@ -608,7 +609,7 @@ export function deviceHandle(uuid) {
*/
export function noteDevice(uuid, note = {}, opts = {}) {
const object = get(objectsGroup)?.getObjectByProperty('uuid', uuid);
- const event = { note: 60, velocity: 1, ...note, at: typeof note.at === 'number' ? note.at : Date.now() };
+ const event = { note: 60, velocity: 1, ...note, at: typeof note.at === 'number' ? note.at : sessionNow() };
deliverNote(object, event);
if (opts.replicate === false) return event;
/** @type {any} */
diff --git a/src/lib/audioEngine.js b/src/lib/audioEngine.js
index dcdedc82..4ae9db4d 100644
--- a/src/lib/audioEngine.js
+++ b/src/lib/audioEngine.js
@@ -1,6 +1,8 @@
+// 25-E: wall stamps on the wire are SESSION time; `sessionClock` is itself a store-only leaf
+import { sessionNow } from './sessionClock';
// The audio ENGINE (roadmap #22 A1, cloud plans-core/pending/22-a-audio-engine.md).
//
-// A deliberate LEAF: it imports nothing of ours, so `peerHandler` / `sessions` /
+// A deliberate LEAF: it imports nothing of ours (bar the store-only `sessionClock`), so `peerHandler` / `sessions` /
// `autosave` can all reach it and so its maths is testable with no GL context and
// no scene. That is the `scenePost` rule, and it is what keeps the whole audio
// stack out of the TDZ cycle family around `history`.
@@ -170,8 +172,8 @@ function audioClockOffset() {
}
/**
- * Map a WALL-CLOCK stamp (a `Date.now()` value, which is what every replicated
- * message carries) onto this context's `currentTime`, so a "play at beat 4"
+ * Map a WALL-CLOCK stamp (a `sessionNow()` value since 25-E, which is what every
+ * replicated message carries) onto this context's `currentTime`, so a "play at beat 4"
* message can become an `osc.start(t)`.
*
* Through the clock filter above, so two stamps a beat apart map to audio times a
@@ -185,7 +187,7 @@ function audioClockOffset() {
*/
export function audioTimeFor(wallMs) {
const off = audioClockOffset();
- return off + (performance.now() + (wallMs - Date.now())) / 1000;
+ return off + (performance.now() + (wallMs - sessionNow())) / 1000;
}
/** This context's own clock. @returns {number} */
diff --git a/src/lib/audioPatch.js b/src/lib/audioPatch.js
index 4b062974..5eae850c 100644
--- a/src/lib/audioPatch.js
+++ b/src/lib/audioPatch.js
@@ -1,5 +1,6 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { writable, get } from 'svelte/store';
import { globalScene, objectsGroup } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
@@ -9,6 +10,7 @@ import { registerHistoryKind, recordEntry } from './history';
import { ensureAudioContext } from './audioEngine';
import { deviceHandle, deviceSpec, isDeviceObject } from './audioDevices';
import { wireframeActive } from './viewMode';
+import { safeStorage } from './safeStorage';
// THE PATCH (roadmap #23 A4, cloud plans-core/pending/23-a-audio-engine.md).
//
@@ -118,7 +120,7 @@ let applyingHistory = false;
function commit(fn, opts = {}) {
const before = get(patch);
const next = normalizePatch(fn(before));
- next.changedAt = Math.max(Date.now(), (before.changedAt || 0) + 1);
+ next.changedAt = Math.max(sessionNow(), (before.changedAt || 0) + 1);
patch.set(next);
if (opts.record !== false && !applyingHistory) recordPatchEntry(before, next);
broadcastPatch();
@@ -307,7 +309,7 @@ export function patchSnapshot(opts = {}) {
*/
export function patchRestore(payload, replicate = false) {
const next = normalizePatch(payload);
- next.changedAt = Math.max(Date.now(), (get(patch).changedAt || 0) + 1);
+ next.changedAt = Math.max(sessionNow(), (get(patch).changedAt || 0) + 1);
patch.set(next);
if (replicate) broadcastPatch();
return next;
@@ -381,7 +383,7 @@ export function reconcileRouting() {
/** LOCAL pref: draw the cables. On by default — a patch you cannot see is not much of
* a patch. */
-export const showCables = writable(typeof localStorage === 'undefined' || localStorage.getItem('showCables') !== 'false');
+export const showCables = writable(typeof localStorage === 'undefined' || safeStorage.getItem('showCables') !== 'false');
/** The flowSockets palette, by PORT kind, so a wire means the same thing in the 3D
* world and in the node editor: audio = orange (an effect), cv = number blue, midi =
@@ -550,7 +552,7 @@ export function startCables() {
});
showCables.subscribe((value) => {
try {
- localStorage.setItem('showCables', String(value));
+ safeStorage.setItem('showCables', String(value));
} catch {}
});
}
diff --git a/src/lib/autosave.js b/src/lib/autosave.js
index b699a2f3..ca7c37f4 100644
--- a/src/lib/autosave.js
+++ b/src/lib/autosave.js
@@ -3,7 +3,7 @@ import * as THREE from 'three';
import { get, writable } from 'svelte/store';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-import { objectsGroup, globalCamera, orbitControls } from '../stores/sceneStore';
+import { objectsGroup, globalCamera, orbitControls, pokeScene } from '../stores/sceneStore';
import { flowGraphs, restoreGraphs, SCENE_GRAPH } from '../stores/flowStore';
import { serializeGraphs } from './flowGraphs';
import { serializeNode, serializeEdge } from './nodesHandler';
@@ -22,11 +22,17 @@ import { transport, transportSnapshot, transportRestore } from './musicClock';
import { patch, patchSnapshot, patchRestore } from './audioPatch';
import { hudDocs, hudDocsSnapshot, hudDocsRestore } from './hudDocs';
import { gameState, gameStateSnapshot, gameStateRestore } from './gameState';
-import { peers, showToast, showInfoToast } from '../stores/appStore';
+import { peers, showToast, showInfoToast, dismissToastById } from '../stores/appStore';
import { isMultiMaterial, serializeMeshWithGroups } from './materialsHandler';
import { idbGet, idbPut, idbDelete } from './idb';
+// 27-B: recovery paths report through the diagnostics ring instead of console.log,
+// so a user can hand over what happened (hardening audit H4). A zero-import leaf.
+import { log, registerDiagnosticsSection } from './diagnostics';
// #20 P5: selection + edit session + panel layout, restored only on an EXPLICIT restore
import { captureEditResume, applyEditResume } from './editResume';
+import { disposeTree, keepSet } from './disposeTree';
+import { safeStorage } from './safeStorage';
+import { registerMetricSource } from './sceneBudget';
// Crash safety: snapshots of the scene (GLTF json), the node graph and the
// camera go to IndexedDB — debounced 30s after any change plus a 3-minute
@@ -35,9 +41,91 @@ import { captureEditResume, applyEditResume } from './editResume';
const DEBOUNCE_MS = 30_000;
const INTERVAL_MS = 180_000;
const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
+/**
+ * 27-H (hardening audit M5) — THE CADENCE ADAPTS TO WHAT A SAVE COSTS.
+ *
+ * A snapshot is one GLTF export of the whole scene on the main thread, so its cost
+ * grows with the scene while the interval stayed flat at 30s: on a big scene that is a
+ * hitch every half minute for as long as you keep editing, which is the "the app
+ * stutters periodically" report waiting to be filed. Above this threshold the interval
+ * doubles per doubling of the cost, so an export stays a roughly constant FRACTION of
+ * the time between saves instead of growing without bound.
+ */
+const SLOW_EXPORT_MS = 150;
+const MAX_DEBOUNCE_MS = 300_000;
+
+/**
+ * What autosave is doing and what it last cost. Rendered by the Storage panel, because
+ * a save cadence that quietly moved from 30s to 5 minutes is exactly the kind of
+ * adaptive behaviour a user should be able to SEE rather than guess at.
+ * @type {import('svelte/store').Writable<{lastExportMs: number, lastBytes: number,
+ * debounceMs: number, lastSaveAt: number, writes: number, coalesced: number,
+ * lastError: string | null}>}
+ */
+// 26-E (roadmap 26 section 3): what the last snapshot cost, for the budget sampler and
+// the stress rig. The status store already held both numbers; nothing sampled them.
+// Registered, not imported by sceneBudget — that module stays a leaf.
+registerMetricSource('autosaveExportMs', () => get(autosaveStatus).lastExportMs || null);
+registerMetricSource('autosaveBytes', () => get(autosaveStatus).lastBytes || null);
+
+export const autosaveStatus = writable({
+ lastExportMs: 0,
+ lastBytes: 0,
+ debounceMs: DEBOUNCE_MS,
+ lastSaveAt: 0,
+ /** snapshots actually written */
+ writes: 0,
+ /** saves asked for while one was already running, and therefore folded into it */
+ coalesced: 0,
+ lastError: /** @type {string | null} */ (null)
+});
+
+/**
+ * How long to wait after a change, given what the last export cost. PURE and exported
+ * so it can be asserted directly: ONE measurement decides the whole answer, which is
+ * what keeps this from oscillating the way a stateful "double it, halve it" rule does.
+ *
+ * 150ms or less -> 30s (unchanged) · 150-300 -> 1min · 300-600 -> 2min · 600-1200 ->
+ * 4min · beyond that the 5min cap.
+ * @param {number} exportMs @returns {number}
+ */
+export function cadenceFor(exportMs) {
+ if (!(exportMs > SLOW_EXPORT_MS)) return DEBOUNCE_MS;
+ const doublings = Math.ceil(Math.log2(exportMs / SLOW_EXPORT_MS));
+ return Math.min(MAX_DEBOUNCE_MS, DEBOUNCE_MS * 2 ** doublings);
+}
+
+/**
+ * A CHEAP size estimate. This used to be `JSON.stringify(snapshot).length` — a full
+ * serialisation of everything, thrown away immediately, purely to learn a number,
+ * after which the structured clone inside `idbPut` walked the same graph again. Near
+ * the 50MB ceiling the probe alone is hundreds of milliseconds, on the main thread,
+ * every single save.
+ *
+ * Almost every byte of a snapshot lives in a handful of base64 strings whose `.length`
+ * is free to read: the GLTF buffer and image data URIs, and the original file bytes of
+ * each animated import. The rest is structure, estimated from COUNTS. The number is
+ * approximate and says so — it exists to refuse a pathological write early, and
+ * `idbPut` remains the thing that actually fails on size.
+ * @param {any} snapshot @returns {number}
+ */
+export function estimateSnapshotBytes(snapshot) {
+ let bytes = 0;
+ const scene = snapshot?.scene;
+ for (const buffer of scene?.buffers ?? []) bytes += buffer?.uri?.length ?? buffer?.byteLength ?? 0;
+ for (const image of scene?.images ?? []) bytes += image?.uri?.length ?? 0;
+ for (const entry of snapshot?.animated ?? []) bytes += entry?.bytes?.length ?? 0;
+ // a multi-material twin carries its own toJSON, embedded textures included
+ for (const entry of snapshot?.multiMaterial ?? [])
+ for (const image of entry?.element?.images ?? []) bytes += image?.url?.length ?? 0;
+ // structure: node/mesh/accessor metadata, and the graph documents beside it
+ bytes += (scene?.nodes?.length ?? 0) * 400;
+ bytes += (snapshot?.nodes?.length ?? 0) * 300;
+ return bytes;
+}
export const autosaveEnabled = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('autosave') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('autosave') !== 'false'
);
/**
* 18-A: restore the snapshot on boot instead of asking. OFF by default — an
@@ -45,7 +133,7 @@ export const autosaveEnabled = writable(
* construction because checkRestore only ever fires on an EMPTY scene.
*/
export const autoRestoreEnabled = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('autoRestore') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('autoRestore') === 'true'
);
/** restore offer for the toast: { ts, objects, snapshot } | null */
/** @type {import('svelte/store').Writable} */
@@ -91,6 +179,15 @@ function multiMaterialSnapshot() {
function exportScene() {
return new Promise((resolve) => {
+ const started = performance.now();
+ /** M5: the measurement the cadence is derived from. Taken around the WHOLE export,
+ * park and stamp rituals included, because that is what the main thread spends.
+ * @param {any} result */
+ const done = (result) => {
+ const ms = performance.now() - started;
+ autosaveStatus.update((state) => ({ ...state, lastExportMs: ms, debounceMs: cadenceFor(ms) }));
+ resolve(result);
+ };
const group = get(objectsGroup);
if (!group || group.children.length === 0) return resolve(null);
// snapshots must store animation BASE poses, not the current swing (88)
@@ -118,21 +215,76 @@ function exportScene() {
unpark(); // before unstamp, so the parked objects lose their __uuid too
unstamp();
restore();
- resolve(result);
+ done(result);
},
(error) => {
unpark();
unstamp();
restore();
- console.log('autosave export failed', error);
- resolve(null);
+ log('warn', 'autosave', 'export failed', String(error));
+ done(null);
}
);
});
}
-async function saveSnapshot() {
- if (!get(autosaveEnabled)) return;
+/**
+ * 27-H (audit M3): ONE SAVE AT A TIME. `markDirty` rescheduled `saveSnapshot`
+ * unconditionally, and a snapshot is several awaits long (a GLTF export, then a put
+ * that may be bounded at 10s) — so on a scene where the export is slower than the
+ * debounce, every tick started a FRESH full export while the previous one was still
+ * running, each one parking and unparking the same objects. The one that is running
+ * will pick up whatever changed; a save asked for while it runs is remembered and
+ * scheduled once, when it finishes.
+ */
+let saving = false;
+let queuedWhileSaving = false;
+/** @type {Promise | null} the write in flight, so an explicit save can await it */
+let savingPromise = null;
+
+function saveSnapshot() {
+ if (!get(autosaveEnabled)) return Promise.resolve();
+ if (saving) {
+ queuedWhileSaving = true;
+ autosaveStatus.update((state) => ({ ...state, coalesced: state.coalesced + 1 }));
+ return savingPromise ?? Promise.resolve();
+ }
+ saving = true;
+ savingPromise = (async () => {
+ try {
+ await writeSnapshot();
+ } finally {
+ saving = false;
+ savingPromise = null;
+ if (queuedWhileSaving) {
+ queuedWhileSaving = false;
+ schedule();
+ }
+ }
+ })();
+ return savingPromise;
+}
+
+/** Is a snapshot being written right now? (Storage panel / tests) */
+export function isSaving() {
+ return saving;
+}
+
+/**
+ * TEST SEAM: exactly what the debounce timer calls — including the re-entrancy refusal,
+ * which `saveNow` deliberately does NOT do (it waits its turn instead). The suite needs
+ * the timer's path to prove that three ticks during one slow export produce ONE export.
+ */
+export function debugRequestSave() {
+ return saveSnapshot();
+}
+
+async function writeSnapshot() {
+ // What has changed BEFORE any of this runs. It has to be read HERE rather than
+ // beside the write: the GLTF export below is the slow part, so a change made
+ // during it is precisely the one that is NOT in the bytes we are about to store,
+ // and clearing `dirty` unconditionally at the end would mark it saved.
+ const markAtStart = get(dirtyPulse);
// H1: persist EVERY graph document; orphan object graphs (owner object gone)
// are pruned from the OUTPUT only. Legacy nodes/edges fields keep carrying the
// scene graph so an old build can still restore this snapshot.
@@ -208,18 +360,71 @@ async function saveSnapshot() {
? { position: camera.position.toArray(), target: controls?.target?.toArray() ?? [0, 0, 0] }
: null
};
+ const bytes = estimateSnapshotBytes(snapshot);
+ autosaveStatus.update((state) => ({ ...state, lastBytes: bytes }));
try {
- if (JSON.stringify(snapshot).length > MAX_SNAPSHOT_BYTES) {
- console.warn('autosave skipped: snapshot too large');
+ if (bytes > MAX_SNAPSHOT_BYTES) {
+ log('warn', 'autosave', 'snapshot too large, skipped', { bytes });
+ reportSaveFailure(
+ 'too-large',
+ 'This scene is too large to autosave, so crash recovery is off for it. Save it yourself.'
+ );
return;
}
await idbPut('latest', snapshot);
- dirty = false;
+ // a change made DURING the export is not in the bytes just written (the held-body
+ // `lastWritten` rule): clearing unconditionally would mark it saved when it isn't
+ if (get(dirtyPulse) === markAtStart) dirty = false;
+ autosaveStatus.update((state) => ({
+ ...state,
+ lastSaveAt: Date.now(),
+ writes: state.writes + 1,
+ lastError: null
+ }));
+ dismissToastById('autosave-failed');
} catch (error) {
- console.log('autosave failed', error);
+ log('warn', 'autosave', 'snapshot save failed', String(error));
+ const full = isQuotaError(error);
+ reportSaveFailure(
+ full ? 'quota' : 'failed',
+ full
+ ? 'There is no room left to autosave this session. Crash recovery is off until some space is freed.'
+ : 'Autosave could not write a snapshot, so crash recovery is off for now.',
+ String(error)
+ );
}
}
+/**
+ * Is this the disk being full? Every engine spells it differently and two of the three
+ * spellings are legacy numeric codes, so the name test alone would miss Firefox.
+ * @param {any} error
+ */
+function isQuotaError(error) {
+ const name = String(error?.name ?? '');
+ return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED' || error?.code === 22;
+}
+
+/**
+ * 27-H (audit M3): A FAILED AUTOSAVE IS SAID OUT LOUD. It used to reach `console.log`
+ * and stop there — so a full disk meant autosave had silently stopped and the
+ * crash-recovery promise was void with nothing to tell the user, which is the worst
+ * shape a safety feature can fail in. STICKY, because a 5s toast about losing work is
+ * a toast nobody reads, and it carries the way to act on it.
+ * @param {string} kind @param {string} text @param {string} [detail]
+ */
+function reportSaveFailure(kind, text, detail) {
+ autosaveStatus.update((state) => ({ ...state, lastError: detail ?? kind }));
+ showInfoToast('autosave-failed', text, [
+ {
+ label: 'Manage storage',
+ // storageUsage imports THIS module (clearSavedSession), so the edge has to be
+ // dynamic or it is a cycle
+ action: () => import('./storageUsage').then((m) => m.openStorageModal())
+ }
+ ]);
+}
+
/** 21-G8: one-shot listeners for "the scene just got dirtied" — the seam behind the
* "Save into your project" prompt after opening a loose .tpscene. Each fires ONCE and
* is removed BEFORE it runs (a listener that saves would re-enter markDirty).
@@ -244,8 +449,16 @@ function markDirty() {
fn();
} catch {}
}
+ schedule();
+}
+
+/**
+ * Arm the debounce at the CURRENT cadence — 30s normally, longer while the export is
+ * expensive. Split out of `markDirty` because the re-entrancy guard re-arms it too.
+ */
+function schedule() {
clearTimeout(debounceTimer);
- debounceTimer = setTimeout(saveSnapshot, DEBOUNCE_MS);
+ debounceTimer = setTimeout(saveSnapshot, get(autosaveStatus).debounceMs);
}
/** Phase 22 registers its annotations getter/setter here (avoids a hard dependency) */
@@ -275,16 +488,25 @@ async function checkRestore() {
if (!group) return;
setTimeout(() => unsubscribe(), 0);
if (group.children.length !== 0) return;
- const offer = { ts: snapshot.ts, objects: snapshot.objects ?? 0, snapshot };
+ let armed = false;
+ try {
+ armed = typeof localStorage !== 'undefined' && !!safeStorage.getItem('restoreArmed');
+ } catch {
+ /* unreadable storage reads as "not armed" — the old behaviour */
+ }
+ const offer = { ts: snapshot.ts, objects: snapshot.objects ?? 0, snapshot, risky: armed };
// 18-A: with auto-restore on, restore straight away and REPORT it. The
// offer deliberately never reaches `restoreAvailable` — the Toasts mirror
// would flash the "Restore previous session?" prompt for a frame before
// the restore nulled the store again.
- if (get(autoRestoreEnabled)) autoRestore(offer);
+ // 27-D: `risky` means the previous restore of this snapshot never reached a clean
+ // flow tick. Auto-restoring it again is how one bad scene becomes a boot loop the
+ // user cannot escape, so it always goes to the PROMPT, which says why.
+ if (get(autoRestoreEnabled) && !armed) autoRestore(offer);
else restoreAvailable.set(offer);
});
} catch (error) {
- console.log('autosave restore check failed', error);
+ log('warn', 'autosave', 'restore check failed', String(error));
}
}
@@ -325,7 +547,7 @@ function restoreMultiMaterial(entries) {
try {
mesh = loader.parse(entry.element);
} catch (error) {
- console.log('multi-material restore failed', error);
+ log('warn', 'autosave', 'multi-material restore failed', String(error));
continue;
}
stripEditOverlays(mesh);
@@ -336,8 +558,19 @@ function restoreMultiMaterial(entries) {
const parent = twin.parent ?? group;
parent.remove(twin);
parent.add(mesh);
+ // 27-G: the twin was parsed from the GLTF snapshot moments ago and is now replaced,
+ // so nothing else refers to its buffers — but compute a keep set anyway, and AFTER
+ // the add, so a resource the two happen to share is protected.
+ //
+ // The root here is the GROUP, where every other disposal site in this batch uses
+ // the whole SCENE. That is deliberate, not an oversight: the scene root matters
+ // when a helper shares a real mesh's resources (an onion-skin ghost shares its
+ // source geometry), and a twin parsed seconds ago inside this function cannot be
+ // the source of one. autosave does not import globalScene, and adding an import
+ // for symmetry alone would be a worse trade than saying so here.
+ disposeTree(twin, { keep: keepSet(get(objectsGroup), twin) });
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -347,6 +580,15 @@ function restoreMultiMaterial(entries) {
* @returns {Promise} did it land?
*/
async function applyRestore(snapshot) {
+ // 27-D: arm BEFORE the restore, clear on the first clean flow tick (flowRuntime).
+ // A flag still set at the next boot means this snapshot never reached a working
+ // frame — so the next boot must not silently restore it again. Placed here rather
+ // than at each call site so the explicit Restore button is covered too.
+ try {
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('restoreArmed', '1');
+ } catch {
+ /* private mode or a full quota: the guard degrades to the old behaviour */
+ }
const group = get(objectsGroup);
try {
if (snapshot.scene && group) {
@@ -376,7 +618,7 @@ async function applyRestore(snapshot) {
group.add(child);
if (peer) peer.send({ type: 'object', element: child.toJSON() });
});
- objectsGroup.update((value) => value);
+ pokeScene();
}
// multi-material meshes come back from their toJSON, REPLACING the Group of
// single-material children the GLTF export left behind (same twin-replacement
@@ -426,7 +668,7 @@ async function applyRestore(snapshot) {
}
return true;
} catch (error) {
- console.log('restore failed', error);
+ log('warn', 'autosave', 'restore failed', String(error));
return false;
}
}
@@ -448,9 +690,15 @@ export function dismissRestore() {
restoreAvailable.set(null);
}
-/** Immediate save (Settings action / tests) */
+/**
+ * Immediate save (Settings action / tests). With the re-entrancy guard in place a bare
+ * `saveSnapshot()` during an in-flight save would return having only QUEUED one, and
+ * this is the path whose whole promise is "it is on disk when I resolve" — so it waits
+ * for the running write and then takes its own turn.
+ */
export function saveNow() {
- return saveSnapshot();
+ const inflight = savingPromise;
+ return inflight ? inflight.then(() => saveSnapshot()) : saveSnapshot();
}
/**
@@ -504,13 +752,26 @@ export function startAutosave() {
// and once more: a game's state changes touch no object either
gameState.subscribe(() => markDirty());
setInterval(() => {
- if (dirty) saveSnapshot();
+ // M5: the safety-net interval has to respect the adaptive cadence as well, or a
+ // scene that backed off to 5 minutes still pays for a full export every 3
+ // and the backoff buys nothing
+ const state = get(autosaveStatus);
+ if (dirty && Date.now() - state.lastSaveAt >= Math.min(state.debounceMs, INTERVAL_MS)) saveSnapshot();
}, INTERVAL_MS);
window.addEventListener('beforeunload', () => {
// best effort — the async export may not finish, the debounce usually already ran
if (dirty) saveSnapshot();
});
- autosaveEnabled.subscribe((value) => localStorage.setItem('autosave', String(value)));
- autoRestoreEnabled.subscribe((value) => localStorage.setItem('autoRestore', String(value)));
+ autosaveEnabled.subscribe((value) => safeStorage.setItem('autosave', String(value)));
+ autoRestoreEnabled.subscribe((value) => safeStorage.setItem('autoRestore', String(value)));
+ // 27-H: the storage story belongs in the bundle a user hands over. "Autosave last
+ // failed with QuotaExceededError and has been backing off to 5 minutes" is the
+ // single most useful line for a lost-work report, and nowhere else records it.
+ registerDiagnosticsSection('autosave', () => ({
+ ...get(autosaveStatus),
+ enabled: get(autosaveEnabled),
+ dirty,
+ saving
+ }));
checkRestore();
}
diff --git a/src/lib/cameraBookmarks.js b/src/lib/cameraBookmarks.js
index e070a188..ae649d6e 100644
--- a/src/lib/cameraBookmarks.js
+++ b/src/lib/cameraBookmarks.js
@@ -3,6 +3,7 @@ import { globalCamera, orbitControls } from '../stores/sceneStore';
import { showToast } from '../stores/appStore';
import { flyTo } from './objectActions';
import { cameraNear, cameraFar, setCameraNear, setCameraFar } from './cameraClip';
+import { safeStorage } from './safeStorage';
// Saved camera views, persisted LOCALLY (never replicated), recalled from the
// viewport menu, Configure Scene ▸ Camera, or Shift+1..5 for the first five.
@@ -38,7 +39,7 @@ export function normalizeBookmark(entry, index) {
function load() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null;
const list = raw ? JSON.parse(raw) : [];
return Array.isArray(list) ? list.map(normalizeBookmark) : [];
} catch {
@@ -50,7 +51,7 @@ function load() {
export const bookmarks = writable(load());
bookmarks.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value));
});
/** the current view as a bookmark payload, or null when the camera isn't ready */
diff --git a/src/lib/cameraClip.js b/src/lib/cameraClip.js
index cdd0920a..89867559 100644
--- a/src/lib/cameraClip.js
+++ b/src/lib/cameraClip.js
@@ -1,6 +1,7 @@
import { writable, get } from 'svelte/store';
import { editorCam, playerCam, orbitControls } from '../stores/sceneStore';
import { sceneRadius } from './sceneBounds';
+import { safeStorage } from './safeStorage';
// Camera clip planes (123): a LOCAL per-device view preference (never
// replicated) exposed in Configure Scene. The far plane still grows to fit the
@@ -14,7 +15,7 @@ const FAR_CAP = 200000;
/** @param {string} key @param {number} fallback */
function stored(key, fallback) {
try {
- const v = parseFloat(localStorage.getItem(key) ?? '');
+ const v = parseFloat(safeStorage.getItem(key) ?? '');
return isFinite(v) ? v : fallback;
} catch {
return fallback;
@@ -59,7 +60,7 @@ export function setCameraNear(v) {
const n = Math.min(Math.max(v, 0.001), 10);
cameraNear.set(n);
try {
- localStorage.setItem('cameraNear', String(n));
+ safeStorage.setItem('cameraNear', String(n));
} catch {}
applyCameraClip();
}
@@ -69,7 +70,7 @@ export function setCameraFar(v) {
const f = Math.min(Math.max(v, 10), FAR_CAP);
cameraFar.set(f);
try {
- localStorage.setItem('cameraFar', String(f));
+ safeStorage.setItem('cameraFar', String(f));
} catch {}
applyCameraClip();
}
@@ -83,7 +84,7 @@ export const DEFAULT_ORBIT = { rotateSpeed: 1, zoomSpeed: 1, panSpeed: 1, dampin
function storedOrbit() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('orbitPrefs') : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem('orbitPrefs') : null;
return raw ? { ...DEFAULT_ORBIT, ...JSON.parse(raw) } : { ...DEFAULT_ORBIT };
} catch {
return { ...DEFAULT_ORBIT };
@@ -110,7 +111,7 @@ export function applyOrbitPrefs() {
export function setOrbitPrefs(patch) {
orbitPrefs.update((value) => ({ ...value, ...patch }));
try {
- localStorage.setItem('orbitPrefs', JSON.stringify(get(orbitPrefs)));
+ safeStorage.setItem('orbitPrefs', JSON.stringify(get(orbitPrefs)));
} catch {}
applyOrbitPrefs();
}
@@ -118,7 +119,7 @@ export function setOrbitPrefs(patch) {
export function resetOrbitPrefs() {
orbitPrefs.set({ ...DEFAULT_ORBIT });
try {
- localStorage.setItem('orbitPrefs', JSON.stringify(DEFAULT_ORBIT));
+ safeStorage.setItem('orbitPrefs', JSON.stringify(DEFAULT_ORBIT));
} catch {}
applyOrbitPrefs();
}
diff --git a/src/lib/cameraHelpers.js b/src/lib/cameraHelpers.js
index e8b8c19c..ec90b20a 100644
--- a/src/lib/cameraHelpers.js
+++ b/src/lib/cameraHelpers.js
@@ -8,6 +8,7 @@ import { wireframeActive } from './viewMode';
// without the debug toggle, or a camera preview) — see helperLayer.js for the rule
import { markHelper, setMarkersHidden, helpersHidden, helpersInPlay } from './helperLayer';
import { isLocked } from '../stores/sceneStore';
+import { safeStorage } from './safeStorage';
// 16-P5: frustum visualization for camera OBJECTS — the colliderHelpers pattern.
// One wireframe frustum per camera object, built from `userData.camera` and
@@ -18,7 +19,7 @@ import { isLocked } from '../stores/sceneStore';
// much of a camera. `showCameraFrustums` is a LOCAL pref for turning it off.
export const showCameraFrustums = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('showCameraFrustums') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('showCameraFrustums') !== 'false'
);
/** the camera currently PREVIEWED — its own frustum is pointless (you're inside it)
@@ -185,7 +186,7 @@ export function startCameraHelpers() {
});
showCameraFrustums.subscribe((value) => {
try {
- localStorage.setItem('showCameraFrustums', String(value));
+ safeStorage.setItem('showCameraFrustums', String(value));
} catch {}
sync();
});
diff --git a/src/lib/cameraObjects.js b/src/lib/cameraObjects.js
index 989f478b..4aec1ad3 100644
--- a/src/lib/cameraObjects.js
+++ b/src/lib/cameraObjects.js
@@ -1,6 +1,6 @@
import { get } from 'svelte/store';
import * as THREE from 'three';
-import { objectsGroup, globalCamera, orbitControls, globalRenderer, globalScene } from '../stores/sceneStore';
+import { objectsGroup, globalCamera, orbitControls, globalRenderer, globalScene, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordEntry } from './history';
import { flyTo } from './objectActions';
@@ -85,7 +85,7 @@ export function setCameraFor(uuid, patch) {
const peer = get(peers);
if (peer) peer.send({ type: 'objectParameters', parameter: 'camera', uuid, camera: next });
// THREE trees are not reactive — poke so the list/viz/preview see it
- objectsGroup.update((value) => value);
+ pokeScene();
return next;
}
@@ -94,7 +94,7 @@ export function applyRemoteCamera(data) {
const object = get(objectsGroup)?.getObjectByProperty('uuid', data.uuid);
if (!object) return;
object.userData.camera = { ...DEFAULT_CAMERA, ...(data.camera ?? {}) };
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** Build (or update) a real THREE camera from a marker. Used by preview + Capture.
@@ -170,7 +170,7 @@ export function setCameraFromView(uuid) {
});
if (typeof view.fov === 'number' && cameraSpec(object).kind === 'perspective')
setCameraFor(uuid, { fov: Math.round(view.fov) });
- else objectsGroup.update((value) => value);
+ else pokeScene();
}
/**
diff --git a/src/lib/cameraPreview.js b/src/lib/cameraPreview.js
index dff95e65..9c5a976b 100644
--- a/src/lib/cameraPreview.js
+++ b/src/lib/cameraPreview.js
@@ -1,6 +1,6 @@
import { writable, derived, get } from 'svelte/store';
import * as THREE from 'three';
-import { objectsGroup, orbitControls } from '../stores/sceneStore';
+import { objectsGroup, orbitControls, pokeScene } from '../stores/sceneStore';
import { peers, showToast, specatorMode } from '../stores/appStore';
import { recordTransformSet } from './history';
import { findCameraObject, cameraSpec } from './cameraObjects';
@@ -129,7 +129,7 @@ function setMarkerHidden(object, hide) {
object.visible = markerWasVisible;
markerWasVisible = null;
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** Broadcast our preview state so peers can see (and join) it. @param {string|null} uuid */
diff --git a/src/lib/clockSync.js b/src/lib/clockSync.js
new file mode 100644
index 00000000..b1ee3526
--- /dev/null
+++ b/src/lib/clockSync.js
@@ -0,0 +1,157 @@
+import { get } from 'svelte/store';
+import { peers, showToast } from '../stores/appStore';
+import { sessionHost } from './connectionState';
+import {
+ recordClockSample,
+ noteRemoteSessionClock,
+ setClockReference,
+ setClockSelf,
+ sessionOffset,
+ peerClocks,
+ MIN_SAMPLES
+} from './sessionClock';
+
+/**
+ * 25-E — THE WIRE HALF OF THE SESSION CLOCK (the round trip moved here from musicClock,
+ * where 23-A2 built it; `sessionClock.js` is the leaf that turns its answers into
+ * `sessionNow()`).
+ *
+ * Additive on the wire, both ways: a pong gains `so` (the responder's own session offset)
+ * and `ref` (whose clock that is). An OLDER peer answers without them, which reads as "my
+ * raw clock", exactly what it is keeping — and an older peer receiving the extra fields
+ * ignores them. `clockping`/`clockpong` sit on cloudHooks' ALWAYS_ALLOWED floor: a plugin
+ * gating them would silently put a viewer's every stamp out of step with the room.
+ */
+
+/** how many pings the connect burst sends, how far apart, and how long after the
+ * handshake it starts. MEASURED (23-A2): samples taken during the connect storm (the
+ * joiner is receiving objects, compiling shaders, first-painting) carried 100+ ms of
+ * one-sided main-thread delay and pulled a 6-sample median to +427 ms on a true +300 —
+ * so the burst waits for the storm to pass, and the filter discounts what it catches. */
+const BURST = 6;
+const BURST_GAP_MS = 250;
+const BURST_DELAY_MS = 2000;
+/** steady-state re-measure, so a drifting clock is tracked and storm samples age out */
+const RESYNC_MS = 5000;
+/** 25-E: a peer this far off our RAW clock gets one toast — the session corrects for it,
+ * but a device whose date and time are wrong is wrong for every other app too */
+export const SKEW_TOAST_MS = 2000;
+
+/** @param {string} peerId @returns {any} the stable OUTGOING conn, or null */
+function connFor(peerId) {
+ /** @type {any} */
+ const peer = get(peers);
+ const conn = peer?.connections?.[peerId];
+ return conn && conn.open ? conn : null;
+}
+
+/** One ping. Returns false when there is no open conn to send it on. @param {string} peerId */
+export function sendClockPing(peerId) {
+ const conn = connFor(peerId);
+ if (!conn) return false;
+ /** @type {any} */
+ const peer = get(peers);
+ setClockSelf(peer?.peer?.id ?? null);
+ conn.send({ type: 'clockping', sender: peer.peer.id, t0: Date.now() });
+ return true;
+}
+
+/**
+ * Answer a ping. Stamped on receipt (t1) and again on send (t2) so the responder's
+ * own processing time is subtracted out of the round trip. The four stamps stay on the
+ * RAW clock — the estimate is of the machines — and `so`/`ref` say what we do with ours.
+ * Replies over our stable OUTGOING conn to the sender (golden rule 9), falling back to
+ * the conn it arrived on while the dance is still settling.
+ * @param {any} data @param {any} [arrivedOn]
+ */
+export function answerClockPing(data, arrivedOn) {
+ const t1 = Date.now();
+ if (!data || typeof data.t0 !== 'number') return;
+ /** @type {any} */
+ const peer = get(peers);
+ const conn = connFor(data.sender) ?? (arrivedOn && arrivedOn.open ? arrivedOn : null);
+ if (!conn) return;
+ conn.send({
+ type: 'clockpong',
+ sender: peer?.peer?.id ?? '',
+ t0: data.t0,
+ t1,
+ t2: Date.now(),
+ so: sessionOffset(),
+ ref: get(sessionHost) ?? null
+ });
+}
+
+/** Fold a pong into the sender's estimate. @param {any} data */
+export function applyClockPong(data) {
+ const t3 = Date.now();
+ if (!data || typeof data.t0 !== 'number' || typeof data.t1 !== 'number' || typeof data.t2 !== 'number') return;
+ if (!data.sender) return;
+ const sender = String(data.sender);
+ const rtt = t3 - data.t0 - (data.t2 - data.t1);
+ const offset = (data.t1 - data.t0 + (data.t2 - t3)) / 2;
+ // the remote clock first, so the sample that follows decides with both halves known
+ noteRemoteSessionClock(sender, data.so, data.ref);
+ recordClockSample(sender, offset, rtt);
+ maybeWarnSkew(sender);
+}
+
+/** @type {Set} peers we have already told the user about, for this tab */
+const warnedSkew = new Set();
+
+/** @param {number} ms */
+function describe(ms) {
+ const s = Math.round(Math.abs(ms) / 1000);
+ if (s < 120) return s + ' s';
+ const m = Math.round(s / 60);
+ return m < 120 ? m + ' min' : Math.round(m / 60) + ' h';
+}
+
+/**
+ * One toast per peer, once the estimate has something behind it. Says which way and by
+ * how much, and that the session already copes — the useful act is fixing the device.
+ * @param {string} peerId
+ */
+function maybeWarnSkew(peerId) {
+ if (warnedSkew.has(peerId)) return;
+ const est = get(peerClocks)[peerId];
+ if (!est || est.samples < MIN_SAMPLES || Math.abs(est.offset) <= SKEW_TOAST_MS) return;
+ warnedSkew.add(peerId);
+ const label = String(peerId).slice(0, 6).toUpperCase();
+ showToast(
+ label +
+ "'s clock is " +
+ describe(est.offset) +
+ (est.offset > 0 ? ' ahead of' : ' behind') +
+ ' this device. Shared timings follow the session clock, but check the date and time settings on whichever device is wrong.'
+ );
+}
+
+/** @type {any} */
+let resyncTimer = null;
+
+/**
+ * Start measuring a peer: one ping at once (a gross skew is corrected on its first
+ * sample, before the joiner writes much), a short burst once the connect storm has
+ * passed (the median needs several samples before it means anything), then a steady
+ * re-measure every RESYNC_MS for as long as the conn is open. Called from
+ * `sendHandshake`, the one place a conn is known to be OPEN (golden rule 2).
+ * @param {string} peerId
+ */
+export function startClockSync(peerId) {
+ if (typeof setTimeout === 'undefined') return;
+ sendClockPing(peerId);
+ for (let i = 0; i < BURST; i++) setTimeout(() => sendClockPing(peerId), BURST_DELAY_MS + i * BURST_GAP_MS);
+ if (resyncTimer == null) {
+ resyncTimer = setInterval(() => {
+ /** @type {any} */
+ const peer = get(peers);
+ for (const id of Object.keys(peer?.connections ?? {})) sendClockPing(id);
+ }, RESYNC_MS);
+ }
+}
+
+/** The peer whose session we joined is the peer we keep time by. Declared last: the
+ * subscribe runs synchronously at module eval (the module-level-subscribe rule) and
+ * every name it reaches is an import, so nothing here can be read before it exists. */
+sessionHost.subscribe((host) => setClockReference(host));
diff --git a/src/lib/cloudHooks.js b/src/lib/cloudHooks.js
index d7f24dfb..7b4b1332 100644
--- a/src/lib/cloudHooks.js
+++ b/src/lib/cloudHooks.js
@@ -36,6 +36,15 @@ const ALWAYS_ALLOWED = new Set([
// everything), i.e. it would relax the gate by tightening one message. It is presence
// besides, which is this floor's own family.
'atscene',
+ // 25-E: the session clock's round trip. Protocol, not content — a plugin that gated it
+ // for a viewer would leave that viewer stamping on its own machine's clock, so every
+ // latest-wins write it made would sort wrongly against the room's.
+ 'clockping',
+ 'clockpong',
+ // 25-F: whether a join was approved, declined or refused as full. Protocol about the
+ // connection itself, sent before any content — gating it would put a joiner back to
+ // waiting out a 90 s window for an answer that already arrived.
+ 'joinresult',
// DEVX #18: the flow trigger log. On the floor beside `getnodes` for the same reason
// the list gives — answering a full-state REQUEST is how a peer ever syncs, and this
// one decides whether a joiner sees a collected world or a reset one. The `triggers`
diff --git a/src/lib/cloudPlugin.js b/src/lib/cloudPlugin.js
index 807c6545..7ce6d6f7 100644
--- a/src/lib/cloudPlugin.js
+++ b/src/lib/cloudPlugin.js
@@ -24,6 +24,7 @@ import {
// cloudPlugin path is in history's import subtree — App alone imports this module).
import { currentLevel } from './levels';
import { myPlayMode, peerPlayModes } from './gamePresence';
+import { safeStorage } from './safeStorage';
// 28-A (roadmap #28, publish · play · remix): the seams below reach cycle-sensitive
// modules — sessions is history-family, cameraBookmarks imports objectActions, playMode is
@@ -58,7 +59,7 @@ export async function startCloudPlugin() {
try {
url =
(import.meta && import.meta.env && import.meta.env.VITE_CLOUD_PLUGIN) ||
- (typeof localStorage !== 'undefined' && localStorage.getItem('cloudPluginUrl')) ||
+ (typeof localStorage !== 'undefined' && safeStorage.getItem('cloudPluginUrl')) ||
'';
} catch {
url = '';
diff --git a/src/lib/colliderEdit.js b/src/lib/colliderEdit.js
index bc83dd77..7b1dcd03 100644
--- a/src/lib/colliderEdit.js
+++ b/src/lib/colliderEdit.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { globalScene, selectedObject, objectsGroup } from '../stores/sceneStore';
+import { globalScene, selectedObject, objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordEntry } from './history';
import {
@@ -245,7 +245,7 @@ export function commitColliderEdit() {
/** @type {any} */
const peer = get(peers);
if (peer) peer.send({ type: 'objectParameters', parameter: 'physics', uuid, physics: next });
- objectsGroup.update((v) => v);
+ pokeScene();
selectedObject.update((v) => v);
import('./physics').then((m) => m.physicsShapeChanged(uuid)); // live rebuild mid-sim
showToast('Custom collider saved — ' + colliderPieces.length + ' convex piece' + (colliderPieces.length === 1 ? '' : 's'));
diff --git a/src/lib/colliderHelpers.js b/src/lib/colliderHelpers.js
index 2defaf7c..5c7aaeb4 100644
--- a/src/lib/colliderHelpers.js
+++ b/src/lib/colliderHelpers.js
@@ -6,6 +6,7 @@ import { globalScene, objectsGroup } from '../stores/sceneStore';
import { colliderSpecOf } from './colliderSpec';
import { wireframeActive } from './viewMode';
import { scenePhysicsGround } from './scenePhysics';
+import { safeStorage } from './safeStorage';
// CL-A A7: collider visualization (the lightHelpers pattern). Per tracked
// object a wireframe built FROM colliderSpecOf — the SAME spec physics
@@ -15,7 +16,7 @@ import { scenePhysicsGround } from './scenePhysics';
/** global toggle (scene ▸ View), LOCAL pref, default OFF */
export const showColliders = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('showColliders') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('showColliders') === 'true'
);
/** per-object opt-in (Inspector ▸ Physics "Show collider") — session-local,
* NOT persisted or replicated. @type {import('svelte/store').Writable>} */
@@ -264,7 +265,7 @@ export function startColliderHelpers() {
});
showColliders.subscribe((value) => {
try {
- localStorage.setItem('showColliders', String(value));
+ safeStorage.setItem('showColliders', String(value));
} catch {}
sync();
});
diff --git a/src/lib/colocation.js b/src/lib/colocation.js
index f41eb035..09c68364 100644
--- a/src/lib/colocation.js
+++ b/src/lib/colocation.js
@@ -79,6 +79,7 @@
// different building) would place the content somewhere arbitrary.
import { writable, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import * as THREE from 'three';
import { worldRig } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
@@ -311,7 +312,7 @@ export function setRoomAnchor(patch) {
const record = normalizeRoomAnchor({
...base,
...(patch ?? {}),
- at: Math.max(Date.now(), (current?.at ?? 0) + 1)
+ at: Math.max(sessionNow(), (current?.at ?? 0) + 1)
});
roomAnchor.set(record);
/** @type {any} */
diff --git a/src/lib/colocationAnchors.js b/src/lib/colocationAnchors.js
index a34d342f..8f58ab73 100644
--- a/src/lib/colocationAnchors.js
+++ b/src/lib/colocationAnchors.js
@@ -53,6 +53,7 @@ import {
import { calibrating, worldGrabActive } from './colocationCalibrate';
import { forgetNudge } from './colocationNudge';
import { registerVRFrameHook } from './vrControls';
+import { safeStorage } from './safeStorage';
import {
sessionContext,
createAnchorAt,
@@ -88,7 +89,7 @@ const GRAB_ACTIVE_MS = 400;
/** @returns {Record} */
function loadRecords() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORE_KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(STORE_KEY) : null;
const stored = raw ? JSON.parse(raw) : null;
return stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {};
} catch {
@@ -105,7 +106,7 @@ export const anchorRecords = writable(loadRecords());
function saveRecords(map) {
anchorRecords.set(map);
try {
- if (typeof localStorage !== 'undefined') localStorage.setItem(STORE_KEY, JSON.stringify(map));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(STORE_KEY, JSON.stringify(map));
} catch {
// private mode / quota: the in-memory mirror still works for this run
}
diff --git a/src/lib/colocationNudge.js b/src/lib/colocationNudge.js
index b47a0452..3e93def0 100644
--- a/src/lib/colocationNudge.js
+++ b/src/lib/colocationNudge.js
@@ -39,6 +39,7 @@ import { registerVRFrameHook } from './vrControls';
import { registerVRMenuEntry } from './vrRadialMenu';
import { getInput } from './inputRuntime';
import { calibrating } from './colocationCalibrate';
+import { safeStorage } from './safeStorage';
const STORE_KEY = 'colocation-nudge-v1';
@@ -59,7 +60,7 @@ export const nudgeMode = writable(false);
function readAll() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORE_KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(STORE_KEY) : null;
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
@@ -70,7 +71,7 @@ function readAll() {
/** @param {any} all */
function writeAll(all) {
try {
- if (typeof localStorage !== 'undefined') localStorage.setItem(STORE_KEY, JSON.stringify(all));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(STORE_KEY, JSON.stringify(all));
} catch {
// private mode / quota: the live correction still works for this session
}
@@ -313,7 +314,7 @@ export function resetColocationNudge() {
loadedKey = null;
lastTick = 0;
try {
- if (typeof localStorage !== 'undefined') localStorage.removeItem(STORE_KEY);
+ if (typeof localStorage !== 'undefined') safeStorage.removeItem(STORE_KEY);
} catch {
// nothing to do
}
diff --git a/src/lib/colocationPresence.js b/src/lib/colocationPresence.js
index 7578d291..8f3b7b95 100644
--- a/src/lib/colocationPresence.js
+++ b/src/lib/colocationPresence.js
@@ -52,6 +52,7 @@
import { writable, derived, get } from 'svelte/store';
import { peers } from '../stores/appStore';
import { roomAlignment, roomKey } from './colocation';
+import { safeStorage } from './safeStorage';
/** REMOTE peers only, `peerId -> roomKey`. A peer NOT in this map is not colocated —
* absence is the single representation of that, so nothing ever writes a null row.
@@ -66,7 +67,7 @@ export const peerColocation = writable({});
* hands are visible but the thing they hold is not.
* @type {import('svelte/store').Writable} */
export const colocatedGhostHands = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('colocatedGhostHands') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('colocatedGhostHands') !== 'false'
);
/** How faint. Low enough to read as a hint rather than as an avatar, high enough to
@@ -246,5 +247,5 @@ export function resetColocationPresence() {
// Declared last so nothing above it can be read by this subscriber before its `let`s
// exist — the same TDZ rule the wiring comment states.
colocatedGhostHands.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('colocatedGhostHands', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('colocatedGhostHands', String(value));
});
diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js
index b2ed200c..8dba4266 100644
--- a/src/lib/commandsHandler.svelte.js
+++ b/src/lib/commandsHandler.svelte.js
@@ -1,5 +1,5 @@
import * as THREE from 'three';
-import { globalScene, objectsGroup, showGrid, TControls, lockedObjects, selectedObject, globalCamera, peerHands } from '../stores/sceneStore.js';
+import { globalScene, objectsGroup, showGrid, TControls, lockedObjects, selectedObject, globalCamera, peerHands, pokeScene, beginSceneBatch, endSceneBatch } from '../stores/sceneStore.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { createGeometry, createLight, createGroup } from '$lib/geometries.svelte'
@@ -20,9 +20,18 @@ 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';
+// 27-G (audit H6): removing an object frees NOTHING on the GPU. These free what only
+// 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, ingestVerdict, profileFor } from './sceneBudget';
+import { globalRenderer } from '../stores/sceneStore.js';
//Access scene Store
let scene = $state();
@@ -62,10 +71,14 @@ globalCamera.subscribe(value => { camera = value });
const loader = new THREE.ObjectLoader();
-let uuids = [];
export function userData(data) {
+ // 27-A (audit H1): the roster applier called .forEach on whatever arrived. A malformed
+ // `userdata` threw out of the dispatcher, which had no try/catch — the A1 note below
+ // records the same class of failure in `specator`.
+ if (!Array.isArray(data)) return;
data.forEach(element => {
+ if (!Array.isArray(element) || typeof element[0] !== 'string') return;
console.log('received new approved host : ' + element[0])
if (!users.some(u => u[0] === element[0]))
users.push(element)
@@ -139,9 +152,13 @@ export function sceneCommand(command) {
} else {
let object = sceneObjects.getObjectByProperty('uuid', command.split(' ')[1])
if (object != null) {
+ // the undo entry is a toJSON SNAPSHOT (history.captureObjectSnapshot),
+ // not a live reference, so freeing the buffers here cannot strand it
recordObjectPresence('delete', object);
+ const keep = keepSet(sceneRoot(), object);
// parent-aware so nested objects are removed too
(object.parent ?? sceneObjects).remove(object);
+ disposeTree(object, { keep });
}
peer.send({type: 'delete', uuid: command.split(' ')[1], peerId: peer.peer.id});
}
@@ -150,12 +167,12 @@ export function sceneCommand(command) {
if (command.split(' ')[1] == 'on')
{
showGrid.set(true);
- localStorage.removeItem('showGrid')
+ safeStorage.removeItem('showGrid')
}
else if (command.split(' ')[1] == 'off')
{
showGrid.set(false);
- localStorage.setItem('showGrid', false);
+ safeStorage.setItem('showGrid', false);
}
}
else if (command.startsWith('/create')) {
@@ -232,15 +249,32 @@ export function sceneCommand(command) {
}
}
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
* Full local scene wipe (both the local /clear all and the clearscene message):
* objects, module viewport content, annotations, locks and byte registries.
*/
+/** The scene ROOT for keep-set purposes. Scene-root helpers share resources with real
+ * meshes on purpose (an onion-skin ghost shares its source mesh's geometry), so a keep
+ * set computed over the replicated group alone would free things still being drawn. */
+function sceneRoot() {
+ return scene ?? sceneObjects;
+}
+
export function clearSceneLocal() {
controls?.detach();
+ // 26-B: anything still parked in the ingest queue belongs to the scene being wiped
+ dropIngestQueue();
+ clearLoadingBatch();
+ // 27-G: `clear()` drops the references and frees nothing, so a session that opens and
+ // clears several scenes pays for every one of them until the context dies.
+ const doomed = sceneObjects ? [...sceneObjects.children] : [];
+ if (doomed.length) {
+ const keep = keepSet(sceneRoot(), doomed);
+ for (const child of doomed) disposeTree(child, { keep });
+ }
sceneObjects?.clear();
runSceneClearHandlers(); // modules remove their scene-root content
annotations.set([]);
@@ -250,7 +284,7 @@ export function clearSceneLocal() {
// authored clips were the one registry a wipe used to leak (dropAnimation had
// no call site at all before 17-E)
dropAllAnimations();
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** A peer wiped the shared scene @param {string} peerId */
@@ -260,8 +294,10 @@ export function applyClearScene(peerId) {
}
export function lockRestore(lockeditems) {
+ // 27-A: same trust, same fix — a non-array here threw inside the handshake.
+ if (!Array.isArray(lockeditems)) return;
// Filter out the current peer id locks
- locked = locked.concat(lockeditems.filter((lock) => lock[0] != peer.peer.id));
+ locked = locked.concat(lockeditems.filter((lock) => Array.isArray(lock) && lock[0] != peer.peer.id));
// Update the locked objects store
lockedObjects.set(locked);
}
@@ -289,12 +325,21 @@ export function handleDisconnected(peerId) {
});
dropPeerCursor(peerId);
dropPeerQuality(peerId); // N3: drop the peer's network-quality telemetry
+ dropWireErrors(peerId); // 27-A: and its wire-failure counters (golden rule 3)
dropPeerClock(peerId); // 23-A2: and their clock-offset samples
// CN: host bookkeeping — the host leaving means we're no longer "joined"
if (get(sessionHost) === peerId) sessionHost.set(null);
dropPeerJoined(peerId);
voicePeerDisconnected(peerId);
physicsPeerDisconnected(peerId);
+ // 26-B (audit M2): the objects they were sending are never coming. Clearing the
+ // batch here is what stops "Receiving objects: 3/40" living forever on screen, and
+ // it drops the parked queue so a half-sent scene does not trickle in afterwards.
+ if (loadingSender === peerId) {
+ const left = /** @type {string[]} */ (get(loading)).length;
+ clearLoadingBatch();
+ if (left) showToast('The scene transfer stopped — ' + peerId + ' left.');
+ }
}
// Local age-out for roster entries that never grew a connection (a peer that
@@ -362,14 +407,132 @@ export function checkLocks(data) {
if (locked.length !== before) lockedObjects.set(locked);
}
-export async function createLoader(count, uuids) {
+/** 26-B (audit M2): who announced the batch we are receiving, so their teardown can
+ * clear it. LOCAL — nothing about this crosses the wire. */
+/** @type {string | null} */
+let loadingSender = null;
+/** @type {any} */
+let loadingStallTimer = null;
+/** The progress bar sticks at "3/40" forever when the sender leaves mid-send or a parse
+ * rejects. Nothing cleared it: the only writer was the Toasts effect, which removes a
+ * uuid when its object APPEARS, and an object that never arrives never appears. */
+const LOADING_STALL_MS = 60000;
+let loadingStallMs = LOADING_STALL_MS;
+/** TEST-ONLY: shorten the stall so "silence, not duration" is provable in seconds.
+ * @param {number} [ms] omit to restore the real value */
+export function setLoadingStallMsForTest(ms) {
+ loadingStallMs = Number.isFinite(ms) && /** @type {number} */ (ms) > 0 ? /** @type {number} */ (ms) : LOADING_STALL_MS;
+}
+
+// 26-E: THE STALL IS SILENCE, NOT DURATION. The timer was armed once, at the announcement,
+// and never again — so any transfer that simply took longer than a minute was declared
+// dead while it was still arriving. The stress rig measured exactly that: a joiner
+// receiving 3,000 boxes on a real GPU was still landing ~10 objects a second at 63s when
+// the bar cleared and the toast said "1085 objects never arrived"; all 3,000 arrived by
+// 180s. Every uuid that lands now re-arms it (the `loading` subscription below), so the
+// 60s is measured from the LAST sign of life, which is what M2 meant by a stall.
+function armLoadingStall() {
+ clearTimeout(loadingStallTimer);
+ loadingStallTimer = setTimeout(() => {
+ const left = /** @type {string[]} */ (get(loading));
+ if (!left.length) return;
+ console.log('Receiving objects: giving up on ' + left.length + ' that never arrived');
+ clearLoadingBatch();
+ showToast(left.length + ' object' + (left.length === 1 ? '' : 's') + ' never arrived.');
+ }, loadingStallMs);
+}
+
+// 26-E (roadmap 26 section 3, "handshake time-to-synced"): how long the last RECEIVED
+// batch took, from its `loading` announcement to the last object landing. The receive
+// side is where the cost is felt, and it is the one moment both ends of the interval
+// are known locally — no clock is compared across peers. LOCAL, never sent.
+/** @type {number} */
+let loadingStartedAt = 0;
+/** @type {number} */
+let loadingAnnounced = 0;
+/** uuids still outstanding when the batch was CLOSED rather than finished (a stall, a
+ * departed sender, a cleared scene) — so a batch that never finished cannot report a
+ * sync time as though it had. */
+let loadingLeftAtClear = 0;
+/** @type {{ms: number, objects: number, complete: boolean, at: number} | null} */
+let lastSync = null;
+/** The last batch that ENDED (finished or closed), or null before one has run. */
+export function lastSyncStats() {
+ return lastSync;
+}
+// Both ends of a batch pass through the store: the Toasts reconcile empties it as the
+// last object appears, and `clearLoadingBatch` empties it on every other way out.
+// Subscribing here sees both without touching either writer.
+/** outstanding count at the last notification, so only PROGRESS re-arms the stall */
+let loadingLastLeft = 0;
+loading.subscribe((/** @type {any} */ left) => {
+ const count = Array.isArray(left) ? left.length : 0;
+ // progress on an open batch: re-arm — but only a timer that is running, never one the
+ // ingest fork parked on purpose while its question is open
+ if (loadingStartedAt && count > 0 && count < loadingLastLeft && loadingStallTimer) armLoadingStall();
+ loadingLastLeft = count;
+ if (!loadingStartedAt || count) return;
+ lastSync = {
+ ms: Math.round(performance.now() - loadingStartedAt),
+ objects: loadingAnnounced,
+ complete: loadingLeftAtClear === 0,
+ at: Date.now()
+ };
+ loadingStartedAt = 0;
+ loadingLeftAtClear = 0;
+});
+// only a batch that FINISHED has a sync time; a closed one says null rather than a
+// number that would read as a fast join
+registerMetricSource('syncMs', () => (lastSync?.complete ? lastSync.ms : null));
+registerMetricSource('syncObjects', () => (lastSync?.complete ? lastSync.objects : null));
+
+/** Close the batch: the bar goes away, the stall timer disarms. Idempotent. */
+export function clearLoadingBatch() {
+ if (loadingStartedAt) loadingLeftAtClear = /** @type {string[]} */ (get(loading)).length;
+ clearTimeout(loadingStallTimer);
+ loadingStallTimer = null;
+ loadingSender = null;
+ loading.set([]);
+}
+
+/** Count a uuid as ARRIVED even though no object exists for it — a parse that rejected,
+ * or an object the sender dropped. Without this the bar waits out the full stall.
+ * @param {string[] | string} uuids */
+export function noteLoadFailed(uuids) {
+ const gone = new Set(Array.isArray(uuids) ? uuids : [uuids]);
+ const left = /** @type {string[]} */ (get(loading)).filter((u) => !gone.has(u));
+ loading.set(left);
+ if (!left.length) clearLoadingBatch();
+}
+
+/** @param {number} count @param {string[]} uuids @param {string} [senderId] */
+export async function createLoader(count, uuids, senderId) {
// console.log("create loader for " + count + " objects: " + uuids);
- loading.set(uuids);
+ loading.set(Array.isArray(uuids) ? uuids : []);
loadingcount.set(count);
- //Trigger reactivity for UI list of objects on remote
- loading.update((value) => value);
- //Trigger reactivity for UI list of objects on remote
- loadingcount.update((value) => value);
+ loadingSender = senderId ?? null;
+ // an empty announcement opens nothing to finish, so it starts no clock
+ loadingStartedAt = Array.isArray(uuids) && uuids.length ? performance.now() : 0;
+ loadingAnnounced = Number(count) || 0;
+ // 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();
}
export async function colorObject(uuid, color, near, far) {
@@ -426,7 +589,7 @@ export async function objectParameters(data) {
smoothWeldedNormals(mesh.geometry);
} else mesh.geometry.computeVertexNormals();
mesh.geometry.attributes.normal.needsUpdate = true;
- objectsGroup.update((value) => value);
+ pokeScene();
}
} else if (data.parameter == 'physics') {
// P-A: userData.physics is the source of truth for the Inspector-set
@@ -435,7 +598,7 @@ export async function objectParameters(data) {
if (mesh) {
if (data.physics) mesh.userData.physics = data.physics;
else delete mesh.userData.physics;
- objectsGroup.update((value) => value); // collider viz re-syncs
+ pokeScene(); // collider viz re-syncs
physicsShapeChanged(data.uuid); // CL-A A2: live mid-sim rebuild
}
} else if (data.parameter == 'origin') {
@@ -445,7 +608,7 @@ export async function objectParameters(data) {
if (mesh) {
if (data.origin) mesh.userData.origin = data.origin;
else delete mesh.userData.origin;
- objectsGroup.update((value) => value);
+ pokeScene();
physicsShapeChanged(data.uuid); // the body/collider pose follows the pivot
}
} else if (data.parameter == 'particles') {
@@ -455,7 +618,7 @@ export async function objectParameters(data) {
if (mesh) {
if (data.particles) mesh.userData.particles = data.particles;
else delete mesh.userData.particles;
- objectsGroup.update((value) => value);
+ pokeScene();
}
} else if (data.parameter == 'device') {
// 23-A3: userData.device is a device object's whole configuration ({kind,
@@ -469,7 +632,7 @@ export async function objectParameters(data) {
if (mesh) {
if (data.camera) mesh.userData.camera = data.camera;
else delete mesh.userData.camera;
- objectsGroup.update((value) => value); // frustum viz + preview re-read
+ pokeScene(); // frustum viz + preview re-read
}
} else if (data.parameter == 'renderOrder') {
let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid);
@@ -483,11 +646,13 @@ export async function objectParameters(data) {
export async function deleteObject(uuid) {
let object = sceneObjects.getObjectByProperty('uuid', uuid)
if (!object) return;
+ const keep = keepSet(sceneRoot(), object);
object.parent?.remove(object);
if(selected?.uuid == uuid) controls.detach();
sceneObjects.remove(sceneObjects.getObjectByProperty('uuid', uuid));
+ disposeTree(object, { keep });
//Trigger reactivity for UI list of objects on remote
- objectsGroup.update((value) => value);
+ pokeScene();
}
@@ -508,6 +673,181 @@ export async function deleteObject(uuid) {
* @param {string} [groupuuid] @param {number[]} [pos] @param {number[]} [rot] @param {number[]} [scale]
*/
export async function createObject(object, uuid, override, groupuuid, pos, rot, scale) {
+ return enqueueIngest([object, uuid, override, groupuuid, pos, rot, scale]);
+}
+
+// ---------------------------------------------------------------------------
+// 26-B (roadmap 26 Stage 0) — TIME-SLICED INGEST.
+//
+// The dispatcher calls `createObject` once per incoming `object` message and never
+// awaits it, so a 1,000-object handshake used to start 1,000 overlapping parses in the
+// same task: `GLTFLoader.parse` is main-thread by design, so the tab had no frame to
+// give anyone until the last one finished. Ordering was also only accidental — two
+// parses that resolved out of order could attach a child before its group existed.
+//
+// The queue fixes both with one mechanism. Objects are applied STRICTLY IN THE ORDER
+// RECEIVED, and the drainer yields to the event loop every SLICE_MS of work, so input,
+// rendering and the poke flush all get a turn while a big scene lands. A batch is open
+// for the whole drain, which is what puts `pokeScene` into its one-per-frame mode.
+//
+// A macrotask (setTimeout 0) is the yield, not a microtask: a microtask chain never
+// returns to the browser, so it would slice the work without ever letting a frame run.
+// ---------------------------------------------------------------------------
+
+/** How long the drainer may hold the thread before yielding. 8ms leaves half a 60Hz
+ * frame for everything else. */
+const INGEST_SLICE_MS = 8;
+
+/** @type {{args: any[], resolve: (v?: any) => void, reject: (e: any) => void}[]} */
+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) => {
+ ingestQueue.push({ args, resolve, reject });
+ if (!ingestDraining) {
+ ingestDraining = true;
+ beginSceneBatch();
+ void drainIngest();
+ }
+ });
+}
+
+async function drainIngest() {
+ try {
+ 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
+ console.log('Failed to create an incoming object: ' + error);
+ const uuid = job.args[1];
+ noteLoadFailed(Array.isArray(uuid) ? uuid : [job.args[0]?.element?.object?.uuid].filter(Boolean));
+ job.reject(error);
+ }
+ }
+ 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);
+ ingestQueue = [];
+ return dropped;
+}
+
+/** How many objects are parked. Read by the suite and the 26-A meter. */
+export function ingestBacklog() {
+ return ingestQueue.length;
+}
+registerMetricSource('ingestBacklog', ingestBacklog);
+
+/**
+ * @param {any} object @param {string[]|null} uuid @param {boolean} [override]
+ * @param {string} [groupuuid] @param {number[]} [pos] @param {number[]} [rot] @param {number[]} [scale]
+ */
+async function applyCreateObject(object, uuid, override, groupuuid, pos, rot, scale) {
let parent;
if (uuid == null) {
let mesh = loader.parse(object.element);
@@ -522,6 +862,9 @@ export async function createObject(object, uuid, override, groupuuid, pos, rot,
parent = existing.parent ?? sceneObjects;
parent.remove(existing);
parent.add(mesh)
+ // AFTER the replacement is in the scene: anything the two share is then in the
+ // keep set and survives, which a dispose before the add would have freed.
+ disposeTree(existing, { keep: keepSet(sceneRoot(), existing) });
} else if (sceneObjects.getObjectByProperty('uuid', mesh.uuid) == null) {
// …and an override for something we never had falls through to here. It used to
// read `overrideObject.parent` unconditionally and THROW on null.
@@ -548,7 +891,9 @@ export async function createObject(object, uuid, override, groupuuid, pos, rot,
// one, and even a plain re-send attached a duplicate into the group.
if (!override) return;
if (controls?.object?.uuid === existing.uuid) controls.detach();
+ const keepExisting = keepSet(sceneRoot(), existing);
existing.parent?.remove(existing);
+ disposeTree(existing, { keep: keepExisting });
}
sceneObjects.add(mesh)
if (groupuuid){
@@ -575,7 +920,7 @@ export async function createObject(object, uuid, override, groupuuid, pos, rot,
});
}
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -589,30 +934,39 @@ export async function createObject(object, uuid, override, groupuuid, pos, rot,
* walk emits is byte-identical to what it always sent.
*/
export function sendObjects(peerId, element, opts = {}) {
- let conn; let groupid;
+ let groupid;
if (peerId === null) {
groupid = element.uuid;
- conn = peer;
- conn.send({type: 'group', name: element.name, uuid: element.uuid, groupparent: null,
+ peer.send({type: 'group', name: element.name, uuid: element.uuid, groupparent: null,
pos: element.position.toArray(),
rot: element.rotation.toArray(),
scale: element.scale.toArray(),
...(opts.override ? { override: true } : {})
});
}
- else
- conn = peer.connections[peerId];
- let objects = [];
-
- // Iterate over all objects in the scene
- let count = countObjects(element);
+ // 26-B (audit M1): the uuid list is built PER CALL. It used to be a module-level
+ // array that `countObjects` PUSHED onto and only the timer emptied, so two
+ // approvals 400ms apart both counted into it: the second joiner was told to expect
+ // the first joiner's objects too and its progress bar read "12/40" forever, while
+ // `count` itself was the RUNNING TOTAL rather than this send's.
+ const uuidList = [];
+ const count = countObjects(element, uuidList);
console.log("Sending " + count + " objects to " + peerId);
// Wait 500ms to ensure the connection is established before sending the objects
setTimeout(() => {
+ // …and RESOLVE THE CONNECTION HERE, not 500ms ago. `peer.connections[peerId]`
+ // is undefined while the dial is still in flight and closed when the joiner
+ // gave up in between; both used to throw INSIDE A TIMER, where nothing catches
+ // it — the handshake reply simply vanished with an uncaught TypeError.
+ const conn = peerId === null ? peer : peer?.connections?.[peerId];
+ if (!conn || (peerId !== null && !conn.open)) {
+ console.log('Not sending ' + count + ' objects to ' + peerId + ': the connection is gone');
+ return;
+ }
// Send amount of objects to be sent and their uuids
- conn.send({type: 'loading', count: count, uuids: uuids});
+ conn.send({type: 'loading', count: count, uuids: uuidList});
// park animated objects at their base pose so the receiver captures the
// TRUE animation base, not a mid-swing pose (88). The walk below reads
// every transform synchronously, so restore right after.
@@ -622,7 +976,6 @@ export function sendObjects(peerId, element, opts = {}) {
} finally {
restore();
}
- uuids = [];
}, 500);
}
@@ -795,7 +1148,8 @@ export function sendObject(conn, element, groupuuid, opts = {}) {
}
-function countObjects(element) {
+/** @param {any} element @param {string[]} sink the CALLER's uuid list (audit M1) */
+function countObjects(element, sink) {
let objects = [];
if (typeof element !== 'undefined') {
objects = element.children;
@@ -804,10 +1158,9 @@ function countObjects(element) {
}
objects.forEach(element => {
if (element.type == "Group" && !hasAnimatedImport(element.uuid)) {
- countObjects(element);
+ countObjects(element, sink);
}
- uuids.push(element.uuid)
+ sink.push(element.uuid)
})
- // console.log(uuids.length)
- return uuids.length;
+ return sink.length;
}
diff --git a/src/lib/connectionState.js b/src/lib/connectionState.js
index ffa55343..aeaaa46a 100644
--- a/src/lib/connectionState.js
+++ b/src/lib/connectionState.js
@@ -1,4 +1,9 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
+// 25-E: the session clock is a sibling leaf; re-exported here because this is where peer
+// code already looks for "what session am I in", and resetSession must reset it too
+import { resetSessionClock } from './sessionClock';
+export { sessionNow, sessionClock, sessionClockDebug } from './sessionClock';
/**
* Session-connection state (roadmap #14 CN). STORE-ONLY module (svelte/store only)
@@ -33,10 +38,172 @@ export function dropPeerJoined(peerId) {
peerJoinedAt.set(next);
}
+/**
+ * 27-F: the signaling link's retry state, for the Connect pill's chip (audit H2).
+ * A STORE rather than a toast per attempt: an unbounded retry toasting each time is
+ * spam, while a chip is a state you can look at. peerHandler already imports this
+ * leaf, so surfacing it costs no new module edge.
+ * @type {import('svelte/store').Writable<{retrying: boolean, attempt: number}>}
+ */
+export const signalingRetry = writable({ retrying: false, attempt: 0 });
+
+/** @param {number} attempt */
+export function noteSignalingRetry(attempt) {
+ signalingRetry.set({ retrying: true, attempt });
+}
+
+/** The link is back (or we gave the peer up) — clear the chip. */
+export function clearSignalingRetry() {
+ const now = get(signalingRetry);
+ if (!now.retrying && now.attempt === 0) return;
+ signalingRetry.set({ retrying: false, attempt: 0 });
+}
+
+/**
+ * 27-E (roadmap 25) — HOW LONG AN APPROVAL MAY HANG, on BOTH sides of it.
+ *
+ * Today it hangs forever: a joiner sits on "Requesting AB12" with no countdown and no
+ * end, and a host who walked away collects cards without bound. 90 s is a human act — the
+ * host may be in a headset, on another tab, or mid-gesture — while past about two minutes
+ * the joiner has stopped watching and a dial-back lands in a tab that has moved on. ONE
+ * constant, so the pill's countdown and the card's age can never disagree.
+ */
+export const APPROVAL_WINDOW_MS = 90_000;
+
+/** Beyond this many cards the oldest EXPIRED ones are dropped first, then the oldest
+ * pending — bounding the array `handleConnection` pushes into (audit H3). */
+export const MAX_PENDING_APPROVALS = 12;
+
+/**
+ * 27-E — SESSION SIZE. The mesh is FULL: every peer holds N-1 data connections and, with
+ * voice on, N-1 media connections, and every mutation fans out N-1 times. 10 is the
+ * tested target; 8 is the default because the costs that bite first (voice encoders,
+ * presence streams) are per-peer and land hardest on the slowest device in the room.
+ * SOFT warns and still approves; HARD refuses, because past it the session degrades for
+ * everyone rather than only for the person who just joined.
+ */
+export const SOFT_PEER_CAP_DEFAULT = 8;
+export const HARD_PEER_CAP = 16;
+
+/**
+ * How many people are in the session, counting YOURSELF.
+ *
+ * `openedPeers` is the set of peers whose data channel is actually open. `userdata` is
+ * the WHITELIST, and it is written at DIAL time — so it counts everyone who was ever
+ * invited, including people who never arrived and people who have since left. Counting
+ * it means a host who dialled sixteen names refuses every approval while sitting alone.
+ * That trap is documented in this repo and this batch walked straight into it in four
+ * places, which is why the arithmetic now lives here and nowhere else.
+ *
+ * Pure and peer-SHAPED rather than a derived store, so every caller can pass whatever it
+ * already holds: the store value in a component, or `this` inside PeerConnection.
+ * @param {{ openedPeers?: { size?: number } } | null | undefined} peers
+ */
+export function sessionSize(peers) {
+ return (peers?.openedPeers?.size ?? 0) + 1;
+}
+
+/** True when one more person would take the mesh past what it can carry (audit L7).
+ * @param {{ openedPeers?: { size?: number } } | null | undefined} peers */
+export function roomIsFull(peers) {
+ return sessionSize(peers) >= HARD_PEER_CAP;
+}
+
+function readSoftCap() {
+ if (typeof localStorage === 'undefined') return SOFT_PEER_CAP_DEFAULT;
+ const raw = Number(safeStorage.getItem('connect:softPeerCap'));
+ return Number.isFinite(raw) && raw >= 2 && raw <= HARD_PEER_CAP ? raw : SOFT_PEER_CAP_DEFAULT;
+}
+
+/** LOCAL, like every other connection preference. @type {import('svelte/store').Writable} */
+export const softPeerCap = writable(readSoftCap());
+softPeerCap.subscribe((v) => {
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('connect:softPeerCap', String(v));
+});
+
+/**
+ * When each pending request started, keyed by peer id — the ONE clock the joiner's
+ * countdown and the host's card age both read. A map rather than a field on the request
+ * rows, because those rows are plain arrays and objects that several modules already write.
+ * @type {import('svelte/store').Writable>}
+ */
+export const approvalStartedAt = writable({});
+
+/** @param {string} peerId */
+export function noteApprovalStarted(peerId) {
+ approvalStartedAt.update((m) => (m[peerId] ? m : { ...m, [peerId]: Date.now() }));
+}
+
+/** @param {string} peerId */
+export function clearApprovalStarted(peerId) {
+ approvalStartedAt.update((m) => {
+ if (!(peerId in m)) return m;
+ const next = { ...m };
+ delete next[peerId];
+ return next;
+ });
+}
+
+/** Milliseconds left in a request's window, 0 once it has expired.
+ * Takes `undefined` because callers look the stamp up in `approvalStartedAt` BY PEER ID
+ * and a miss is ordinary — an absent stamp reads as expired, which is the safe direction.
+ * @param {number | undefined} startedAt */
+export function approvalRemaining(startedAt) {
+ return Math.max(0, APPROVAL_WINDOW_MS - (Date.now() - (startedAt || 0)));
+}
+
+/**
+ * 25-F — A REAL "NO". Until this, an incoming connection from the host WAS the approval
+ * signal, and a refusal had no channel at all: the host closes a stranger's conn before
+ * it opens, so a Reject left the joiner on "Requesting" for the full 90 s window and then
+ * told it the host "did not answer" — which is false, and a full room said the same.
+ *
+ * The answer rides the CONNECTION METADATA of a short dial from the host, so it arrives
+ * at the joiner's `connection` event through the signaling server with no ICE at all —
+ * a pair of peers that could never open a data channel still hears "declined".
+ *
+ * joiner dials with `{jr: 1}` "I understand a join result" (an older joiner sends
+ * nothing, and is never sent a refusal dial, because
+ * it would read ANY incoming conn from the host as
+ * approval — the whole reason the capability exists)
+ * host dials `{joinresult: R}` R = 'approved' on the approve dial-back (and a
+ * `joinresult` data message first in its handshake),
+ * 'denied' / 'full' on a refusal dial that is never
+ * added to the mesh and closes itself
+ *
+ * Absent means the old behaviour on both sides: an incoming conn from a peer we are
+ * waiting on is an approval.
+ */
+export const JOIN_RESULTS = /** @type {const} */ (['approved', 'denied', 'full']);
+
+/** @param {any} v @returns {v is 'denied' | 'full'} */
+export function isRefusal(v) {
+ return v === 'denied' || v === 'full';
+}
+
+/**
+ * The last refusal this joiner received, for the Connect pill: `{peerId, result, at}`, or
+ * null. Cleared by the next dial, by dismissing it, and by leaving the session.
+ * @type {import('svelte/store').Writable<{peerId: string, result: 'denied' | 'full', at: number} | null>}
+ */
+export const joinRefusal = writable(null);
+
+/** @param {string} peerId @param {'denied' | 'full'} result */
+export function noteJoinRefusal(peerId, result) {
+ joinRefusal.set({ peerId, result, at: Date.now() });
+}
+
+export function clearJoinRefusal() {
+ if (get(joinRefusal)) joinRefusal.set(null);
+}
+
/** Full reset — leaving the session / cancelling out. */
export function resetSession() {
sessionHost.set(null);
peerJoinedAt.set({});
+ approvalStartedAt.set({}); // 27-E: no request survives leaving the session
+ resetSessionClock(); // 25-E: our own clock is the only one left
+ joinRefusal.set(null); // 25-F
}
/**
@@ -77,7 +244,7 @@ export const mergeOnConnect = writable(readMergeOnConnect());
* default, never a crash. The `readFlag` idiom from sharedLibrary. */
function readMergeOnConnect() {
try {
- return localStorage.getItem('connect:mergeOnConnect') === 'true';
+ return safeStorage.getItem('connect:mergeOnConnect') === 'true';
} catch {
return false;
}
@@ -87,6 +254,6 @@ function readMergeOnConnect() {
// callback only ever reads its own argument, so it is safe wherever it sits.
mergeOnConnect.subscribe((v) => {
try {
- localStorage.setItem('connect:mergeOnConnect', String(v));
+ safeStorage.setItem('connect:mergeOnConnect', String(v));
} catch {}
});
diff --git a/src/lib/diagnostics.js b/src/lib/diagnostics.js
new file mode 100644
index 00000000..cd75647e
--- /dev/null
+++ b/src/lib/diagnostics.js
@@ -0,0 +1,262 @@
+import { writable } from 'svelte/store';
+import { APP_VERSION, COMMIT_SHA, IS_DEV } from './version.js';
+
+// 27-B (hardening audit H4) — THE ONE PLACE A FAILURE LEAVES A TRACE.
+//
+// Every recovery path in this app used to end in `console.log` (135 of them in src/lib
+// against 17 console.error/warn), and there was no `window.onerror` or
+// `unhandledrejection` handler anywhere. Two consequences, both of which this module
+// exists to end:
+//
+// · an uncaught error inside a store subscriber breaks THAT subscriber chain and
+// nothing else — svelte does not re-subscribe — so the app half-works and says
+// nothing at all;
+// · a user cannot hand over what happened. Every hard bug in this project's history
+// (the P-A connect dance, B5's mesh formation, the R22 room rounds) was diagnosed by
+// adding logs AFTER a report and asking the user to reproduce it.
+//
+// A ZERO-DEPENDENCY LEAF, deliberately: `version.js` (itself import-free) and
+// svelte/store are the only imports, so ANY module can log without thinking about
+// cycles — and the modules that most need to log (peerHandler, flowRuntime, autosave,
+// moduleSDK) are exactly the ones sitting inside the documented import cycles.
+//
+// The BUNDLE reads its context through REGISTERED SECTIONS rather than by importing the
+// stores. That keeps this file a leaf and makes the seam additive: a module (or the
+// cloud plugin) contributes a section without this file knowing it exists. Sections are
+// SYNCHRONOUS, because the bundle is assembled at the moment the user presses the
+// button, and an await there would report a different instant than the one they saw.
+//
+// NOTHING LEAVES THE BROWSER. The bundle goes to the clipboard and nowhere else; there
+// is no endpoint, no beacon and no telemetry. The user pastes it, or it does not travel.
+
+/** How many entries the ring holds. ~300 lines is a page of context — enough to see the
+ * sequence that led to a failure, small enough to paste into an issue. */
+const CAP = 300;
+
+/** How much of one entry's `data` is kept, in characters. A stringified scene object
+ * would otherwise push the whole ring out of the buffer in one call. */
+const DATA_CAP = 400;
+
+/** @typedef {{t: number, level: 'debug'|'info'|'warn'|'error', scope: string, message: string, data?: string}} Entry */
+
+/** @type {Entry[]} */
+const ring = [];
+
+/** The last uncaught error/rejection, or null. Toasts.svelte MIRRORS this into one
+ * sticky card (the `restoreAvailable` idiom) rather than this module importing appStore
+ * — a leaf that toasts is a leaf that imports the UI. */
+/** @type {import('svelte/store').Writable<{message: string, at: number} | null>} */
+export const lastUncaught = writable(null);
+
+/** Bumped on every entry, so a panel can react without reading the ring. */
+export const diagnosticsCount = writable(0);
+
+/** @type {Map any>} */
+const sections = new Map();
+
+let started = false;
+/** @type {{log: typeof console.log, warn: typeof console.warn, error: typeof console.error} | null} */
+let realConsole = null;
+
+/** @param {unknown} value @returns {string | undefined} */
+function briefly(value) {
+ if (value === undefined) return undefined;
+ let text;
+ try {
+ text = typeof value === 'string' ? value : JSON.stringify(value);
+ } catch {
+ // a THREE object, a DOM node, anything circular
+ text = String(value);
+ }
+ if (text === undefined) return undefined;
+ return text.length > DATA_CAP ? text.slice(0, DATA_CAP) + '…' : text;
+}
+
+/**
+ * Record one line. Cheap by construction: a push, a shift and a store bump — no
+ * formatting until somebody asks for the bundle.
+ * @param {Entry['level']} level
+ * @param {string} scope the module, e.g. 'peer', 'autosave', 'flow'
+ * @param {string} message
+ * @param {unknown} [data]
+ */
+export function log(level, scope, message, data) {
+ ring.push({ t: Date.now(), level, scope, message, data: briefly(data) });
+ while (ring.length > CAP) ring.shift();
+ diagnosticsCount.update((n) => n + 1);
+ // In DEV the console stays the developer's: `log()` forwards, so a `log('warn', …)`
+ // reads exactly like the `console.log` it replaced. In PROD the shim below is what
+ // catches console output, and forwarding here would double every line.
+ if (IS_DEV) {
+ const out = realConsole ?? console;
+ const write = level === 'error' ? out.error : level === 'warn' ? out.warn : out.log;
+ if (data === undefined) write.call(console, `[${scope}] ${message}`);
+ else write.call(console, `[${scope}] ${message}`, data);
+ }
+}
+
+/** The ring as printable lines, oldest first. @returns {string[]} */
+export function lines() {
+ return ring.map(
+ (e) =>
+ new Date(e.t).toISOString().slice(11, 23) +
+ ' ' +
+ e.level.toUpperCase().padEnd(5) +
+ ' [' +
+ e.scope +
+ '] ' +
+ e.message +
+ (e.data ? ' ' + e.data : '')
+ );
+}
+
+/** Drop everything (tests, and the "start again" case). */
+export function clearDiagnostics() {
+ ring.length = 0;
+ diagnosticsCount.set(0);
+ lastUncaught.set(null);
+}
+
+/**
+ * Contribute a named section to the bundle. The function must be SYNCHRONOUS and must
+ * not throw — and if it does throw, the bundle records that instead of failing, because
+ * a diagnostics bundle that cannot be produced when something is broken is worthless.
+ * @param {string} name @param {() => any} read @returns {() => void} unregister
+ */
+export function registerDiagnosticsSection(name, read) {
+ sections.set(name, read);
+ return () => sections.delete(name);
+}
+
+/**
+ * Everything a report needs, assembled now. Synchronous on purpose (see the header).
+ * `extra` carries the one fact that cannot be read synchronously — the storage estimate
+ * — which `copyDiagnostics` awaits before calling this.
+ * @param {Record} [extra]
+ */
+export function bundle(extra = {}) {
+ /** @type {Record} */
+ const out = {
+ version: APP_VERSION,
+ sha: COMMIT_SHA,
+ dev: IS_DEV,
+ at: new Date().toISOString(),
+ ua: typeof navigator === 'undefined' ? '' : navigator.userAgent,
+ language: typeof navigator === 'undefined' ? '' : navigator.language,
+ viewport:
+ typeof window === 'undefined' ? '' : window.innerWidth + 'x' + window.innerHeight + '@' + (window.devicePixelRatio ?? 1),
+ entries: ring.length,
+ sections: /** @type {Record} */ ({}),
+ ...extra
+ };
+ for (const [name, read] of sections) {
+ try {
+ out.sections[name] = read();
+ } catch (error) {
+ out.sections[name] = { failed: String(error) };
+ }
+ }
+ out.lines = lines();
+ return out;
+}
+
+/** The bundle as the text that goes on the clipboard. @param {Record} [extra] */
+export function bundleText(extra = {}) {
+ try {
+ return JSON.stringify(bundle(extra), null, 2);
+ } catch (error) {
+ // the bundle itself must never be the thing that fails
+ return 'diagnostics bundle failed: ' + String(error) + '\n' + lines().join('\n');
+ }
+}
+
+/**
+ * Put the bundle on the clipboard. Async only because `storage.estimate()` is, and a
+ * report that says how full the disk is answers the whole autosave-stopped class of
+ * question at a glance. Falls back to a hidden textarea where the async clipboard is
+ * unavailable (a non-secure context, an older browser).
+ * @returns {Promise} did it land on the clipboard?
+ */
+export async function copyDiagnostics() {
+ /** @type {Record} */
+ const extra = {};
+ try {
+ if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
+ const estimate = await navigator.storage.estimate();
+ extra.storage = { usage: estimate.usage ?? 0, quota: estimate.quota ?? 0 };
+ }
+ } catch {
+ /* private mode, or a browser without it — the rest of the bundle still stands */
+ }
+ const text = bundleText(extra);
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ /* fall through to the textarea */
+ }
+ try {
+ const area = document.createElement('textarea');
+ area.value = text;
+ area.setAttribute('readonly', '');
+ area.style.position = 'fixed';
+ area.style.opacity = '0';
+ document.body.appendChild(area);
+ area.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(area);
+ return ok;
+ } catch (error) {
+ log('warn', 'diagnostics', 'could not copy the bundle', String(error));
+ return false;
+ }
+}
+
+/**
+ * Install the global capture. Idempotent, and safe to call before anything else in
+ * App.svelte's onMount — it is the first thing that runs there precisely so that a
+ * failure DURING boot is already being recorded.
+ *
+ * The console shim is PROD-ONLY: it tees `console.log/warn/error` into the ring so the
+ * 135 legacy call sites are covered before they are migrated one by one, while a
+ * developer's console keeps its exact line numbers and object inspection in dev.
+ */
+export function startDiagnostics() {
+ if (started || typeof window === 'undefined') return;
+ started = true;
+
+ window.addEventListener('error', (event) => {
+ // `event.error` is absent for a resource load failure, where `message` still is not
+ const message = event.error?.message ?? event.message ?? 'unknown error';
+ const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : '';
+ log('error', 'window', message + where, event.error?.stack);
+ lastUncaught.set({ message, at: Date.now() });
+ });
+
+ window.addEventListener('unhandledrejection', (event) => {
+ const reason = /** @type {any} */ (event).reason;
+ const message = reason?.message ?? String(reason ?? 'unknown rejection');
+ log('error', 'promise', message, reason?.stack);
+ lastUncaught.set({ message, at: Date.now() });
+ });
+
+ if (!IS_DEV) {
+ realConsole = { log: console.log, warn: console.warn, error: console.error };
+ /** @param {Entry['level']} level @param {(...args: any[]) => void} original */
+ const tee = (level, original) =>
+ /** @param {any[]} args */
+ (...args) => {
+ try {
+ log(level, 'console', args.map((a) => (typeof a === 'string' ? a : briefly(a) ?? '')).join(' '));
+ } catch {
+ /* never let logging break the thing that logged */
+ }
+ original.apply(console, args);
+ };
+ console.log = tee('info', realConsole.log);
+ console.warn = tee('warn', realConsole.warn);
+ console.error = tee('error', realConsole.error);
+ }
+
+ log('info', 'app', 'started ' + APP_VERSION + ' (' + COMMIT_SHA + ')');
+}
diff --git a/src/lib/disposeTree.js b/src/lib/disposeTree.js
new file mode 100644
index 00000000..13d5d03b
--- /dev/null
+++ b/src/lib/disposeTree.js
@@ -0,0 +1,118 @@
+// 27-G (audit H6, M13) — GIVING GPU MEMORY BACK.
+//
+// Removing an object from the scene drops the JS reference and NOTHING else: its
+// geometry, its materials and every texture they hold stay resident on the GPU until the
+// context dies. `deleteObject` has always been `parent.remove(object)` and no more, so a
+// session that imports and deletes the same model ten times pays for ten copies. That is
+// audit H6.
+//
+// THE WHOLE DIFFICULTY IS SHARING, not freeing. This codebase shares resources
+// deliberately and in several directions: `clone()` shares geometry and material, which
+// is why `editOverlays` detaches without disposing; `onionSkin` frees the materials it
+// made and never the geometry it borrowed; a duplicated object, a prefab instance and a
+// material fanned across a selection can all hold the same texture. Disposing a texture
+// that something else still draws with does not throw — it renders BLACK, later, somewhere
+// else, with nothing to connect it to the delete that caused it.
+//
+// So the rule is: work out what the REST of the scene still holds, in one pass, and free
+// only what nothing else refers to. `keepSet` answers that question and `disposeTree`
+// obeys it. A LEAF (THREE only), so the sharing logic is testable with no renderer.
+
+import * as THREE from 'three';
+
+/** @param {any} material @param {(t: any) => void} visit */
+function eachTexture(material, visit) {
+ if (!material) return;
+ // Scan the material's OWN properties rather than a hardcoded list of map names.
+ // three.js grows new map slots release to release, and a list silently stops
+ // covering the newest one — a leak that looks exactly like no leak.
+ for (const key of Object.keys(material)) {
+ const value = /** @type {any} */ (material)[key];
+ if (value && value.isTexture) visit(value);
+ }
+}
+
+/** @param {any} object @param {(r: any) => void} visit */
+function eachResource(object, visit) {
+ if (!object) return;
+ if (object.geometry) visit(object.geometry);
+ const material = object.material;
+ if (!material) return;
+ const list = Array.isArray(material) ? material : [material];
+ for (const m of list) {
+ if (!m) continue;
+ visit(m);
+ eachTexture(m, visit);
+ }
+}
+
+/**
+ * Everything the scene still holds OUTSIDE `doomed` — geometries, materials and textures,
+ * in ONE traversal. Pass the result to `disposeTree` as its `keep` set.
+ *
+ * `doomed` may be a single object or an array; anything at or beneath one of them is
+ * skipped, because those are precisely the references about to go away.
+ * @param {any} scene @param {any | any[]} doomed
+ */
+export function keepSet(scene, doomed) {
+ const roots = Array.isArray(doomed) ? doomed.filter(Boolean) : doomed ? [doomed] : [];
+ const dying = new Set();
+ for (const root of roots) root.traverse?.((/** @type {any} */ o) => dying.add(o));
+ /** @type {Set} */
+ const keep = new Set();
+ scene?.traverse?.((/** @type {any} */ o) => {
+ if (dying.has(o)) return;
+ eachResource(o, (r) => keep.add(r));
+ });
+ return keep;
+}
+
+/**
+ * Free the GPU resources under `root`, skipping anything in `keep`.
+ *
+ * Returns what it actually freed, which is what makes this testable and what the suite
+ * asserts on — a disposal that silently frees nothing looks identical to one that works
+ * until you read `renderer.info.memory`.
+ *
+ * Deliberately does NOT remove `root` from its parent: callers already do that, and
+ * doing it here would make the function's name a lie about half of what it does.
+ * @param {any} root
+ * @param {{ keep?: Set }} [options]
+ */
+export function disposeTree(root, options = {}) {
+ const keep = options.keep ?? new Set();
+ const freed = { geometries: 0, materials: 0, textures: 0 };
+ if (!root) return freed;
+ // one object can reference the same material twice (an array with repeats); a local
+ // seen-set keeps the counts honest
+ const seen = new Set();
+ root.traverse?.((/** @type {any} */ o) => {
+ eachResource(o, (r) => {
+ if (!r || keep.has(r) || seen.has(r)) return;
+ seen.add(r);
+ if (typeof r.dispose !== 'function') return;
+ if (r.isTexture) freed.textures++;
+ else if (r.isMaterial) freed.materials++;
+ else if (r.isBufferGeometry) freed.geometries++;
+ else return; // something else entirely: leave it alone
+ r.dispose();
+ });
+ });
+ return freed;
+}
+
+/**
+ * The ordinary call: take the object out of the scene AND free what only it was using.
+ * The keep set is computed BEFORE the removal, against the scene it is still part of —
+ * `keepSet` excludes the doomed subtree itself, so the order is safe either way, but
+ * computing it first means one traversal of a scene that has not been mutated underneath.
+ * @param {any} scene @param {any} object
+ */
+export function removeAndDispose(scene, object) {
+ if (!object) return { geometries: 0, materials: 0, textures: 0 };
+ const keep = keepSet(scene, object);
+ object.parent?.remove(object);
+ return disposeTree(object, { keep });
+}
+
+export { THREE };
diff --git a/src/lib/docking.js b/src/lib/docking.js
index ec88fb1e..5450ea80 100644
--- a/src/lib/docking.js
+++ b/src/lib/docking.js
@@ -1,6 +1,7 @@
import { get } from 'svelte/store';
import { inspectorClose, closeMenu } from '../stores/appStore';
import { bottomDockWouldTake } from './bottomDockDrop';
+import { safeStorage } from './safeStorage';
// Docking lite (phase 81L). Drag a window near the left/right screen edge to
// dock it as a full-height panel (--z-drawer tier); drag its header away to
@@ -19,17 +20,17 @@ let docked = { left: null, right: null };
const registry = new Map(); // key -> {node, prevRect, handle}
try {
- const saved = JSON.parse(localStorage.getItem('dockedWindows') ?? 'null');
+ const saved = JSON.parse(safeStorage.getItem('dockedWindows') ?? 'null');
if (saved) docked = { left: saved.left ?? null, right: saved.right ?? null };
} catch {}
function persist() {
- localStorage.setItem('dockedWindows', JSON.stringify(docked));
+ safeStorage.setItem('dockedWindows', JSON.stringify(docked));
}
/** @param {string} key */
function widthOf(key) {
- const value = parseInt(localStorage.getItem('dockWidth:' + key) ?? '300');
+ const value = parseInt(safeStorage.getItem('dockWidth:' + key) ?? '300');
return Math.min(Math.max(Number.isNaN(value) ? 300 : value, 250), Math.round(window.innerWidth * 0.4));
}
@@ -102,7 +103,7 @@ function apply(key) {
const move = (/** @type {any} */ ev) => {
const delta = currentSide === 'left' ? ev.clientX - startX : startX - ev.clientX;
const next = Math.min(Math.max(250, startWidth + delta), Math.round(window.innerWidth * 0.4));
- localStorage.setItem('dockWidth:' + key, String(next));
+ safeStorage.setItem('dockWidth:' + key, String(next));
apply(key);
};
const up = () => {
diff --git a/src/lib/dragWindow.js b/src/lib/dragWindow.js
index 382d5138..33a1b346 100644
--- a/src/lib/dragWindow.js
+++ b/src/lib/dragWindow.js
@@ -3,6 +3,7 @@
// Windows sit on the --z-window tier; the caller sets size and z-index.
import { clampWinSize, clampResize, bottomReserve } from './windowSize';
+import { safeStorage } from './safeStorage';
// 169: live reset registry — every draggable window (this action + the object
// list's own dragMe) registers a reset fn so Settings can rescue windows stuck
@@ -42,10 +43,10 @@ export function revealWindow(key) {
* button, so it is the honest hatch rather than a second one. */
export function resetWindowLayout() {
if (typeof localStorage !== 'undefined') {
- for (const key of Object.keys(localStorage))
- if (key.startsWith('win:')) localStorage.removeItem(key);
+ for (const key of safeStorage.keys())
+ if (key.startsWith('win:')) safeStorage.removeItem(key);
['objectListRect', 'explorerWinW', 'explorerWinH', 'explorerHeight', 'explorerTreeW', 'uvWinW', 'uvWinH', 'controlsLayout'].forEach((k) =>
- localStorage.removeItem(k)
+ safeStorage.removeItem(k)
);
}
resetters.forEach((fn) => {
@@ -76,7 +77,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi
/** @type {any} */
let rect = null;
try {
- rect = JSON.parse(localStorage.getItem('win:' + key) ?? 'null');
+ rect = JSON.parse(safeStorage.getItem('win:' + key) ?? 'null');
} catch {
rect = null;
}
@@ -189,7 +190,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi
payload.w = rect.w;
if (axis !== 'x') payload.h = rect.h;
}
- localStorage.setItem('win:' + key, JSON.stringify(payload));
+ safeStorage.setItem('win:' + key, JSON.stringify(payload));
}
// right/bottom-anchored defaults need the rendered size — resolve on the
@@ -268,7 +269,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi
// 169: reset this window to its default spot (Settings rescue)
function resetToDefault() {
try {
- localStorage.removeItem('win:' + key);
+ safeStorage.removeItem('win:' + key);
} catch {}
rect = { ...defaultRect };
if (resizable) {
diff --git a/src/lib/drawMode.js b/src/lib/drawMode.js
index 488304f3..23910fec 100644
--- a/src/lib/drawMode.js
+++ b/src/lib/drawMode.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordObjectPresence } from './history';
@@ -199,7 +199,7 @@ export function endStroke() {
mesh.userData.shadow = false; // draw strokes don't cast (basic-material lines)
group.add(mesh);
- objectsGroup.update((value) => value);
+ pokeScene();
recordObjectPresence('create', mesh);
/** @type {any} */
const peer = get(peers);
diff --git a/src/lib/environment.js b/src/lib/environment.js
index 23df42f7..8eaedb53 100644
--- a/src/lib/environment.js
+++ b/src/lib/environment.js
@@ -1,13 +1,15 @@
import * as THREE from 'three';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { writable, get } from 'svelte/store';
-import { globalScene, globalRenderer, objectsGroup, backgroundColor, TControls, passthroughActive } from '../stores/sceneStore';
+import { globalScene, globalRenderer, objectsGroup, backgroundColor, TControls, passthroughActive, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { sceneRadius } from './sceneBounds';
import { registerSystemGroup } from './moduleSDK';
import { createLight } from './geometries.svelte';
-import { cappedShadowSize, shadowQuality } from './lightParams';
+import { cappedShadowSize, shadowsDisabled } from './lightParams';
import { wireframeActive } from './viewMode';
import { idbGet, idbPut, idbDelete, idbKeys } from './idb';
+import { safeStorage } from './safeStorage';
// Environment v2 (phase 70). Everything environmental lives under ONE group at
// the scene root: `environment-root` — the preset rig (hemi+sun) plus any
@@ -66,7 +68,7 @@ const DEFAULT_STATE = { preset: 'studio', exposure: 1, customPreset: null, light
function persisted() {
try {
- const raw = localStorage.getItem('environment');
+ const raw = safeStorage.getItem('environment');
if (raw) return { ...DEFAULT_STATE, ...JSON.parse(raw) };
} catch {}
return { ...DEFAULT_STATE };
@@ -239,7 +241,8 @@ export function applyEnvironment() {
// honor a persisted 'off' shadow pref here too: the renderer arrives
// after lightParams' first subscribe fires (which would no-op on a null
// renderer), so re-assert it on every apply
- if (renderer.shadowMap) renderer.shadowMap.enabled = get(shadowQuality) !== 'off';
+ // (26-D: through shadowsDisabled, so the quality governor's override survives an apply)
+ if (renderer.shadowMap) renderer.shadowMap.enabled = !shadowsDisabled();
}
const { hemi, sun } = rigLights(scene, !!preset.hemi);
@@ -279,7 +282,7 @@ export function applyEnvironment() {
// correctly over the camera feed, and that darkening is what glues a virtual
// object to a real table (the sky/fog lift above is the whole AR stand-down;
// the sun rig keeps casting untouched)
- const shadowsOff = get(shadowQuality) === 'off';
+ const shadowsOff = shadowsDisabled();
const catcher = shadowCatcher(scene, !!(preset.sun && !shadowsOff));
if (catcher) {
catcher.visible = !!(preset.sun && !shadowsOff) && !wireframeActive();
@@ -292,7 +295,7 @@ export function applyEnvironment() {
/** Apply a state change locally, persist and replicate @param {any} partial */
function commit(partial) {
- const state = { ...get(environment), ...partial, changedAt: Date.now() };
+ const state = { ...get(environment), ...partial, changedAt: sessionNow() };
environment.set(state);
applyEnvironment();
/** @type {any} */
@@ -402,7 +405,7 @@ export function convertToEnvironment(uuid) {
const controls = get(TControls);
if (controls?.object?.uuid === uuid) controls.detach();
object.parent?.remove(object);
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer) peer.send({ type: 'delete', uuid, peerId: peer.peer.id });
@@ -423,7 +426,7 @@ export function convertFromEnvironment(id) {
if (def.groundColor && light.groundColor) light.groundColor.set(def.groundColor);
light.intensity = def.intensity ?? 1;
if (def.position) light.position.fromArray(def.position);
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer) {
@@ -583,9 +586,9 @@ export function environmentRestore(payload, replicate = false) {
exposure: payload.exposure ?? 1,
customPreset: payload.customPreset ?? null,
lights: payload.lights ?? [],
- changedAt: Date.now()
+ changedAt: sessionNow()
}
- : { ...DEFAULT_STATE, changedAt: Date.now() };
+ : { ...DEFAULT_STATE, changedAt: sessionNow() };
environment.set(state);
applyEnvironment();
if (!replicate) return;
@@ -608,7 +611,7 @@ export function startEnvironment() {
loadEnvPresets();
environment.subscribe((state) => {
try {
- localStorage.setItem('environment', JSON.stringify(state));
+ safeStorage.setItem('environment', JSON.stringify(state));
} catch {}
});
// scene/renderer arrive async at boot
diff --git a/src/lib/explorerView.js b/src/lib/explorerView.js
index 61dddd88..bd3e89b9 100644
--- a/src/lib/explorerView.js
+++ b/src/lib/explorerView.js
@@ -19,6 +19,7 @@
// columns that distinguish the bin or leave dead columns in the library.
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
/**
* @typedef {{key: string, label: string, always?: boolean, numeric?: boolean, width?: string}} ExplorerColumn
@@ -74,7 +75,7 @@ const GROUP_KEY = 'explorer:deletedGroup';
function load(key, fallback) {
if (typeof localStorage === 'undefined') return fallback;
try {
- const raw = localStorage.getItem(key);
+ const raw = safeStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? { ...fallback, ...parsed } : fallback;
@@ -87,7 +88,7 @@ function load(key, fallback) {
function save(key, value) {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem(key, JSON.stringify(value));
+ safeStorage.setItem(key, JSON.stringify(value));
} catch {}
}
@@ -97,7 +98,7 @@ function save(key, value) {
*/
export const explorerViewMode = writable(
/** @type {'thumbnails'|'list'} */ (
- typeof localStorage !== 'undefined' && localStorage.getItem(MODE_KEY) === 'list'
+ typeof localStorage !== 'undefined' && safeStorage.getItem(MODE_KEY) === 'list'
? 'list'
: 'thumbnails'
)
@@ -105,7 +106,7 @@ export const explorerViewMode = writable(
explorerViewMode.subscribe((v) => {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem(MODE_KEY, v);
+ safeStorage.setItem(MODE_KEY, v);
} catch {}
});
@@ -200,7 +201,7 @@ explorerSort.subscribe((v) => save(SORT_KEY, v));
*/
export const explorerDeletedGroup = writable(
/** @type {'none'|'deleter'} */ (
- typeof localStorage !== 'undefined' && localStorage.getItem(GROUP_KEY) === 'deleter'
+ typeof localStorage !== 'undefined' && safeStorage.getItem(GROUP_KEY) === 'deleter'
? 'deleter'
: 'none'
)
@@ -208,7 +209,7 @@ export const explorerDeletedGroup = writable(
explorerDeletedGroup.subscribe((v) => {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem(GROUP_KEY, v);
+ safeStorage.setItem(GROUP_KEY, v);
} catch {}
});
@@ -228,7 +229,7 @@ const BIN_SPENT_KEY = 'explorer:binShowSpent';
*/
export const explorerBinLayout = writable(
/** @type {'tree'|'plain'} */ (
- typeof localStorage !== 'undefined' && localStorage.getItem(BIN_LAYOUT_KEY) === 'plain'
+ typeof localStorage !== 'undefined' && safeStorage.getItem(BIN_LAYOUT_KEY) === 'plain'
? 'plain'
: 'tree'
)
@@ -236,7 +237,7 @@ export const explorerBinLayout = writable(
explorerBinLayout.subscribe((v) => {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem(BIN_LAYOUT_KEY, v);
+ safeStorage.setItem(BIN_LAYOUT_KEY, v);
} catch {}
});
@@ -250,12 +251,12 @@ explorerBinLayout.subscribe((v) => {
* row of grid height. @type {import('svelte/store').Writable}
*/
export const explorerBinShowSpent = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem(BIN_SPENT_KEY) === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem(BIN_SPENT_KEY) === 'true'
);
explorerBinShowSpent.subscribe((v) => {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem(BIN_SPENT_KEY, String(v));
+ safeStorage.setItem(BIN_SPENT_KEY, String(v));
} catch {}
});
diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js
index 9ed2fab3..b88fda84 100644
--- a/src/lib/faceEdit.js
+++ b/src/lib/faceEdit.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { globalScene, globalCamera, objectsGroup, TControls, lockedObjects, isVRMode } from '../stores/sceneStore';
+import { globalScene, globalCamera, objectsGroup, TControls, lockedObjects, isVRMode, pokeScene } from '../stores/sceneStore';
// 15-F: session-scoped undo — editSession imports ONLY history (an edge we
// already have), so this closes no cycle
import { noteEditEnter, noteEditExit, sealEditHistorySession } from './editSession';
@@ -45,6 +45,7 @@ import {
endProportionalWheel
} from './proportional';
import { showProportionalRingAt, hideProportionalRing } from './proportionalRing';
+import { safeStorage } from './safeStorage';
// the custom transform PIVOT (a LOCAL per-object pref). Another leaf — meshPivot
// imports THREE, the two stores and `proportional`, and nothing from here.
import {
@@ -100,11 +101,11 @@ export const VR_FACE_CAP = 2500;
* @type {import('svelte/store').Writable} */
export const vrFaceCap = writable(
typeof localStorage !== 'undefined'
- ? parseInt(localStorage.getItem('vrFaceCap') ?? '') || VR_FACE_CAP
+ ? parseInt(safeStorage.getItem('vrFaceCap') ?? '') || VR_FACE_CAP
: VR_FACE_CAP
);
if (typeof localStorage !== 'undefined')
- vrFaceCap.subscribe((value) => localStorage.setItem('vrFaceCap', String(value)));
+ vrFaceCap.subscribe((value) => safeStorage.setItem('vrFaceCap', String(value)));
/** D7: over-limit / blocked-edit warning with a deep link into the Settings
* VR section (works in noVR immediately; VR users see it on exit — on-device
@@ -1480,7 +1481,7 @@ export function applyMeshGeo(uuid, positions, groups, uvs, faceCounts, faceTris)
// module eval (it imports us — a dynamic import back would be a SECOND module
// instance under vite's ?t= HMR stamps, whose editingObject is always null).
vertexSessionRefresher?.(uuid);
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Average normals across position-welded vertices of a NON-INDEXED geometry
@@ -1539,10 +1540,10 @@ let wireSource = null;
/** wireframe overlay display toggle — honored by BOTH edit modes, local pref */
export const meshEditWireframe = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('meshEditWireframe') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('meshEditWireframe') !== 'false'
);
meshEditWireframe.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditWireframe', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditWireframe', String(value));
if (wire) wire.visible = value; // live toggle mid-session (face mode)
});
@@ -1552,10 +1553,10 @@ meshEditWireframe.subscribe((value) => {
* editorNavigation (W/A/S/D/Q/E fly is suppressed while it's on; toggling the
* pref OFF is the escape hatch that returns the camera keys, quiz 15-D3). */
export const meshEditHotkeys = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('meshEditHotkeys') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('meshEditHotkeys') !== 'false'
);
meshEditHotkeys.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditHotkeys', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditHotkeys', String(value));
});
/** Show the object SELECTION OUTLINE while mesh-editing — local pref, default
@@ -1564,10 +1565,10 @@ meshEditHotkeys.subscribe((value) => {
* what they do with depthTest/renderOrder: while you are editing elements, the
* object-level outline is pure glare. Read by Outline.svelte. */
export const meshEditOutline = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('meshEditOutline') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('meshEditOutline') === 'true'
);
meshEditOutline.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditOutline', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditOutline', String(value));
});
/** Show the raw TRIANGULATION in the edit wireframe — local pref, default OFF.
@@ -1576,7 +1577,7 @@ meshEditOutline.subscribe((value) => {
* not dissolvable, so drawing it advertised an edge the tools refuse to touch.
* Every modeller shows quads in edit mode for the same reason. */
export const meshEditTriWire = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('meshEditTriWire') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('meshEditTriWire') === 'true'
);
/** meshEdit owns the vertex-mode overlay; it imports THIS module, so it hands
@@ -1591,7 +1592,7 @@ export function registerVertexWireRebuild(fn) {
}
meshEditTriWire.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditTriWire', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditTriWire', String(value));
// the edge set differs, so this rebuilds rather than toggling visibility.
// `wire` is the only session state read here: faceEdited lives further down
// the file and would TDZ-crash the SSR eval, so refreshFaceWireframe (which
@@ -5432,7 +5433,7 @@ export function setShadingSmooth(smooth) {
uuid: faceEdited.uuid,
shading: faceEdited.userData.shading
});
- objectsGroup.update((v) => v);
+ pokeScene();
showToast(smooth ? 'Shading: smooth' : 'Shading: flat');
return true;
}
@@ -6315,7 +6316,7 @@ function applyGeometrySnapshot(positions, groups, uvs, faces) {
refreshFaceOverlay();
refreshEdgeHighlight(); // M4: baked in world space, same as the face overlay
refreshFaceWireframe(); // B2: the overlay wraps the NEW geometry
- objectsGroup.update((v) => v);
+ pokeScene();
}
/**
@@ -6485,7 +6486,7 @@ function liveGeometryUpdate() {
// grab it draws from the grab's own live endpoints (see refreshEdgeOverlay)
refreshEdgeOverlay();
refreshFaceWireframe(); // B2: track the gesture live
- objectsGroup.update((v) => v);
+ pokeScene();
const now = Date.now();
// The PREVIEW is the one thing that must stay small. A gesture streams this
// ~5×/s, so a mesh at the commit ceiling would be ~60 MB/s at every peer —
@@ -6919,12 +6920,12 @@ export function registerGizmoPrefListener(fn) {
* subscriber runs at module eval (the store-subscriber TDZ gotcha).
* @type {import('svelte/store').Writable<'local'|'world'>} */
export const faceGizmoSpace = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('faceGizmoSpace') === 'world'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('faceGizmoSpace') === 'world'
? 'world'
: 'local'
);
faceGizmoSpace.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('faceGizmoSpace', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('faceGizmoSpace', String(value));
/** @type {any} */
const controls = get(TControls);
// live flip while the face gizmo is seated
@@ -6943,10 +6944,10 @@ faceGizmoSpace.subscribe((value) => {
* of the way" — modelling with click-select and the ops toolbar only.
* @type {import('svelte/store').Writable} */
export const meshGizmoEnabled = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('meshGizmoEnabled') !== '0' : true
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('meshGizmoEnabled') !== '0' : true
);
meshGizmoEnabled.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('meshGizmoEnabled', value ? '1' : '0');
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('meshGizmoEnabled', value ? '1' : '0');
if (typeof window === 'undefined') return;
// live: seat or drop the gizmo the moment the switch flips, in whichever mode is open.
// 24-B1: switching it back ON also restores a pick the mode key hid, so the toolbox
diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js
index b93a4c32..21c53980 100644
--- a/src/lib/fileHandler.svelte.js
+++ b/src/lib/fileHandler.svelte.js
@@ -9,7 +9,7 @@ import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { get } from 'svelte/store';
import { scenePost } from '$lib/scenePost';
-import { objectsGroup, TControls, selectedObject, selectedObjects } from '../stores/sceneStore.js';
+import { objectsGroup, TControls, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore.js';
import { sendObjects } from './commandsHandler.svelte';
import { recordObjectPresence } from '$lib/history';
// 17-D2: the .mtl texture path reuses the app's own downscale-to-dataURL step.
@@ -25,6 +25,7 @@ import { parkAnimatedAtBase } from '$lib/flowRuntime';
import { stripEditOverlays } from '$lib/editOverlays';
import { saveFileBase } from '$lib/saveName';
import { peers, fixLight, loadingFile, showToast } from '../stores/appStore';
+import { safeStorage } from './safeStorage';
//Access objects Store
let sceneObjects = $state();
@@ -61,7 +62,7 @@ export function currentSceneName() {
// B3: .tpscene export prefs (set from the Sidebar export-settings cog)
export function tpsceneOptions() {
const read = (/** @type {string} */ k, /** @type {boolean} */ dflt) => {
- const v = typeof localStorage !== 'undefined' ? localStorage.getItem(k) : null;
+ const v = typeof localStorage !== 'undefined' ? safeStorage.getItem(k) : null;
return v === null ? dflt : v === 'true';
};
// 21-I5 REVISED: there is deliberately no `versions` option here. This path exports
@@ -329,7 +330,7 @@ function addAnimatedImport(result, buffer, name, kind) {
const root = result.scene;
root.name = name ?? 'Animated import';
sceneObjects.add(root);
- objectsGroup.update((value) => value);
+ pokeScene();
controls.attach(root);
registerAnimatedImport(root, result.animations, buffer, kind ?? 'gltf');
recordAnimatedImport(root);
@@ -349,7 +350,7 @@ function addImported(imported, name, position) {
if (position) imported.position.fromArray(position);
sceneObjects.add(imported);
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
controls.attach(imported);
recordObjectPresence('create', imported);
sendObjects(/** @type {any} */ (null), imported);
@@ -733,7 +734,7 @@ try {
sceneObjects.add(mesh)
});
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
//Send object to peers
peer.send({type: 'object', element: json, uuids: uuids})
} else if (file.name.split('.').pop() == 'json') {
@@ -752,7 +753,7 @@ try {
peer.send({type: 'object', element: child.toJSON()})
});
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
// Free memory by emptying the array
objectsArray.length = 0;
console.log('Scene load complete');
diff --git a/src/lib/filePreview.js b/src/lib/filePreview.js
index ba589b58..4669ca90 100644
--- a/src/lib/filePreview.js
+++ b/src/lib/filePreview.js
@@ -22,6 +22,7 @@
// Deriving it a second time here would be a copy of that logic guaranteed to drift.
import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
/**
* What the preview window can actually SHOW. A `.txt` opens in the code editor and a
@@ -199,7 +200,7 @@ previewAutoPlay.subscribe((v) => saveFlag('preview:autoPlay', v));
*/
export function previewFps() {
if (typeof localStorage === 'undefined') return 30;
- const raw = Number(localStorage.getItem('animationFps'));
+ const raw = Number(safeStorage.getItem('animationFps'));
return Number.isFinite(raw) && raw >= 1 && raw <= 240 ? Math.round(raw) : 30;
}
@@ -242,14 +243,14 @@ export function frameAt(t, duration, fps = previewFps()) {
/** @param {string} key @param {boolean} fallback */
function readFlag(key, fallback) {
if (typeof localStorage === 'undefined') return fallback;
- const raw = localStorage.getItem(key);
+ const raw = safeStorage.getItem(key);
return raw === null ? fallback : raw === 'true';
}
/** @param {string} key @param {boolean} value */
function saveFlag(key, value) {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem(key, String(value));
+ safeStorage.setItem(key, String(value));
} catch {}
}
diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js
index 6048db04..a927a19a 100644
--- a/src/lib/flowRuntime.js
+++ b/src/lib/flowRuntime.js
@@ -1,9 +1,10 @@
import * as THREE from 'three';
+import { sessionNow, onSessionClockJump } from './sessionClock'; // 25-E: the synced clock is the SESSION's
import { get } from 'svelte/store';
-import { flowGraphs, mutedFlowObjects, syncedAnimations, flowValues, flowTriggers, SCENE_GRAPH, startGraphMirror, allNodes, allEdges } from '../stores/flowStore';
+import { flowGraphs, mutedFlowObjects, syncedAnimations, flowValues, flowTriggers, SCENE_GRAPH, startGraphMirror, allNodes, allEdges, flowPaused} from '../stores/flowStore';
// 21-F2: `isLocked` is the LOCAL play substate the recipe gate reads — see gamePlayActive
import { objectsGroup, isLocked } from '../stores/sceneStore';
-import { peers, showToast } from '../stores/appStore';
+import { peers, showToast, showInfoToast, dismissToastById} from '../stores/appStore';
import { animationTypes } from './nodeCatalog';
// 21-E7.6: hudKinds is a leaf (it reads only moduleHudKinds, itself svelte/store-only)
import { isIndexValuedKind } from './hudKinds';
@@ -63,6 +64,10 @@ import {
setPlayMoveSpeed,
DEFAULT_FLY_SPEED
} from './charController';
+// 27-B: recovery paths report through the diagnostics ring instead of console.log,
+// so a user can hand over what happened (hardening audit H4). A zero-import leaf.
+import { log } from './diagnostics';
+import { safeStorage } from './safeStorage';
// H3: inputRuntime is reached via a PRIMED dynamic import (the moduleSDK
// pattern) — a static edge would close the TDZ cycle history -> flowRuntime ->
@@ -149,6 +154,18 @@ export function triggerHistoryEpoch() {
return triggerHistoryAt;
}
+// 25-E: THE CUTOFFS FOLLOW THE CLOCK. The epoch above and every `actionSeenAt` entry are
+// SESSION seconds recorded as local cutoffs, and a joiner records most of them during its
+// handshake — before its clock has been corrected onto the host's. A -90 s correction
+// would then leave every one of them 90 s in the future, so every live pulse would be
+// refused as older than the node acting on it. Shift them by the jump instead. A callback
+// registration, not a subscribe, so nothing here runs at module eval.
+onSessionClockJump((deltaMs) => {
+ const d = deltaMs / 1000;
+ if (triggerHistoryAt) triggerHistoryAt += d;
+ for (const [id, seen] of actionSeenAt) actionSeenAt.set(id, seen + d);
+});
+
/**
* Register an action node's first-seen moment. Called for EVERY action node on EVERY
* tick, whether or not a stamp exists — the cutoff has to be set by mere PRESENCE. The
@@ -2452,7 +2469,7 @@ export function speedOf(uuid) {
/** Synced seconds — same formula as the tick clock. */
function syncedNow() {
- return synced ? (Date.now() % 86400000) / 1000 : performance.now() / 1000;
+ return synced ? (sessionNow() % 86400000) / 1000 : performance.now() / 1000;
}
/**
@@ -2767,7 +2784,7 @@ function applyAnimation(object, base, anim, time, ctx) {
trigger: moduleTriggerInfo(anim, ctx)
});
} catch (error) {
- console.log('module effect ' + anim.type + ' failed', error);
+ noteFrameFailure('module effect ' + anim.type, error);
}
return;
}
@@ -2871,7 +2888,7 @@ function applyAnimation(object, base, anim, time, ctx) {
{
note: Number.isFinite(+data.note) ? +data.note : 60,
velocity: typeof data.velocity === 'number' ? data.velocity : 0.9,
- at: Math.floor(Date.now() / 86400000) * 86400000 + stamp * 1000
+ at: Math.floor(sessionNow() / 86400000) * 86400000 + stamp * 1000
},
{ replicate: false }
);
@@ -2952,8 +2969,23 @@ function applyPathPatrol(object, data, time) {
// threlte's task loop (setAnimationLoop — XR-aware) while presenting; the
// timestamp guard makes a double delivery (both loops in one frame) a no-op.
let lastRunAt = -1000;
+/** 27-C: per-frame failures are rate-limited per KIND — first three, then one per 300.
+ * A throwing frame task is the loudest thing in the app otherwise, and the noise is what
+ * costs you the first failure. @type {Record} */
+const frameFailCounts = {};
+
+/** @param {string} kind @param {unknown} error */
+function noteFrameFailure(kind, error) {
+ const n = (frameFailCounts[kind] = (frameFailCounts[kind] ?? 0) + 1);
+ if (n <= 3 || n % 300 === 0) log('warn', 'flow', kind + ' failed (' + n + ')', String(error));
+}
+
/** @param {number} now */
function runTick(now) {
+ if (failTicksRemaining > 0) {
+ failTicksRemaining--;
+ throw new Error('forced tick failure (test hook)');
+ }
if (now - lastRunAt < 3) return;
lastRunAt = now;
// wall clock (wrapped daily to keep float noise low) -> same phase on every peer
@@ -2965,7 +2997,7 @@ function runTick(now) {
// now lands in THIS tick's trigger snapshot, exactly as a keydown arriving between
// frames would. (It also rides pumpFlowTick, so a pad works in a headset for free.)
inputRuntimeRef?.pollGamepads();
- const time = synced ? (Date.now() % 86400000) / 1000 : now / 1000;
+ const time = synced ? (sessionNow() % 86400000) / 1000 : now / 1000;
const ctx = runtimeCtx(); // 134: scene + trigger state for the evaluators
// collect active animations per scene object
@@ -3235,7 +3267,10 @@ function runTick(now) {
try {
task(time);
} catch (error) {
- console.log('module frame task failed', error);
+ // 27-C: a module task that throws EVERY frame wrote 60 lines a second into the
+ // ring, which evicts the context around the first failure — the only line that
+ // says what broke. First three, then one per 300.
+ noteFrameFailure('module frame task', error);
}
});
@@ -3248,22 +3283,101 @@ function runTick(now) {
try {
postTick(now);
} catch (error) {
- console.log('post-tick hook failed', error);
+ noteFrameFailure('post-tick hook', error);
}
}
}
+// 27-C (audit top-10 #3): ONE THROW USED TO END EVERY ANIMATION AND EVERY PHYSICS STEP
+// FOR THE SESSION. `tick` called `runTick` and then re-armed the frame, so an exception
+// escaped before `requestAnimationFrame` ran and nothing ever scheduled another frame —
+// no error surfaced, the scene simply stopped moving. The frame is re-armed in a
+// `finally`, which is the whole fix; the counter below is what stops a permanently
+// broken graph burning a core at 60Hz with nobody watching.
+const TICK_FAIL_LIMIT = 120; // ~2s of failing frames at 60Hz
+let tickFails = 0;
+/** TEST-ONLY: force the next N ticks to throw. There is no organic way in — every real
+ * path into runTick (module tasks, the post-tick hook, scripts) is individually caught,
+ * which IS this phase — so the threshold and the re-arm would otherwise be unprovable. */
+let failTicksRemaining = 0;
+
+/**
+ * 27-D: a completed tick is what makes a restored snapshot TRUSTWORTHY. `autosave` arms
+ * `restoreArmed` before it applies one; if that flag is still set at the next boot, the
+ * restore never reached a clean frame, so the next boot offers the prompt with a warning
+ * instead of auto-restoring the same scene into the same crash.
+ *
+ * Written straight to localStorage rather than through `autosave`: the import edge runs
+ * autosave -> flowRuntime, and reversing it would close a cycle into the history family.
+ * The `armed` latch keeps this to ONE write, not one per frame.
+ */
+let armedCleared = false;
+function clearRestoreArmed() {
+ if (armedCleared || typeof localStorage === 'undefined') return;
+ armedCleared = true;
+ try {
+ safeStorage.removeItem('restoreArmed');
+ } catch {
+ /* private mode, quota, a browser refusing site data — nothing to do */
+ }
+}
+
+/** Shared by the desktop scheduler and the XR pump — both must survive a throw.
+ * @param {number} now */
+function safeRunTick(now) {
+ try {
+ runTick(now);
+ tickFails = 0;
+ clearRestoreArmed();
+ return true;
+ } catch (error) {
+ tickFails++;
+ // first three, then one per 300: a per-frame log is 60 lines a second, which
+ // buries the very first failure — the one that says what broke.
+ if (tickFails <= 3 || tickFails % 300 === 0)
+ log('error', 'flow', 'tick failed (' + tickFails + ' in a row)', String(error));
+ if (tickFails >= TICK_FAIL_LIMIT && !get(flowPaused).paused) {
+ flowPaused.set({ paused: true, reason: String(error) });
+ showInfoToast(
+ 'flow-paused',
+ 'Flow runtime paused after repeated errors. Your scene is intact; fix the node and resume.',
+ [{ label: 'Resume', action: () => resumeFlowRuntime() }]
+ );
+ }
+ return false;
+ }
+}
+
+/** Clear the paused state and start ticking again (the Resume button, and 27-D's
+ * safe-mode exit). Idempotent. */
+/** TEST-ONLY, see failTicksRemaining. @param {number} n */
+export function failTicksForTest(n) {
+ failTicksRemaining = Math.max(0, Number(n) || 0);
+}
+
+export function resumeFlowRuntime() {
+ tickFails = 0;
+ flowPaused.set({ paused: false, reason: '' });
+ dismissToastById('flow-paused');
+}
+
/** the desktop scheduler (suspended by the browser while in immersive XR) */
/** @param {number} now */
function tick(now) {
- runTick(now);
- requestAnimationFrame(tick);
+ try {
+ if (!get(flowPaused).paused) safeRunTick(now);
+ } finally {
+ // ALWAYS re-arm. A frame loop that can stop being scheduled is a frame loop that
+ // ends the session's animation on the first bad node.
+ requestAnimationFrame(tick);
+ }
}
/** XR-side pump: Scene.svelte calls this from threlte's task loop while
* presenting, so flow + physics keep running in the headset. @param {number} now */
export function pumpFlowTick(now) {
- runTick(now);
+ if (get(flowPaused).paused) return;
+ safeRunTick(now);
}
/** @type {((now: number) => void) | null} */
@@ -3357,7 +3471,7 @@ export function startFlowRuntime() {
});
syncedAnimations.subscribe((value) => {
synced = value;
- if (typeof localStorage !== 'undefined') localStorage.setItem('syncedAnimations', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('syncedAnimations', String(value));
});
requestAnimationFrame(tick);
diff --git a/src/lib/gameState.js b/src/lib/gameState.js
index 265f771f..73b74e1a 100644
--- a/src/lib/gameState.js
+++ b/src/lib/gameState.js
@@ -27,6 +27,7 @@
// state enters `playing`, so all views converge with no new message and no forced viewpoint.
import { writable, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
/** The states a game moves through. `over` carries an `outcome` string, which is how
* win/lose is expressed without a node of its own. */
@@ -120,7 +121,7 @@ export function commitGameState(patch, opts = {}) {
const after = normalizeGameState({
...before,
...patch,
- changedAt: opts.stamp ?? Math.max(Date.now(), (before.changedAt ?? 0) + 1)
+ changedAt: opts.stamp ?? Math.max(sessionNow(), (before.changedAt ?? 0) + 1)
});
gameState.set(after);
if (!opts.silent) {
@@ -145,16 +146,16 @@ export function setGameState(state, opts = {}) {
// resuming FROM a pause keeps the round and its startedAt; a fresh start (from
// menu/over) re-stamps and bumps the round. The pause span is banked either way.
if (before.state === 'paused') {
- patch.pausedMs = before.pausedMs + (before.pausedAt ? Date.now() - before.pausedAt : 0);
+ patch.pausedMs = before.pausedMs + (before.pausedAt ? sessionNow() - before.pausedAt : 0);
patch.pausedAt = 0;
} else {
- patch.startedAt = Date.now();
+ patch.startedAt = sessionNow();
patch.round = opts.round ?? before.round + 1;
patch.pausedAt = 0;
patch.pausedMs = 0;
}
}
- if (state === 'paused' && entering) patch.pausedAt = Date.now();
+ if (state === 'paused' && entering) patch.pausedAt = sessionNow();
if (state !== 'paused' && state !== 'playing' && entering) {
// leaving the round entirely closes any live pause span
patch.pausedAt = 0;
@@ -170,8 +171,8 @@ export function gameElapsed() {
// counting through - which it measurably did.
const { startedAt, pausedAt, pausedMs } = get(gameState);
if (!startedAt) return 0;
- const live = pausedAt ? Date.now() - pausedAt : 0;
- return Math.max(0, (Date.now() - startedAt - pausedMs - live) / 1000);
+ const live = pausedAt ? sessionNow() - pausedAt : 0;
+ return Math.max(0, (sessionNow() - startedAt - pausedMs - live) / 1000);
}
// ---- 21-F2: what "a round" means to everything derived from it -------------------
@@ -258,11 +259,11 @@ export function gameStateSnapshot() {
export function gameStateRestore(payload, replicate = false) {
if (!payload) {
// a scene with no game field resets, or the previous scene's round would leak in
- gameState.set({ ...DEFAULT, changedAt: Date.now() });
+ gameState.set({ ...DEFAULT, changedAt: sessionNow() });
if (replicate && broadcastHook) broadcastHook(get(gameState));
return;
}
- commitGameState(normalizeGameState(payload), { silent: !replicate, stamp: Date.now() });
+ commitGameState(normalizeGameState(payload), { silent: !replicate, stamp: sessionNow() });
}
/** Test/serializer seam. */
diff --git a/src/lib/gameSync.js b/src/lib/gameSync.js
index 2138b125..772ec3d9 100644
--- a/src/lib/gameSync.js
+++ b/src/lib/gameSync.js
@@ -10,6 +10,7 @@
// keyed-document one: one message, one stamp, no per-key map.
import { get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers } from '../stores/appStore';
import { registerHistoryKind, recordEntry } from './history';
import {
@@ -76,7 +77,7 @@ registerHistoryKind('game', (/** @type {any} */ entry, /** @type {any} */ state)
// silently restored `before`).
const target = state === entry.before ? entry.before : entry.after;
// through the single write path, so an undo replicates exactly like an edit
- commitGameState(normalizeGameState(target), { stamp: Date.now() });
+ commitGameState(normalizeGameState(target), { stamp: sessionNow() });
return true;
});
diff --git a/src/lib/gamepadPrefs.js b/src/lib/gamepadPrefs.js
index 6c7dbd9e..11e4be78 100644
--- a/src/lib/gamepadPrefs.js
+++ b/src/lib/gamepadPrefs.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// 21-E5: THE GAMEPAD LEAF — the standard-mapping table plus this device's preferences.
//
@@ -105,7 +106,7 @@ export function normalizeGamepadPrefs(raw) {
function load() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null;
return normalizeGamepadPrefs(raw ? JSON.parse(raw) : {});
} catch {
return { ...DEFAULT_GAMEPAD_PREFS };
@@ -116,7 +117,7 @@ function load() {
export const gamepadPrefs = writable(load());
gamepadPrefs.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value));
});
/** @param {Partial} patch */
diff --git a/src/lib/geometries.svelte.js b/src/lib/geometries.svelte.js
index dd3bef28..ff003b00 100644
--- a/src/lib/geometries.svelte.js
+++ b/src/lib/geometries.svelte.js
@@ -14,7 +14,10 @@ function initRectAreaUniforms() {
RectAreaLightUniformsLib.init();
}
import { notifyExternalMove, noteObjectPose } from '$lib/flowRuntime';
-import { globalScene, objectsGroup, TControls, lockedObjects, selectedObject, selectedObjects } from '../stores/sceneStore.js';
+import { globalScene, objectsGroup, TControls, lockedObjects, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore.js';
+// 27-A: a transform off the wire is sanitised before it reaches the scene graph
+import { sanitizeTransform } from './wireValidate';
+import { noteWireError } from './wireErrors';
//Access scene Store
let scene = $state();
@@ -131,7 +134,7 @@ export function createGeometry(command, uuid) {
if (['Wedge', 'Stairs', 'Arch', 'Corner'].includes(geometry)) object.userData.colliderHint = 'hull';
sceneObjects.add(object);
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
// console.log('createGeometry: ' + geometry);
if (!uuid) controls.attach(object);
if (!uuid) selectedObject.set(object);
@@ -196,7 +199,7 @@ export function createLight(command, uuid) {
if (uuid) light.uuid = uuid
sceneObjects.add(light);
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
// console.log('createLight: ' + light);
if (!uuid) controls.attach(light);
if (!uuid) selectedObject.set(light);
@@ -231,7 +234,7 @@ export function createGroup(command, uuid, groupuuid, name, groupparent, pos, ro
group.scale.set(scale[0], scale[1], scale[2]);
}
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
return group.uuid
} else {
// R22 round 32 — A GROUP IS KEYED BY UUID TOO. This branch created a second
@@ -252,7 +255,7 @@ export function createGroup(command, uuid, groupuuid, name, groupparent, pos, ro
held.rotation.set(rot[0], rot[1], rot[2]);
held.scale.set(scale[0], scale[1], scale[2]);
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
return held.uuid;
}
@@ -272,7 +275,7 @@ export function createGroup(command, uuid, groupuuid, name, groupparent, pos, ro
}
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
// console.log('createGroup: ' + group);
if (!uuid) controls.attach(group);
if (!uuid) selectedObject.set(group);
@@ -300,7 +303,7 @@ export function changeName(uuid, name) {
if(object) {
object.name = name;
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
}
}
@@ -311,6 +314,22 @@ export function moveGeometry(uuid, pos, rot, scale) {
// per component (B5).
const object = sceneObjects.getObjectByProperty('uuid', uuid);
if(object) {
+ // 27-A (audit M7): the BACKSTOP, not the primary gate. wireValidate refuses a
+ // `move` whose components are not finite, so wire traffic never reaches here in
+ // that state; this covers any caller that does not pass through the dispatcher.
+ // A NON-FINITE component is worse than a malformed message — it
+ // applies cleanly, poisons the object's matrix, and every consumer that measures
+ // the scene afterwards (Box3 bounds, frame-to-fit, the body's next physics step)
+ // reads NaN forever with nothing pointing back at the message that did it. Each
+ // bad component falls back to the pose the object already has.
+ const safe = sanitizeTransform(pos, rot, scale, {
+ pos: object.position.toArray(),
+ rot: [object.rotation.x, object.rotation.y, object.rotation.z],
+ scale: object.scale.toArray()
+ });
+ if (!safe) return;
+ if (safe.repaired) noteWireError('local', 'move-nan');
+ pos = safe.pos; rot = safe.rot; scale = safe.scale;
object.position.set(pos[0], pos[1], pos[2]);
object.rotation.set(rot[0], rot[1], rot[2]);
object.scale.set(scale[0], scale[1], scale[2]);
@@ -321,9 +340,31 @@ export function moveGeometry(uuid, pos, rot, scale) {
}
}
+/**
+ * 27-E (audit H7): peer avatars are INDEXED, not searched. This is the hottest receive
+ * path there is — one message per remote peer per send-gate tick — and it walked the
+ * WHOLE scene graph each time (`getObjectByName` is a full traverse). With 2,000 objects
+ * and nine peers that is millions of node visits a second before anybody edits anything.
+ * The index is a cache keyed by peer id, re-resolved whenever it misses or goes stale, so
+ * an avatar that mounts later or is replaced still works with no lifecycle to maintain.
+ * @type {Map}
+ */
+const peerAvatars = new Map();
+
+/** Drop one peer's cached avatar (teardown, and whenever the object leaves the scene).
+ * @param {string} peerId */
+export function dropPeerAvatar(peerId) {
+ peerAvatars.delete(peerId);
+}
+
export function moveCamera(data) {
- // console.log('moveCamera: ' + data.position[1] + ' ' + data.rotation[1]);
- let peerMesh = scene.getObjectByName(data.peerId)
+ let peerMesh = peerAvatars.get(data.peerId);
+ // stale (avatar replaced, scene cleared) or never seen: resolve once and remember
+ if (!peerMesh || peerMesh.parent === null || peerMesh.name !== data.peerId) {
+ peerMesh = scene.getObjectByName(data.peerId);
+ if (peerMesh) peerAvatars.set(data.peerId, peerMesh);
+ else peerAvatars.delete(data.peerId);
+ }
if (!peerMesh) return;
peerMesh.position.set(data.position[0], data.position[1], data.position[2]);
peerMesh.rotation.set(data.rotation[0], data.rotation[1], data.rotation[2]);
diff --git a/src/lib/geometryEdit.js b/src/lib/geometryEdit.js
index 684d9fb7..d99876cb 100644
--- a/src/lib/geometryEdit.js
+++ b/src/lib/geometryEdit.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { recordEntry, registerHistoryKind } from './history';
import { GEOMETRY_PARAMS, geometrySpec } from './geometryParams';
@@ -113,7 +113,7 @@ export function applyGeometry(uuid, patch, options = {}) {
// disabled after a rebuild that just threw those edits away.
delete object.userData.vertexEdited;
delete object.userData.faceEdited;
- objectsGroup.update((value) => value);
+ pokeScene();
if (record)
recordEntry({ kind: 'geometry', uuid, before, after: { gtype: current.gtype, params } });
if (replicate) {
@@ -137,7 +137,7 @@ export function applyRemoteGeometry(data) {
object.userData.geometryParams = { gtype: data.gtype, params: { ...data.params } };
delete object.userData.vertexEdited;
delete object.userData.faceEdited; // same lock, same reset as the local path
- objectsGroup.update((value) => value);
+ pokeScene();
}
// undo/redo replays the full param set — `state` is the recorded
diff --git a/src/lib/githubStars.js b/src/lib/githubStars.js
index 985270d6..7311beaa 100644
--- a/src/lib/githubStars.js
+++ b/src/lib/githubStars.js
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// 15-M: the repo's GitHub star count, for the Welcome overlay's GitHub link.
// Deliberately tiny and FAIL-QUIET: unauthenticated api.github.com allows 60
@@ -19,7 +20,7 @@ let started = false;
/** Read the cached count (fresh or stale) @returns {{n: number, ts: number}|null} */
function cached() {
try {
- const raw = localStorage.getItem(CACHE_KEY);
+ const raw = safeStorage.getItem(CACHE_KEY);
if (!raw) return null;
const entry = JSON.parse(raw);
return typeof entry?.n === 'number' ? entry : null;
@@ -45,7 +46,7 @@ export function loadGithubStars() {
if (typeof n !== 'number') return; // rate limited / offline — keep the cache
githubStars.set(n);
try {
- localStorage.setItem(CACHE_KEY, JSON.stringify({ n, ts: Date.now() }));
+ safeStorage.setItem(CACHE_KEY, JSON.stringify({ n, ts: Date.now() }));
} catch {}
})
.catch(() => {}); // offline / blocked: the link renders without a count
diff --git a/src/lib/gridSettings.js b/src/lib/gridSettings.js
index 6e50354b..09b66afb 100644
--- a/src/lib/gridSettings.js
+++ b/src/lib/gridSettings.js
@@ -1,5 +1,6 @@
import { writable, get } from 'svelte/store';
import { snapSettings } from './snapping';
+import { safeStorage } from './safeStorage';
// Grid appearance (16-P3): a LOCAL per-device view preference, never replicated —
// same family as `showGrid`, `viewMode` and the cameraClip planes. Peers each get
@@ -43,7 +44,7 @@ export const DEFAULT_GRID = {
function load() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null;
// unknown/missing keys fall back to defaults, so old payloads keep working
const stored = raw ? JSON.parse(raw) : {};
const value = { ...DEFAULT_GRID, ...stored };
@@ -61,7 +62,7 @@ function load() {
export const gridSettings = writable(load());
gridSettings.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value));
});
/** @param {Partial} patch */
diff --git a/src/lib/handModels.js b/src/lib/handModels.js
index fe2dda2e..ebdffe31 100644
--- a/src/lib/handModels.js
+++ b/src/lib/handModels.js
@@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store';
import { peers } from '../stores/appStore';
import { itemByHash, itemBlob } from './explorer';
import { requestAsset, sendAsset } from './assetShare';
+import { safeStorage } from './safeStorage';
// Custom hand models (R-3): a user's chosen hand GLB is part of their IDENTITY
// (the avatar-photo precedent) — the content HASH rides a tiny `handmodel`
@@ -16,7 +17,7 @@ import { requestAsset, sendAsset } from './assetShare';
/** my chosen hand model hash ('' = none), LOCAL pref that broadcasts */
export const myHandModel = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('myHandModel') ?? '' : ''
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('myHandModel') ?? '' : ''
);
/** @type {import('svelte/store').Writable>} peerId -> hash */
@@ -96,7 +97,7 @@ export function startHandModels() {
started = true;
myHandModel.subscribe((hash) => {
try {
- localStorage.setItem('myHandModel', hash ?? '');
+ safeStorage.setItem('myHandModel', hash ?? '');
} catch {}
});
// missing bytes may arrive later (assetShare pull) — retry pending parses
diff --git a/src/lib/helperLayer.js b/src/lib/helperLayer.js
index 6d417e35..1a5c55d2 100644
--- a/src/lib/helperLayer.js
+++ b/src/lib/helperLayer.js
@@ -23,6 +23,7 @@
// Imports sceneStore only (the lightHelpers/cameraHelpers family), no THREE.
import { get, writable } from 'svelte/store';
import { isLocked, editorCam, globalCamera, objectsGroup } from '../stores/sceneStore';
+import { safeStorage } from './safeStorage';
export const HELPER_LAYER = 1;
@@ -30,10 +31,10 @@ export const HELPER_LAYER = 1;
* in Play and a DEBUG chip sits in the play HUD so a screenshot cannot be mistaken for
* the game. @type {import('svelte/store').Writable} */
export const helpersInPlay = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('helpersInPlay') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('helpersInPlay') === 'true'
);
helpersInPlay.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('helpersInPlay', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('helpersInPlay', String(value));
});
/** Put a scene-root helper (and its whole subtree) on the helper layer, only.
diff --git a/src/lib/history.js b/src/lib/history.js
index de027235..6f0d3734 100644
--- a/src/lib/history.js
+++ b/src/lib/history.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, derived, get } from 'svelte/store';
-import { objectsGroup, TControls, selectedObject } from '../stores/sceneStore';
+import { objectsGroup, TControls, selectedObject, pokeScene } from '../stores/sceneStore';
import { peers, showToast, closeSelectionInspector } from '../stores/appStore';
import { notifyExternalMove } from '$lib/flowRuntime';
import { parkEditOverlays, stripEditOverlays } from '$lib/editOverlays';
@@ -258,7 +258,7 @@ function applyPresence(entry, state) {
? group.getObjectByProperty('uuid', entry.snapshot.parentUuid)
: null;
(parent ?? group).add(object);
- objectsGroup.update((value) => value);
+ pokeScene();
// receivers take the same ObjectLoader path as light/parent sync
if (peer)
peer.send({ type: 'object', element: entry.snapshot.element, groupuuid: entry.snapshot.parentUuid ?? undefined });
@@ -284,7 +284,7 @@ function applyPresence(entry, state) {
closeSelectionInspector();
}
existing.parent?.remove(existing);
- objectsGroup.update((value) => value);
+ pokeScene();
if (peer) peer.send({ type: 'delete', uuid: entry.uuid, peerId: peer.peer.id });
return true;
}
@@ -311,7 +311,7 @@ registerHistoryKind('transformSet', (entry, state) => {
peer.send({ type: 'move', uuid: item.uuid, pos: target.pos, rot: target.rot, scale: target.scale });
any = true;
});
- if (any) objectsGroup.update((value) => value);
+ if (any) pokeScene();
else showToast('Cannot undo/redo: the objects no longer exist');
return any;
});
@@ -330,7 +330,7 @@ function applyState(entry, state) {
object.rotation.set(state.rot[0], state.rot[1], state.rot[2]);
object.scale.fromArray(state.scale);
notifyExternalMove(entry.uuid); // undoing an animated object rewrites its base
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer) peer.send({ type: 'move', uuid: entry.uuid, pos: state.pos, rot: state.rot, scale: state.scale });
diff --git a/src/lib/hudDocs.js b/src/lib/hudDocs.js
index af9c371a..8b38b473 100644
--- a/src/lib/hudDocs.js
+++ b/src/lib/hudDocs.js
@@ -22,11 +22,13 @@
// PURPOSE — one player on the start menu while another plays.
import { writable, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
// 21-D1: the kind REGISTRY. hudKinds imports nothing, so this stays a leaf.
import { HUD_KINDS as REGISTERED_KINDS, defaultsForKind, styleDefaultsForKind, kindDef } from './hudKinds';
// 21-D6: a screen can follow the GAME STATE. gameState is a leaf too, so this closes no
// cycle — and it is what lets a menu hide itself when the game starts, with no wiring.
import { gameState } from './gameState';
+import { safeStorage } from './safeStorage';
/** The scene-wide HUD, and the only key the v1 UI creates. */
export const HUD_SCENE_KEY = 'scene';
@@ -85,12 +87,12 @@ export const hudSelection = writable({});
* `viewportOverrides.hud` is the separate, persistent local kill switch.
* @type {import('svelte/store').Writable} */
export const hudPreviewInViewport = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('hudPreviewInViewport') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('hudPreviewInViewport') === 'true'
);
if (typeof localStorage !== 'undefined')
hudPreviewInViewport.subscribe((on) => {
try {
- localStorage.setItem('hudPreviewInViewport', String(!!on));
+ safeStorage.setItem('hudPreviewInViewport', String(!!on));
} catch {}
});
@@ -162,7 +164,7 @@ export function setHudValue(id, value, opts = {}) {
if (opts.at < held) return;
valueStamps[key] = opts.at;
} else if (opts.shared) {
- valueStamps[key] = Math.max(Date.now(), (valueStamps[key] ?? 0) + 1);
+ valueStamps[key] = Math.max(sessionNow(), (valueStamps[key] ?? 0) + 1);
}
hudValues.update((all) => (all[key] === value ? all : { ...all, [key]: value }));
if (opts.shared && !opts.silent) valueBroadcastHook?.(key, value, valueStamps[key]);
@@ -591,7 +593,7 @@ export function setHudDocFor(key, patch, opts = {}) {
// those edits share a bare Date.now() and the receiver's latest-wins guard
// drops every one after the first — measured in the shader round: the drag
// AND the undo after it silently failed to replicate.
- changedAt: opts.stamp ?? Math.max(Date.now(), (all[key]?.changedAt ?? 0) + 1)
+ changedAt: opts.stamp ?? Math.max(sessionNow(), (all[key]?.changedAt ?? 0) + 1)
});
next[key] = after;
}
@@ -718,7 +720,7 @@ export function hudDocsRestore(map, replace = false, replicate = false) {
clearHudRuntimeRows();
}
if (!map || typeof map !== 'object') return;
- const stamp = Date.now();
+ const stamp = sessionNow();
let i = 0;
for (const [key, doc] of Object.entries(map)) {
if (!doc) continue;
diff --git a/src/lib/hudSync.js b/src/lib/hudSync.js
index 8697cb18..2f1af1d7 100644
--- a/src/lib/hudSync.js
+++ b/src/lib/hudSync.js
@@ -21,6 +21,7 @@
// No `handleDisconnected` cleanup: documents are SCENE data, not per-peer state.
import { get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers } from '../stores/appStore';
import { registerHistoryKind, recordEntry } from './history';
import {
@@ -51,7 +52,7 @@ function broadcast(key, doc) {
const peer = get(peers);
if (!peer) return;
if (doc) peer.send({ type: 'hud', key, doc: wireDoc(doc) });
- else peer.send({ type: 'huddelete', key, changedAt: Date.now() });
+ else peer.send({ type: 'huddelete', key, changedAt: sessionNow() });
}
/**
diff --git a/src/lib/idb.js b/src/lib/idb.js
index d5cbfb38..3c63c1df 100644
--- a/src/lib/idb.js
+++ b/src/lib/idb.js
@@ -1,57 +1,272 @@
// Minimal promise wrapper around IndexedDB — used for autosave snapshots,
// which regularly exceed the localStorage size limit.
+//
+// 27-H (hardening audit M3) — A PROMISE FROM HERE ALWAYS SETTLES.
+//
+// It used to settle on the request's own `onsuccess` / `onerror` and nothing else, so a
+// transaction that ABORTED without firing either left the promise pending FOREVER and an
+// `await` on it stalled its caller with no error anywhere: no rejection, no
+// `unhandledrejection`, nothing in the console. `storageUsage.js` measured the symptom
+// from the outside ("a scan opened from the header chip stopped after three keys") and
+// wrote a bounded read around it; this is the fix that finding is owed.
+//
+// Three rules now, and they compose:
+// 1. `tx.onabort` REJECTS. An abort is a real outcome — quota, a closing connection, a
+// `tx.abort()` from anywhere — and it has to reach the caller as one.
+// 2. Every op is bounded by `withTimeout`. Rule 1 covers the aborts the browser tells
+// us about; a timeout covers the ones it does not, which is the whole class of "the
+// request object simply never fires again". A bounded failure a caller can report
+// beats an unbounded wait it cannot.
+// 3. `open()` is CACHED. Every call used to open its own connection — one per read,
+// one per write — and a storage scan makes a few hundred of them in a burst. The
+// cache is dropped whenever the connection dies (`onclose`, `onversionchange`, or a
+// `transaction()` that throws because the handle is closing), so the next call
+// reopens rather than inheriting a dead handle.
+import { log } from './diagnostics';
const DB_NAME = 'theprototype';
const STORE = 'snapshots';
+/**
+ * How long any one operation may take before it is reported as failed.
+ *
+ * MEASURED before choosing it (storage-hardening §1): a 25 MB put — larger than the
+ * Explorer's own 25 MB import cap and half the autosave snapshot ceiling — completes in
+ * well under a second on this hardware, so 10s is roughly two orders of magnitude of
+ * headroom over the largest write the app can make. The number exists to bound a HANG,
+ * not to police slowness, and the suite asserts the margin so a future change that makes
+ * writes genuinely slow turns it red rather than silently failing a user's import.
+ */
+export const OP_TIMEOUT_MS = 10_000;
+
+/** @type {number | null} test override for the timeout (null = OP_TIMEOUT_MS) */
+let timeoutOverride = null;
+/** @type {'abort' | 'stall' | 'quota' | null} test override for the next transaction */
+let forcedFailure = null;
+/** @type {any} the error a forced failure should report instead of the transaction's own */
+let forcedError = null;
+
+/**
+ * TEST SEAM: make the next transaction fail the way the real ones do.
+ * `'abort'` aborts it, `'stall'` swallows every completion callback (the state that
+ * used to hang forever and now hits the timeout), and `'quota'` reports the exact
+ * `QuotaExceededError` a full disk reports — which cannot be provoked honestly in a
+ * headless run, where the origin is granted tens of gigabytes. One-shot: each clears
+ * itself as soon as it is used, so a suite cannot poison the rest of its own run.
+ * @param {'abort' | 'stall' | 'quota' | null} mode
+ */
+export function debugForceNextTx(mode) {
+ forcedFailure = mode;
+}
+
+/**
+ * TEST SEAM: shorten the timeout so the bounded-failure path can be exercised in a suite
+ * without a ten-second wait. `null` restores the default.
+ * @param {number | null} ms
+ */
+export function debugTimeoutMs(ms) {
+ timeoutOverride = ms;
+}
+
+/** @returns {number} */
+function limit() {
+ return timeoutOverride ?? OP_TIMEOUT_MS;
+}
+
+/**
+ * Bound a promise. Exported because it is the pure half of this module and is unit
+ * tested with no IndexedDB at all (tests/unit/idbTimeout).
+ *
+ * The timer is cleared on BOTH settlements, not only on the win: a 10s handle left
+ * running for every read would keep a storage scan's few hundred timers alive and, in a
+ * test environment, hold the process open.
+ * @template T
+ * @param {Promise} promise @param {number} ms @param {string} label
+ * @returns {Promise}
+ */
+export function withTimeout(promise, ms, label) {
+ /** @type {any} */
+ let timer = null;
+ const settled = promise.then(
+ (value) => {
+ clearTimeout(timer);
+ return value;
+ },
+ (error) => {
+ clearTimeout(timer);
+ throw error;
+ }
+ );
+ return Promise.race([
+ settled,
+ new Promise((_resolve, reject) => {
+ timer = setTimeout(() => {
+ const error = new Error(`idb ${label} timed out after ${ms}ms`);
+ // @ts-ignore - a marker the callers can branch on without string matching
+ error.timedOut = true;
+ log('warn', 'idb', 'operation timed out', { op: label, ms });
+ reject(error);
+ }, ms);
+ })
+ ]);
+}
+
+/** @type {Promise | null} */
+let dbPromise = null;
+
+/** Drop the cached connection so the next call reopens. @param {Promise} [only] */
+function invalidate(only) {
+ if (!only || dbPromise === only) dbPromise = null;
+}
+
/** @returns {Promise} */
function open() {
- return new Promise((resolve, reject) => {
+ if (dbPromise) return dbPromise;
+ /** @type {Promise} */
+ const pending = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE);
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ const db = request.result;
+ // A cached handle that the browser closes underneath us (a tab in another
+ // window upgrading the schema, storage being cleared, the OS reclaiming it)
+ // would otherwise be handed out forever, and every transaction on it throws.
+ db.onclose = () => invalidate(pending);
+ db.onversionchange = () => {
+ db.close();
+ invalidate(pending);
+ };
+ resolve(db);
+ };
+ request.onerror = () => reject(request.error ?? new Error('idb open failed'));
+ request.onblocked = () => reject(new Error('idb open blocked'));
});
+ dbPromise = pending;
+ // a FAILED open must not be cached, or one transient error disables storage for the
+ // life of the tab
+ pending.catch(() => invalidate(pending));
+ return withTimeout(pending, limit(), 'open');
}
-/** @param {string} key */
-export async function idbGet(key) {
- const db = await open();
+/**
+ * Run one transaction against the cached connection, reopening once if the handle turned
+ * out to be dead. `db.transaction()` throws synchronously on a closing connection, which
+ * is exactly the case the cache introduces — so the retry is what pays for the cache.
+ * @template T
+ * @param {string} label @param {(db: IDBDatabase) => Promise} body @returns {Promise}
+ */
+async function withDb(label, body) {
+ try {
+ return await withTimeout(body(await open()), limit(), label);
+ } catch (error) {
+ const name = /** @type {any} */ (error)?.name;
+ if (name !== 'InvalidStateError' && name !== 'TransactionInactiveError') throw error;
+ invalidate();
+ log('warn', 'idb', 'connection was stale, reopening', { op: label });
+ return withTimeout(body(await open()), limit(), label);
+ }
+}
+
+/**
+ * Settle on every outcome a transaction has: complete, error AND abort. The abort arm is
+ * the one that was missing, and it is not hypothetical — `tx.abort()` fires it with
+ * `tx.error === null`, which is why the fallback message exists.
+ * @param {IDBTransaction} tx @param {() => any} value @returns {Promise}
+ */
+function settle(tx, value) {
return new Promise((resolve, reject) => {
- const request = db.transaction(STORE).objectStore(STORE).get(key);
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
+ /** @param {string} fallback */
+ const fail = (fallback) => {
+ const forced = forcedError;
+ forcedError = null;
+ reject(forced ?? tx.error ?? new Error(fallback));
+ };
+ tx.oncomplete = () => resolve(value());
+ tx.onerror = () => fail('idb transaction failed');
+ tx.onabort = () => fail('idb transaction aborted');
+ });
+}
+
+/**
+ * Apply a one-shot test override to a live transaction.
+ *
+ * `'abort'` aborts AFTER the request has succeeded, and the timing is the whole point:
+ * abort a transaction with a request still in flight and that request errors first, which
+ * BUBBLES to `tx.onerror` — so the old wrapper happened to settle. Abort once every
+ * request has already succeeded and `onabort` is the ONLY event that fires, which is the
+ * case that hung forever and the one the counterfactual has to reproduce.
+ *
+ * `'stall'` removes every handler the transaction could settle through: the shape of an
+ * operation the browser never reports on at all, which only the timeout can catch.
+ *
+ * `'quota'` aborts the same way and hands `settle` the error a full disk raises, so the
+ * whole failure path downstream — the name test in autosave, the sticky toast, the
+ * diagnostics line — runs against the real exception rather than a stand-in for it.
+ * @param {IDBTransaction} tx @param {IDBRequest} [request]
+ */
+function applyForcedFailure(tx, request) {
+ const mode = forcedFailure;
+ forcedFailure = null;
+ if (mode === 'abort' || mode === 'quota') {
+ if (mode === 'quota')
+ forcedError = new DOMException('The quota has been exceeded.', 'QuotaExceededError');
+ const fire = () => {
+ try {
+ tx.abort();
+ } catch {}
+ };
+ // `onsuccess` is free to overwrite: every read below takes its value at
+ // `oncomplete`, not from this handler
+ if (request) request.onsuccess = fire;
+ else queueMicrotask(fire);
+ } else if (mode === 'stall')
+ queueMicrotask(() => {
+ tx.oncomplete = null;
+ tx.onerror = null;
+ tx.onabort = null;
+ });
+}
+
+/** @param {string} key */
+export function idbGet(key) {
+ return withDb('get', (db) => {
+ const tx = db.transaction(STORE);
+ const request = tx.objectStore(STORE).get(key);
+ const promise = settle(tx, () => request.result);
+ applyForcedFailure(tx, request);
+ return promise;
});
}
/** @param {string} key @param {any} value */
-export async function idbPut(key, value) {
- const db = await open();
- return new Promise((resolve, reject) => {
+export function idbPut(key, value) {
+ return withDb('put', (db) => {
const tx = db.transaction(STORE, 'readwrite');
- tx.objectStore(STORE).put(value, key);
- tx.oncomplete = () => resolve(undefined);
- tx.onerror = () => reject(tx.error);
+ const request = tx.objectStore(STORE).put(value, key);
+ const promise = settle(tx, () => undefined);
+ applyForcedFailure(tx, request);
+ return promise;
});
}
/** All keys in the store (used to list saved environment presets) */
-export async function idbKeys() {
- const db = await open();
- return new Promise((resolve, reject) => {
- const request = db.transaction(STORE).objectStore(STORE).getAllKeys();
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
+export function idbKeys() {
+ return withDb('keys', (db) => {
+ const tx = db.transaction(STORE);
+ const request = tx.objectStore(STORE).getAllKeys();
+ const promise = settle(tx, () => request.result);
+ applyForcedFailure(tx, request);
+ return promise;
});
}
/** @param {string} key */
-export async function idbDelete(key) {
- const db = await open();
- return new Promise((resolve, reject) => {
+export function idbDelete(key) {
+ return withDb('delete', (db) => {
const tx = db.transaction(STORE, 'readwrite');
- tx.objectStore(STORE).delete(key);
- tx.oncomplete = () => resolve(undefined);
- tx.onerror = () => reject(tx.error);
+ const request = tx.objectStore(STORE).delete(key);
+ const promise = settle(tx, () => undefined);
+ applyForcedFailure(tx, request);
+ return promise;
});
}
diff --git a/src/lib/importDuplicates.js b/src/lib/importDuplicates.js
index 7805feb0..e48c3bb1 100644
--- a/src/lib/importDuplicates.js
+++ b/src/lib/importDuplicates.js
@@ -28,13 +28,14 @@
import { writable, get } from 'svelte/store';
import { explorerItems, hiddenItems, registerDuplicateResolver } from './explorer';
import { showToast } from '../stores/appStore';
+import { safeStorage } from './safeStorage';
export const DUPLICATE_MODES = ['ask', 'skip', 'copy'];
const STORAGE_KEY = 'importDuplicateMode';
function readMode() {
try {
- const stored = localStorage.getItem(STORAGE_KEY);
+ const stored = safeStorage.getItem(STORAGE_KEY);
if (stored && DUPLICATE_MODES.includes(stored)) return stored;
} catch {}
return 'ask';
@@ -45,7 +46,7 @@ function readMode() {
export const duplicateImportMode = writable(readMode());
duplicateImportMode.subscribe((mode) => {
try {
- localStorage.setItem(STORAGE_KEY, String(mode));
+ safeStorage.setItem(STORAGE_KEY, String(mode));
} catch {}
});
diff --git a/src/lib/levels.js b/src/lib/levels.js
index 6afac655..2bb9e299 100644
--- a/src/lib/levels.js
+++ b/src/lib/levels.js
@@ -29,6 +29,7 @@
// would close the TDZ cycle (the moduleSDK rule).
import { writable, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { showToast, showInfoToast, dismissToastById, peers } from '../stores/appStore';
// R22 round 34: the adopt message names the peer who saved. `sessions.js` — which this
// module already imports — imports lockControl too, so this closes no new edge.
@@ -1179,7 +1180,7 @@ async function announceSceneName(cameFrom, name, hash, opts) {
try {
/** @type {any} */
const peer = get(peers);
- peer.send({ type: 'sceneadopt', name, hash, peerId: peer.peer.id, at: Date.now() });
+ peer.send({ type: 'sceneadopt', name, hash, peerId: peer.peer.id, at: sessionNow() });
} catch {
return { told: 0, note };
}
diff --git a/src/lib/lightHelpers.js b/src/lib/lightHelpers.js
index 37e66933..3eb4599e 100644
--- a/src/lib/lightHelpers.js
+++ b/src/lib/lightHelpers.js
@@ -4,6 +4,7 @@ import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js
import { globalScene, objectsGroup } from '../stores/sceneStore';
// 24-E2: helpers + proxies live on the helper layer (the editor camera enables it)
import { markHelper } from './helperLayer';
+import { safeStorage } from './safeStorage';
// Makes lights visible and draggable: a type-specific helper plus a small
// wireframe "bulb" pick proxy per light. Helpers and proxies live at the
@@ -12,16 +13,16 @@ import { markHelper } from './helperLayer';
// uuid; Scene.svelte routes clicks on them to selectObject(lightUuid).
export const showLightHelpers = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('showLightHelpers') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('showLightHelpers') !== 'false'
);
/** 24-E1: how far along its forward a directional/spot light's target sits (the
* helper's line length; display only — the direction is what shadows read, and the
* distance changes nothing for either light type). LOCAL pref, Settings ▸ Scene. */
export const lightHelperLength = writable(
- typeof localStorage === 'undefined' ? 2 : Math.max(0.2, Number(localStorage.getItem('lightHelperLength')) || 2)
+ typeof localStorage === 'undefined' ? 2 : Math.max(0.2, Number(safeStorage.getItem('lightHelperLength')) || 2)
);
lightHelperLength.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('lightHelperLength', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('lightHelperLength', String(value));
});
const forward = new THREE.Vector3();
const worldQuat = new THREE.Quaternion();
@@ -159,7 +160,7 @@ export function startLightHelpers() {
});
showLightHelpers.subscribe((value) => {
visible = value;
- localStorage.setItem('showLightHelpers', String(value));
+ safeStorage.setItem('showLightHelpers', String(value));
applyVisibility();
});
}
diff --git a/src/lib/lightParams.js b/src/lib/lightParams.js
index d954eaa9..8de78721 100644
--- a/src/lib/lightParams.js
+++ b/src/lib/lightParams.js
@@ -1,6 +1,8 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
import { objectsGroup, globalScene, globalRenderer } from '../stores/sceneStore';
+import { safeStorage } from './safeStorage';
+import { qualityOverrides } from './qualityGovernor';
// Light parameter registry (phase 79): type-specific settings the Inspector
// renders (color/intensity/visible are common rows it already has). Values
@@ -36,7 +38,7 @@ export const SHADOW_SIZES = [512, 1024, 2048];
const QUALITY_CAPS = { off: 512, low: 512, medium: 1024, high: 2048 };
export const shadowQuality = writable(
typeof localStorage !== 'undefined'
- ? localStorage.getItem('shadowQuality') ?? 'high'
+ ? safeStorage.getItem('shadowQuality') ?? 'high'
: 'high'
);
@@ -50,8 +52,19 @@ export function cappedShadowSize(wanted) {
/** re-apply the cap to every shadow-casting light (on quality change) —
* walks both objectsGroup and the scene-root environment rig, and toggles the
* renderer's shadow map on the 'off' setting */
+/**
+ * Are shadows off on THIS device right now: the user's saved 'off', or 26-D's quality
+ * governor holding its shadows step (a local override that never writes the preference).
+ * The ONE read every place that sets `renderer.shadowMap.enabled` must use — the first
+ * version of the override was undone within a frame by environment.applyEnvironment, which
+ * re-asserted the saved preference on every apply.
+ */
+export function shadowsDisabled() {
+ return get(shadowQuality) === 'off' || get(qualityOverrides).shadowsOff;
+}
+
export function applyShadowQualityCap() {
- const off = get(shadowQuality) === 'off';
+ const off = shadowsDisabled();
/** @type {any} */
const renderer = get(globalRenderer);
if (renderer?.shadowMap) {
@@ -127,7 +140,13 @@ export function startLightParams() {
if (started || typeof window === 'undefined') return;
started = true;
shadowQuality.subscribe((value) => {
- localStorage.setItem('shadowQuality', String(value));
+ safeStorage.setItem('shadowQuality', String(value));
+ applyShadowQualityCap();
+ });
+ let lastShadowsOff = false;
+ qualityOverrides.subscribe((o) => {
+ if (o.shadowsOff === lastShadowsOff) return;
+ lastShadowsOff = o.shadowsOff;
applyShadowQualityCap();
});
}
diff --git a/src/lib/loopGuard.js b/src/lib/loopGuard.js
new file mode 100644
index 00000000..ab4d14f3
--- /dev/null
+++ b/src/lib/loopGuard.js
@@ -0,0 +1,301 @@
+// 27-D (audit C1) — THE LOOP GUARD.
+//
+// A Script node runs on EVERY peer, every frame, inside the shared flow tick. So a
+// `while (true)` in one does not hang its author: it hangs the tab of everyone in the
+// session, with no way out but closing it. That is audit finding C1 — the only CRITICAL
+// one — and it is the whole reason this file exists.
+//
+// A LEAF on purpose: a string in, a string out, importing nothing. The part most likely
+// to be subtly wrong is deciding what is CODE and what is a STRING, and as a leaf that
+// decision is testable with no browser, no scene and no peer (the netBackoff /
+// wireValidate shape).
+//
+// WHAT IT DOES: declare a counter per run and inject a check at the top of every loop
+// BODY. The budget is per FRAME, not per session, because the function is called once a
+// frame — a loop running a thousand times a frame is ordinary, one running a million has
+// stopped being a loop and become a hang.
+//
+// WHAT IT DELIBERATELY DOES NOT DO: parse JavaScript. It is a SCANNER that knows just
+// enough to tell code from a string, a template literal, a comment and a regex, because
+// `// while (true)` must not be instrumented and `a / b` must not be read as the start
+// of a regex. Anything it cannot bracket-match it REFUSES, and a refusal surfaces as the
+// node's error badge rather than silently running unguarded — the one outcome worse than
+// refusing is pretending to have guarded something.
+
+/** Iterations per RUN before a loop is called a hang. */
+export const LOOP_LIMIT = 1_000_000;
+
+/** The counter's name. A user script declaring the same name is a duplicate-declaration
+ * SyntaxError, which shows up as an ordinary script error badge. */
+export const GUARD_VAR = '__lg';
+
+const GUARD = `if(++${GUARD_VAR}>${LOOP_LIMIT})throw new Error("Script loop limit");`;
+const DECL = `let ${GUARD_VAR}=0;\n`;
+
+/** Words after which a `/` starts a REGEX, not a division. `return /x/` is the one that
+ * bit: `return` ends in an identifier character, so testing the bare character reads it as
+ * division and then swallows the rest of the line hunting for a divisor. */
+const REGEX_PRECEDERS = new Set([
+ 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete',
+ 'void', 'throw', 'case', 'do', 'else', 'yield', 'await'
+]);
+
+/** identifier characters, for word boundaries and the regex heuristic */
+const isIdent = (/** @type {string} */ c) => !!c && /[A-Za-z0-9_$]/.test(c);
+
+/** The identifier immediately before index `i`, ignoring whitespace.
+ * @param {string} code @param {number} i */
+function wordBefore(code, i) {
+ let j = i - 1;
+ while (j >= 0 && /\s/.test(code[j])) j--;
+ const end = j + 1;
+ while (j >= 0 && isIdent(code[j])) j--;
+ return code.slice(j + 1, end);
+}
+
+/**
+ * Walk `code` from `start`, calling `visit(i, ch)` for every character that is REAL CODE
+ * — never inside a string, template, comment or regex literal. `visit` returns 'stop' to
+ * end the walk. Returns the index it stopped at, or -1 if it ran to the end, or null when
+ * the source is malformed (an unterminated string or comment).
+ * @param {string} code @param {number} start
+ * @param {(i: number, ch: string) => (string | void)} visit
+ */
+function walk(code, start, visit) {
+ let i = start;
+ // the last code character seen, which is how a regex is told from a division
+ let prev = '';
+ while (i < code.length) {
+ const ch = code[i];
+ const next = code[i + 1];
+ // comments
+ if (ch === '/' && next === '/') {
+ i = code.indexOf('\n', i);
+ if (i === -1) return -1; // a trailing line comment is fine
+ continue;
+ }
+ if (ch === '/' && next === '*') {
+ const end = code.indexOf('*/', i + 2);
+ if (end === -1) return null; // unterminated block comment
+ i = end + 2;
+ continue;
+ }
+ // strings and templates
+ if (ch === '"' || ch === "'" || ch === '`') {
+ const quote = ch;
+ let j = i + 1;
+ let closed = false;
+ while (j < code.length) {
+ if (code[j] === '\\') {
+ j += 2;
+ continue;
+ }
+ if (code[j] === quote) {
+ closed = true;
+ break;
+ }
+ // `${ ... }` inside a template holds real code, but nothing we need to
+ // instrument can legally live there without braces we would already be
+ // tracking — skip it wholesale, brace-matched so a nested `}` is safe.
+ if (quote === '`' && code[j] === '$' && code[j + 1] === '{') {
+ let depth = 1;
+ j += 2;
+ while (j < code.length && depth > 0) {
+ if (code[j] === '{') depth++;
+ else if (code[j] === '}') depth--;
+ j++;
+ }
+ continue;
+ }
+ j++;
+ }
+ if (!closed) return null; // unterminated string
+ prev = quote;
+ i = j + 1;
+ continue;
+ }
+ // a regex literal, but only where a value may begin
+ if (ch === '/' && (isIdent(prev) ? REGEX_PRECEDERS.has(wordBefore(code, i)) : prev !== ')' && prev !== ']')) {
+ let j = i + 1;
+ let closed = false;
+ let inClass = false;
+ while (j < code.length) {
+ const c = code[j];
+ if (c === '\\') {
+ j += 2;
+ continue;
+ }
+ if (c === '\n') break; // a regex cannot span lines: it was a division
+ if (c === '[') inClass = true;
+ else if (c === ']') inClass = false;
+ else if (c === '/' && !inClass) {
+ closed = true;
+ break;
+ }
+ j++;
+ }
+ if (closed) {
+ prev = '/';
+ i = j + 1;
+ continue;
+ }
+ // fall through: it was a division after all
+ }
+ if (visit(i, ch) === 'stop') return i;
+ if (!/\s/.test(ch)) prev = ch;
+ i++;
+ }
+ return -1;
+}
+
+/**
+ * Index of the bracket matching the one at `open`, or -1. Strings and comments inside are
+ * skipped, which is the entire point of doing this with the scanner rather than a regex.
+ * @param {string} code @param {number} open
+ */
+function matchBracket(code, open) {
+ const pairs = { '(': ')', '[': ']', '{': '}' };
+ const close = pairs[/** @type {'('|'['|'{'} */ (code[open])];
+ if (!close) return -1;
+ let depth = 0;
+ let found = -1;
+ const bad = walk(code, open, (i, ch) => {
+ if (ch === code[open]) depth++;
+ else if (ch === close) {
+ depth--;
+ if (depth === 0) {
+ found = i;
+ return 'stop';
+ }
+ }
+ });
+ if (bad === null) return -1;
+ return found;
+}
+
+/**
+ * The end of the single statement starting at `from` — the first `;` outside any bracket.
+ * Used only for an UNBRACED loop body, which has to be wrapped in braces to hold a guard.
+ * @param {string} code @param {number} from
+ */
+function statementEnd(code, from) {
+ let depth = 0;
+ let found = -1;
+ const bad = walk(code, from, (i, ch) => {
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
+ else if (ch === ';' && depth <= 0) {
+ found = i;
+ return 'stop';
+ }
+ });
+ if (bad === null) return -1;
+ return found;
+}
+
+/** first code index at or after `i` that is not whitespace (comments are skipped by walk)
+ * @param {string} code @param {number} i */
+function firstCode(code, i) {
+ let found = -1;
+ walk(code, i, (j, ch) => {
+ if (!/\s/.test(ch)) {
+ found = j;
+ return 'stop';
+ }
+ });
+ return found;
+}
+
+/**
+ * Instrument every loop in `code`. Returns the transformed body INCLUDING the counter
+ * declaration, ready to hand to `new Function`, or an error explaining the refusal.
+ * @param {string} code
+ * @returns {{ code: string, loops: number } | { error: string }}
+ */
+export function instrument(code) {
+ const src = String(code ?? '');
+ /** @type {{ pos: number, text: string }[]} */
+ const edits = [];
+ /** positions of `while` keywords that TERMINATE a do-loop rather than start one */
+ const skipWhile = new Set();
+ let loops = 0;
+ let failure = '';
+
+ const bad = walk(src, 0, (i, ch) => {
+ if (!isIdent(ch) || isIdent(src[i - 1])) return; // mid-word, or not a word start
+ // read the whole word so `format(` is never mistaken for `for (`
+ let end = i;
+ while (end < src.length && isIdent(src[end])) end++;
+ const word = src.slice(i, end);
+ if (word !== 'for' && word !== 'while' && word !== 'do') return;
+ if (src[i - 1] === '.') return; // a member called `while`, not the keyword
+ // the `while (cond)` closing a do-loop has no body; its body was guarded already
+ if (word === 'while' && skipWhile.has(i)) return;
+
+ let bodyAt;
+ if (word === 'do') {
+ bodyAt = firstCode(src, end);
+ if (bodyAt !== -1) {
+ const bodyEnd =
+ src[bodyAt] === '{' ? matchBracket(src, bodyAt) + 1 : statementEnd(src, bodyAt) + 1;
+ if (bodyEnd > 0) {
+ const w = firstCode(src, bodyEnd);
+ if (w !== -1 && src.startsWith('while', w)) skipWhile.add(w);
+ }
+ }
+ } else {
+ const paren = firstCode(src, end);
+ // `for await (` is still a for loop
+ if (paren !== -1 && /[A-Za-z]/.test(src[paren])) {
+ let w = paren;
+ while (w < src.length && isIdent(src[w])) w++;
+ bodyAt = firstCode(src, w);
+ } else bodyAt = paren;
+ if (bodyAt === -1 || src[bodyAt] !== '(') {
+ failure = 'could not read the ' + word + ' header';
+ return 'stop';
+ }
+ const closeParen = matchBracket(src, bodyAt);
+ if (closeParen === -1) {
+ failure = 'unbalanced ( in a ' + word + ' header';
+ return 'stop';
+ }
+ bodyAt = firstCode(src, closeParen + 1);
+ }
+ if (bodyAt === -1) {
+ failure = 'a ' + word + ' loop with no body';
+ return 'stop';
+ }
+ loops++;
+ if (src[bodyAt] === '{') {
+ edits.push({ pos: bodyAt + 1, text: GUARD });
+ return;
+ }
+ // an unbraced body cannot hold a guard, so give it braces
+ const semi = statementEnd(src, bodyAt);
+ if (semi === -1) {
+ failure = 'could not find the end of an unbraced ' + word + ' body';
+ return 'stop';
+ }
+ edits.push({ pos: bodyAt, text: '{' + GUARD });
+ edits.push({ pos: semi + 1, text: '}' });
+ });
+
+ if (bad === null) return { error: 'unterminated string or comment' };
+ if (failure) return { error: failure };
+
+ // apply back to front so earlier offsets stay valid; ties keep insertion order, which
+ // is what nests an inner loop's braces inside an outer one's
+ // Furthest POSITION first. Push order is not enough: an inner loop's opening brace sits
+ // at a LOWER offset than an outer loop's closing one, so applying in push order shifts
+ // the string out from under a later edit — measured as `Unexpected token }` on nested
+ // unbraced loops. Ties keep push order reversed, which nests inner braces innermost.
+ let out = src;
+ edits
+ .map((e, k) => ({ pos: e.pos, text: e.text, k }))
+ .sort((a, b) => b.pos - a.pos || b.k - a.k)
+ .forEach((e) => {
+ out = out.slice(0, e.pos) + e.text + out.slice(e.pos);
+ });
+ return { code: DECL + out, loops };
+}
diff --git a/src/lib/materialsHandler.js b/src/lib/materialsHandler.js
index 837d29c1..dfd8cdfd 100644
--- a/src/lib/materialsHandler.js
+++ b/src/lib/materialsHandler.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordEntry, registerHistoryKind } from '$lib/history';
@@ -100,7 +100,7 @@ registerHistoryKind('material', (entry, state) => {
if (object.material?.color) object.material.color.set(state.value);
broadcast({ type: 'color', uuid: entry.uuid, color: state.value });
}
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
});
@@ -149,7 +149,7 @@ export function applyMaterials(object, payload, replicate = false) {
object.geometry.addGroup(group.start, group.count, group.materialIndex);
}
object.material.needsUpdate ??= true;
- objectsGroup.update((value) => value);
+ pokeScene();
if (replicate)
broadcast({ type: 'objectParameters', parameter: 'materials', uuid: object.uuid, payload });
}
@@ -219,7 +219,7 @@ export function setObjectMaterials(uuid, materials, groups) {
object.geometry.clearGroups();
for (const group of groups) object.geometry.addGroup(group.start, group.count, group.materialIndex);
}
- objectsGroup.update((value) => value);
+ pokeScene();
const after = materialsPayload(object);
recordEntry({
kind: 'material',
@@ -381,7 +381,7 @@ export function applyMap(object, dataURL, slot = 0) {
material.map = null;
delete material.userData.mapDataUrl;
material.needsUpdate = true;
- objectsGroup.update((value) => value);
+ pokeScene();
return;
}
// set synchronously so the UI thumbnail appears immediately
@@ -402,7 +402,7 @@ export function applyMap(object, dataURL, slot = 0) {
material.map?.dispose();
material.map = texture;
material.needsUpdate = true;
- objectsGroup.update((value) => value);
+ pokeScene();
});
}
@@ -531,7 +531,7 @@ export function switchMaterialType(uuid, type, replicate = true) {
}
object.material = fresh;
fresh.needsUpdate = true;
- objectsGroup.update((value) => value);
+ pokeScene();
if (replicate)
broadcast({ type: 'objectParameters', parameter: 'material', uuid: uuid, material: type });
}
@@ -549,7 +549,7 @@ export function setObjectColor(uuid, hex, replicate = true) {
const before = '#' + material.color.getHexString();
material.color.set(hex);
material.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
if (replicate) {
recordMaterialChange(uuid, 'color', null, before, hex);
broadcast({ type: 'color', uuid: uuid, color: hex });
@@ -573,7 +573,7 @@ export function setMaterialParam(uuid, key, value, replicate = true) {
if (isColor) material[key].set(value);
else material[key] = value;
material.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
if (replicate)
broadcast({ type: 'objectParameters', parameter: 'materialParam', uuid: uuid, key: key, value: value });
}
diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js
index 753dbc5d..e292cdd8 100644
--- a/src/lib/meshEdit.js
+++ b/src/lib/meshEdit.js
@@ -7,8 +7,7 @@ import {
TControls,
lockedObjects,
isVRMode,
- transformMode
-} from '../stores/sceneStore';
+ transformMode, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { registerHistoryKind, recordEntry } from './history';
// 15-F: session-scoped undo — editSession imports ONLY history (an edge we
@@ -56,6 +55,7 @@ import { slideClamp } from './meshToolParams';
// W9: where the viewport is. A leaf (svelte/store + sceneStore) — no new edge out of
// the history-cycle family this module belongs to.
import { canvasRect } from './canvasRect';
+import { safeStorage } from './safeStorage';
// the custom transform PIVOT (local pref). Another leaf: meshPivot imports THREE
// + the two stores + proportional, and nothing from here or faceEdit.
import {
@@ -138,13 +138,13 @@ const HANDLE_MULTI = 0x22c55e; // 177: ctrl/shift multi-select for Create face
* @type {import('svelte/store').Writable} */
export const vertexHandleScale = writable(
typeof localStorage !== 'undefined'
- ? Math.min(Math.max(parseFloat(localStorage.getItem('vertexHandleScale') ?? '') || 1, 0.1), 4)
+ ? Math.min(Math.max(parseFloat(safeStorage.getItem('vertexHandleScale') ?? '') || 1, 0.1), 4)
: 1
);
/** Screen-constant handle size (default ON — see refreshHandleMatrix). A local pref.
* @type {import('svelte/store').Writable} */
export const vertexHandleAdaptive = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('vertexHandleAdaptive') !== '0' : true
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('vertexHandleAdaptive') !== '0' : true
);
/** reused so the per-frame path allocates nothing */
const scaleVector = new THREE.Vector3();
@@ -187,14 +187,14 @@ const APPARENT_PX = 9;
vertexHandleAdaptive.subscribe((value) => {
if (typeof localStorage !== 'undefined')
- localStorage.setItem('vertexHandleAdaptive', value ? '1' : '0');
+ safeStorage.setItem('vertexHandleAdaptive', value ? '1' : '0');
if (!handleMesh || !edited) return;
// re-pose every handle: the matrices carry the scale, so switching modes is a rewrite
for (let i = 0; i < handles.length; i++) refreshHandleMatrix(i);
});
vertexHandleScale.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('vertexHandleScale', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('vertexHandleScale', String(value));
// live, and cheap: the size lives in the instance MATRICES, so nothing is rebuilt and
// no handle index moves — the selection survives a size change
if (!handleMesh || !edited) return;
@@ -1636,7 +1636,7 @@ export function applyVerts(uuid, indices, positionArray) {
overlay.geometry = editWireGeometry(object.geometry);
}
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
// ---- VR vertex editing (113): drive a handle from a controller, no gizmo ----
@@ -1649,11 +1649,11 @@ export const VR_VERTEX_CAP = 800;
* @type {import('svelte/store').Writable} */
export const vrVertexCap = writable(
typeof localStorage !== 'undefined'
- ? parseInt(localStorage.getItem('vrVertexCap') ?? '') || VR_VERTEX_CAP
+ ? parseInt(safeStorage.getItem('vrVertexCap') ?? '') || VR_VERTEX_CAP
: VR_VERTEX_CAP
);
if (typeof localStorage !== 'undefined')
- vrVertexCap.subscribe((value) => localStorage.setItem('vrVertexCap', String(value)));
+ vrVertexCap.subscribe((value) => safeStorage.setItem('vrVertexCap', String(value)));
/** Vertex (position entry) count of an object's geometry @param {any} object */
export function vertexCount(object) {
diff --git a/src/lib/meshPivot.js b/src/lib/meshPivot.js
index abfb83a6..1fc2376b 100644
--- a/src/lib/meshPivot.js
+++ b/src/lib/meshPivot.js
@@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store';
import { globalScene, globalCamera, globalRenderer, TControls, transformMode } from '../stores/sceneStore';
import { showToast, showInfoToast, dismissToastById } from '../stores/appStore';
import { proportionalAnchor } from './proportional';
+import { safeStorage } from './safeStorage';
// The mesh editor's CUSTOM TRANSFORM PIVOT — where the gizmo sits, and what
// rotate/scale turn around, in all three element modes.
@@ -40,7 +41,7 @@ const MAX_STORED = 200;
/** @returns {Record} */
function load() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null;
const stored = raw ? JSON.parse(raw) : {};
if (!stored || typeof stored !== 'object') return {};
/** @type {Record} */
@@ -72,7 +73,7 @@ export const meshPivotPicking = writable(false);
export const meshPivotMoving = writable(false);
meshPivots.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value));
});
/** meshEdit/faceEdit register here so the gizmo re-seats the moment the pivot
diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js
index 48b2cf74..1fb11ddc 100644
--- a/src/lib/moduleSDK.js
+++ b/src/lib/moduleSDK.js
@@ -1,4 +1,5 @@
import { keyOf, letterOf } from './keyOf';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked } from '../stores/sceneStore';
@@ -36,6 +37,9 @@ import { setPeerVar, myPeerVar, leaderboardRows } from './peerVars';
import { createFlowNode, createFlowEdge, serializeNode, serializeEdge, setNodeData as sendNodeData } from './nodesHandler';
import { APP_VERSION } from './version.js';
import { ndcFromClient } from './canvasRect';
+// 27-B: recovery paths report through the diagnostics ring (hardening audit H4)
+import { log } from './diagnostics';
+import { safeStorage } from './safeStorage';
// Module SDK v1 — in-repo modules under src/modules// register through
// the api object passed to their register(api). See MODULES.md for the guide.
@@ -78,7 +82,7 @@ export function fireClickMiss() {
try {
handler();
} catch (error) {
- console.log('module click-miss handler failed', error);
+ log('warn', 'module', 'click-miss handler failed', String(error));
}
}
}
@@ -108,7 +112,7 @@ export function runSceneClearHandlers() {
try {
fn();
} catch (error) {
- console.log('module scene-clear handler failed', error);
+ log('warn', 'module', 'scene-clear handler failed', String(error));
}
});
}
@@ -1466,7 +1470,7 @@ export function registerModuleAssets(id, assets) {
* with this so time-based effects agree across peers.
*/
export function runtimeNow() {
- return get(syncedAnimations) ? (Date.now() % 86400000) / 1000 : performance.now() / 1000;
+ return get(syncedAnimations) ? (sessionNow() % 86400000) / 1000 : performance.now() / 1000;
}
/**
@@ -1481,9 +1485,9 @@ export function initModules(modules) {
try {
mod.register(makeApi(mod.id, mod.name || mod.id));
loadedModules.push({ id: mod.id, name: mod.name, version: mod.version, description: mod.description });
- console.log('module loaded: ' + mod.id + ' v' + mod.version);
+ log('info', 'module', 'loaded ' + mod.id + ' v' + mod.version);
} catch (error) {
- console.log('module ' + mod.id + ' failed to register', error);
+ log('warn', 'module', mod.id + ' failed to register', String(error));
showToast('Module "' + mod.id + '" failed to load');
}
});
@@ -1508,7 +1512,7 @@ export function deactivateModule(id) {
try {
disposals[i]();
} catch (error) {
- console.log('module ' + id + ' teardown step failed', error);
+ log('warn', 'module', id + ' teardown step failed', String(error));
}
}
Object.values(moduleAssets[id] ?? {}).forEach((url) => {
@@ -1534,7 +1538,7 @@ export function isModuleLoaded(id) {
function readDisabled() {
try {
- return JSON.parse(localStorage.getItem('disabledModules') ?? '[]');
+ return JSON.parse(safeStorage.getItem('disabledModules') ?? '[]');
} catch {
return [];
}
@@ -1546,7 +1550,7 @@ export const disabledModules = writable(
);
disabledModules.subscribe((list) => {
if (typeof localStorage !== 'undefined')
- localStorage.setItem('disabledModules', JSON.stringify(list));
+ safeStorage.setItem('disabledModules', JSON.stringify(list));
});
/**
@@ -1573,7 +1577,7 @@ export function applyModuleMessage(data) {
try {
fn(data);
} catch (error) {
- console.log('module ' + data.moduleId + ' message handler failed', error);
+ log('warn', 'module', data.moduleId + ' message handler failed', String(error));
}
});
}
@@ -1625,7 +1629,7 @@ export function sendModuleStates(peerId, attempt = 0) {
const state = sync.getState();
if (state != null) states[id] = state;
} catch (error) {
- console.log('module ' + id + ' getState failed', error);
+ log('warn', 'module', id + ' getState failed', String(error));
}
});
if (Object.keys(states).length === 0) return;
@@ -1644,7 +1648,7 @@ export function applyModuleStates(states) {
try {
stateSyncs[id]?.applyState(state);
} catch (error) {
- console.log('module ' + id + ' applyState failed', error);
+ log('warn', 'module', id + ' applyState failed', String(error));
}
});
}
diff --git a/src/lib/moveSmoothing.js b/src/lib/moveSmoothing.js
index 3eb01f19..7601d08e 100644
--- a/src/lib/moveSmoothing.js
+++ b/src/lib/moveSmoothing.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
// 21-B: a thrown crate is SMOOTH on the peer watching it.
@@ -105,7 +105,7 @@ export function noteRemoteMove(uuid, object, before) {
object.position.copy(pending.to.pos);
object.quaternion.copy(pending.to.quat);
eases.delete(uuid);
- objectsGroup.update((value) => value);
+ pokeScene();
}, interval + 60)
);
return true;
@@ -133,7 +133,7 @@ export function tickMoveSmoothing() {
object.position.lerpVectors(ease.from.pos, ease.to.pos, t);
object.quaternion.slerpQuaternions(ease.from.quat, ease.to.quat, t);
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** the sim stopped, the peer left, the scene changed — land everything at once */
diff --git a/src/lib/multiTransform.js b/src/lib/multiTransform.js
index be7dd722..37836629 100644
--- a/src/lib/multiTransform.js
+++ b/src/lib/multiTransform.js
@@ -1,10 +1,11 @@
import * as THREE from 'three';
import { get, writable } from 'svelte/store';
-import { globalScene, objectsGroup, TControls, selectedObjects, isVRMode } from '../stores/sceneStore';
+import { globalScene, objectsGroup, TControls, selectedObjects, isVRMode, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { recordTransformSet } from './history';
import { hasOrigin, originWorld, setOriginFromWorld } from './objectOrigin';
import { suspendAnimation, resumeAnimation } from './flowRuntime';
+import { safeStorage } from './safeStorage';
// physics is reached DYNAMICALLY: a static import would close the cycle
// multiTransform -> physics -> lockControl -> objectActions -> multiTransform
// (the vite-dev TDZ trap; Rollup tolerates it, the dev server 500s)
@@ -56,13 +57,13 @@ let lastLiveSend = 0;
/** @type {import('svelte/store').Writable<'median'|'active'|'parent'|'individual'>} */
export const pivotMode = writable(
/** @type {any} */ (
- typeof localStorage !== 'undefined' && ['median', 'active', 'parent', 'individual'].includes(localStorage.getItem('pivotMode') || '')
- ? localStorage.getItem('pivotMode')
+ typeof localStorage !== 'undefined' && ['median', 'active', 'parent', 'individual'].includes(safeStorage.getItem('pivotMode') || '')
+ ? safeStorage.getItem('pivotMode')
: 'median'
)
);
pivotMode.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('pivotMode', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('pivotMode', String(value));
});
/** The parent every member shares, when it is a real object (not objectsGroup).
@@ -255,7 +256,7 @@ export function applyPivotTransform(mutate) {
if (customOrigin) customOrigin.copy(pivot.position);
if (transientPivot) transientPivot.copy(pivot.position);
publishPivotPose();
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
}
@@ -404,7 +405,7 @@ function onDraggingChanged(/** @type {any} */ event) {
pivotStartInverse = null;
// the transient snap anchor rides where the drag left the pivot (19-B)
if (transientPivot) transientPivot.copy(pivot.position);
- objectsGroup.update((value) => value);
+ pokeScene();
}
}
diff --git a/src/lib/musicClock.js b/src/lib/musicClock.js
index 86768bfc..d47cde25 100644
--- a/src/lib/musicClock.js
+++ b/src/lib/musicClock.js
@@ -1,4 +1,6 @@
import { writable, get } from 'svelte/store';
+// 25-E: the transport keeps time by the SESSION clock, like every other stamp site
+import { sessionNow, peerClocks, clockSamples } from './sessionClock';
import { peers } from '../stores/appStore';
// The 'transport' history kind. Safe as a static import for the same reason
// scenePost's is: history's own subtree is three/stores/flowRuntime/editOverlays/
@@ -32,7 +34,8 @@ import { syncedAnimations } from '../stores/flowStore';
// and `sceneMusic` all assume every peer's `Date.now()` agrees. A `clockping` /
// `clockpong` round trip estimates it NTP-style, median of the last N.
//
-// ONE CLOCK BASIS (finding 5). Beats are `(Date.now() - startedAt) / 1000 * bpm / 60`
+// ONE CLOCK BASIS (finding 5). Beats are `(sessionNow() - startedAt) / 1000 * bpm / 60`
+// (25-E: `Date.now()` until the session clock existed — see the offset section below)
// — the `sceneMusic` basis, which has no daily wrap. `flowRuntime`'s
// `Date.now() % 86400000 / 1000` is LEFT ALONE ON PURPOSE: it is fine for a sine LFO
// and fatal for a transport, because a loop whose duration does not divide 86 400 s
@@ -138,7 +141,7 @@ export const transport = writable(normalizeTransport(null));
* through this one function.
* @param {Transport} state @param {number} [wallMs]
*/
-export function beatAt(state, wallMs = Date.now()) {
+export function beatAt(state, wallMs = sessionNow()) {
if (!state.playing || !state.startedAt) return 0;
return Math.max(0, ((wallMs - state.startedAt) / 1000) * (state.bpm / 60));
}
@@ -175,7 +178,7 @@ export function swungBeat(beat, swing) {
/** A read of the transport for a HUD or a value node: `{bpm, beat, bar, step, phase,
* playing, loopBeats}`. `phase` is the position inside the current loop in 0..1. */
-export function transportNow(wallMs = Date.now()) {
+export function transportNow(wallMs = sessionNow()) {
const state = get(transport);
const beat = beatAt(state, wallMs);
const loop = loopBeats(state);
@@ -200,7 +203,7 @@ registerModuleValueNode(
'transportbeat',
(data, time) => {
const synced = get(syncedAnimations) && typeof time === 'number';
- const wallMs = synced ? Math.floor(Date.now() / 86400000) * 86400000 + time * 1000 : Date.now();
+ const wallMs = synced ? Math.floor(sessionNow() / 86400000) * 86400000 + time * 1000 : sessionNow();
const t = transportNow(wallMs);
switch (data?.read) {
case 'bar':
@@ -238,7 +241,7 @@ let applyingHistory = false;
function commit(fn) {
const before = get(transport);
const next = normalizeTransport(fn(before));
- next.changedAt = Math.max(Date.now(), (before.changedAt || 0) + 1);
+ next.changedAt = Math.max(sessionNow(), (before.changedAt || 0) + 1);
transport.set(next);
if (!applyingHistory) recordTransportEntry(before, next);
broadcastTransport();
@@ -278,7 +281,7 @@ export function setTransport(patch) {
/** @type {any} */
const merged = { ...state, ...(patch ?? {}) };
if (state.playing && typeof patch?.bpm === 'number' && patch.bpm !== state.bpm) {
- const now = Date.now();
+ const now = sessionNow();
const bpm = num(patch.bpm, 20, 300, state.bpm);
merged.bpm = bpm;
merged.startedAt = now - (beatAt(state, now) * 60000) / bpm;
@@ -304,7 +307,7 @@ export function setBarsPerLoop(bars) {
/** Start from beat 0 at `at` (default now). Every peer starts inside the same beat
* from the same stamp — the `sceneMusic` loop-phase model. @param {number} [at] */
-export function playTransport(at = Date.now()) {
+export function playTransport(at = sessionNow()) {
return commit((state) => ({ ...state, playing: true, startedAt: at }));
}
@@ -397,13 +400,13 @@ export function transportRestore(payload, replicate = false, opts = {}) {
const resume = opts.resume !== false;
const next = normalizeTransport(payload);
if (next.playing) {
- if (resume) next.startedAt = Date.now();
+ if (resume) next.startedAt = sessionNow();
else next.playing = false;
}
// a restore is an authoritative local write, so it must WIN over whatever changedAt
// the file carries (an old file's stamp is in the past) — and stay monotonic, since
// it can land in the same millisecond as the write before it
- next.changedAt = Math.max(Date.now(), (get(transport).changedAt || 0) + 1);
+ next.changedAt = Math.max(sessionNow(), (get(transport).changedAt || 0) + 1);
transport.set(next);
if (replicate) broadcastTransport();
return next;
@@ -521,13 +524,13 @@ transport.subscribe((state) => {
const run = runKey(state);
if (run === seenRun) return;
seenRun = run;
- runSeenAt = Date.now();
+ runSeenAt = sessionNow();
if (events.length) tick(runSeenAt);
});
/** One look-ahead pass. Exported for the suite, which drives it by hand to prove the
* horizon and the no-double-fire rule without waiting on real time. */
-export function tick(wallMs = Date.now()) {
+export function tick(wallMs = sessionNow()) {
// feed the engine's clock filter every tick, so `audioTimeFor` sees many phases of
// the device callback (see the clock section of audioEngine.js)
sampleAudioClock();
@@ -580,177 +583,23 @@ function fire(state, event, beat, late) {
// ---- peer clock offset (finding 6) ----------------------------------------------
//
-// NTP's four-stamp round trip, over the data channel the peers already share:
-// t0 we send `clockping` (our clock)
-// t1 they receive it (their clock)
-// t2 they send `clockpong` (their clock)
-// t3 we receive it (our clock)
-// rtt = (t3 - t0) - (t2 - t1)
-// offset = ((t1 - t0) + (t2 - t3)) / 2 their clock minus ours
-// The error of one sample is bounded by the round trip's ASYMMETRY, at most rtt/2.
-// The estimate is the MEDIAN of the last N: a median rejects the one sample that
-// went through a slow relay, a mean does not.
-
-/** samples kept per peer */
-const CLOCK_RING = 12;
-/** how many pings the connect burst sends, how far apart, and how long after the
- * handshake it starts. MEASURED: samples taken during the connect storm (the joiner is
- * receiving objects, compiling shaders, first-painting) carried 100+ ms of one-sided
- * main-thread delay and pulled a 6-sample median to +427 ms on a true +300 — so the
- * burst waits for the storm to pass, and the filter below discounts what it catches. */
-const BURST = 6;
-const BURST_GAP_MS = 250;
-const BURST_DELAY_MS = 2000;
-/** steady-state re-measure, so a drifting clock is tracked and storm samples age out */
-const RESYNC_MS = 5000;
-
-/** @type {Record} */
-const clockSamples = {};
-
-/** peerId -> `{offset, rtt, samples}` — offset is THEIR clock minus OURS, in ms.
- * Local, derived, never replicated (the `peerQuality` precedent).
- * @type {import('svelte/store').Writable>} */
-export const peerClocks = writable({});
-
-/** @param {number[]} arr */
-function median(arr) {
- const s = [...arr].sort((a, b) => a - b);
- const m = Math.floor(s.length / 2);
- return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
-}
-
-/**
- * The estimate from a ring of samples: the MEDIAN OFFSET OF THE LOWEST-RTT HALF.
- *
- * A sample's error is its round trip's asymmetry, and asymmetry comes from queueing —
- * a packet that waited (in the network, or on a busy main thread before the handler
- * ran) is late on ONE leg. The samples with the shortest round trips waited the least,
- * so NTP's clock filter keeps the minimum-delay sample; taking the median of the best
- * half keeps that bias-rejection while still outvoting a single odd reading. Pure,
- * exported for the suite. @param {{offsets: number[], rtts: number[]}} ring
- */
-export function estimateFromSamples(ring) {
- const n = ring.offsets.length;
- if (!n) return null;
- const order = ring.rtts.map((rtt, i) => i).sort((a, b) => ring.rtts[a] - ring.rtts[b]);
- const best = order.slice(0, Math.max(1, Math.ceil(n / 2)));
- return {
- offset: median(best.map((i) => ring.offsets[i])),
- rtt: median(best.map((i) => ring.rtts[i])),
- samples: n
- };
-}
-
-/**
- * Fold one measurement into a peer's ring and republish the median. Pure enough to
- * test without a connection. @param {string} peerId @param {number} offset @param {number} rtt
- */
-export function recordClockSample(peerId, offset, rtt) {
- if (!Number.isFinite(offset) || !Number.isFinite(rtt) || rtt < 0) return;
- const ring = (clockSamples[peerId] ??= { offsets: [], rtts: [] });
- ring.offsets.push(offset);
- ring.rtts.push(rtt);
- while (ring.offsets.length > CLOCK_RING) {
- ring.offsets.shift();
- ring.rtts.shift();
- }
- const estimate = estimateFromSamples(ring);
- if (estimate) peerClocks.update((map) => ({ ...map, [peerId]: estimate }));
-}
-
-/** The estimated offset of a peer's clock from ours (ms, theirs minus ours), or null
- * before the first sample lands. @param {string} peerId */
-export function peerClockOffset(peerId) {
- return get(peerClocks)[peerId]?.offset ?? null;
-}
-
-/**
- * A stamp taken on `peerId`'s clock, expressed on OURS. The primitive for the
- * colocated case (see the header): only meaningful when the GRID is corrected by the
- * same rule, so nothing in core applies it by default. Unknown peer = unchanged.
- * @param {string} peerId @param {number} wallMs
- */
-export function correctRemoteStamp(peerId, wallMs) {
- const offset = peerClockOffset(peerId);
- return offset == null ? wallMs : wallMs - offset;
-}
-
-/** Drop a peer's samples (handleDisconnected — golden rule 3). @param {string} peerId */
-export function dropPeerClock(peerId) {
- delete clockSamples[peerId];
- peerClocks.update((map) => {
- if (!(peerId in map)) return map;
- const next = { ...map };
- delete next[peerId];
- return next;
- });
-}
-
-/** @param {string} peerId @returns {any} the stable OUTGOING conn, or null */
-function connFor(peerId) {
- /** @type {any} */
- const peer = get(peers);
- const conn = peer?.connections?.[peerId];
- return conn && conn.open ? conn : null;
-}
-
-/** One ping. Returns false when there is no open conn to send it on. @param {string} peerId */
-export function sendClockPing(peerId) {
- const conn = connFor(peerId);
- if (!conn) return false;
- /** @type {any} */
- const peer = get(peers);
- conn.send({ type: 'clockping', sender: peer.peer.id, t0: Date.now() });
- return true;
-}
-
-/**
- * Answer a ping. Stamped on receipt (t1) and again on send (t2) so the responder's
- * own processing time is subtracted out of the round trip. Replies over our stable
- * OUTGOING conn to the sender (golden rule 9), falling back to the conn it arrived on
- * while the dance is still settling. @param {any} data @param {any} [arrivedOn]
- */
-export function answerClockPing(data, arrivedOn) {
- const t1 = Date.now();
- if (!data || typeof data.t0 !== 'number') return;
- /** @type {any} */
- const peer = get(peers);
- const conn = connFor(data.sender) ?? (arrivedOn && arrivedOn.open ? arrivedOn : null);
- if (!conn) return;
- conn.send({ type: 'clockpong', sender: peer?.peer?.id ?? '', t0: data.t0, t1, t2: Date.now() });
-}
-
-/** Fold a pong into the sender's estimate. @param {any} data */
-export function applyClockPong(data) {
- const t3 = Date.now();
- if (!data || typeof data.t0 !== 'number' || typeof data.t1 !== 'number' || typeof data.t2 !== 'number') return;
- if (!data.sender) return;
- const rtt = t3 - data.t0 - (data.t2 - data.t1);
- const offset = (data.t1 - data.t0 + (data.t2 - t3)) / 2;
- recordClockSample(String(data.sender), offset, rtt);
-}
-
-/** @type {any} */
-let resyncTimer = null;
-
-/**
- * Start measuring a peer: a short burst now (so an estimate exists within a second of
- * connecting — the median needs several samples before it means anything), then a
- * steady re-measure every RESYNC_MS for as long as the conn is open. Called from
- * `sendHandshake`, which is the one place a conn is known to be OPEN (golden rule 2).
- * @param {string} peerId
- */
-export function startClockSync(peerId) {
- if (typeof setTimeout === 'undefined') return;
- for (let i = 0; i < BURST; i++) setTimeout(() => sendClockPing(peerId), BURST_DELAY_MS + i * BURST_GAP_MS);
- if (resyncTimer == null) {
- resyncTimer = setInterval(() => {
- /** @type {any} */
- const peer = get(peers);
- for (const id of Object.keys(peer?.connections ?? {})) sendClockPing(id);
- }, RESYNC_MS);
- }
-}
+// 25-E MOVED THE ESTIMATOR OUT. It was built here for the transport and then applied to
+// nothing, because only the music line knew it existed; the session needed it far more
+// (every latest-wins stamp, every trigger pulse, the synced flow clock). The four-stamp
+// maths and the ring live in the `sessionClock` leaf, the round trip in `clockSync`,
+// and this module now keeps time by `sessionNow()` like every other stamp site — which
+// is the "correct BOTH the grid and the stamp" rule from the header, applied to the grid
+// (`startedAt`) and every note stamp at once. Re-exported so the suite and any caller
+// that learned the names here keep working.
+export {
+ peerClocks,
+ estimateFromSamples,
+ recordClockSample,
+ peerClockOffset,
+ correctRemoteStamp,
+ dropPeerClock
+} from './sessionClock';
+export { sendClockPing, answerClockPing, applyClockPong, startClockSync } from './clockSync';
// ---- debug ----------------------------------------------------------------------
diff --git a/src/lib/musicToolbox.js b/src/lib/musicToolbox.js
index 50aae68a..319cbfc3 100644
--- a/src/lib/musicToolbox.js
+++ b/src/lib/musicToolbox.js
@@ -3,6 +3,7 @@ import { writable, get } from 'svelte/store';
import MusicToolbox from '../components/menu/MusicToolbox.svelte';
import { registerModuleToolbox, unregisterModuleToolbox } from './moduleToolboxes';
import { setDeviceFor, deviceCatalog, deviceCatalogVersion } from './audioDevices';
+import { safeStorage } from './safeStorage';
// THE MUSIC TOOLBOX (roadmap #23 B2, cloud plans-core/pending/23-b-interfaces.md).
//
@@ -77,7 +78,7 @@ const PRESETS_KEY = 'musicPresets';
/** @returns {Record}[]>} kind -> presets */
function loadPresets() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(PRESETS_KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(PRESETS_KEY) : null;
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
@@ -91,7 +92,7 @@ export const musicPresets = writable(loadPresets());
function persist() {
try {
- localStorage.setItem(PRESETS_KEY, JSON.stringify(get(musicPresets)));
+ safeStorage.setItem(PRESETS_KEY, JSON.stringify(get(musicPresets)));
} catch {}
}
diff --git a/src/lib/netBackoff.js b/src/lib/netBackoff.js
index b29a6be7..b0e08aeb 100644
--- a/src/lib/netBackoff.js
+++ b/src/lib/netBackoff.js
@@ -6,25 +6,41 @@
// finalizes the disconnect). Defaults: 500 / 1000 / 2000 / 4000 ms, capped.
/**
+ * 27-F: `jitter`, `rng` and an unbounded `max` are ADDITIVE and inert by default, so
+ * every existing caller is byte-identical.
* @param {number} attempt 1-indexed attempt number
- * @param {{ base?: number, factor?: number, cap?: number, max?: number }} [opts]
+ * @param {{ base?: number, factor?: number, cap?: number, max?: number, jitter?: number,
+ * rng?: () => number }} [opts]
* @returns {number | null} delay in ms, or null when exhausted
*/
export function backoffDelay(attempt, opts = {}) {
- const { base = 500, factor = 2, cap = 8000, max = 4 } = opts;
+ const { base = 500, factor = 2, cap = 8000, max = 4, jitter = 0, rng = Math.random } = opts;
if (!Number.isFinite(attempt) || attempt < 1 || attempt > max) return null;
- return Math.min(cap, Math.round(base * Math.pow(factor, attempt - 1)));
+ const delay = Math.min(cap, Math.round(base * Math.pow(factor, attempt - 1)));
+ if (!jitter) return delay;
+ // 27-F: +/- a fraction of the delay, clamped at 0 (a negative wait would hammer the
+ // server). The ONLY non-determinism in this module, and it takes an injectable `rng`
+ // so the schedule stays unit-testable — `max: Infinity` is the signaling reconnect,
+ // where giving up strands the tab with a dead invite id (audit H2).
+ const spread = delay * jitter;
+ return Math.max(0, Math.round(delay + (rng() * 2 - 1) * spread));
}
/**
- * The full schedule as an array of delays (ms), length = max.
- * @param {{ base?: number, factor?: number, cap?: number, max?: number }} [opts]
+ * The full schedule as an array of delays (ms), length = max (or `limit` when max is
+ * unbounded — see the body).
+ * @param {{ base?: number, factor?: number, cap?: number, max?: number, jitter?: number,
+ * rng?: () => number, limit?: number }} [opts]
* @returns {number[]}
*/
export function backoffSchedule(opts = {}) {
- const { max = 4 } = opts;
+ // 27-F: an UNBOUNDED `max` has no full schedule, so `limit` bounds what this returns.
+ // Without it the loop below never terminates — measured as `RangeError: Invalid array
+ // length` on the pre-27-F module.
+ const { max = 4, limit = 10 } = opts;
+ const upTo = Number.isFinite(max) ? max : limit;
const out = [];
- for (let i = 1; i <= max; i++) {
+ for (let i = 1; i <= upTo; i++) {
const d = backoffDelay(i, opts);
if (d === null) break;
out.push(d);
diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js
index 5aec23fe..202e82ba 100644
--- a/src/lib/objectActions.js
+++ b/src/lib/objectActions.js
@@ -16,8 +16,7 @@ import {
orbitControls,
isVRMode,
gizmoSuppressed,
- cameraClaim
-} from '../stores/sceneStore';
+ cameraClaim, pokeScene } from '../stores/sceneStore';
import { attachMultiPivot, releaseMultiPivot, hasCustomOrigin, pivotPose, setPivotOrigin } from './multiTransform';
import { focusTargetFace, faceEditObject, hideElementSelection, restoreElementSelection } from './faceEdit';
import { focusTargetVertex, editingObject, hideVertexSelection, restoreVertexSelection } from './meshEdit';
@@ -379,7 +378,7 @@ export function deleteObjectsByUuid(uuids) {
object.parent?.remove(object);
if (peer) peer.send({ type: 'delete', uuid, peerId: peer.peer.id });
}
- objectsGroup.update((value) => value);
+ pokeScene();
return uuids.length;
}
@@ -511,7 +510,7 @@ export function duplicateObject(uuid, options = {}) {
}
if (options.transient) markTransient(clone);
source.parent.add(clone);
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
@@ -604,7 +603,7 @@ export function applyRemoteDuplicate(sourceUuid, uuids, name, pos, transient) {
clone.position.fromArray(pos);
if (transient) markTransient(clone);
source.parent.add(clone);
- objectsGroup.update((value) => value);
+ pokeScene();
}
// name/visibility undo entries replay by setting the recorded value directly
@@ -664,7 +663,7 @@ registerHistoryKind('props', (entry, state) => {
if (peer)
peer.send({ type: 'objectParameters', parameter: 'origin', uuid: entry.uuid, origin: state.origin });
}
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
});
@@ -691,7 +690,7 @@ export function toggleObjectVisibility(uuid) {
after: { visible: !object.visible }
});
object.visible = !object.visible;
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer)
@@ -706,7 +705,7 @@ export function renameObject(uuid, name) {
if (object.name !== name)
recordEntry({ kind: 'props', uuid: uuid, before: { name: object.name }, after: { name: name } });
object.name = name;
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer) peer.send({ type: 'name', uuid: uuid, name: name });
@@ -755,7 +754,7 @@ export function moveObjectToGroup(uuid, target) {
const toParent = object.parent === group ? 'root' : object.parent?.uuid;
if (fromParent !== toParent)
recordEntry({ kind: 'group', uuid: uuid, before: { parent: fromParent }, after: { parent: toParent } });
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -825,7 +824,7 @@ export function groupSelection() {
}
for (const uuid of uuids) moveObjectToGroup(uuid, groupUuid);
endHistoryBatch('Group objects');
- objectsGroup.update((value) => value);
+ pokeScene();
applySelectionSet([groupUuid]);
return groupUuid;
}
@@ -1038,7 +1037,7 @@ export async function convertToMesh(uuids) {
});
endHistoryBatch('Convert to mesh');
- objectsGroup.update((value) => value);
+ pokeScene();
applySelectionSet([mesh.uuid]);
showToast(`Merged ${sources.length} meshes into "${mesh.name}"`);
return mesh.uuid;
@@ -1074,7 +1073,7 @@ export function alignToGround(uuid) {
};
recordTransform({ uuid: object.uuid, before: before, after: after });
resumeAnimation(object.uuid); // dropped spot becomes the new animation base
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer)
@@ -1242,7 +1241,7 @@ export function isolateObjects(uuids) {
}
}
isolationSnapshot = snapshot;
- objectsGroup.update((v) => v);
+ pokeScene();
if (hidden) showToast('Isolated — press Esc to bring the scene back');
return hidden;
}
@@ -1258,6 +1257,6 @@ export function clearIsolation() {
if (object && visible && object.visible === false) object.visible = true;
}
isolationSnapshot = null;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
diff --git a/src/lib/objectListNav.js b/src/lib/objectListNav.js
index e5e9d13f..ab83a7e3 100644
--- a/src/lib/objectListNav.js
+++ b/src/lib/objectListNav.js
@@ -4,7 +4,7 @@
// can all use it.
/**
- * @typedef {{ uuid: string, depth: number, hasKids: boolean, parent: string | null, name: string }} ObjectRow
+ * @typedef {{ uuid: string, depth: number, hasKids: boolean, parent: string | null, name: string, object: any }} ObjectRow
*/
/**
@@ -25,7 +25,9 @@ export function visibleObjectRows(group, expanded, filter) {
if (!object || object.userData?.__localOnly) return;
if (filter && !filter.has(object.uuid)) return;
const kids = object.children ?? [];
- rows.push({ uuid: object.uuid, depth, hasKids: kids.length > 0, parent, name: object.name || object.type || '' });
+ // 26-B: the OBJECT rides along so the virtualised list can render a row without
+ // walking the tree again to find it (additive — every existing reader ignores it).
+ rows.push({ uuid: object.uuid, depth, hasKids: kids.length > 0, parent, name: object.name || object.type || '', object });
if (kids.length && expanded?.has(object.uuid)) for (const kid of kids) walk(kid, depth + 1, object.uuid);
};
for (const child of group?.children ?? []) walk(child, 0, null);
diff --git a/src/lib/objectOrigin.js b/src/lib/objectOrigin.js
index 3f50168d..3f8aa882 100644
--- a/src/lib/objectOrigin.js
+++ b/src/lib/objectOrigin.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { recordEntry } from './history';
@@ -94,7 +94,7 @@ export function setOriginFor(uuid, local) {
/** @type {any} */
const peer = get(peers);
peer?.send({ type: 'objectParameters', parameter: 'origin', uuid, origin: next });
- objectsGroup.update((v) => v);
+ pokeScene();
return next;
}
diff --git a/src/lib/objectPermissions.js b/src/lib/objectPermissions.js
index d115d35a..c815d2bf 100644
--- a/src/lib/objectPermissions.js
+++ b/src/lib/objectPermissions.js
@@ -10,7 +10,7 @@ import { get } from 'svelte/store';
import { rolesInfo } from './cloudHooks';
import { parkEditOverlays } from './editOverlays';
import { showToast, showLocalObjects, peers } from '../stores/appStore';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
/** broadcast message `type`s that CREATE a scene object (peerHandler send-gate) */
const CREATE_TYPES = new Set(['create', 'light', 'group', 'object', 'objectfile', 'duplicate']);
@@ -74,7 +74,7 @@ export function shareObject(object, groupUuid = null) {
} finally {
unpark();
}
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
diff --git a/src/lib/onionSkin.js b/src/lib/onionSkin.js
index 396ad30b..157fdf2d 100644
--- a/src/lib/onionSkin.js
+++ b/src/lib/onionSkin.js
@@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store';
import { globalScene, objectsGroup, selectedObject } from '../stores/sceneStore';
import { activeClip, keyTimes, poseAt, ghostBase, playheadOf } from './animationPreview';
import { wireframeActive } from './viewMode';
+import { safeStorage } from './safeStorage';
// 17-E F6: ONION SKIN — faint copies of the object at the neighbouring keys, so you
// can see where a movement came from and where it is going while you work on the
@@ -19,14 +20,14 @@ import { wireframeActive } from './viewMode';
// is not what someone opening a file wants to see.
export const showOnionSkin = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('showOnionSkin') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('showOnionSkin') === 'true'
);
/** @param {boolean} on */
export function setOnionSkin(on) {
showOnionSkin.set(on);
try {
- localStorage.setItem('showOnionSkin', on ? 'true' : 'false');
+ safeStorage.setItem('showOnionSkin', on ? 'true' : 'false');
} catch {}
}
diff --git a/src/lib/overloadGuard.js b/src/lib/overloadGuard.js
new file mode 100644
index 00000000..fdebf0ee
--- /dev/null
+++ b/src/lib/overloadGuard.js
@@ -0,0 +1,241 @@
+import { writable, get } from 'svelte/store';
+import { objectsGroup, globalRenderer, pokeScene } from '../stores/sceneStore';
+import { BUDGETS, profileFor, registerFrameObserver, sceneMetrics, isHeavy, qualityBaseline } 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;
+ // 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;
+ }
+ return false;
+}
+
+/** Is the scene big enough that pausing it and setting part of it aside could help?
+ * The size axes only (`sceneBudget.HEAVY_AXES`); `unknown` (nothing sampled yet) is NOT
+ * heavy, so a freshly booted page can never be paused before the first reading.
+ * 26-D: judged against what the scene cost BEFORE the quality governor reduced it
+ * (`qualityBaseline`) — the governor drawing less must not talk this guard out of a
+ * scene that is still too heavy. */
+export function sceneIsHeavy() {
+ const metrics = get(sceneMetrics);
+ const profile = metrics?.profile === 'vr' ? 'vr' : 'desktop';
+ return isHeavy(metrics, profile, get(qualityBaseline));
+}
+
+/** @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/packs.js b/src/lib/packs.js
index 325cff61..2c0fda65 100644
--- a/src/lib/packs.js
+++ b/src/lib/packs.js
@@ -1,6 +1,7 @@
import { writable, get } from 'svelte/store';
import { contentBase } from './contentBase';
import { addItemFromBytes, createFolder, explorerFolders } from './explorer';
+import { safeStorage } from './safeStorage';
// N6 (roadmap 7 / ship-qa D1): object packs. Two sources, one normalized model:
// - DEFAULT packs from static/libraryList.json (bundled today; the model bytes
@@ -39,7 +40,7 @@ let loadSeq = 0;
/** @returns {any[]} imported packs persisted locally */
function getInstalled() {
try {
- return JSON.parse(localStorage.getItem(INSTALLED_KEY) || '[]');
+ return JSON.parse(safeStorage.getItem(INSTALLED_KEY) || '[]');
} catch {
return [];
}
@@ -47,7 +48,7 @@ function getInstalled() {
/** @param {any[]} list */
function setInstalled(list) {
try {
- localStorage.setItem(INSTALLED_KEY, JSON.stringify(list));
+ safeStorage.setItem(INSTALLED_KEY, JSON.stringify(list));
} catch {}
}
@@ -59,7 +60,7 @@ const THUMB_KEY = 'packThumbCache';
/** @returns {Record} */
function getThumbCache() {
try {
- return JSON.parse(localStorage.getItem(THUMB_KEY) || '{}');
+ return JSON.parse(safeStorage.getItem(THUMB_KEY) || '{}');
} catch {
return {};
}
@@ -74,7 +75,7 @@ export function rememberThumb(packName, itemName, url) {
if (c[`${packName}/${itemName}`] === url) return;
c[`${packName}/${itemName}`] = url;
try {
- localStorage.setItem(THUMB_KEY, JSON.stringify(c));
+ safeStorage.setItem(THUMB_KEY, JSON.stringify(c));
} catch {}
}
// 21-G1: PACK RENAME. The report was "the Audio Essentials folder can't be renamed", and
@@ -94,7 +95,7 @@ const TITLE_KEY = 'packTitles';
/** @returns {Record} */
function getTitleOverrides() {
try {
- return JSON.parse(localStorage.getItem(TITLE_KEY) || '{}');
+ return JSON.parse(safeStorage.getItem(TITLE_KEY) || '{}');
} catch {
return {};
}
@@ -114,7 +115,7 @@ export function renamePack(name, title) {
const map = getTitleOverrides();
map[name] = clean;
try {
- localStorage.setItem(TITLE_KEY, JSON.stringify(map));
+ safeStorage.setItem(TITLE_KEY, JSON.stringify(map));
} catch {}
packs.update((list) => list.map((/** @type {any} */ p) => (p.name === name ? { ...p, title: clean } : p)));
return true;
@@ -125,7 +126,7 @@ function dropTitleOverride(packName) {
if (!(packName in map)) return;
delete map[packName];
try {
- localStorage.setItem(TITLE_KEY, JSON.stringify(map));
+ safeStorage.setItem(TITLE_KEY, JSON.stringify(map));
} catch {}
}
@@ -137,7 +138,7 @@ function dropPackThumbs(packName) {
for (const k of Object.keys(c)) if (k.startsWith(prefix)) (delete c[k], (changed = true));
if (changed)
try {
- localStorage.setItem(THUMB_KEY, JSON.stringify(c));
+ safeStorage.setItem(THUMB_KEY, JSON.stringify(c));
} catch {}
}
diff --git a/src/lib/panelToggles.js b/src/lib/panelToggles.js
index 87082b9c..ce54ce77 100644
--- a/src/lib/panelToggles.js
+++ b/src/lib/panelToggles.js
@@ -21,6 +21,7 @@ import {
import { raiseWindow, isTopVisibleWindow } from './windowFocus';
import { groupOfKey, activateTab } from './windowTabs';
import { revealWindow } from './dragWindow';
+import { safeStorage } from './safeStorage';
// ONE decision tree for the Controls panel buttons AND their keyboard shortcuts
// (O / N). Before this module the Object list button had taskbar semantics
@@ -105,7 +106,7 @@ function isDockedPresent(key) {
/** Would opening this panel put it in the dock? @param {PanelConfig} cfg */
function opensDocked(cfg) {
if (!cfg.dockedLs) return false; // floating-only panel
- return typeof localStorage === 'undefined' || localStorage.getItem(cfg.dockedLs) !== 'false';
+ return typeof localStorage === 'undefined' || safeStorage.getItem(cfg.dockedLs) !== 'false';
}
/** Is this panel the one the dock is actually SHOWING? @param {PanelConfig} cfg */
diff --git a/src/lib/particleActions.js b/src/lib/particleActions.js
index aba452f1..7ce4b07e 100644
--- a/src/lib/particleActions.js
+++ b/src/lib/particleActions.js
@@ -1,5 +1,6 @@
import { get } from 'svelte/store';
-import { objectsGroup, selectedObject } from '../stores/sceneStore';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
+import { objectsGroup, selectedObject, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { recordEntry } from './history';
import { particlePreset, PARTICLE_DEFAULTS } from './particlePresets';
@@ -22,7 +23,7 @@ function objectOf(uuid) {
/** poke the stores so the Inspector/object list re-render */
function poke() {
- objectsGroup.update((v) => v);
+ pokeScene();
selectedObject.update((v) => v);
}
@@ -68,7 +69,7 @@ export function removeObjectParticles(uuid) {
* @param {string} uuid
*/
export function burstObjectParticles(uuid) {
- const t = (Date.now() % 86400000) / 1000; // same formula as the flow tick clock
+ const t = (sessionNow() % 86400000) / 1000; // same formula as the flow tick clock
applyBurst(uuid, t);
/** @type {any} */
const peer = get(peers);
diff --git a/src/lib/particleRuntime.js b/src/lib/particleRuntime.js
index 918df55c..14de142b 100644
--- a/src/lib/particleRuntime.js
+++ b/src/lib/particleRuntime.js
@@ -6,6 +6,12 @@ import { showToast } from '../stores/appStore';
import { wireframeActive } from './viewMode';
import { particleVertexShader, particleFragmentShader, spriteTexture, wrapTime } from './particleShader';
import { PARTICLE_DEFAULTS } from './particlePresets';
+import { qualityOverrides } from './qualityGovernor';
+
+// 26-D: the governor's particle step caps every emitter at the VR count (roadmap 26 Stage 3's
+// third bullet). Read through a subscription — this runs per frame per emitter.
+let particlesCapped = false;
+qualityOverrides.subscribe((o) => (particlesCapped = o.particlesCapped));
// Particle emitter runtime (PFX-A). flowRuntime hands over the live emitters
// each tick — `particle` NODE pairs (like sound) plus every object carrying
@@ -263,7 +269,7 @@ export function updateParticles(pairs, sceneObjects, time) {
const camera = get(globalCamera);
const height = renderer?.domElement?.height ?? 600;
const sizeScale = height / (2 * Math.tan(((camera?.fov ?? 40) * Math.PI) / 360));
- const vr = get(isVRMode);
+ const vr = get(isVRMode) || particlesCapped;
const tw = wrapTime(time);
const wanted = new Set();
diff --git a/src/lib/peerApproval.js b/src/lib/peerApproval.js
index 78cbe5da..0a050c9e 100644
--- a/src/lib/peerApproval.js
+++ b/src/lib/peerApproval.js
@@ -1,6 +1,16 @@
import { get } from 'svelte/store';
import { peers, userdata, pendingApprovals, waitingForApproval, showToast } from '../stores/appStore';
-import { sessionHost } from './connectionState';
+import {
+ sessionHost,
+ APPROVAL_WINDOW_MS,
+ HARD_PEER_CAP,
+ noteApprovalStarted,
+ clearApprovalStarted,
+ roomIsFull,
+ isRefusal,
+ noteJoinRefusal,
+ clearJoinRefusal
+} from './connectionState';
// Pending-connection approval (211). Kept in its own store-only module so VR
// (vrControls -> executeVRMenuAction) can call it WITHOUT statically importing
@@ -13,26 +23,75 @@ import { sessionHost } from './connectionState';
* and connect back (the requester already whitelisted us). @param {string} peerId
*/
export function approvePeer(peerId) {
+ /** @type {any} */
+ const peer = get(peers);
+ // 25-F: past the hard cap an approval is a refusal the joiner can HEAR. The desktop card
+ // offers "Tell them it's full" itself; this is the path every other caller takes (the
+ // VR panel's yes, a plugin), which used to approve straight past the cap.
+ if (peer && roomIsFull(peer)) {
+ denyPeer(peerId, 'full');
+ showToast('This session is full (' + HARD_PEER_CAP + ' people) — ' + label(peerId) + ' was told.');
+ return;
+ }
pendingApprovals.set(get(pendingApprovals).filter((/** @type {any} */ p) => p.peerId !== peerId));
+ clearApprovalStarted(peerId);
const users = /** @type {any[]} */ (get(userdata));
if (!users.some((/** @type {any} */ u) => u[0] === peerId)) users.push([peerId, '', '']);
userdata.set(/** @type {any} */ (users));
- /** @type {any} */
- const peer = get(peers);
if (!peer) return;
peer.send({ type: 'userdata', userdata: get(userdata) });
- peer.connectToPeer(peerId, true);
+ // 25-F: the dial-back SAYS it is an approval (older peers still read the conn alone)
+ if (typeof peer.approveDialBack === 'function') peer.approveDialBack(peerId);
+ else peer.connectToPeer(peerId, true);
+}
+
+/** @param {string} peerId */
+function label(peerId) {
+ return String(peerId).slice(0, 6).toUpperCase();
}
/**
* Deny a pending request: drop it from the queue and close any lingering incoming
- * connection. The peer stays off the whitelist. @param {string} peerId
+ * connection. The peer stays off the whitelist.
+ *
+ * 25-F: and TELL them, when their dial said they can hear it (`hearsNo` on the card) — a
+ * short refusal dial whose metadata is the answer. A joiner that did not say so is an
+ * older build, which reads ANY incoming conn from the host as an approval, so it gets the
+ * old silence and its own 90 s expiry rather than a false "approved".
+ * @param {string} peerId @param {'denied' | 'full'} [result]
*/
-export function denyPeer(peerId) {
+export function denyPeer(peerId, result = 'denied') {
+ const card = /** @type {any[]} */ (get(pendingApprovals)).find((p) => p.peerId === peerId);
pendingApprovals.set(get(pendingApprovals).filter((/** @type {any} */ p) => p.peerId !== peerId));
+ clearApprovalStarted(peerId);
/** @type {any} */
const peer = get(peers);
peer?.connections?.[peerId]?.close?.();
+ if (card?.hearsNo && typeof peer?.sendJoinResult === 'function') peer.sendJoinResult(peerId, result);
+}
+
+/**
+ * 25-F — the JOINER's half: the host said no (or that the room is full). End the request
+ * exactly as a cancel does (the waiting row, the optimistic whitelist row, the timer, the
+ * never-open conn) and say which it was. A refusal from a peer we are NOT waiting on —
+ * a second approver after we joined, or an answer that outlived our own 90 s expiry — is
+ * ignored: nothing is pending, so there is nothing to end and nobody to tell.
+ * @param {string} peerId @param {any} result @returns {boolean} whether it ended a request
+ */
+export function applyJoinRefusal(peerId, result) {
+ if (!isRefusal(result)) return false;
+ const waiting = /** @type {any[]} */ (get(waitingForApproval));
+ if (!waiting.some((/** @type {any} */ w) => w[0] === peerId && w[1] === 'pending')) return false;
+ cancelOutboundRequest(peerId);
+ noteJoinRefusal(peerId, result);
+ if (result === 'full') {
+ showToast(label(peerId) + "'s session is full (" + HARD_PEER_CAP + ' people). Try again when someone leaves.', [
+ { label: 'Try again', action: () => requestConnect(peerId) }
+ ]);
+ } else {
+ showToast(label(peerId) + ' declined your connection request.');
+ }
+ return true;
}
/**
@@ -163,6 +222,7 @@ function dial(peerId) {
/** @type {any} */
const peer = get(peers);
if (!peer) return;
+ clearJoinRefusal(); // 25-F: a new request replaces the last answer on the pill
const users = /** @type {any[]} */ (get(userdata));
if (!users.some((/** @type {any} */ u) => u[0] === peerId)) {
users.push([peerId, '', '']);
@@ -172,6 +232,11 @@ function dial(peerId) {
const waiting = /** @type {any[]} */ (get(waitingForApproval));
if (!waiting.some((/** @type {any} */ w) => w[0] === peerId)) waiting.push([peerId, 'pending']);
waitingForApproval.set(/** @type {any} */ (waiting));
+ // 27-E: a request that can hang forever is the worst of the three states a dial can
+ // be in — "no" at least ends. Stamp the shared clock (the pill's countdown reads it)
+ // and arm the expiry.
+ noteApprovalStarted(peerId);
+ armApprovalTimeout(peerId);
} else {
const pend = /** @type {any[]} */ (get(pendingApprovals));
pend.push({ peerId, status: 'retry' });
@@ -179,6 +244,58 @@ function dial(peerId) {
}
}
+/** @type {Map} one expiry timer per outbound request */
+const approvalTimers = new Map();
+
+/**
+ * 27-E: end the wait. The window is the SAME constant the host's card ages against, so
+ * the two sides never disagree about whether a request is still live.
+ * @param {string} peerId
+ */
+function armApprovalTimeout(peerId) {
+ clearApprovalTimeout(peerId);
+ approvalTimers.set(
+ peerId,
+ setTimeout(() => {
+ approvalTimers.delete(peerId);
+ // still pending? (approval clears the row, so this is the only way to be here)
+ const waiting = /** @type {any[]} */ (get(waitingForApproval));
+ if (!waiting.some((/** @type {any} */ w) => w[0] === peerId && w[1] === 'pending')) return;
+ cancelOutboundRequest(peerId);
+ const label = String(peerId).slice(0, 6).toUpperCase();
+ showToast(label + ' did not answer in ' + Math.round(APPROVAL_WINDOW_MS / 1000) + 's.', [
+ { label: 'Try again', action: () => requestConnect(peerId) }
+ ]);
+ }, APPROVAL_WINDOW_MS)
+ );
+}
+
+/** @param {string} peerId */
+export function clearApprovalTimeout(peerId) {
+ const t = approvalTimers.get(peerId);
+ if (t) clearTimeout(t);
+ approvalTimers.delete(peerId);
+ // 27-E: cancelling a TIMER is not ending a REQUEST, so the clock STAYS here.
+ // `armApprovalTimeout` calls this defensively to avoid a duplicate timer, and
+ // clearing the stamp here deleted it one line after `dial` wrote it — so every
+ // outbound request lost its countdown, and the two sides disagreed about the
+ // age of the same request. The paths that really END a request clear it.
+}
+
+/**
+ * 27-E: the peer is not online at all — peerjs says so through `peer-unavailable`. The
+ * pill used to stay on "Requesting" beside a toast saying the opposite, and the whitelist
+ * row we added optimistically at dial time stayed forever. End it now; the caller owns
+ * the message, since only it knows whether this id was ever plausible.
+ * @param {string} peerId
+ */
+export function abandonOutboundRequest(peerId) {
+ const waiting = /** @type {any[]} */ (get(waitingForApproval));
+ if (!waiting.some((/** @type {any} */ w) => w[0] === peerId)) return false;
+ cancelOutboundRequest(peerId);
+ return true;
+}
+
/**
* Cancel OUR pending outbound request (CN, roadmap #14): drop the
* waitingForApproval entry, close + forget the never-opened conn (onConnClose sees
@@ -187,6 +304,8 @@ function dial(peerId) {
* restoreConnection retry loop too (its stale-conn guard). @param {string} peerId
*/
export function cancelOutboundRequest(peerId) {
+ clearApprovalTimeout(peerId); // 27-E: no orphan timer, no stale countdown
+ clearApprovalStarted(peerId); // and the request really is over, so drop the clock
waitingForApproval.set(
get(waitingForApproval).filter((/** @type {any} */ w) => w[0] !== peerId)
);
diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js
index 743be46a..8cafb7b5 100644
--- a/src/lib/peerHandler.svelte.js
+++ b/src/lib/peerHandler.svelte.js
@@ -16,10 +16,21 @@ import { applyMeshGeo } from '$lib/faceEdit';
// materialsHandler, history) are already in this file's subtree.
import { applyUvPaint, applyUvPaintEnd } from '$lib/uvEditor';
import { applySplineEdit } from '$lib/splineTool';
-import { initVoiceChat, attachVoiceToPeer, voicePeerConnected } from '$lib/voiceChat';
+import { initVoiceChat, attachVoiceToPeer, voicePeerConnected, releaseMic } from '$lib/voiceChat';
import { resolvePeerOptions, describePeerServer, peerServerStatus, parseInviteHash, decodeInviteServer, applyInviteServerOverride, inviteServerOverride } from '$lib/peerServer';
-import { sessionHost, markPeerJoined, resetSession } from '$lib/connectionState';
+// 27-B/27-G integration: the RECOVERY story belongs in the copyable bundle, not in a
+// console nobody reads. diagnostics.js is a zero-dependency leaf, so this closes no cycle.
+import { log } from '$lib/diagnostics';
+// 25-F: the join result (peerApproval is store-only, so a static edge closes no cycle)
+import { isRefusal } from '$lib/connectionState';
+import { applyJoinRefusal } from '$lib/peerApproval';
+import { sessionHost, markPeerJoined, resetSession, signalingRetry, noteSignalingRetry, clearSignalingRetry, noteApprovalStarted, clearApprovalStarted, approvalStartedAt, APPROVAL_WINDOW_MS, MAX_PENDING_APPROVALS, HARD_PEER_CAP, roomIsFull } from '$lib/connectionState';
import { canApply, getAuthProvider, dispatchCloudMessage, rolesInfo } from '$lib/cloudHooks';
+// 27-A (audit H1): shape validation + per-peer failure counters. Both are LEAVES, so the
+// 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';
@@ -58,7 +69,10 @@ import { applyRemoteColocation, dropPeerColocation, sendColocationState } from '
import { applyRemoteScenePost, scenePostStates, sendScenePost } from '$lib/scenePost';
// 23-A2: the musical transport (a latest-wins singleton like scenephysics) and the
// peer clock-offset estimate it carries alongside
-import { applyRemoteTransport, transportState, sendTransport, answerClockPing, applyClockPong, startClockSync } from '$lib/musicClock';
+import { applyRemoteTransport, transportState, sendTransport } from '$lib/musicClock';
+// 25-E: the clock round trip is the SESSION's now, not the music line's (sessionClock.js)
+import { answerClockPing, applyClockPong, startClockSync } from '$lib/clockSync';
+import { sessionNow } from '$lib/sessionClock';
import { applyRemoteDeviceNote } from '$lib/audioDevices';
// 23-A4: the patch (cables between device ports), a latest-wins singleton
import { applyRemotePatch, patchState, sendPatch } from '$lib/audioPatch';
@@ -78,7 +92,7 @@ import { applySessionProposal, applySessionAnswer, deferUntilShareChoice, localS
import { applyRemoteGeometry } from '$lib/geometryEdit';
import { applyLightTarget } from '$lib/lightParams';
import { applyObjectFile } from '$lib/animatedImports';
-import { lockedObjects, selectedObject, peerHands, objectsGroup } from '../stores/sceneStore';
+import { lockedObjects, selectedObject, peerHands, objectsGroup, pokeScene } from '../stores/sceneStore';
import { addMessage, peers, userdata, pendingApprovals, waitingForApproval, showToast } from '../stores/appStore';
import { get } from 'svelte/store';
@@ -97,6 +111,10 @@ export function createPeer() {
// remote had already adopted as its send channel. Killing young conns is how
// mesh formation shredded itself above ~5 peers.
const DIAL_GRACE_MS = 10000;
+// 27-F: the SIGNALING schedule — 800ms doubling to a 8s ceiling, forever, +/-25% so a
+// room full of tabs does not return in lockstep. Separate from the per-peer conn
+// backoff below, which is bounded on purpose (a peer really can be gone).
+const RETRY_BACKOFF = { base: 800, cap: 8000, max: Infinity, jitter: 0.25 };
// restoreConnection's own retry cadence (pre-existing 4s) — also used to spot
// a restore dial that is already in flight so parallel calls don't stack.
const RESTORE_RETRY_MS = 4000;
@@ -118,6 +136,43 @@ userdata.subscribe(value => { users = value });
* Pure presence, re-sent continuously, useless to somebody in a different world. */
const STREAM_TYPES = new Set(['camera', 'vrhands']);
+/**
+ * 25-F: what every dial carries. `jr: 1` says this build understands a join RESULT, which
+ * is what lets a host send a refusal without an older joiner mistaking the refusal dial
+ * for an approval (see `JOIN_RESULTS` in connectionState). `result` is set only by the
+ * host's approve dial-back.
+ * @param {string} [result] @returns {{metadata: Record}}
+ */
+function dialOptions(result) {
+ return { metadata: result ? { jr: 1, joinresult: result } : { jr: 1 } };
+}
+
+/** How long a refusal dial may hang before it is closed regardless. The answer is in its
+ * metadata, which the joiner has at its `connection` event — the conn never needs to
+ * open, and a joiner that closes it at once is the normal case. */
+const REFUSAL_DIAL_MS = 15000;
+
+/**
+ * 27-E: keep the pending queue bounded, dropping the EXPIRED first and only then the
+ * oldest still-live request. A missed request is worse than a stale card, so nothing is
+ * dropped while there is room — this only decides who goes when there is not.
+ * @param {any[]} approvals @returns {any[]}
+ */
+function boundApprovals(approvals) {
+ if (approvals.length <= MAX_PENDING_APPROVALS) return approvals;
+ const started = get(approvalStartedAt);
+ const age = (/** @type {any} */ a) => Date.now() - (started[a.peerId] ?? 0);
+ const expired = approvals.filter((a) => age(a) > APPROVAL_WINDOW_MS).sort((a, b) => age(b) - age(a));
+ const live = approvals.filter((a) => age(a) <= APPROVAL_WINDOW_MS).sort((a, b) => age(b) - age(a));
+ const drop = new Set();
+ for (const a of [...expired, ...live]) {
+ if (approvals.length - drop.size <= MAX_PENDING_APPROVALS) break;
+ drop.add(a.peerId);
+ }
+ for (const peerId of drop) clearApprovalStarted(peerId);
+ return approvals.filter((a) => !drop.has(a.peerId));
+}
+
export class PeerConnection {
constructor(id, updateIdFn) {
this.updateIdFn = updateIdFn;
@@ -147,6 +202,11 @@ export class PeerConnection {
* dials (the `hosts` flow) don't request state, and an adopted inbound
* conn requests it only when it stands in for one of these (B5) */
this.wantsStateFrom = new Set();
+ /** 25-F: peers whose NEXT dial is an approval dial-back, so its handshake opens with
+ * `joinresult: approved` @type {Set} */
+ this.approvedDialBacks = new Set();
+ /** 25-F: peers a refusal dial is out to — their `peer-unavailable` is not news @type {Set} */
+ this.refusalDials = new Set();
// CN-3: an invite link can pin the signaling world (#A1B2C~srv=…). Parse it
// HERE, before resolvePeerOptions runs — the peer.on('open') hash flow below
@@ -178,16 +238,25 @@ export class PeerConnection {
this.peer = new Peer(this.myId, options);
};
+ // 27-F (audit H2): THE recreate ritual, in one place. Four callers had their own
+ // copy of destroy -> createPeerForMode -> attachVoiceToPeer -> wire (the public
+ // fallback, the runtime switchServer, the id-collision retry) and the fourth —
+ // a peer whose link CLOSED — did not exist at all, which is why a closed peer
+ // stayed dead: `reconnect()` cannot revive a spent Peer object.
+ const recreatePeer = (/** @type {boolean} */ forcePublic) => {
+ try { this.peer.destroy(); } catch (e) { /* already gone */ }
+ createPeerForMode(!!forcePublic);
+ attachVoiceToPeer(this); // rebind the incoming-call handler to the new peer
+ wire();
+ };
+
// The pinned self-hosted server never opened -> rebuild against the public
// PeerJS cloud and re-wire. Default mode only; custom/public never fall back.
const fallbackToPublic = () => {
this.didFallback = true;
this.canFallback = false;
showToast('Your peer server is unreachable - switching to the public PeerJS server.');
- try { this.peer.destroy(); } catch (e) { /* already gone */ }
- createPeerForMode(true);
- attachVoiceToPeer(this); // rebind the incoming-call handler to the new peer
- wire();
+ recreatePeer(true);
};
// 24-D2: switch the signaling server at RUNTIME — fallbackToPublic generalised.
@@ -218,10 +287,7 @@ export class PeerConnection {
this.idRetries = 0;
this.reconnectAttempts = 0;
this.serverErrorAt = 0;
- try { this.peer.destroy(); } catch (e) { /* already gone */ }
- createPeerForMode(!!ov?.forcePublic);
- attachVoiceToPeer(this);
- wire();
+ recreatePeer(!!ov?.forcePublic);
};
rebuild(target);
const pinned = !!(target && (target.forcePublic || target.custom?.host));
@@ -256,6 +322,9 @@ export class PeerConnection {
this.peer.on('open', (id) => {
console.log(id);
this.hasOpened = true;
+ // 27-F: say it ONCE, and only to somebody who saw it go away.
+ if (get(signalingRetry).retrying) showToast('Reconnected to the peer server.');
+ clearSignalingRetry();
this.reconnectAttempts = 0; // a fresh/re-established server link resets the backoff
if (this.updateIdFn) this.updateIdFn(id);
if (!window.location.hash.slice(1)) return;
@@ -275,27 +344,40 @@ export class PeerConnection {
window.location.hash = '';
});
- this.peer.on('close', function() { console.log('server closed') });
+ // 27-F: a closed Peer is SPENT — `reconnect()` does nothing for it, which is why
+ // this used to be a dead end with only a page reload out of it. Rebuild on the
+ // SAME id (an id is a per-server registration, so the invite link a user copied
+ // a minute ago still works when the link comes back).
+ this.peer.on('close', () => {
+ log('error', 'net', 'signaling server closed');
+ this.reconnectAttempts++;
+ const delay = backoffDelay(this.reconnectAttempts, RETRY_BACKOFF) ?? 8000;
+ if (this.reconnectAttempts === 1) showToast('The peer server closed the link - reconnecting...');
+ noteSignalingRetry(this.reconnectAttempts);
+ setTimeout(() => { if (!this.peer?.open) recreatePeer(this.didFallback); }, delay);
+ });
- // Surface signaling-server problems to the user. Reconnect on a bounded
- // exponential backoff instead of hammering reconnect() immediately (172).
+ // 27-F (audit H2): THE RETRY NEVER GIVES UP. It used to stop after five attempts
+ // (~20 s) and tell the user to reload — but a reload drops every live
+ // DataConnection AND the invite id, while the thing that failed is usually a lid
+ // closing, a phone locking or a wifi hop. What protects the server is the CAPPED
+ // interval (plus jitter, so N tabs dropped by one hop do not return in lockstep);
+ // the attempt COUNT protected nobody. One toast on the way in, a CHIP for the
+ // live state — an unbounded retry that toasts per attempt is spam.
this.reconnectAttempts = 0;
this.peer.on('disconnected', () => {
- console.log('server disconnected');
+ log('warn', 'net', 'signaling server disconnected');
if (this.peer.destroyed) return;
this.reconnectAttempts++;
- const delay = backoffDelay(this.reconnectAttempts, { base: 800, max: 5 });
- if (delay === null) {
- showToast('Could not reach the peer server. Please reload the page.');
- return;
- }
- showToast('Lost connection to the peer server, reconnecting... (attempt ' + this.reconnectAttempts + ')');
+ const delay = backoffDelay(this.reconnectAttempts, RETRY_BACKOFF) ?? 8000;
+ if (this.reconnectAttempts === 1) showToast('Lost the peer server - reconnecting...');
+ noteSignalingRetry(this.reconnectAttempts);
setTimeout(() => {
if (!this.peer.destroyed && this.peer.disconnected) this.peer.reconnect();
}, delay);
});
this.peer.on('error', (err) => {
- console.log('peer error: ' + err.type, err);
+ log('error', 'net', 'peer error', { type: err?.type, error: String(err) });
// Pinned self-hosted server never opened -> retry on the public cloud
// (default mode only; custom/public keep canFallback false).
if (!this.hasOpened && this.canFallback && !this.didFallback &&
@@ -311,17 +393,32 @@ export class PeerConnection {
// never persisted, so nothing is pinned to it before the link opens —
// take a new one instead of making the user reload. Lengthening the id
// was assumed to be a compat break; it isn't, but it also isn't needed.
+ // 27-F: the SAME collision, met on a REBUILD. The branch below only covers the
+ // first open, so a peer rebuilt after a close — while the server still holds the
+ // old registration for a moment — fell through to "please reload", which is the
+ // dead end this phase exists to remove. Wait out the registration and rebuild.
+ if (err.type === 'unavailable-id' && this.hasOpened && this.idRetries < 3) {
+ this.idRetries++;
+ const wait = backoffDelay(this.idRetries, RETRY_BACKOFF) ?? 8000;
+ log('warn', 'net', 'id still held by the old registration — rebuilding', { wait });
+ noteSignalingRetry(this.idRetries);
+ setTimeout(() => { if (!this.peer?.open) recreatePeer(this.didFallback); }, wait);
+ return;
+ }
if (err.type === 'unavailable-id' && !this.hasOpened && this.idRetries < 3) {
this.idRetries++;
this.myId = createPeer();
- console.log('session id collided — retrying as ' + this.myId);
- try { this.peer.destroy(); } catch (e) { /* already gone */ }
- createPeerForMode(this.didFallback);
- attachVoiceToPeer(this); // rebind the incoming-call handler to the new peer
- wire();
+ log('warn', 'net', 'session id collided — retrying', { id: this.myId });
+ recreatePeer(this.didFallback);
return;
}
if (err.type === 'peer-unavailable') {
+ // 27-E: end the request this names. The pill used to sit on "Requesting"
+ // beside this very toast, and the optimistic whitelist row never went away.
+ const id = String(err.message ?? '').match(/[0-9a-z]{3,}/i)?.[0] ?? '';
+ // 25-F: a refusal to somebody who already gave up and left is not news
+ if (id && this.refusalDials.has(id)) return;
+ if (id) import('$lib/peerApproval').then((m) => m.abandonOutboundRequest(id)).catch(() => {});
showToast('Peer is unreachable. Check the ID and ask them to stay online.');
} else if (err.type === 'unavailable-id') {
showToast('Your session ID is already in use. Please reload the page.');
@@ -348,6 +445,18 @@ export class PeerConnection {
window.addEventListener('pagehide', () => {
try { this.broadcast({ type: 'disconnected', peerId: this.peer.id }); } catch (e) { /* going down anyway */ }
});
+ // 27-F: the two events that mean "there is a point in trying NOW" — a wifi hop
+ // ends as `online`, a lid or a phone lock ends as `visible`. Both RESET the
+ // schedule: the wait is there to be kind to a server that is down, not to a
+ // link that has just come back.
+ const retryNow = () => {
+ if (!this.peer || this.peer.open) return;
+ this.reconnectAttempts = 0;
+ if (this.peer.destroyed) recreatePeer(this.didFallback);
+ else if (this.peer.disconnected) this.peer.reconnect();
+ };
+ window.addEventListener('online', retryNow);
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) retryNow(); });
}
// Wire the message dispatcher onto a connection. Historically only INBOUND
@@ -355,14 +464,29 @@ export class PeerConnection {
// peer may send back over OUR outgoing conn, so those wire it too (P-A).
this.wireData = handleData.bind(this);
+ /** @this {any} @param {any} conn */
function handleConnection(conn) {
+ // 25-F: A REFUSAL DIAL. Its metadata IS the answer, so it is read here, before
+ // anything below can treat an incoming conn from the host as the approval it
+ // used to mean. Never whitelisted, never adopted, never wired — closed at once.
+ if (isRefusal(conn?.metadata?.joinresult)) {
+ const ended = applyJoinRefusal(conn.peer, conn.metadata.joinresult);
+ log('info', 'net', 'join refused', { peer: conn.peer, result: conn.metadata.joinresult, ended });
+ try { conn.close(); } catch {}
+ return;
+ }
+
// Update approval status on expected connections
let waiting = get(waitingForApproval);
waiting.forEach(element => {
if(element[0] === conn.peer) {
- // Clear waiting list for approved peers
- waiting = waiting.filter(e => e[1] !== 'approved');
+ // 27-E (audit M10): the row is REMOVED on approval, not mutated in place
+ // with a discarded filter — the old shape grew one dead row per join for
+ // the tab's lifetime, and mutating a store's array in place is how the
+ // next reader gets a value nobody published.
+ clearApprovalStarted(conn.peer);
+ waitingForApproval.set(get(waitingForApproval).filter((/** @type {any} */ w) => w[0] !== conn.peer));
element[1] = 'approved';
// CN: OUR outbound request was approved — that peer is the session
@@ -393,7 +517,14 @@ export class PeerConnection {
if (!found) {
const auth = getAuthProvider();
try {
- if (auth && typeof auth.authorize === 'function' && auth.authorize(conn.peer)) {
+ const authorized = !!auth && typeof auth.authorize === 'function' && auth.authorize(conn.peer);
+ if (authorized && roomIsFull(this) && conn?.metadata?.jr) {
+ // 25-F: a plugin would let them in, but the mesh cannot take one more —
+ // say so rather than auto-approving past the hard cap
+ this.sendJoinResult(conn.peer, 'full');
+ conn.close();
+ return;
+ } else if (authorized) {
found = true;
// AUTO-APPROVE == the manual Approve: whitelist the peer, broadcast the
// roster, and DIAL BACK. The joiner only leaves its "waiting for
@@ -405,7 +536,7 @@ export class PeerConnection {
userdata.set(roster);
}
get(peers).send({ type: 'userdata', userdata: get(userdata) });
- get(peers).connectToPeer(conn.peer, true);
+ this.approveDialBack(conn.peer);
}
} catch (e) {
console.error('cloud auth provider threw:', e);
@@ -415,9 +546,20 @@ export class PeerConnection {
if (!found) {
// If peer is not found, add it to the pending approvals
var approvals = get(pendingApprovals);
- if (!approvals.some(toast => toast.peerId === conn.peer)) {
- approvals.push({ peerId: conn.peer });
- pendingApprovals.set(approvals);
+ // 25-F: remember whether this dial can HEAR a refusal (see denyPeer). A re-dial
+ // refreshes the answer on the card that is already there.
+ const hearsNo = !!conn?.metadata?.jr;
+ const known = approvals.find(toast => toast.peerId === conn.peer);
+ if (known && known.hearsNo !== hearsNo) {
+ pendingApprovals.set(/** @type {any} */ (approvals.map((/** @type {any} */ a) => (a.peerId === conn.peer ? { ...a, hearsNo } : a))));
+ }
+ if (!known) {
+ approvals.push({ peerId: conn.peer, hearsNo });
+ // 27-E: stamp the SAME clock the joiner's countdown uses, so the card's
+ // age and their pill agree; and BOUND the queue — a host who walked away
+ // used to collect a card per dial with nothing dropping them (audit H3).
+ noteApprovalStarted(conn.peer);
+ pendingApprovals.set(boundApprovals(approvals));
}
conn.close();
}
@@ -430,7 +572,7 @@ export class PeerConnection {
conn.on('open', () => {
const existing = this.connections[conn.peer];
if (existing?.open) return; // stable outgoing conn stays preferred
- console.log('adopting inbound connection from ' + conn.peer + ' as the send channel');
+ log('warn', 'net', 'adopting inbound connection as the send channel', { peer: conn.peer });
if (existing) { try { existing.close(); } catch {} }
this.connections[conn.peer] = conn;
conn.on('close', () => this.onConnClose(conn.peer, conn));
@@ -451,7 +593,17 @@ export class PeerConnection {
/** @this {any} @param {any} conn */
function handleData(conn) {
- conn.on('data', (data) => {
+ // 27-A: a conn reports its OWN failures now. eventemitter3 swallows an 'error'
+ // nobody listens for, so a send to a half-open conn and a failed negotiation were
+ // both invisible. This is the one function every creation site already calls —
+ // the four dials and the adopted inbound conn — so one listener pair covers all.
+ conn.on('error', (/** @type {any} */ err) => noteWireError(conn.peer, 'conn-error', err?.type ?? err));
+ conn.on('iceStateChanged', (/** @type {any} */ state) => {
+ if (state === 'failed' || state === 'closed') noteWireError(conn.peer, 'ice-' + state);
+ });
+ // The dispatch chain itself, called from the guarded handler below. Naming it is
+ // what lets a try/catch wrap 440 lines without re-indenting any of them.
+ const dispatch = (/** @type {any} */ data) => {
// M1a (open-core): the ONE receive-side capability gate. Default allows
// everything (byte-identical OSS behavior); a cloud plugin's provider
// drops disallowed message types from a peer (e.g. a viewer's mutations).
@@ -488,6 +640,12 @@ export class PeerConnection {
console.log('Connecting to received hosts');
data.hosts.forEach( id =>
{
+ // 27-E (audit L7): a joiner must not fill the mesh past the cap the
+ // approving side is enforcing, or the room grows by the back door.
+ // Counted off the OPEN connections, never `userdata` — that roster is
+ // the whitelist, written at dial time, so it counts people who were
+ // invited and never arrived.
+ if (roomIsFull(this)) return;
// mesh fill: connect, but DON'T request full state — the scene
// is one shared state and we already pull it from the peer we
// joined. Requesting it from everyone made a joiner download
@@ -513,7 +671,7 @@ export class PeerConnection {
const made = get(objectsGroup)?.getObjectByProperty('uuid', data.uuid);
if (made) {
made.userData = { ...made.userData, ...data.userData };
- objectsGroup.update((value) => value);
+ pokeScene();
}
}
} else if(data.type == 'name') {
@@ -595,9 +753,17 @@ export class PeerConnection {
// the room gate every full-state reply here takes: a peer standing in
// another scene must not be handed this one's tempo
if (sameRoomOrUnknown(conn.peer)) sendTransport(data.sender);
+ } else if(data.type == 'joinresult') {
+ // 25-F: the answer to our join request as a MESSAGE. A refusal normally
+ // arrives as dial metadata and never reaches here; this path is the same
+ // answer from a sender that has an open conn to say it on. 'approved' needs
+ // nothing — the conn it arrived on already approved us (handleConnection).
+ if (isRefusal(data.result)) applyJoinRefusal(conn.peer, data.result);
} else if(data.type == 'clockping') {
// 23-A2: the peer clock-offset round trip. Answered over the stable OUTGOING
- // conn to the sender (golden rule 9), this conn only as the fallback.
+ // conn to the sender (golden rule 9), this conn only as the fallback. 25-E:
+ // the pong now also says which clock WE keep, so a joiner of a joiner
+ // inherits the session's time (clockSync.js).
answerClockPing(data, conn);
} else if(data.type == 'clockpong') {
applyClockPong(data);
@@ -742,7 +908,9 @@ export class PeerConnection {
} else if(data.type == 'color') {
colorObject(data.uuid, data.color, data.near, data.far);
} else if(data.type == 'loading') {
- createLoader(data.count, data.uuids);
+ // 26-B (audit M2): WHO announced it, so their teardown can clear the batch. Local
+ // only — the message is unchanged, so an older peer is unaffected.
+ createLoader(data.count, data.uuids, conn.peer);
} else if(data.type == 'disconnected') {
if (data.peerId === conn.peer) {
// the peer says goodbye ITSELF (leaveSession / tab close): tear
@@ -922,11 +1090,46 @@ export class PeerConnection {
...map,
[data.peerId]: { left: data.left, right: data.right, active: data.active !== false, ts: Date.now() }
}));
- } else if(data.startsWith('/')) {
- sceneCommand(data);
+ } else {
+ // 27-A (audit M11): THE RAW-STRING BRANCH IS GONE. It routed a peer's
+ // string straight into sceneCommand, where '/clear all' wipes the scene
+ // AND re-broadcasts it — a receiver re-broadcasting is golden rule 1
+ // inverted. Nothing sends raw strings (sendMessage runs slash commands
+ // locally), so an unreachable branch was standing armed. What is here now
+ // is the counter that says a peer sent something this build cannot apply,
+ // which is how version skew becomes visible instead of silent.
+ noteWireError(conn.peer, 'unknown:' + data.type);
}
- }
- );
+ };
+
+ conn.on('data', (data) => {
+ // 27-A (audit H1): SHAPE FIRST, before any gate reads `data.type`. A null, a
+ // string or a number used to fall through the whole chain to
+ // `data.startsWith(...)` and throw out of the handler, where peerjs swallowed
+ // it and nothing counted it. canApply stays the first POLICY gate; this is
+ // only "is this a message at all".
+ if (!data || typeof data !== 'object') {
+ 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.
+ if (!validateWireMessage(data)) {
+ noteWireError(conn.peer, 'invalid:' + data.type);
+ return;
+ }
+ try {
+ dispatch(data);
+ } catch (error) {
+ // One bad message must not take this connection's handler down with it.
+ noteWireError(conn.peer, data.type, error);
+ }
+ });
}
}
@@ -950,7 +1153,7 @@ export class PeerConnection {
// scene privately it answers `{scene:'', hash:'', private:true}`, so the very first
// message of a handshake is where the name stops. Reading `myScene()` here (which is
// the SCREEN's answer, name and all) would leak it to every peer that ever connects.
- conn.send({ type: 'atscene', peerId: this.peer.id, ...mySceneWire(), at: Date.now() });
+ conn.send({ type: 'atscene', peerId: this.peer.id, ...mySceneWire(), at: sessionNow() });
}
/**
@@ -1014,6 +1217,11 @@ export class PeerConnection {
// Must only be called once the connection is open — messages sent earlier are dropped by peerjs.
/** @param {any} conn @param {string} peerId @param {boolean} getobjects @param {string} id */
sendHandshake(conn, peerId, getobjects, id) {
+ // 25-F: an approval dial-back SAYS it is one, ahead of everything else (the roadmap's
+ // "first message"). The metadata already carried it; this is the same answer for a
+ // peer that reads messages rather than metadata, and it is idempotent on a joiner
+ // that has already moved on.
+ if (this.approvedDialBacks.delete(peerId)) conn.send({ type: 'joinresult', result: 'approved' });
// a conn opened to them — whatever goodbye they once sent is history
this.gracefulLeft.delete(peerId);
let hosts = [id];
@@ -1027,6 +1235,11 @@ export class PeerConnection {
// A1: WHERE WE ARE, ahead of everything else — the reasoning lives on
// `sendMyScene`, which A2 shares with the arrival re-sync for the same reason.
this.sendMyScene(conn);
+ // 25-E: the clock round trip goes out SECOND — ahead of every full-state request —
+ // so on an ordered conn the host's first pong lands before its content does, and a
+ // grossly wrong joiner clock is corrected before the history it would mis-stamp
+ // arrives (flowRuntime shifts its cutoffs for whatever still lands first).
+ startClockSync(peerId);
// R22 round 35: `locked` is ROOM_SCOPED and this is a DIRECT send, so the broadcast
// gate never sees it — a private peer would hand a stranger the uuids it is holding in
// a scene that stranger cannot see. Our own table is stale while private anyway (every
@@ -1100,9 +1313,44 @@ export class PeerConnection {
if (getobjects && !holdContent) conn.send({type: 'getnodedefs', sender: this.peer.id})
// join them into the voice mesh if our mic is live
voicePeerConnected(peerId);
- // 23-A2: start estimating their clock's offset from ours — here because this is
- // the one place the conn is known to be OPEN (golden rule 2)
- startClockSync(peerId);
+ }
+
+ /**
+ * 25-F: approve and dial back, marking the dial as the approval so the joiner is told
+ * rather than left to infer it. @param {string} peerId
+ */
+ approveDialBack(peerId) {
+ this.approvedDialBacks.add(peerId);
+ this.connectToPeer(peerId, true);
+ }
+
+ /**
+ * 25-F: tell a would-be joiner "no" (or "full"). A short dial whose METADATA is the
+ * answer — delivered through signaling with the offer, so it arrives even where a data
+ * channel could never open — never added to `connections`, never wired, closed by the
+ * joiner at once and by us after REFUSAL_DIAL_MS whatever happens. Callers only send
+ * this to a dial that advertised `jr` (see denyPeer). @param {string} peerId
+ * @param {'denied' | 'full'} result @returns {boolean} whether a dial went out
+ */
+ sendJoinResult(peerId, result) {
+ if (!isRefusal(result) || !this.peer?.open) return false;
+ const conn = this.peer.connect(peerId, dialOptions(result));
+ if (!conn) return false;
+ this.refusalDials.add(peerId);
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ try { conn.close(); } catch {}
+ // keep the quiet-unavailable mark a little longer: the error can trail the close
+ setTimeout(() => this.refusalDials.delete(peerId), 5000);
+ };
+ conn.on?.('close', finish);
+ conn.on?.('error', finish);
+ conn.on?.('open', () => setTimeout(finish, 1000));
+ setTimeout(finish, REFUSAL_DIAL_MS);
+ log('info', 'net', 'join result sent', { peer: peerId, result });
+ return true;
}
connectToPeer(peerId, getobjects = true, id = this.peer.id) {
@@ -1111,11 +1359,13 @@ export class PeerConnection {
if (getobjects) this.wantsStateFrom.add(peerId);
if (!this.connections[peerId]) {
console.log("Connecting to " + peerId);
- const conn = this.peer.connect(peerId);
+ // 25-F: an approval dial-back says so in its metadata; every dial says it can
+ // hear a join result
+ const conn = this.peer.connect(peerId, dialOptions(this.approvedDialBacks.has(peerId) ? 'approved' : undefined));
// peer.connect returns undefined when the signaling link is down
// (disconnected peer) — bail instead of throwing on conn.on below (CN)
if (!conn) {
- console.log('connect to ' + peerId + ' failed: signaling link is down');
+ log('error', 'net', 'connect failed: signaling link is down', { peer: peerId });
showToast('Cannot reach the signaling server - the connection request was not sent.');
return;
}
@@ -1186,7 +1436,7 @@ export class PeerConnection {
// finish its own 4s cycle instead of resetting the negotiation (B5)
const inFlight = this.connections[peerId];
if (inFlight && Date.now() - (inFlight.__dialedAt ?? 0) < RESTORE_RETRY_MS && attempt === 0) return;
- console.log('Restoring connection: ' + peerId + (attempt ? ' (attempt ' + (attempt + 1) + ')' : ''));
+ log('warn', 'net', 'restoring connection', { peer: peerId, attempt: (attempt || 0) + 1 });
// drop the stale never-opened conn FIRST — left in peerjs's per-peer
// bookkeeping it can wedge the fresh negotiation (offer never starts)
const stale = this.connections[peerId];
@@ -1194,16 +1444,16 @@ export class PeerConnection {
try { stale.close(); } catch {}
delete this.connections[peerId];
}
- const conn = this.peer.connect(peerId);
+ const conn = this.peer.connect(peerId, dialOptions());
if (!conn) {
- console.log('restore to ' + peerId + ' failed: signaling link is down');
+ log('error', 'net', 'restore failed: signaling link is down', { peer: peerId });
return;
}
/** @type {any} */ (conn).__dialedAt = Date.now();
this.connections[peerId] = conn;
conn.on('close', () => this.onConnClose(peerId, conn));
conn.on('open', () => {
- console.log('Connection to ' + peerId + ' restored');
+ log('info', 'net', 'connection restored', { peer: peerId });
this.openedPeers.add(peerId);
markPeerJoined(peerId);
peers.update((value) => value);
@@ -1214,7 +1464,7 @@ export class PeerConnection {
// still ours, still never opened -> replace the stale conn and retry
if (this.connections[peerId] !== conn || conn.open) return;
if (attempt >= 4) {
- console.log('restore to ' + peerId + ' gave up after ' + (attempt + 1) + ' attempts');
+ log('error', 'net', 'restore gave up', { peer: peerId, attempts: attempt + 1 });
return;
}
try { conn.close(); } catch {}
@@ -1246,7 +1496,7 @@ export class PeerConnection {
this.finalizeDisconnect(peerId, false);
return;
}
- console.log('connection to ' + peerId + ' dropped without a goodbye - trying to get them back');
+ log('warn', 'net', 'connection dropped without a goodbye — trying to get them back', { peer: peerId });
this.scheduleReconnect(peerId, 1);
}
@@ -1286,13 +1536,13 @@ export class PeerConnection {
// a dial started now would be torn down before it could ever open
const lastCheck = backoffDelay(attempt + 1, { base: 500, max: 5 }) === null;
if (!this.connections[peerId] && !lastCheck && this.peer.id < peerId) {
- const conn = this.peer.connect(peerId);
+ const conn = this.peer.connect(peerId, dialOptions());
if (conn) {
/** @type {any} */ (conn).__dialedAt = Date.now();
this.connections[peerId] = conn;
conn.on('close', () => this.onConnClose(peerId, conn));
conn.on('open', () => {
- console.log('reconnected to ' + peerId);
+ log('info', 'net', 'reconnected', { peer: peerId });
this.reconnecting.delete(peerId);
peers.update((value) => value);
this.sendHandshake(conn, peerId, true, this.peer.id);
@@ -1367,6 +1617,10 @@ export class PeerConnection {
userdata.set(get(userdata).filter(u => u[0] === this.peer.id));
waitingForApproval.set([]);
pendingApprovals.set([]);
+ // 27-H (audit M9): leaving a session must hand the microphone back. Nothing here
+ // touched voice, so the tab's recording indicator stayed on and the device stayed
+ // claimed after you left — for the life of the page.
+ releaseMic();
resetSession();
checkLocks();
peers.update((value) => value);
@@ -1377,6 +1631,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
@@ -1431,7 +1686,7 @@ export class PeerConnection {
try {
conn.send(payload);
} catch (err) {
- console.log('send to ' + peerId + ' failed', err);
+ log('error', 'net', 'send failed', { peer: peerId, error: String(err) });
}
});
}
diff --git a/src/lib/peerScenes.js b/src/lib/peerScenes.js
index b0a06263..88713320 100644
--- a/src/lib/peerScenes.js
+++ b/src/lib/peerScenes.js
@@ -33,6 +33,7 @@
// (currentLevel only) and appStore. Nothing here registers a history kind.
import { writable, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers } from '../stores/appStore';
import { lockedObjects, selectedObject } from '../stores/sceneStore';
import { currentLevel } from './levels';
@@ -121,7 +122,7 @@ function broadcast(where) {
// a monotonic-enough stamp: this is latest-wins per SENDER and only that sender
// ever writes the row, so a plain clock is sufficient and ordering across peers
// is never compared
- at: Date.now()
+ at: sessionNow()
});
}
diff --git a/src/lib/peerServer.js b/src/lib/peerServer.js
index 252a60f9..0ab0b2ba 100644
--- a/src/lib/peerServer.js
+++ b/src/lib/peerServer.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
/**
* Peer signaling-server selection + ICE (STUN/TURN) config.
@@ -154,7 +155,7 @@ function defaults() {
function load() {
if (typeof localStorage === 'undefined') return defaults();
try {
- const raw = localStorage.getItem(LS_KEY);
+ const raw = safeStorage.getItem(LS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return { ...defaults(), ...parsed, custom: { ...defaults().custom, ...(parsed.custom || {}) } };
@@ -170,7 +171,7 @@ export const peerServerConfig = writable(load());
peerServerConfig.subscribe((v) => {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem(LS_KEY, JSON.stringify(v));
+ safeStorage.setItem(LS_KEY, JSON.stringify(v));
} catch {
/* storage full / disabled */
}
diff --git a/src/lib/peerVars.js b/src/lib/peerVars.js
index 697072d8..8960d125 100644
--- a/src/lib/peerVars.js
+++ b/src/lib/peerVars.js
@@ -44,6 +44,7 @@
// `flowRuntime` imports it statically.
import { writable, derived, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers, userdata } from '../stores/appStore';
/** How many names one peer may hold. A leaderboard, not a database — and a bound is
@@ -125,7 +126,7 @@ export function broadcastPeerVars(force = false) {
const peer = get(peers);
const id = peer?.peer?.id;
if (!id) return false;
- sentAt = Math.max(Date.now(), sentAt + 1);
+ sentAt = Math.max(sessionNow(), sentAt + 1);
peer.send({ type: 'peervars', peerId: id, vars: { ...vars }, at: sentAt });
return true;
}
@@ -359,7 +360,7 @@ export function clearPeerVars(announce = true) {
const id = peer?.peer?.id;
if (id) {
sentJson = '{}';
- sentAt = Math.max(Date.now(), sentAt + 1);
+ sentAt = Math.max(sessionNow(), sentAt + 1);
peer.send({ type: 'peervars', peerId: id, vars: {}, at: sentAt });
return;
}
diff --git a/src/lib/physics.js b/src/lib/physics.js
index 002e4cff..9ef86c34 100644
--- a/src/lib/physics.js
+++ b/src/lib/physics.js
@@ -1,7 +1,10 @@
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 { registerMetricSource } from './sceneBudget';
import { writable, get } from 'svelte/store';
import { flowGraphs, allNodes, allEdges, SCENE_GRAPH } from '../stores/flowStore';
-import { objectsGroup, lockedObjects, selectedObject, selectedObjects } from '../stores/sceneStore';
+import { objectsGroup, lockedObjects, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore';
import { peers, showToast, openSceneSection } from '../stores/appStore';
import { recordTransformSet, recordEntry } from './history';
import {
@@ -458,7 +461,7 @@ export function setPhysicsFor(uuid, patch) {
/** @type {any} */
const peer = get(peers);
peer?.send({ type: 'objectParameters', parameter: 'physics', uuid, physics: next });
- objectsGroup.update((v) => v); // collider viz re-syncs from the poke
+ pokeScene(); // collider viz re-syncs from the poke
physicsShapeChanged(uuid); // CL-A A2: live mid-sim collider rebuild
return next;
}
@@ -488,7 +491,7 @@ export function enablePhysicsOnSelection() {
showToast('Select an object first — then Enable physics makes it fall and collide');
return 0;
}
- objectsGroup.update((v) => v);
+ pokeScene();
selectedObject.update((v) => v);
showToast(count === 1 ? 'Physics enabled — dynamic, mass 1' : 'Physics enabled on ' + count + ' objects — dynamic, mass 1');
return count;
@@ -824,6 +827,7 @@ async function startSimulation() {
// ColliderDesc.trimesh (fixed bodies only) and terrain from a heightfield —
// both deferred; every collider today is a cuboid AABB or an opt-in hull.
bodies = [];
+ stepTimes = []; // 26-E: a new run's cost is not the last run's
beforeStates = [];
suspendedForRun = [];
fixedBodies = new Map();
@@ -1236,15 +1240,108 @@ export function applyThrow(data) {
// external kinematic hold and EATS the throw
entry.lastWritten.pos.copy(object.position);
entry.lastWritten.quat.copy(object.quaternion);
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
}
const FIXED_DT = 1 / 60;
const MAX_SUBSTEPS = 8;
+/** @param {number} now */
+// 27-C (audit M7): rapier steps inside a WASM boundary, and a NaN transform off the wire
+// or a poisoned body makes it panic. The throw escaped into flowRuntime's post-tick slot,
+// which logged it 60 times a second forever with the simulation already dead and nothing
+// telling the user. Now a throw stops the run ONCE, says so, and leaves the scene intact.
/** @param {number} now */
function step(now) {
+ try {
+ const started = performance.now();
+ stepInner(now);
+ noteStepMs(performance.now() - started);
+ // 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.
+ try {
+ stopSimulation({ reason: 'error' });
+ } catch (stopError) {
+ // a teardown that also throws must not take the frame loop with it
+ console.warn('stopping after a physics failure also failed', stopError);
+ }
+ showToast('Physics stopped after an error - the scene is intact. Press play to run it again.');
+ }
+}
+
+const slowStepWatch = createStreakWatch({ overMs: PHYSICS_SLOW_MS, count: PHYSICS_SLOW_STEPS });
+
+// 26-E: what a simulation COSTS, for the budget sampler and the stress rig. Roadmap 26
+// section 2 budgets dynamic bodies (<200 desktop) and section 3 names the step time;
+// neither was readable anywhere. Registered, never imported — sceneBudget is a leaf and
+// physics sits in the history family. A step ring rather than the last value, because
+// the question is the same as for frames: the step you FEEL is the slow one.
+const STEP_RING = 120;
+/** @type {number[]} */
+let stepTimes = [];
+/** @param {number} ms */
+function noteStepMs(ms) {
+ stepTimes.push(ms);
+ if (stepTimes.length > STEP_RING) stepTimes.shift();
+}
+/** p95 of the recent steps, or null when no simulation is running (a stale ring from a
+ * run that ended must not read as a live cost). */
+export function physicsStepStats() {
+ if (!world || !stepTimes.length) return null;
+ const sorted = [...stepTimes].sort((a, b) => a - b);
+ const at = (/** @type {number} */ q) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))];
+ return { n: sorted.length, p50: at(0.5), p95: at(0.95), max: sorted[sorted.length - 1] };
+}
+registerMetricSource('bodies', () => (world ? bodies.length : 0));
+registerMetricSource('physicsStepMs', () => {
+ const stats = physicsStepStats();
+ return stats ? Math.round(stats.p95 * 100) / 100 : null;
+});
+
+/** 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() {
+ throwOnNextStep = true;
+}
+
+/** @param {number} now */
+function stepInner(now) {
+ if (throwOnNextStep) {
+ throwOnNextStep = false;
+ throw new Error('forced physics failure (test hook)');
+ }
if (!world) return;
if (get(simPaused)) {
lastStep = now; // don't accumulate a giant timestep across the pause
@@ -1416,7 +1513,7 @@ function step(now) {
}
});
pendingOob.forEach((entry) => handleOutOfBounds(entry, oobActionNow));
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -1503,7 +1600,8 @@ export function pauseSimulation(paused) {
if (peer) peer.send({ type: 'simulate', running: true, paused: next, peerId: peer.peer.id });
}
-/** @param {{reset?: boolean}=} opts reset restores the initial layout (no undo entry) */
+/** @param {{reset?: boolean, reason?: string}=} opts reset restores the initial layout
+ * (no undo entry); 27-C passes a `reason` when a failing step stops the run. */
export function stopSimulation(opts = {}) {
if (!get(simulating)) return;
setPostTick(null); // clear the hook BEFORE freeing the world
@@ -1567,7 +1665,7 @@ export function stopSimulation(opts = {}) {
simPaused.set(false);
if (peer) peer.send({ type: 'simulate', running: false, peerId: peer.peer.id });
if (items.length > 0) showToast('Simulation stopped — Ctrl+Z restores the initial layout');
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** Reset: restore the initial layout and stop (no history entry — net no-op). */
diff --git a/src/lib/ping.js b/src/lib/ping.js
index e196a9fe..b7b94a53 100644
--- a/src/lib/ping.js
+++ b/src/lib/ping.js
@@ -4,6 +4,7 @@ import { peers, username } from '../stores/appStore';
import { objectsGroup } from '../stores/sceneStore';
import { peerColor } from './lockControl';
import { playPing } from './pingAudio';
+import { safeStorage } from './safeStorage';
// Ping a world point (or object) so every peer sees a pulse there for ~4s.
// V2 (87): pings carry the sender's chosen color + chime — everyone renders
@@ -16,14 +17,14 @@ export const pings = writable([]);
// per-user ping preferences (Settings; '' color = automatic peer color)
export const pingColor = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('pingColor') ?? '' : ''
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('pingColor') ?? '' : ''
);
export const pingSound = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('pingSound') ?? 'ding' : 'ding'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('pingSound') ?? 'ding' : 'ding'
);
if (typeof localStorage !== 'undefined') {
- pingColor.subscribe((value) => localStorage.setItem('pingColor', value));
- pingSound.subscribe((value) => localStorage.setItem('pingSound', value));
+ pingColor.subscribe((value) => safeStorage.setItem('pingColor', value));
+ pingSound.subscribe((value) => safeStorage.setItem('pingSound', value));
}
/** @param {any} ping */
diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js
index 236f79ea..001c13b6 100644
--- a/src/lib/playInteract.js
+++ b/src/lib/playInteract.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { isLocked, isVRMode, playPointerFree, objectsGroup, globalScene, lockedObjects } from '../stores/sceneStore';
+import { isLocked, isVRMode, playPointerFree, objectsGroup, globalScene, lockedObjects, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { sceneHits } from './scenePick';
import { topLevelObjectOf } from './objectActions';
@@ -375,7 +375,7 @@ export function tickPlayInteract(delta, camera) {
scale: grab.object.scale.toArray()
});
}
- objectsGroup.update((v) => v);
+ pokeScene();
return;
}
diff --git a/src/lib/prefabs.js b/src/lib/prefabs.js
index 7c318063..a88d0409 100644
--- a/src/lib/prefabs.js
+++ b/src/lib/prefabs.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordObjectPresence, beginHistoryBatch, endHistoryBatch } from './history';
import { patch as audioPatch, addCablesRemapped } from './audioPatch';
@@ -449,7 +449,7 @@ export function instantiatePrefab(prefab, position) {
if (cables.length) beginHistoryBatch();
try {
group.add(object);
- objectsGroup.update((value) => value);
+ pokeScene();
recordObjectPresence('create', object);
/** @type {any} */
const peer = get(peers);
diff --git a/src/lib/projectFile.js b/src/lib/projectFile.js
index a882ff38..7bf8dbbc 100644
--- a/src/lib/projectFile.js
+++ b/src/lib/projectFile.js
@@ -58,6 +58,7 @@ import {
projectName
} from './projectManifest';
import { ensureScenesFolder, currentLevel } from './levels';
+import { safeStorage } from './safeStorage';
/** V4's gating pattern with its own int: a NEWER format ASKS before importing, an
* older or absent one loads silently. `appVersion` beside it is display-only
@@ -511,7 +512,7 @@ export async function exportProjectFromSession(payload) {
* export preference. */
export function projectVersionsEnabled() {
try {
- return localStorage.getItem('tpProjectVersions') !== 'false';
+ return safeStorage.getItem('tpProjectVersions') !== 'false';
} catch {
return true;
}
diff --git a/src/lib/projectManifest.js b/src/lib/projectManifest.js
index 82ae392f..3bd44a22 100644
--- a/src/lib/projectManifest.js
+++ b/src/lib/projectManifest.js
@@ -25,12 +25,14 @@
// bytes serves them.
import { writable, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers, showToast, explorerClose, revealExplorerItem } from '../stores/appStore';
import { bottomDockActive } from './bottomDock';
import { showChoice } from './confirmDialog';
import { sessionHost } from './connectionState';
import { isViewer } from './objectPermissions';
import { idbGet, idbPut } from './idb';
+import { safeStorage } from './safeStorage';
const IDB_KEY = 'project:manifest';
/** versions of ONE scene kept locally beyond the pinned set (fork 4) — the DEFAULT of
@@ -50,7 +52,7 @@ export const keepVersionsSetting = writable(readKeepVersions());
function readKeepVersions() {
try {
- const raw = localStorage.getItem('project:keepVersions');
+ const raw = safeStorage.getItem('project:keepVersions');
if (raw === null) return KEEP_VERSIONS;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : KEEP_VERSIONS;
@@ -61,7 +63,7 @@ function readKeepVersions() {
keepVersionsSetting.subscribe((n) => {
try {
- localStorage.setItem('project:keepVersions', String(n));
+ safeStorage.setItem('project:keepVersions', String(n));
} catch {}
});
@@ -440,7 +442,7 @@ async function persist() {
function commitManifest(next, opts = {}) {
const before = get(projectManifest);
const doc = normalizeManifest(next);
- doc.changedAt = Math.max(Date.now(), (before.changedAt ?? 0) + 1, (opts.above ?? 0) + 1);
+ doc.changedAt = Math.max(sessionNow(), (before.changedAt ?? 0) + 1, (opts.above ?? 0) + 1);
projectManifest.set(doc);
void persist();
if (opts.replicate !== false) {
diff --git a/src/lib/proportional.js b/src/lib/proportional.js
index f7da0758..e427c17f 100644
--- a/src/lib/proportional.js
+++ b/src/lib/proportional.js
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// 19-A P4: PROPORTIONAL EDITING's shared state, split out of meshEdit as a LEAF
// (svelte/store only) so faceEdit can read it too. faceEdit cannot import
@@ -17,11 +18,11 @@ export const proportionalEdit = writable(false);
* @type {import('svelte/store').Writable} */
export const proportionalRadius = writable(
typeof localStorage !== 'undefined'
- ? Math.min(Math.max(parseFloat(localStorage.getItem('proportionalRadius') ?? '') || 1, 0.01), 100)
+ ? Math.min(Math.max(parseFloat(safeStorage.getItem('proportionalRadius') ?? '') || 1, 0.01), 100)
: 1
);
proportionalRadius.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('proportionalRadius', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('proportionalRadius', String(value));
});
/**
diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js
new file mode 100644
index 00000000..f70913cc
--- /dev/null
+++ b/src/lib/qualityGovernor.js
@@ -0,0 +1,242 @@
+import { writable, get } from 'svelte/store';
+import {
+ createGovernor,
+ overridesAt,
+ stepLabelsAt,
+ drawGapFor,
+ FULL_QUALITY,
+ MAX_LEVEL
+} from './qualityGovernorCore';
+import {
+ registerFrameObserver,
+ registerLongTaskObserver,
+ sceneMetrics,
+ isHeavy,
+ qualityBaseline
+} from './sceneBudget';
+import { renderPaused } from './overloadGuard';
+import { sceneBatchOpen } from '../stores/sceneStore';
+import { safeStorage } from './safeStorage';
+
+// 26-D (roadmap 26 section 4, Stage 1) — ADAPTIVE QUALITY: THE WIRING.
+//
+// The RULE is `qualityGovernorCore.js` (pure, unit-tested); this file feeds it the frames
+// sceneBudget's loop already measures, publishes what it decided, and holds the two
+// things a person can say about it (pin it, give it back). Every consumer READS a store —
+// lightParams (shadows), Outline (AO, the post stack, the composer size, the ingest draw
+// gap), Scene (the pixel ratio, the presence send gap), particleRuntime (the particle cap)
+// — so this module imports none of them and stays a leaf beside overloadGuard.
+//
+// NOTHING HERE PERSISTS OR REPLICATES except the one opt-out, `autoQuality`: a level is a
+// fact about this device right now. In particular a reduced shadow setting is NEVER written
+// to `shadowQuality` — that is the user's preference, saved to storage, and a governor that
+// wrote it would leave a person with shadows off forever after one heavy scene.
+//
+// WHAT IT MUST NOT DO: fight 26-G. The freeze streak only counts while the scene is heavy,
+// and turning shadows off halves the draw calls, which would make the scene read as light.
+// So the first step records the size readings as they were (`qualityBaseline`) and 26-G
+// judges against those until the governor is back at full quality. The perf-governor
+// suite proves it: a heavy scene reduced to green calls still pauses on a frozen streak.
+//
+// VR: sceneBudget's loop is window rAF, which does not run inside an immersive session, so
+// the governor sees no frames in a headset and never acts there. The VR thresholds are
+// carried in the core for when it does — owed on a headset.
+
+/** @typedef {import('./qualityGovernorCore').QualityOverrides} QualityOverrides */
+
+/** What every consumer reads. LOCAL, never saved, never sent.
+ * @type {import('svelte/store').Writable} */
+export const qualityOverrides = writable({ ...FULL_QUALITY });
+
+/** The ingest draw gap in ms (0 = draw every frame). Its own store because it changes on a
+ * different clock from a quality level: it lasts exactly as long as one received batch. */
+export const ingestDrawGap = writable(0);
+
+/**
+ * For the chip and the suite.
+ * @type {import('svelte/store').Writable<{level: number, max: number, pinned: boolean, labels: string[], reason: string, at: number, snoozedUntil: number}>}
+ */
+export const qualityState = writable({
+ level: 0,
+ max: MAX_LEVEL,
+ pinned: false,
+ labels: /** @type {string[]} */ ([]),
+ reason: '',
+ at: 0,
+ snoozedUntil: 0
+});
+
+/** The opt-out. LOCAL preference, default ON. */
+export const autoQuality = writable(safeStorage.getItem('autoQuality') !== 'false');
+
+/** A decision is taken at most this often; the frames themselves are noted every frame. */
+const DECIDE_EVERY_MS = 250;
+/** "Restore full quality" means it: no automatic step for this long afterwards. */
+export const RELEASE_SNOOZE_MS = 60000;
+
+const governor = createGovernor();
+let lastDecideAt = 0;
+let wasHidden = false;
+let pinned = false;
+let snoozedUntil = 0;
+let drawGapEngaged = false;
+/** @type {string} */
+let lastReason = '';
+
+function now() {
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
+}
+
+/** @param {number} level @param {string} reason */
+function publish(level, reason) {
+ const at = Date.now();
+ if (level > 0 && !get(qualityBaseline)) {
+ // the FIRST step: remember what the scene cost at full quality (see the header)
+ const m = get(sceneMetrics);
+ qualityBaseline.set({
+ objects: Number(m.objects) || 0,
+ triangles: Number(m.triangles) || 0,
+ calls: Number(m.calls) || 0
+ });
+ }
+ if (level === 0) qualityBaseline.set(null);
+ qualityOverrides.set(overridesAt(level));
+ lastReason = reason;
+ qualityState.set({
+ level,
+ max: MAX_LEVEL,
+ pinned,
+ labels: stepLabelsAt(level),
+ reason,
+ at,
+ snoozedUntil
+ });
+}
+
+/** The profile and heaviness the core needs, read off the last sample. */
+function context() {
+ const metrics = get(sceneMetrics);
+ const profile = metrics?.profile === 'vr' ? 'vr' : 'desktop';
+ return { metrics, profile: /** @type {'desktop'|'vr'} */ (profile), heavy: isHeavy(metrics, profile, get(qualityBaseline)) };
+}
+
+/**
+ * Fed every frame by sceneBudget's loop.
+ * @param {number} ms
+ */
+export function noteFrameForQuality(ms) {
+ const t = now();
+ // a hidden tab is throttled to ~1Hz on purpose and its first frame back spans the
+ // whole absence; neither is the scene being slow (26-G's rule, same reasons)
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
+ wasHidden = true;
+ governor.forget();
+ return;
+ }
+ if (wasHidden) {
+ wasHidden = false;
+ governor.forget();
+ return;
+ }
+ // paused by 26-G: no frames are being drawn, so none of these describe drawing
+ if (get(renderPaused)) {
+ governor.forget();
+ return;
+ }
+ governor.noteFrame(ms, t);
+ if (t - lastDecideAt < DECIDE_EVERY_MS) return;
+ lastDecideAt = t;
+ decideNow(t);
+}
+
+/** One decision, now. Exported for the suite, which drives time it cannot wait out.
+ * @param {number} [t] */
+export function decideNow(t = now()) {
+ const ctx = context();
+ const enabled = get(autoQuality);
+ // "Restore full quality" snoozes CLIMBING for a minute; it never blocks a walk back down
+ const snoozed = Date.now() < snoozedUntil;
+ const d = enabled
+ ? governor.decide(t, { profile: ctx.profile, heavy: ctx.heavy && !snoozed, pinned })
+ : { level: governor.level(), moved: null, reason: 'off', p95: null };
+ if (d.moved) publish(d.level, d.reason);
+
+ // THE INGEST RULE (26-E): while a received batch drains through slow frames, draw at
+ // most four frames a second so the queue gets the main thread back
+ const draining = sceneBatchOpen();
+ const backlog = Number(ctx.metrics?.ingestBacklog) || 0;
+ const gap = enabled ? drawGapFor({ engaged: drawGapEngaged, draining, backlog, p95: d.p95, profile: ctx.profile }) : 0;
+ drawGapEngaged = gap > 0;
+ if (get(ingestDrawGap) !== gap) ingestDrawGap.set(gap);
+ return d;
+}
+
+/** Keep the current level: no walking back up until released. */
+export function pinQuality() {
+ pinned = true;
+ publish(governor.level(), lastReason || 'pinned');
+}
+
+/** Full quality now, and no automatic step for a minute — "give it back" must stick long
+ * enough to see what it looks like. */
+export function releaseQuality() {
+ pinned = false;
+ snoozedUntil = Date.now() + RELEASE_SNOOZE_MS;
+ governor.setLevel(0, now());
+ publish(0, 'released');
+}
+
+/** @param {boolean} on */
+export function setAutoQuality(on) {
+ autoQuality.set(!!on);
+}
+
+// ONE path for the opt-out, whoever flips it (Settings binds the store, the suite calls
+// setAutoQuality): remember it locally, and turning it off gives full quality back NOW
+let autoQualitySeen = false;
+autoQuality.subscribe((on) => {
+ // the first call is the value just read from storage — nothing to write or undo
+ if (!autoQualitySeen) {
+ autoQualitySeen = true;
+ return;
+ }
+ safeStorage.setItem('autoQuality', on ? 'true' : 'false');
+ if (!on) {
+ pinned = false;
+ governor.setLevel(0, now());
+ publish(0, 'turned off');
+ ingestDrawGap.set(0);
+ drawGapEngaged = false;
+ }
+});
+
+/** TEST-ONLY: feed a synthetic frame / long task at an explicit time, and reset. */
+export const governorForTest = {
+ /** @param {number} ms @param {number} t */
+ frame(ms, t) {
+ governor.noteFrame(ms, t);
+ },
+ /** @param {number} t */
+ longTask(t) {
+ governor.noteLongTask(t);
+ },
+ /** @param {number} level */
+ setLevel(level) {
+ governor.setLevel(level, now());
+ publish(governor.level(), 'test');
+ },
+ reset() {
+ pinned = false;
+ snoozedUntil = 0;
+ drawGapEngaged = false;
+ lastDecideAt = 0;
+ governor.setLevel(0, -1e9);
+ governor.forget();
+ ingestDrawGap.set(0);
+ publish(0, 'reset');
+ },
+ level: () => governor.level()
+};
+
+registerFrameObserver(noteFrameForQuality);
+registerLongTaskObserver(() => governor.noteLongTask(now()));
diff --git a/src/lib/qualityGovernorCore.js b/src/lib/qualityGovernorCore.js
new file mode 100644
index 00000000..2450520d
--- /dev/null
+++ b/src/lib/qualityGovernorCore.js
@@ -0,0 +1,265 @@
+// 26-D (roadmap 26 section 4, Stage 1) — THE ADAPTIVE QUALITY GOVERNOR'S DECISION RULE.
+//
+// PURE and import-free: frame times and long-task moments go in, a level comes out. The
+// wiring (which store a level writes, which frame loop feeds it, what the chip says) is
+// `qualityGovernor.js`; this file is only the rule, so every threshold and every hold is
+// provable with no GPU, no browser and no clock (vitest drives it with invented times).
+//
+// WHAT 26-E MEASURED, AND WHAT THE STEP ORDER TAKES FROM IT (Radeon 890M, 1280x720):
+// - a many-object scene is bound by DRAW CALLS — CPU time per call — and the shadow pass
+// draws every mesh a second time: 1,000 boxes = 1,943 calls, 3,000 = 5,323 (p95 50ms).
+// Turning the post stack off moved nothing (shaded: 5,236 calls, p95 49.9).
+// - a dense scene (6M triangles) held 60fps on the same GPU, so resolution is the lever
+// only where FILL is the cost — a weaker GPU, a HiDPI screen.
+// - the biggest freeze was not drawing a scene but RECEIVING one: ingest is frame-bound,
+// 3,000 objects took ~180s to land while the joiner drew them and 5.6s with drawing
+// paused. That is its own rule below (`drawGapFor`), not a quality step.
+// So the order is SHADOWS first (halves the calls where the cost is), then resolution in
+// the roadmap's 0.85 steps, then AO and the rest of the post stack, then particles and the
+// presence stream. The roadmap listed resolution first; the measurement is why it is not.
+//
+// Every step is REVERSIBLE and LOCAL — nothing here writes a preference, a document or a
+// message. A level is a fact about this device right now.
+
+/** @typedef {{dprScale: number, shadowsOff: boolean, aoOff: boolean, postOff: boolean, particlesCapped: boolean, presenceSlow: boolean}} QualityOverrides */
+
+/** @type {QualityOverrides} */
+export const FULL_QUALITY = Object.freeze({
+ dprScale: 1,
+ shadowsOff: false,
+ aoOff: false,
+ postOff: false,
+ particlesCapped: false,
+ presenceSlow: false
+});
+
+/**
+ * The steps, in the order they are taken. Each names only what it changes; a level is the
+ * sum of every step up to it (a later `dprScale` replaces an earlier one).
+ * @type {{key: string, label: string, set: Partial}[]}
+ */
+export const GOVERNOR_STEPS = [
+ { key: 'shadows', label: 'Shadows off', set: { shadowsOff: true } },
+ { key: 'res85', label: 'Resolution 85%', set: { dprScale: 0.85 } },
+ { key: 'res72', label: 'Resolution 72%', set: { dprScale: 0.72 } },
+ { key: 'ao', label: 'Ambient occlusion off', set: { aoOff: true } },
+ { key: 'res61', label: 'Resolution 61%', set: { dprScale: 0.61 } },
+ { key: 'post', label: 'Scene look (post-processing) off', set: { postOff: true } },
+ { key: 'res50', label: 'Resolution 50%', set: { dprScale: 0.5 } },
+ { key: 'particles', label: 'Fewer particles', set: { particlesCapped: true } },
+ { key: 'presence', label: 'Your camera updates to peers halved', set: { presenceSlow: true } }
+];
+
+export const MAX_LEVEL = GOVERNOR_STEPS.length;
+
+/** @param {number} level @returns {QualityOverrides} */
+export function overridesAt(level) {
+ /** @type {QualityOverrides} */
+ const out = { ...FULL_QUALITY };
+ const n = Math.max(0, Math.min(MAX_LEVEL, Math.floor(level) || 0));
+ for (let i = 0; i < n; i++) Object.assign(out, GOVERNOR_STEPS[i].set);
+ return out;
+}
+
+/** The labels of every step in effect at `level`, for the chip's tooltip. @param {number} level */
+export function stepLabelsAt(level) {
+ return GOVERNOR_STEPS.slice(0, Math.max(0, Math.min(MAX_LEVEL, level))).map((s) => s.label);
+}
+
+/**
+ * Thresholds per profile. Desktop: slower than 30fps, and recovery under 20ms. The roadmap
+ * said "p95 > 33ms"; 26-E measured why that cannot be the number — frames are VSYNC-
+ * QUANTISED, so a scene holding a steady 30fps reads 33.3-33.4ms at every percentile, and a
+ * 33ms trigger would call it overloaded and keep stepping to the bottom of the ladder. 35ms
+ * is the first reading that means a 30fps frame was actually missed. VR: 72Hz (13.9ms) and
+ * recovery at 90Hz (11.1ms) — the roadmap's, UNMEASURED: the governor's frame source does not
+ * run inside an XR session yet, so they are carried for when it does (owed on a headset).
+ */
+export const THRESHOLDS = {
+ desktop: { overMs: 35, underMs: 20 },
+ vr: { overMs: 13.9, underMs: 11.1 }
+};
+
+export const TIMING = {
+ /** p95 over this much recent time decides "overloaded" */
+ triggerWindowMs: 2000,
+ /** a step is held at least this long before the next one (the roadmap's 3s) */
+ stepHoldMs: 3000,
+ /** recovery needs this much consecutive good time (the roadmap's 10s) */
+ recoverWindowMs: 10000,
+ /** long tasks counted over this window… */
+ longTaskWindowMs: 5000,
+ /** …more than this many is overloaded, whatever the frames say */
+ longTasksOver: 2,
+ /** a step back UP within this long after a walk back DOWN doubles the next recovery */
+ flapWindowMs: 20000,
+ /** the recovery hold never grows past this */
+ maxRecoverHoldMs: 80000,
+ /** a window must be at least this full before it may decide anything */
+ coverage: 0.75,
+ /** frames this soon after a change are not evidence: the change itself costs a hitch
+ * (turning shadows off recompiles every lit material, a dpr change reallocates the
+ * composer). MEASURED: without this, 3,000 real boxes took a second, needless step
+ * on the recompile frames right after the shadows step. */
+ settleMs: 600
+};
+
+/** Nearest-rank percentile (sceneBudget's rule). @param {number[]} sorted @param {number} q */
+function percentile(sorted, q) {
+ if (!sorted.length) return null;
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))];
+}
+
+/**
+ * One governor. `note*` feed it; `decide` says whether the level moves.
+ *
+ * HOLD, NOT HURRY: a decision is only ever taken on a FULL window, and every level change
+ * throws the window away — the frames that justified the last step describe a scene that
+ * no longer exists. So a step is followed by at least `stepHoldMs` of fresh frames before
+ * the next, and a walk back down by `recoverWindowMs` of them.
+ *
+ * FLAPPING is the failure mode of every automatic quality system (drop, recover, drop…,
+ * each transition itself a visible hitch). A step back up shortly after a walk down
+ * doubles the recovery hold for next time, capped; a step up long after the last walk down
+ * is just a heavier scene, and resets it.
+ * @param {{timing?: Partial}} [opts]
+ */
+export function createGovernor(opts = {}) {
+ const timing = { ...TIMING, ...(opts.timing ?? {}) };
+ /** @type {{at: number, ms: number}[]} */
+ let frames = [];
+ /** @type {number[]} */
+ let longTasks = [];
+ let level = 0;
+ let changedAt = -Infinity;
+ let lastDownAt = -Infinity;
+ let recoverHoldMs = timing.recoverWindowMs;
+ let settleUntil = -Infinity;
+
+ function trim(/** @type {number} */ now) {
+ // keep enough for the LONGEST window any decision reads — the recovery hold grows
+ // when the governor flaps, and a ring trimmed shorter than it could never recover
+ const keep = Math.max(recoverHoldMs, timing.triggerWindowMs);
+ if (frames.length && frames[0].at < now - keep) frames = frames.filter((f) => f.at >= now - keep);
+ if (longTasks.length && longTasks[0] < now - timing.longTaskWindowMs)
+ longTasks = longTasks.filter((t) => t >= now - timing.longTaskWindowMs);
+ }
+
+ /** p95 over the last `windowMs`, or null when the window is not full enough to judge */
+ function p95Over(/** @type {number} */ now, /** @type {number} */ windowMs) {
+ const from = now - windowMs;
+ const inWindow = frames.filter((f) => f.at >= from);
+ if (inWindow.length < 8) return null;
+ // covered: the oldest sample reaches back far enough, and nothing older than the
+ // last change can be in here (the window was emptied then)
+ if (inWindow[0].at - inWindow[0].ms > from + windowMs * (1 - timing.coverage)) return null;
+ return percentile(inWindow.map((f) => f.ms).sort((a, b) => a - b), 0.95);
+ }
+
+ function change(/** @type {number} */ to, /** @type {number} */ now) {
+ if (to < level) lastDownAt = now;
+ else if (to > level)
+ // a step up soon after a walk down is a FLAP: make the next recovery wait longer.
+ // A step up long after one is simply a scene that got heavier: forgive the history
+ recoverHoldMs =
+ now - lastDownAt < timing.flapWindowMs
+ ? Math.min(timing.maxRecoverHoldMs, recoverHoldMs * 2)
+ : timing.recoverWindowMs;
+ level = to;
+ changedAt = now;
+ settleUntil = now + timing.settleMs;
+ frames = [];
+ longTasks = [];
+ }
+
+ return {
+ /** @param {number} ms @param {number} now */
+ noteFrame(ms, now) {
+ if (!Number.isFinite(ms) || ms <= 0) return;
+ if (now < settleUntil) return;
+ frames.push({ at: now, ms });
+ trim(now);
+ },
+ /** @param {number} now */
+ noteLongTask(now) {
+ if (now < settleUntil) return;
+ longTasks.push(now);
+ trim(now);
+ },
+ /** Throw the evidence away — a hidden tab, a pause, a resume. The next decision waits
+ * for a full window of frames that describe the present. */
+ forget() {
+ frames = [];
+ longTasks = [];
+ },
+ /**
+ * @param {number} now
+ * @param {{profile?: 'desktop'|'vr', heavy: boolean, pinned?: boolean}} ctx
+ * @returns {{level: number, moved: 'up'|'down'|null, reason: string, p95: number|null}}
+ */
+ decide(now, ctx) {
+ trim(now);
+ const t = THRESHOLDS[ctx.profile === 'vr' ? 'vr' : 'desktop'];
+ const p95 = p95Over(now, timing.triggerWindowMs);
+ const tasks = longTasks.filter((at) => at >= now - timing.longTaskWindowMs).length;
+ const overloaded = (p95 != null && p95 > t.overMs) || tasks > timing.longTasksOver;
+ const since = now - changedAt;
+
+ // UP: overloaded, the scene is heavy enough that setting quality aside could help,
+ // and the last step has had its hold
+ if (overloaded && ctx.heavy && level < MAX_LEVEL && since >= timing.stepHoldMs) {
+ change(level + 1, now);
+ return { level, moved: 'up', reason: p95 != null && p95 > t.overMs ? 'frames' : 'long tasks', p95 };
+ }
+ if (level > 0 && !ctx.pinned && since >= recoverHoldMs) {
+ // the scene is no longer heavy: a light scene is never governed, so give it back
+ if (!ctx.heavy) {
+ change(level - 1, now);
+ return { level, moved: 'down', reason: 'scene is light', p95 };
+ }
+ const calm = p95Over(now, recoverHoldMs);
+ if (calm != null && calm < t.underMs && tasks === 0) {
+ change(level - 1, now);
+ return { level, moved: 'down', reason: 'recovered', p95: calm };
+ }
+ }
+ return { level, moved: null, reason: overloaded ? 'overloaded' : 'steady', p95 };
+ },
+ /** Set the level directly (the chip's "restore full quality"). @param {number} to @param {number} now */
+ setLevel(to, now) {
+ const next = Math.max(0, Math.min(MAX_LEVEL, Math.floor(to) || 0));
+ if (next !== level) change(next, now);
+ return level;
+ },
+ level: () => level,
+ recoverHoldMs: () => recoverHoldMs
+ };
+}
+
+/**
+ * THE INGEST RULE (26-E's biggest finding). While a received scene is still draining into
+ * this one, a slow frame is not only a slow frame — it is the drain's throughput, because
+ * every object's parse waits for a frame to pass. So while a batch drains AND drawing is
+ * slow, draw at most one frame every `INGEST_DRAW_GAP_MS`: the scene keeps visibly
+ * filling in, and the queue gets the main thread back. Returns the gap to enforce, or 0.
+ *
+ * STICKY FOR THE DRAIN: once throttled, the frames are cheap BECAUSE they are throttled, so
+ * re-judging on them would switch the throttle off, which makes them slow, which switches
+ * it on — a flicker at the period of the frame window. It is engaged once per drain and
+ * released when the drain ends. Not a level: it ends by itself, so it stays off the chip.
+ * @param {{engaged: boolean, draining: boolean, backlog: number, p95: number|null, profile?: 'desktop'|'vr'}} ctx
+ */
+export function drawGapFor(ctx) {
+ if (!ctx.draining) return 0;
+ if (ctx.engaged) return INGEST_DRAW_GAP_MS;
+ if (!(ctx.backlog > INGEST_MIN_BACKLOG)) return 0;
+ const t = THRESHOLDS[ctx.profile === 'vr' ? 'vr' : 'desktop'];
+ // a fast frame costs the drain nothing worth saving — only a slow one is throttled
+ if (ctx.p95 == null || ctx.p95 <= t.underMs) return 0;
+ return INGEST_DRAW_GAP_MS;
+}
+
+/** Below this many parked objects a drain finishes in well under a second anyway. */
+export const INGEST_MIN_BACKLOG = 50;
+/** Four frames a second while a big scene lands: enough to see it arrive. */
+export const INGEST_DRAW_GAP_MS = 250;
diff --git a/src/lib/safeStorage.js b/src/lib/safeStorage.js
new file mode 100644
index 00000000..2d071317
--- /dev/null
+++ b/src/lib/safeStorage.js
@@ -0,0 +1,166 @@
+// 27-H (hardening audit M4) — LOCAL STORAGE THAT CANNOT TAKE A SUBSCRIBER DOWN WITH IT.
+//
+// THE FINDING: ~500 bare `localStorage` calls across ~90 files, and `setItem` THROWS
+// synchronously in Safari private mode and whenever the origin's quota is full. Most of
+// these sit inside `$effect`s and store subscribers, so the throw does not merely fail to
+// persist a setting — it kills that subscriber for the rest of the session, and the UI it
+// drives stops updating. "The theme picker stopped working" is what that looks like from
+// the outside, and nothing in it points at storage.
+//
+// Reading is not safe either, which is less well known: in a sandboxed iframe, and under
+// some enterprise policies, merely TOUCHING `window.localStorage` throws SecurityError —
+// so even `typeof localStorage === 'undefined'` guards, which this codebase has a hundred
+// of, do not cover it. Every access here goes through one try/catch.
+//
+// THE FALLBACK IS PER-KEY, and that is what makes the promise honest. A setting whose
+// write failed is remembered in memory, so it still APPLIES for this session and reads
+// back as what you set; it simply does not survive a reload. That is the degradation a
+// user can live with. A successful write drops the key from memory again, because
+// localStorage is then the truth and a stale shadow would outvote it.
+//
+// A DELIBERATE LEAF: this module imports NOTHING. It is reached from stores, from
+// components, from the diagnostics layer's own neighbours and from modules on every side
+// of the history-cycle family, so any import at all here is a future cycle. It is also
+// what lets the unit layer test it with no browser.
+
+/** keys whose real write failed, or everything when storage is unreachable @type {Map} */
+const memory = new Map();
+/** how many writes have fallen back — read by the diagnostics section and the suite */
+let failures = 0;
+/** @type {string | null} the last failure's name, so a report can say WHICH kind it was */
+let lastError = null;
+
+/**
+ * The backing store, or null when it is unreachable. The property access itself is inside
+ * the try: that is the SecurityError case above, and it is the one every `typeof` guard
+ * in this codebase misses.
+ * @returns {Storage | null}
+ */
+function backing() {
+ try {
+ return typeof localStorage === 'undefined' ? null : localStorage;
+ } catch {
+ return null;
+ }
+}
+
+/** @param {any} error */
+function noteFailure(error) {
+ failures++;
+ lastError = String(error?.name || error || 'unknown');
+}
+
+/**
+ * Read a key. Memory first, because a key is only in memory when its real write FAILED,
+ * and the value you just set is the one you expect to read back.
+ * @param {string} key @returns {string | null}
+ */
+export function getItem(key) {
+ if (memory.has(key)) return /** @type {string} */ (memory.get(key));
+ try {
+ return backing()?.getItem(key) ?? null;
+ } catch (error) {
+ noteFailure(error);
+ return null;
+ }
+}
+
+/**
+ * Write a key. NEVER throws — that is the entire point — and returns whether it reached
+ * real storage, for the rare caller that wants to say so.
+ * @param {string} key @param {any} value @returns {boolean}
+ */
+export function setItem(key, value) {
+ const text = String(value);
+ const store = backing();
+ if (store) {
+ try {
+ store.setItem(key, text);
+ // the real store is the truth again; a leftover shadow would outvote it
+ memory.delete(key);
+ return true;
+ } catch (error) {
+ noteFailure(error);
+ }
+ }
+ memory.set(key, text);
+ return false;
+}
+
+/** @param {string} key */
+export function removeItem(key) {
+ memory.delete(key);
+ try {
+ backing()?.removeItem(key);
+ } catch (error) {
+ noteFailure(error);
+ }
+}
+
+/**
+ * Every stored key, real and fallen-back (the "reset my window layout" sweep needs it).
+ *
+ * Enumerated through `length` + `key(i)` rather than `Object.keys`, which is what the
+ * call site this replaces used: `Object.keys` happens to work on the real `Storage`
+ * exotic object and returns METHOD NAMES on anything that merely implements the
+ * interface, so the standards-defined enumeration is both more correct and the one a
+ * stand-in can satisfy.
+ */
+export function keys() {
+ /** @type {Set} */
+ const out = new Set(memory.keys());
+ try {
+ const store = backing();
+ if (store) for (let i = 0; i < store.length; i++) {
+ const key = store.key(i);
+ if (key != null) out.add(key);
+ }
+ } catch (error) {
+ noteFailure(error);
+ }
+ return [...out];
+}
+
+/** Wipe everything (Settings ▸ Reset settings) */
+export function clear() {
+ memory.clear();
+ try {
+ backing()?.clear();
+ } catch (error) {
+ noteFailure(error);
+ }
+}
+
+/** The spec's short names, for new code. Identical behaviour. */
+export const get = getItem;
+export const set = setItem;
+export const remove = removeItem;
+
+/**
+ * A DROP-IN for the `localStorage` object itself, so the codemod that replaced ~500 call
+ * sites is one identifier per line and nothing else — a rename a reviewer can check by
+ * eye, rather than 500 opportunities to change a semicolon.
+ */
+export const safeStorage = { getItem, setItem, removeItem, clear, keys };
+
+/**
+ * Is persistence working, and what has it cost? The diagnostics bundle asks; so does the
+ * suite. `degraded` is the thing worth reading: it means settings are applying but not
+ * surviving a reload, which is otherwise completely invisible.
+ */
+export function storageDebug() {
+ return {
+ available: !!backing(),
+ degraded: memory.size > 0 || failures > 0,
+ fallbackKeys: memory.size,
+ failures,
+ lastError
+ };
+}
+
+/** TEST SEAM: forget the fallback, so one suite section cannot colour the next. */
+export function debugResetStorage() {
+ memory.clear();
+ failures = 0;
+ lastError = null;
+}
diff --git a/src/lib/saveName.js b/src/lib/saveName.js
index c73bfd73..5bc427e8 100644
--- a/src/lib/saveName.js
+++ b/src/lib/saveName.js
@@ -20,6 +20,7 @@
// delegates to `fileNameBase`.
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
/** What a save is called when nothing else is said: the thing's own name. */
export const DEFAULT_TEMPLATE = '[name]';
@@ -135,7 +136,7 @@ const KEY = 'saveNameTemplate';
function readTemplate() {
try {
- const raw = localStorage.getItem(KEY);
+ const raw = safeStorage.getItem(KEY);
return raw === null ? DEFAULT_TEMPLATE : String(raw);
} catch {
return DEFAULT_TEMPLATE;
@@ -149,7 +150,7 @@ export const saveNameTemplate = writable(readTemplate());
saveNameTemplate.subscribe((value) => {
try {
- localStorage.setItem(KEY, String(value ?? ''));
+ safeStorage.setItem(KEY, String(value ?? ''));
} catch {}
});
diff --git a/src/lib/sceneBudget.js b/src/lib/sceneBudget.js
new file mode 100644
index 00000000..bf2adaea
--- /dev/null
+++ b/src/lib/sceneBudget.js
@@ -0,0 +1,654 @@
+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'
+ },
+ // 26-E MEASURED the two render axes below (tests/e2e/scene-stress.cjs, Radeon 890M
+ // iGPU, 1280x720): they are counted per DISPLAY frame across every render() call now,
+ // which the starting numbers never were — a frame is ~13 calls with the composer, and
+ // the shadow pass draws each mesh again, so both read about TWICE the naive count.
+ // VR/mobile columns are still the starting estimates; they are owed on a headset.
+ {
+ key: 'triangles',
+ label: 'Triangles / frame',
+ unit: '',
+ // 6.0M/frame (15 x 200k-tri models, shadow pass included) held a locked 60fps on
+ // an integrated GPU; the red edge above that is extrapolated, not measured
+ desktop: [4000000, 8000000],
+ 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: '',
+ // measured: 1,943 calls (1,000 boxes) 60fps p95 16.7 · 2,799-3,625 p95 33 ·
+ // 4,446 a steady 30fps · 5,323 p95 50 · 16,342 p95 133. Calls, not triangles, are
+ // what binds a many-object scene: it is CPU time per call
+ desktop: [2000, 4500],
+ 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) };
+ });
+}
+
+/**
+ * 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'
+ };
+}
+
+// --- is the scene heavy? (26-G's gate, 26-D's gate) --------------------------------
+
+/** The scene-size axes only — never frame time itself, which would make the rule
+ * circular: "slow, therefore heavy, therefore act on the slowness". */
+export const HEAVY_AXES = ['objects', 'triangles', 'calls'];
+
+/**
+ * The size readings as they were BEFORE the quality governor (26-D) took anything away,
+ * or null while nothing is reduced. LOCAL.
+ *
+ * WHY IT EXISTS: turning shadows off halves the draw calls (26-E measured the shadow pass
+ * as the second copy of every mesh). A heaviness rule reading the live calls would then
+ * see a lighter scene, so 26-G's freeze streak — which only counts while the scene is
+ * heavy — would stand down BECAUSE the governor helped, and the one scene that most needs
+ * the last-resort pause could no longer get it. The scene did not get lighter; this
+ * device drew less of it.
+ * @type {import('svelte/store').Writable<{objects: number, triangles: number, calls: number} | null>}
+ */
+export const qualityBaseline = writable(null);
+
+/**
+ * Heavy = any size axis at amber or worse. While a baseline stands, each axis reads the
+ * larger of now and then — unless the scene really did shrink (fewer than 70% of the
+ * objects the baseline was taken with), in which case the baseline no longer describes
+ * this scene and is ignored. PURE.
+ * @param {Record} metrics @param {'desktop'|'vr'} profile
+ * @param {{objects: number, triangles: number, calls: number} | null} [baseline]
+ */
+export function isHeavy(metrics, profile, baseline = null) {
+ const valid = !!baseline && Number(metrics?.objects) >= 0.7 * Number(baseline.objects);
+ return HEAVY_AXES.some((key) => {
+ const now = metrics?.[key];
+ const then = valid ? /** @type {any} */ (baseline)[key] : null;
+ const reading = Number.isFinite(then) && (!Number.isFinite(now) || then > now) ? then : now;
+ const tier = tierOf(key, reading, profile);
+ return tier === 'amber' || 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.
+
+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;
+
+/** @type {Set<(ms: number) => void>} */
+const longTaskObservers = new Set();
+
+/**
+ * Hear every long task as it is observed. 26-D's governor is the reader (more than two
+ * in five seconds is "overloaded" whatever the frames say); registered, never imported,
+ * for the same reason as `registerFrameObserver`.
+ * @param {(ms: number) => void} fn @returns {() => void} unregister
+ */
+export function registerLongTaskObserver(fn) {
+ longTaskObservers.add(fn);
+ return () => longTaskObservers.delete(fn);
+}
+
+/** @param {number} ms */
+export function noteLongTask(ms) {
+ const now = Date.now();
+ for (const fn of longTaskObservers) {
+ try {
+ fn(ms);
+ } catch {
+ /* isolated — one bad observer must not end the observation */
+ }
+ }
+ 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 };
+}
+
+// --- per-frame render totals (26-E) -----------------------------------------------
+//
+// THE FINDING the stress rig made on its first run: 1,000 boxes on screen, and the meter
+// read `triangles: 1, calls: 1`. `renderer.info` is AUTO-RESET at the start of every
+// `renderer.render()` call, and a desktop frame is not one call — the EffectComposer
+// renders the scene into a target, then N8AO, then the outline, then a fullscreen
+// triangle to the canvas, each its own `render()`. Whatever reads `info` afterwards sees
+// the LAST pass: one triangle, one call. So the triangle and draw-call budgets could
+// never leave green, and 26-G's `sceneIsHeavy` was really asking about objects alone.
+//
+// The fix counts EVERY `render()` and divides by the display frames the sampler saw.
+// Deliberately NOT `info.autoReset = false`: that changes what `info` means for every
+// other reader (the VR stats plate, the diagnostics section, a test that resets and
+// renders once), and inside a WebXR session `window.requestAnimationFrame` does not run,
+// so nothing would ever reset it again and the plate would count up forever. A wrapper
+// on the instance leaves `info` byte-identical for everyone and works in XR too.
+
+const renderAcc = { calls: 0, triangles: 0, renders: 0 };
+/** Display frames the sampler loop counted since the last sample. */
+let renderFrames = 0;
+
+/**
+ * Wrap this renderer's `render` so each call adds what it drew to the accumulator. Once
+ * per instance (a restored context can hand the store a NEW renderer, which gets its own).
+ * @param {any} renderer
+ */
+export function countRenderCalls(renderer) {
+ if (!renderer || typeof renderer.render !== 'function' || renderer.__budgetRender) return false;
+ const original = renderer.render;
+ renderer.__budgetRender = original;
+ renderer.render = function (/** @type {any[]} */ ...args) {
+ const info = this.info?.render;
+ // with autoReset ON (three's default) render() zeroes the counters itself, so the
+ // base is 0; with it OFF somebody is accumulating on purpose and we take the delta
+ const baseCalls = info && this.info.autoReset === false ? info.calls : 0;
+ const baseTris = info && this.info.autoReset === false ? info.triangles : 0;
+ const result = original.apply(this, args);
+ if (info) {
+ renderAcc.calls += info.calls - baseCalls;
+ renderAcc.triangles += info.triangles - baseTris;
+ renderAcc.renders++;
+ }
+ return result;
+ };
+ return true;
+}
+
+/** Undo `countRenderCalls` — the sampler stopping must leave the renderer as it found it.
+ * @param {any} renderer */
+export function uncountRenderCalls(renderer) {
+ if (!renderer?.__budgetRender) return;
+ renderer.render = renderer.__budgetRender;
+ delete renderer.__budgetRender;
+}
+
+/** @type {{calls: number, triangles: number, rendersPerFrame: number} | null} */
+let lastTotals = null;
+
+/** Per display frame since the last call, then start a new window. A window with no
+ * frame in it (two forced readings back to back, a paused loop) keeps the previous
+ * reading rather than inventing a zero — "nothing measured" is not "nothing drawn". */
+function takeRenderTotals() {
+ const frames = renderFrames;
+ if (frames === 0) return lastTotals;
+ const out = {
+ calls: Math.round(renderAcc.calls / frames),
+ triangles: Math.round(renderAcc.triangles / frames),
+ rendersPerFrame: Math.round((renderAcc.renders / frames) * 10) / 10
+ };
+ lastTotals = out;
+ renderAcc.calls = 0;
+ renderAcc.triangles = 0;
+ renderAcc.renders = 0;
+ renderFrames = 0;
+ return out;
+}
+
+function sample() {
+ /** @type {any} */
+ const renderer = get(globalRenderer);
+ if (running) countRenderCalls(renderer);
+ const info = renderer?.info;
+ const totals = takeRenderTotals();
+ 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,
+ // per DISPLAY frame across every render() call; before the sampler has counted a
+ // frame (a forced reading straight after boot) fall back to the raw last pass
+ triangles: totals ? totals.triangles : (info?.render?.triangles ?? null),
+ calls: totals ? totals.calls : (info?.render?.calls ?? null),
+ rendersPerFrame: totals ? totals.rendersPerFrame : 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);
+}
+
+/** @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) {
+ const ms = now - lastFrameAt;
+ noteFrame(ms);
+ for (const fn of frameObservers) {
+ try {
+ fn(ms);
+ } catch {
+ /* isolated — see registerFrameObserver */
+ }
+ }
+ }
+ lastFrameAt = now;
+ renderFrames++;
+ 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;
+ renderFrames = 0;
+ countRenderCalls(get(globalRenderer));
+ startLongTasks();
+ rafId = requestAnimationFrame(loop);
+}
+
+export function stopSceneMetrics() {
+ running = false;
+ if (rafId != null) cancelAnimationFrame(rafId);
+ rafId = null;
+ stopLongTasks();
+ uncountRenderCalls(get(globalRenderer));
+}
+
+/** 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/src/lib/sceneMusic.js b/src/lib/sceneMusic.js
index f975c799..4f50eac9 100644
--- a/src/lib/sceneMusic.js
+++ b/src/lib/sceneMusic.js
@@ -1,8 +1,10 @@
import { writable, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers } from '../stores/appStore';
import { ensureAudioContext, bus } from './audioEngine';
import { itemByHash, itemBlob } from './explorer';
import { requestAsset, sendAsset } from './assetShare';
+import { safeStorage } from './safeStorage';
// Scene music (M-1): ONE shared background track per scene — a singleton synced
// latest-wins like the environment, so everyone hears the same track at the same
@@ -19,10 +21,10 @@ export const music = writable({ ...DEFAULT });
// per-device overlay (LOCAL, persisted) — your own volume trim + mute
export const musicLocalVolume = writable(
- typeof localStorage !== 'undefined' ? +(localStorage.getItem('musicLocalVolume') ?? '1') : 1
+ typeof localStorage !== 'undefined' ? +(safeStorage.getItem('musicLocalVolume') ?? '1') : 1
);
export const musicMuted = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('musicMuted') === 'true' : false
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('musicMuted') === 'true' : false
);
/** whether the audio context is currently blocked by the browser autoplay policy */
@@ -100,7 +102,7 @@ function startSource(state) {
src.loop = true;
src.connect(gain);
// synced phase: everyone starts inside the same loop cycle
- const offset = ((Date.now() - (state.startedAt || Date.now())) / 1000) % buffer.duration;
+ const offset = ((sessionNow() - (state.startedAt || sessionNow())) / 1000) % buffer.duration;
src.start(0, Math.max(0, offset));
source = src;
startedKey = state.hash + '|' + state.startedAt;
@@ -137,7 +139,7 @@ function reconcile() {
/** Apply a change locally + replicate. @param {any} partial */
export function commitMusic(partial) {
- const state = { ...get(music), ...partial, changedAt: Date.now() };
+ const state = { ...get(music), ...partial, changedAt: sessionNow() };
music.set(state);
reconcile();
/** @type {any} */
@@ -148,13 +150,13 @@ export function commitMusic(partial) {
/** Set (or clear) the shared track by content hash; pushes the bytes to peers.
* @param {string|null} hash @param {string} name */
export function setMusicTrack(hash, name = '') {
- commitMusic({ hash, name, playing: !!hash, startedAt: hash ? Date.now() : 0 });
+ commitMusic({ hash, name, playing: !!hash, startedAt: hash ? sessionNow() : 0 });
if (hash) sendAsset(hash);
}
/** Transport: play (restarts the synced phase) / stop. @param {boolean} playing */
export function setMusicPlaying(playing) {
- commitMusic({ playing, startedAt: playing ? Date.now() : get(music).startedAt });
+ commitMusic({ playing, startedAt: playing ? sessionNow() : get(music).startedAt });
}
/** Shared volume (0..1) — adjusts gain without restarting. @param {number} v */
@@ -209,10 +211,10 @@ export function musicRestore(payload, replicate = false) {
name: payload.name ?? '',
volume: payload.volume ?? 0.8,
playing: !!payload.playing,
- startedAt: payload.playing ? Date.now() : 0,
- changedAt: Date.now()
+ startedAt: payload.playing ? sessionNow() : 0,
+ changedAt: sessionNow()
}
- : { ...DEFAULT, changedAt: Date.now() };
+ : { ...DEFAULT, changedAt: sessionNow() };
music.set(state);
reconcile();
if (!replicate) return;
@@ -235,13 +237,13 @@ export function startSceneMusic() {
started = true;
musicLocalVolume.subscribe((v) => {
try {
- localStorage.setItem('musicLocalVolume', String(v));
+ safeStorage.setItem('musicLocalVolume', String(v));
} catch {}
reconcile();
});
musicMuted.subscribe((v) => {
try {
- localStorage.setItem('musicMuted', String(v));
+ safeStorage.setItem('musicMuted', String(v));
} catch {}
reconcile();
});
@@ -251,7 +253,7 @@ export function startSceneMusic() {
/** test/debug view of the live music chain */
export function musicDebug() {
const state = get(music);
- const offset = buffer && state.startedAt ? ((Date.now() - state.startedAt) / 1000) % buffer.duration : 0;
+ const offset = buffer && state.startedAt ? ((sessionNow() - state.startedAt) / 1000) % buffer.duration : 0;
return {
hash: state.hash,
playing: state.playing,
diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js
index 08b8ebe8..c9a3141e 100644
--- a/src/lib/scenePhysics.js
+++ b/src/lib/scenePhysics.js
@@ -1,4 +1,5 @@
import { writable, derived, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers } from '../stores/appStore';
// CL-A A6 / 21-B B1: scene-wide physics settings. ONE shared object for the
@@ -192,7 +193,7 @@ export function setScenePhysics(partial) {
// previous stamp so the sequence stays strictly increasing
const state = normalizeScenePhysics({
...merged,
- changedAt: Math.max(Date.now(), (current.changedAt ?? 0) + 1)
+ changedAt: Math.max(sessionNow(), (current.changedAt ?? 0) + 1)
});
scenePhysicsState_.set(state);
/** @type {any} */
@@ -253,7 +254,7 @@ export function scenePhysicsRestore(payload, replicate = false) {
// changedAt the save happens to carry (an old file's stamp is in the past).
// Monotonic for the same reason setScenePhysics is: a restore can land in the
// same millisecond as the write before it, and an equal stamp is a coin toss.
- next.changedAt = Math.max(Date.now(), (get(scenePhysicsState_).changedAt ?? 0) + 1);
+ next.changedAt = Math.max(sessionNow(), (get(scenePhysicsState_).changedAt ?? 0) + 1);
scenePhysicsState_.set(next);
if (replicate) {
/** @type {any} */
diff --git a/src/lib/scenePost.js b/src/lib/scenePost.js
index 5a1f77a3..0583233b 100644
--- a/src/lib/scenePost.js
+++ b/src/lib/scenePost.js
@@ -1,4 +1,5 @@
import { writable, derived, get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers } from '../stores/appStore';
import { viewportOverrides } from './viewportOverrides';
// L2: the 'look' history kind. Safe as a static import — history's own subtree is
@@ -415,7 +416,7 @@ function commit(fn, key = POST_SCENE_KEY) {
// MONOTONIC per key (the shaderGraph lesson): a gesture writes several times in one
// millisecond, so a bare Date.now() gives those edits the SAME stamp and a receiver
// guarding with <= drops all but the first.
- next.changedAt = Math.max(Date.now(), (postStackFor(key).changedAt || 0) + 1);
+ next.changedAt = Math.max(sessionNow(), (postStackFor(key).changedAt || 0) + 1);
postStacks.update((map) => ({ ...map, [key]: next }));
if (gesture) return next; // the gesture owns the entry and the broadcast
if (before) recordLookEntry(before, next, key);
@@ -468,7 +469,7 @@ registerHistoryKind('look', (entry, state) => {
applyingHistory = true;
try {
const next = normalizeScenePost(target);
- next.changedAt = Math.max(Date.now(), (postStackFor(key).changedAt || 0) + 1);
+ next.changedAt = Math.max(sessionNow(), (postStackFor(key).changedAt || 0) + 1);
postStacks.update((map) => ({ ...map, [key]: next }));
broadcastScenePost(key);
} finally {
@@ -652,7 +653,7 @@ export function scenePostRestore(payload, replicate = false) {
: { [POST_SCENE_KEY]: payload };
/** @type {Record} */
const next = {};
- let stamp = Date.now();
+ let stamp = sessionNow();
for (const key of Object.keys(source)) {
const doc = normalizeScenePost(source[key]);
// a restore is an authoritative local write, so it must WIN over whatever
diff --git a/src/lib/scriptRuntime.js b/src/lib/scriptRuntime.js
index 2fd8cb37..bd58f691 100644
--- a/src/lib/scriptRuntime.js
+++ b/src/lib/scriptRuntime.js
@@ -1,11 +1,21 @@
import { get } from 'svelte/store';
import { scriptErrors } from '../stores/flowStore';
import { showToast } from '../stores/appStore';
+import { instrument } from './loopGuard';
// Compiles and runs user script code for Script nodes and custom node defs.
// Scripts run on EVERY peer independently — they must be pure functions of
// (object, base, data, time) to stay deterministic. Peers are already trusted
// (connection approval); this is collaborative prototyping, not a sandbox.
+//
+// 27-D (audit C1) adds the two LIVENESS guards that trust does not cover, because a
+// trusted author still writes an infinite loop by ACCIDENT — and this runs on every
+// peer's main thread inside the shared flow tick, so the cost of that accident is
+// everyone's tab, not just the author's:
+// 1. every loop is instrumented (`loopGuard`), so a runaway THROWS instead of hanging
+// 2. a node that merely runs LONG is timed and paused after a sustained run of slow
+// frames — the loop guard cannot see that one, since it returns between frames
+// Neither is a sandbox. They stop a hang, not a hostile script.
/** @type {Map} */
const compiled = new Map();
@@ -15,6 +25,15 @@ function compile(code) {
let entry = compiled.get(code);
if (entry) return entry;
if (compiled.size > 100) compiled.clear(); // stale codes from live editing
+ // Guard the loops BEFORE the code becomes a function. Here rather than at the call
+ // site because this map is keyed by the CODE STRING: each distinct script is
+ // transformed exactly once, and an edit re-instruments it and clears the old badge.
+ const guarded = instrument(code);
+ if ('error' in guarded) {
+ entry = { error: 'Could not guard this script: ' + guarded.error };
+ compiled.set(code, entry);
+ return entry;
+ }
try {
entry = {
fn: new Function(
@@ -23,7 +42,7 @@ function compile(code) {
'data',
'time',
'params',
- '"use strict";\n' + code
+ '"use strict";\n' + guarded.code
)
};
} catch (error) {
@@ -33,6 +52,17 @@ function compile(code) {
return entry;
}
+/** One frame's fair share for ONE node: an eighth of a 60Hz frame, with the rest of the
+ * tick, physics, the renderer and every other node still to run. */
+const SLOW_MS = 8;
+/** Consecutive slow frames before a node is paused — about half a second at 60Hz, long
+ * enough that a GC pause or a tab waking up cannot trip it. */
+const SLOW_FRAMES = 30;
+const PAUSED_BADGE = 'paused: too slow';
+
+/** @type {Map} */
+const budget = new Map();
+
// toast each distinct error once per node (the badge stays until it runs clean)
const toasted = new Map();
@@ -63,8 +93,36 @@ export function runScript(nodeId, code, object, base, data, time) {
reportError(nodeId, entry.error);
return;
}
+ // Per-node time budget. A SUSTAINED run is what matters: one slow frame is a GC pause
+ // or a tab waking up, and pausing a node for that would be its own bug. Keyed by the
+ // CODE as well as the node, so editing the script re-arms it — which is the only way
+ // back, and the one a user reaches for.
+ let b = budget.get(nodeId);
+ if (!b || b.code !== (code || '')) {
+ b = { code: code || '', slow: 0, paused: false };
+ budget.set(nodeId, b);
+ }
+ if (b.paused) {
+ reportError(nodeId, PAUSED_BADGE);
+ return;
+ }
+ const fn = entry.fn;
+ if (!fn) {
+ reportError(nodeId, 'Script could not be compiled');
+ return;
+ }
+ const started = performance.now();
try {
- entry.fn(object, base, data, time, data);
+ fn(object, base, data, time, data);
+ const ms = performance.now() - started;
+ if (ms > SLOW_MS) {
+ b.slow++;
+ if (b.slow >= SLOW_FRAMES) {
+ b.paused = true;
+ reportError(nodeId, PAUSED_BADGE);
+ return;
+ }
+ } else b.slow = 0;
reportError(nodeId, null);
} catch (error) {
reportError(nodeId, String(error));
diff --git a/src/lib/selectionPrefs.js b/src/lib/selectionPrefs.js
index 143b33a8..3e71b379 100644
--- a/src/lib/selectionPrefs.js
+++ b/src/lib/selectionPrefs.js
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// Phase 85: what a DOUBLE-CLICK on an object does, as a LOCAL preference.
//
@@ -26,12 +27,12 @@ const DEFAULT = 'properties';
/** @type {DoubleClickAction} */
const stored =
typeof localStorage !== 'undefined' &&
- DOUBLE_CLICK_ACTIONS.some((a) => a.value === localStorage.getItem(KEY))
- ? /** @type {any} */ (localStorage.getItem(KEY))
+ DOUBLE_CLICK_ACTIONS.some((a) => a.value === safeStorage.getItem(KEY))
+ ? /** @type {any} */ (safeStorage.getItem(KEY))
: DEFAULT;
/** @type {import('svelte/store').Writable} */
export const doubleClickAction = writable(stored);
if (typeof localStorage !== 'undefined')
- doubleClickAction.subscribe((value) => localStorage.setItem(KEY, value));
+ doubleClickAction.subscribe((value) => safeStorage.setItem(KEY, value));
diff --git a/src/lib/sessionClock.js b/src/lib/sessionClock.js
new file mode 100644
index 00000000..b91f0cda
--- /dev/null
+++ b/src/lib/sessionClock.js
@@ -0,0 +1,300 @@
+import { writable, get } from 'svelte/store';
+
+/**
+ * 25-E — ONE CLOCK FOR THE SESSION (audit M8).
+ *
+ * Every stamp that crosses the wire was a `Date.now()` on SOME peer's machine, and every
+ * receiver compared it against its OWN `Date.now()`. Two machines rarely agree: a phone
+ * drifts by seconds, a locked-down laptop by minutes. So a peer whose clock ran 90 s fast
+ * won every latest-wins merge for the next 90 s (its sky, its gravity, its game state
+ * could not be overwritten by anybody else's LATER edit), its flow pulses arrived "from
+ * the future", and every deterministic animation ran 90 s out of phase with the room.
+ *
+ * `sessionNow()` is the answer: the wall clock of the peer whose session we JOINED
+ * (`sessionHost`), estimated NTP-style over the data channel, and our own `Date.now()`
+ * while we host. It is transitive — a joiner that approves somebody else hands on the
+ * clock it adopted, because a pong carries the responder's own session offset — so the
+ * whole mesh keeps ONE time however it was formed. Local-only timing (a debounce, a
+ * toast's life, a retry backoff) stays on `Date.now()`: only a number another machine
+ * will compare needs to be on the session's clock.
+ *
+ * A LEAF on purpose (svelte/store only): flowRuntime, environment, gameState and a dozen
+ * other stamp sites import it, several of them inside the history-cycle family, and
+ * `connectionState` re-exports it so peer code reaches it where it already looks. The
+ * WIRE half — the ping/pong round trip, the connect burst, the skew toast — is
+ * `clockSync.js`, which needs `peers` and may therefore not be imported from here.
+ *
+ * The estimator itself moved here from `musicClock` (23-A2 measured it: noise floor
+ * under 5 ms at true skew 0, convergence within 10 ms on an injected +300 ms). It was
+ * built and then deliberately applied to NOTHING; this module is what applies it.
+ */
+
+// ---- the estimator (moved from musicClock, 23-A2) -----------------------------------
+//
+// NTP's four-stamp round trip, over the data channel the peers already share:
+// t0 we send `clockping` (our clock)
+// t1 they receive it (their clock)
+// t2 they send `clockpong` (their clock)
+// t3 we receive it (our clock)
+// rtt = (t3 - t0) - (t2 - t1)
+// offset = ((t1 - t0) + (t2 - t3)) / 2 their clock minus ours
+// The error of one sample is bounded by the round trip's ASYMMETRY, at most rtt/2.
+
+/** samples kept per peer */
+export const CLOCK_RING = 12;
+
+/** @type {Record} */
+export const clockSamples = {};
+
+/** peerId -> `{offset, rtt, samples}` — offset is THEIR RAW clock minus OURS, in ms.
+ * Local, derived, never replicated (the `peerQuality` precedent).
+ * @type {import('svelte/store').Writable>} */
+export const peerClocks = writable({});
+
+/** @param {number[]} arr */
+function median(arr) {
+ const s = [...arr].sort((a, b) => a - b);
+ const m = Math.floor(s.length / 2);
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
+}
+
+/**
+ * The estimate from a ring of samples: the MEDIAN OFFSET OF THE LOWEST-RTT HALF.
+ *
+ * A sample's error is its round trip's asymmetry, and asymmetry comes from queueing —
+ * a packet that waited (in the network, or on a busy main thread before the handler
+ * ran) is late on ONE leg. The samples with the shortest round trips waited the least,
+ * so NTP's clock filter keeps the minimum-delay sample; taking the median of the best
+ * half keeps that bias-rejection while still outvoting a single odd reading. Pure.
+ * @param {{offsets: number[], rtts: number[]}} ring
+ */
+export function estimateFromSamples(ring) {
+ const n = ring.offsets.length;
+ if (!n) return null;
+ const order = ring.rtts.map((rtt, i) => i).sort((a, b) => ring.rtts[a] - ring.rtts[b]);
+ const best = order.slice(0, Math.max(1, Math.ceil(n / 2)));
+ return {
+ offset: median(best.map((i) => ring.offsets[i])),
+ rtt: median(best.map((i) => ring.rtts[i])),
+ samples: n
+ };
+}
+
+/**
+ * Fold one measurement into a peer's ring and republish the median — and, when that
+ * peer is the one we keep time by, re-decide the session offset. Pure enough to test
+ * without a connection. @param {string} peerId @param {number} offset @param {number} rtt
+ */
+export function recordClockSample(peerId, offset, rtt) {
+ if (!Number.isFinite(offset) || !Number.isFinite(rtt) || rtt < 0) return;
+ const ring = (clockSamples[peerId] ??= { offsets: [], rtts: [] });
+ ring.offsets.push(offset);
+ ring.rtts.push(rtt);
+ while (ring.offsets.length > CLOCK_RING) {
+ ring.offsets.shift();
+ ring.rtts.shift();
+ }
+ const estimate = estimateFromSamples(ring);
+ if (estimate) peerClocks.update((map) => ({ ...map, [peerId]: estimate }));
+ if (peerId === reference) reconsider();
+}
+
+/** The estimated RAW offset of a peer's clock from ours (ms, theirs minus ours), or
+ * null before the first sample lands. @param {string} peerId */
+export function peerClockOffset(peerId) {
+ return get(peerClocks)[peerId]?.offset ?? null;
+}
+
+/**
+ * A stamp taken on `peerId`'s RAW clock, expressed on OURS. Kept for the colocated
+ * music case (musicClock's header); session stamps need no correction at all, which is
+ * the point of `sessionNow`. Unknown peer = unchanged.
+ * @param {string} peerId @param {number} wallMs
+ */
+export function correctRemoteStamp(peerId, wallMs) {
+ const offset = peerClockOffset(peerId);
+ return offset == null ? wallMs : wallMs - offset;
+}
+
+/**
+ * Drop a peer's samples (handleDisconnected — golden rule 3). The SESSION OFFSET is kept
+ * even when the departing peer was our reference: everybody still here keeps time by the
+ * same clock, and snapping back to our own would put every stamp we write from now on
+ * out of step with theirs. Only leaving the session resets it.
+ * @param {string} peerId
+ */
+export function dropPeerClock(peerId) {
+ delete clockSamples[peerId];
+ delete remoteSession[peerId];
+ peerClocks.update((map) => {
+ if (!(peerId in map)) return map;
+ const next = { ...map };
+ delete next[peerId];
+ return next;
+ });
+}
+
+// ---- the session clock ---------------------------------------------------------------
+
+/** Below this, a better estimate is noise and the clock is left alone: every adoption
+ * is a small JUMP in every stamp and every flow `time`, and the estimator's own noise
+ * floor on a real network is several milliseconds. */
+export const ADOPT_THRESHOLD_MS = 50;
+/** A gross skew is corrected on the FIRST sample (storm samples carry ~100 ms of error,
+ * which is nothing against 90 s); a small one waits for the filter to have something to
+ * filter. */
+export const GROSS_SKEW_MS = 1000;
+export const MIN_SAMPLES = 3;
+
+/** ms to add to our `Date.now()` to read the session's clock */
+let offset = 0;
+/** the peer we keep time by — `sessionHost`, null while we host */
+/** @type {string | null} */
+let reference = null;
+/** what each peer's pong said about ITS session clock: `{so, ref}` — `so` is the offset
+ * it adds to its own Date.now, `ref` whose clock that is (the loop guard)
+ * @type {Record} */
+const remoteSession = {};
+/** our own peer id, for the loop guard — handed in by the wire half */
+/** @type {string | null} */
+let myId = null;
+
+/**
+ * What the session clock is doing, for the Statistics/diagnostics surfaces and suites.
+ * @type {import('svelte/store').Writable<{offset: number, reference: string|null, adoptedAt: number, adoptions: number}>}
+ */
+export const sessionClock = writable({ offset: 0, reference: null, adoptedAt: 0, adoptions: 0 });
+
+/**
+ * THE session time, in epoch milliseconds. Use it for every stamp another peer will
+ * compare (a latest-wins `changedAt`, a trigger pulse, a game's `startedAt`) and for every
+ * clock two peers must agree on (the synced flow `time`, the musical transport).
+ * @returns {number}
+ */
+export function sessionNow() {
+ return Date.now() + offset;
+}
+
+/** The current session offset in ms (session minus our raw clock). */
+export function sessionOffset() {
+ return offset;
+}
+
+/** @param {string | null} id */
+export function setClockSelf(id) {
+ myId = id || null;
+}
+
+/**
+ * Keep time by `peerId` (the session host), or by ourselves with null. A NEW reference
+ * with no estimate yet leaves the current offset in place until its first sample lands;
+ * null does NOT reset the offset (see `dropPeerClock`) — `resetSessionClock` does.
+ * @param {string | null} peerId
+ */
+export function setClockReference(peerId) {
+ const next = peerId || null;
+ if (next === reference) return;
+ reference = next;
+ publish();
+ reconsider();
+}
+
+/**
+ * A pong told us about the responder's own session clock. Folded in only when that peer
+ * is our reference. @param {string} peerId @param {any} so @param {any} ref
+ */
+export function noteRemoteSessionClock(peerId, so, ref) {
+ if (typeof so !== 'number' || !Number.isFinite(so)) return; // an older peer: raw clock
+ remoteSession[peerId] = { so, ref: typeof ref === 'string' && ref ? ref : null };
+ if (peerId === reference) reconsider();
+}
+
+/**
+ * The offset the session clock SHOULD have right now, or null when there is no
+ * trustworthy answer yet. Pure over the module's state; exported for the suites.
+ * @returns {number | null}
+ */
+export function targetOffset() {
+ if (!reference) return null;
+ const est = get(peerClocks)[reference];
+ if (!est) return null;
+ const remote = remoteSession[reference];
+ // THE LOOP GUARD: a reference that keeps time by US would hand our own clock back
+ // with its estimation error added, and two peers doing that to each other random-walk
+ // forever. Its RAW clock is still a better answer than nothing, so use that alone.
+ const so = remote && remote.ref !== myId ? remote.so : 0;
+ const target = est.offset + so;
+ if (est.samples < MIN_SAMPLES && Math.abs(target - offset) < GROSS_SKEW_MS) return null;
+ return target;
+}
+
+/** @type {Set<(deltaMs: number) => void>} */
+const jumpListeners = new Set();
+
+/**
+ * Be told when the session clock JUMPS, with the jump in ms (new minus old).
+ *
+ * Anything that recorded a session time as a LOCAL cutoff needs this. The case that forced
+ * it: a joiner's handshake lands the trigger log and the graph BEFORE the first pong, so
+ * flowRuntime records its history epoch and every action node's first-seen time on the
+ * joiner's OWN clock — and when that clock is then corrected by -90 s, every live pulse
+ * reads as 90 s older than the node that would act on it and is refused for a minute and
+ * a half. Shifting the cutoffs by the jump keeps them meaning what they meant.
+ * @param {(deltaMs: number) => void} fn @returns {() => void}
+ */
+export function onSessionClockJump(fn) {
+ jumpListeners.add(fn);
+ return () => jumpListeners.delete(fn);
+}
+
+/** @param {number} next */
+function jumpTo(next) {
+ const delta = next - offset;
+ offset = next;
+ const s = get(sessionClock);
+ sessionClock.set({ offset, reference, adoptedAt: Date.now(), adoptions: s.adoptions + 1 });
+ for (const fn of jumpListeners) {
+ try {
+ fn(delta);
+ } catch (error) {
+ console.warn('[sessionClock] a jump listener threw', error);
+ }
+ }
+}
+
+function reconsider() {
+ const target = targetOffset();
+ if (target == null) return;
+ if (Math.abs(target - offset) < ADOPT_THRESHOLD_MS) return;
+ jumpTo(Math.round(target));
+}
+
+function publish() {
+ const s = get(sessionClock);
+ if (s.reference === reference && s.offset === offset) return;
+ sessionClock.set({ ...s, offset, reference });
+}
+
+/** Leaving the session: our own clock is the only one left. Samples are per-peer and
+ * are dropped by their own teardown, so this only resets the session half. */
+export function resetSessionClock() {
+ reference = null;
+ for (const k of Object.keys(remoteSession)) delete remoteSession[k];
+ if (offset !== 0) jumpTo(0);
+ sessionClock.set({ ...get(sessionClock), offset: 0, reference: null, adoptedAt: 0 });
+}
+
+/** Everything a suite wants in one read. */
+export function sessionClockDebug() {
+ return {
+ now: sessionNow(),
+ offset,
+ reference,
+ myId,
+ target: targetOffset(),
+ remote: JSON.parse(JSON.stringify(remoteSession)),
+ peers: JSON.parse(JSON.stringify(get(peerClocks))),
+ samples: JSON.parse(JSON.stringify(clockSamples)),
+ state: get(sessionClock)
+ };
+}
diff --git a/src/lib/sessions.js b/src/lib/sessions.js
index dc237f59..4dfc1f4c 100644
--- a/src/lib/sessions.js
+++ b/src/lib/sessions.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls } from '../stores/sceneStore';
+import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls, pokeScene } from '../stores/sceneStore';
import { restoreGraphs, clearGraphs, SCENE_GRAPH, allNodes } from '../stores/flowStore';
import { serializeGraphs, copyGraphFrom } from './flowGraphs';
import { serializeNode, serializeEdge, sendNodes } from './nodesHandler';
@@ -1206,7 +1206,7 @@ export function importObjects(payload, indices) {
if (peer) peer.send({ type: 'object', element: object.toJSON() });
added++;
}
- objectsGroup.update((value) => value);
+ pokeScene();
carryObjectDocuments(payload, uuidMap);
showToast('Imported ' + added + ' object' + (added === 1 ? '' : 's') + ' from the session');
return added;
@@ -1321,7 +1321,7 @@ export async function applySession(payload, opts = {}) {
group.add(object); // keep original uuids — every peer converges on them
if (replicate && peer) peer.send({ type: 'object', element });
}
- objectsGroup.update((value) => value);
+ pokeScene();
// animated imports come back from their original bytes (mixers rebuilt, peers
// reparse the same file) and authored tracks from the payload
await animatedImportsRestore(payload.animated ?? [], replicate);
@@ -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 ?? {});
@@ -1713,7 +1776,7 @@ function sweepGateWork() {
if (controls?.object?.uuid === uuid) controls.detach();
object.parent?.remove(object);
}
- objectsGroup.update((value) => value);
+ pokeScene();
clearGraphs(); // H1: a cleared scene empties every graph document
}
diff --git a/src/lib/shaderGraph.js b/src/lib/shaderGraph.js
index 59c63e17..714f0105 100644
--- a/src/lib/shaderGraph.js
+++ b/src/lib/shaderGraph.js
@@ -14,7 +14,8 @@
// tracks the scene's light set, which ShaderFrog silently does not).
import { writable, get } from 'svelte/store';
-import { objectsGroup, globalScene, globalCamera, globalRenderer } from '../stores/sceneStore.js';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
+import { objectsGroup, globalScene, globalCamera, globalRenderer, pokeScene } from '../stores/sceneStore.js';
import { compileShaderGraphToIR } from './shaderCompile.js';
import { compileShaderGraph, INJECT_SHADER_BACKEND, forgetShaderContext } from './shaderBackends.js';
import {
@@ -166,7 +167,7 @@ export function setShaderGraphFor(key, patch, opts = {}) {
// millisecond, and with a bare Date.now() those edits share a stamp — the
// receiver's latest-wins guard then drops every one after the first, so a
// drag (and the undo that follows it) silently failed to replicate.
- changedAt: opts.stamp ?? Math.max(Date.now(), (all[key]?.changedAt ?? 0) + 1)
+ changedAt: opts.stamp ?? Math.max(sessionNow(), (all[key]?.changedAt ?? 0) + 1)
});
next[key] = after;
}
@@ -381,7 +382,7 @@ export function stopReconcile() {
/** Wall clock wrapped daily to keep float precision. @returns {number} */
export function shaderClockNow() {
- return (Date.now() % 86400000) / 1000;
+ return (sessionNow() % 86400000) / 1000;
}
/** @type {number|null} */
@@ -454,7 +455,7 @@ function applyMaterial(object, material) {
// Inspector's `material` derived and its shader-driven notice both read through
// `objectsGroup`, and without the poke they keep showing the pre-shader state. Safe
// from the reconcile's own subscriber because a compile always runs off a timer.
- objectsGroup.update((v) => v);
+ pokeScene();
}
// ---- texture uniforms ------------------------------------------------------------
@@ -519,7 +520,7 @@ export function detachFrom(object) {
if (mine && mine !== base && typeof mine.dispose === 'function') mine.dispose();
// and poke, for the same reason the install does — otherwise the Inspector keeps
// offering Detach for an object that is no longer shader-driven
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Is this object currently shader-driven? @param {string} uuid */
@@ -705,7 +706,7 @@ export function shaderGraphsRestore(map, replace = false) {
for (const [key, doc] of Object.entries(map)) {
if (!doc) continue;
// silent: a restore is not an undo step and must not re-broadcast
- setShaderGraphFor(key, normalizeShaderGraph(doc), { silent: true, stamp: Date.now() });
+ setShaderGraphFor(key, normalizeShaderGraph(doc), { silent: true, stamp: sessionNow() });
}
reconcileShaderGraphs();
}
diff --git a/src/lib/shaderSync.js b/src/lib/shaderSync.js
index 5294f660..ace4875c 100644
--- a/src/lib/shaderSync.js
+++ b/src/lib/shaderSync.js
@@ -11,6 +11,7 @@
// re-broadcasts (golden rule 1); a late joiner pulls the whole map (golden rule 3).
import { get } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers } from '../stores/appStore';
import { registerHistoryKind, recordEntry } from './history';
import {
@@ -37,7 +38,7 @@ function broadcast(key, doc) {
const peer = get(peers);
if (!peer) return;
if (doc) peer.send({ type: 'shadergraph', key, doc: wireDoc(doc) });
- else peer.send({ type: 'shadergraphdelete', key, changedAt: Date.now() });
+ else peer.send({ type: 'shadergraphdelete', key, changedAt: sessionNow() });
}
/**
diff --git a/src/lib/sharedLibrary.js b/src/lib/sharedLibrary.js
index 788a844e..76039524 100644
--- a/src/lib/sharedLibrary.js
+++ b/src/lib/sharedLibrary.js
@@ -83,6 +83,7 @@
// `publishSharedIndex`, which refuses for a viewer.
import { get, writable } from 'svelte/store';
+import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
import { peers, userdata, showToast } from '../stores/appStore';
import {
explorerFolders,
@@ -130,6 +131,7 @@ import { transfers, removeTransfer } from './transferLedger';
// R22 round 33: automatic downloads WAIT while the joiner is being asked what to do with
// its own scene. A store-only leaf, so this edge closes nothing.
import { pendingConnectDecision } from './connectionState';
+import { safeStorage } from './safeStorage';
/**
* Hashes we have ASKED the mesh for and not yet received. A remote card with nothing to
@@ -247,7 +249,7 @@ export function pullSharedItem(hash) {
function projection() {
const doc = get(projectManifest);
const owner = meAsOwner();
- const now = Date.now();
+ const now = sessionNow();
/**
* `at` MUST BE STABLE FOR AN UNCHANGED ROW, or `publishSharedIndex`'s content compare
@@ -460,7 +462,7 @@ export const unshareAuthority = writable(readAuthority());
function readAuthority() {
try {
- return localStorage.getItem('shared:unshareAuthority') === 'owner' ? 'owner' : 'anyone';
+ return safeStorage.getItem('shared:unshareAuthority') === 'owner' ? 'owner' : 'anyone';
} catch {
return 'anyone';
}
@@ -468,7 +470,7 @@ function readAuthority() {
unshareAuthority.subscribe((v) => {
try {
- localStorage.setItem('shared:unshareAuthority', v);
+ safeStorage.setItem('shared:unshareAuthority', v);
} catch {}
});
@@ -507,9 +509,9 @@ export const shareNewFiles = writable(readShareNewFiles());
* was "do not publish everything", never "do not ask me". */
function readShareNewFiles() {
try {
- const raw = localStorage.getItem('shared:shareNewFiles');
+ const raw = safeStorage.getItem('shared:shareNewFiles');
if (raw === 'ask' || raw === 'always' || raw === 'never') return raw;
- return localStorage.getItem('shared:autoShareAll') === 'true' ? 'always' : 'ask';
+ return safeStorage.getItem('shared:autoShareAll') === 'true' ? 'always' : 'ask';
} catch {
return 'ask';
}
@@ -527,7 +529,7 @@ export const autoDownload = writable(readFlag('shared:autoDownload', true));
/** @param {string} key @param {boolean} fallback */
function readFlag(key, fallback) {
try {
- const raw = localStorage.getItem(key);
+ const raw = safeStorage.getItem(key);
return raw === null ? fallback : raw === 'true';
} catch {
return fallback;
@@ -536,12 +538,12 @@ function readFlag(key, fallback) {
shareNewFiles.subscribe((v) => {
try {
- localStorage.setItem('shared:shareNewFiles', v);
+ safeStorage.setItem('shared:shareNewFiles', v);
} catch {}
});
autoDownload.subscribe((v) => {
try {
- localStorage.setItem('shared:autoDownload', String(v));
+ safeStorage.setItem('shared:autoDownload', String(v));
} catch {}
});
@@ -555,7 +557,7 @@ export const deleteWithoutConfirm = writable(readFlag('shared:deleteNoConfirm',
deleteWithoutConfirm.subscribe((v) => {
try {
- localStorage.setItem('shared:deleteNoConfirm', String(v));
+ safeStorage.setItem('shared:deleteNoConfirm', String(v));
} catch {}
});
@@ -576,12 +578,12 @@ export const keepRecycleBin = writable(readFlag('shared:keepRecycleBin', false))
recycleBinEnabled.subscribe((v) => {
try {
- localStorage.setItem('shared:recycleBin', String(v));
+ safeStorage.setItem('shared:recycleBin', String(v));
} catch {}
});
keepRecycleBin.subscribe((v) => {
try {
- localStorage.setItem('shared:keepRecycleBin', String(v));
+ safeStorage.setItem('shared:keepRecycleBin', String(v));
} catch {}
});
@@ -617,7 +619,7 @@ export const deletedLogEnabled = writable(readFlag('shared:deletedLog', true));
deletedLogEnabled.subscribe((v) => {
try {
- localStorage.setItem('shared:deletedLog', String(v));
+ safeStorage.setItem('shared:deletedLog', String(v));
} catch {}
});
@@ -654,7 +656,7 @@ function tomb(keys) {
const doc = get(projectManifest);
/** @type {any} */
const prev = doc.removed ?? {};
- const at = Date.now();
+ const at = sessionNow();
/** @type {any} */
const next = { items: { ...(prev.items ?? {}) }, folders: { ...(prev.folders ?? {}) } };
for (const hash of keys.items ?? []) next.items[hash] = at;
@@ -1045,7 +1047,7 @@ export function logLocalDeletion(spec) {
hash,
name: String(spec.name ?? hash),
kind: String(spec.kind ?? 'text'),
- at: Date.now(),
+ at: sessionNow(),
by: meAsOwner(),
localOnly: true,
...(spec.folderId === undefined ? {} : { folderId: spec.folderId ?? null }),
@@ -1168,7 +1170,7 @@ export function deleteItemsToBin(ids) {
const keepRow = get(recycleBinEnabled) || get(deletedLogEnabled);
const log = [...(doc.deleted ?? [])];
const tombs = tombsOf(doc);
- const at = Date.now();
+ const at = sessionNow();
const by = meAsOwner();
/** @type {Set} */
const gone = new Set();
@@ -1206,7 +1208,7 @@ export function deleteFolderToBin(id) {
const keepRow = get(recycleBinEnabled) || get(deletedLogEnabled);
const log = [...(doc.deleted ?? [])];
const tombs = tombsOf(doc);
- const at = Date.now();
+ const at = sessionNow();
const by = meAsOwner();
// THE ITEMS FIRST, while the folder records still exist: `folderPath` reads the live
// tree, so a row written after the removal would carry an empty path — and the path is
@@ -2370,7 +2372,7 @@ const appliedDeletes = new Set(readApplied());
function readApplied() {
try {
- return JSON.parse(localStorage.getItem('shared:appliedDeletes') ?? '[]');
+ return JSON.parse(safeStorage.getItem('shared:appliedDeletes') ?? '[]');
} catch {
return [];
}
@@ -2381,7 +2383,7 @@ function noteApplied(hash) {
appliedDeletes.add(hash);
try {
// bounded: the log itself is capped at 200, so this cannot outgrow it by much
- localStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes].slice(-400)));
+ safeStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes].slice(-400)));
} catch {}
}
@@ -2389,7 +2391,7 @@ function noteApplied(hash) {
function forgetApplied(hash) {
if (!appliedDeletes.delete(hash)) return;
try {
- localStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes]));
+ safeStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes]));
} catch {}
}
diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js
index 27b77f2d..d871b61b 100644
--- a/src/lib/shortcuts.js
+++ b/src/lib/shortcuts.js
@@ -38,6 +38,7 @@ import { togglePanel, toggleDock } from './panelToggles';
// SSR prerender.
import { requestPlay } from './playMode';
import { selectedObject } from '../stores/sceneStore';
+import { safeStorage } from './safeStorage';
// Single source of truth for keyboard shortcuts: the same registry binds the keys
// and renders the list in Settings -> Shortcuts. Other modules push entries via
@@ -472,8 +473,8 @@ export const shortcuts = [
// A3: the SimControls HUD is off by default; P still works, but the first
// time it's used while the HUD is hidden, point users at the setting so the
// transport (pause/stop/reset) is discoverable.
- if (!get(showSimControls) && typeof localStorage !== 'undefined' && !localStorage.getItem('simHudHintSeen')) {
- localStorage.setItem('simHudHintSeen', '1');
+ if (!get(showSimControls) && typeof localStorage !== 'undefined' && !safeStorage.getItem('simHudHintSeen')) {
+ safeStorage.setItem('simHudHintSeen', '1');
showToast('Simulation controls are hidden — enable them in Settings → Scene to show the pause/stop/reset buttons.', [
{
label: 'Open Settings',
@@ -576,7 +577,7 @@ let overrides = {};
function loadOverrides() {
try {
if (typeof localStorage === 'undefined') return {};
- const raw = localStorage.getItem(OVERRIDES_KEY);
+ const raw = safeStorage.getItem(OVERRIDES_KEY);
const parsed = raw ? JSON.parse(raw) : null;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
/** @type {Record} */
@@ -591,9 +592,9 @@ function loadOverrides() {
function saveOverrides() {
try {
if (typeof localStorage === 'undefined') return;
- if (Object.keys(overrides).length) localStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides));
+ if (Object.keys(overrides).length) safeStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides));
// an empty map is the DEFAULT state, so remove the key rather than store `{}`
- else localStorage.removeItem(OVERRIDES_KEY);
+ else safeStorage.removeItem(OVERRIDES_KEY);
} catch {
/* private mode: the rebind still applies for this session */
}
diff --git a/src/lib/snapping.js b/src/lib/snapping.js
index 357f5f9d..0ff114a2 100644
--- a/src/lib/snapping.js
+++ b/src/lib/snapping.js
@@ -1,18 +1,19 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
import { TControls } from '../stores/sceneStore';
+import { safeStorage } from './safeStorage';
// Grid snapping for the transform gizmo: translate, rotate AND scale.
// Persisted in localStorage. "Snap to surface" is a future improvement.
-const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('snapSettings') : null;
+const stored = typeof localStorage !== 'undefined' ? safeStorage.getItem('snapSettings') : null;
export const snapEnabled = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('snapEnabled') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('snapEnabled') === 'true'
);
// translate drags keep the object resting on whatever is underneath it
export const surfaceSnap = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('surfaceSnap') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('surfaceSnap') === 'true'
);
/** @type {import('svelte/store').Writable<{translate: number, rotateDeg: number, scale: number}>} */
export const snapSettings = writable(stored ? JSON.parse(stored) : { translate: 0.5, rotateDeg: 15, scale: 0.1 });
@@ -40,14 +41,14 @@ export function startSnapping() {
started = true;
TControls.subscribe(apply);
snapEnabled.subscribe((value) => {
- localStorage.setItem('snapEnabled', String(value));
+ safeStorage.setItem('snapEnabled', String(value));
apply();
});
surfaceSnap.subscribe((value) => {
- localStorage.setItem('surfaceSnap', String(value));
+ safeStorage.setItem('surfaceSnap', String(value));
});
snapSettings.subscribe((value) => {
- localStorage.setItem('snapSettings', JSON.stringify(value));
+ safeStorage.setItem('snapSettings', JSON.stringify(value));
apply();
});
}
@@ -74,7 +75,7 @@ export const DEFAULT_SNAP_TARGETS = {
function loadSnapTargets() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('snapTargets') : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem('snapTargets') : null;
// unknown/missing keys fall back to defaults, so old payloads keep working
return { ...DEFAULT_SNAP_TARGETS, ...(raw ? JSON.parse(raw) : {}) };
} catch {
@@ -86,7 +87,7 @@ function loadSnapTargets() {
export const snapTargets = writable(loadSnapTargets());
snapTargets.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('snapTargets', JSON.stringify(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('snapTargets', JSON.stringify(value));
});
const DOWN = new THREE.Vector3(0, -1, 0);
diff --git a/src/lib/splineTool.js b/src/lib/splineTool.js
index af4d3ff6..eea73f24 100644
--- a/src/lib/splineTool.js
+++ b/src/lib/splineTool.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { globalScene, objectsGroup, selectedObject } from '../stores/sceneStore';
+import { globalScene, objectsGroup, selectedObject, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordObjectPresence, recordEntry, registerHistoryKind } from './history';
import { drawMode, drawTool, drawColor, drawSize } from './drawMode';
@@ -245,7 +245,7 @@ export function finishSpline() {
const mesh = createSplineMesh(spline, center);
if (!mesh) return null;
group.add(mesh);
- objectsGroup.update((value) => value);
+ pokeScene();
recordObjectPresence('create', mesh);
/** @type {any} */
const peer = get(peers);
@@ -291,7 +291,7 @@ export function applySplineEdit(uuid, spline) {
object.geometry = geometry;
object.userData.spline = data;
if (object.material && !Array.isArray(object.material)) object.material.color?.set?.(data.color);
- objectsGroup.update((value) => value);
+ pokeScene();
selectedObject.update((value) => value); // keep the Spline inspector rows live
fireRefresh(uuid);
return true;
diff --git a/src/lib/storageUsage.js b/src/lib/storageUsage.js
index c62751df..99fb03ac 100644
--- a/src/lib/storageUsage.js
+++ b/src/lib/storageUsage.js
@@ -205,19 +205,22 @@ const READ_TIMEOUT_MS = 5000;
/** a sentinel the timeout resolves with — `undefined` is a legitimate stored value */
const UNMEASURED = Symbol('unmeasured');
/**
- * A BOUNDED read. `idb.js` settles its promise on the request's own `onsuccess` /
- * `onerror` and nothing else — so a transaction that ABORTS without firing either leaves
- * the promise pending FOREVER, and an `await` on it stalls whatever is holding it with no
- * error anywhere. Measured here, and it is worth stating precisely because the symptom is
- * so unhelpful: a scan opened from the header chip stopped after three keys, the panel
- * kept showing the PREVIOUS reading, `unhandledrejection` never fired, and a scan started
- * a few seconds later over the same store completed normally.
+ * A BOUNDED read. This was written around a bug in `idb.js`: it settled its promise on
+ * the request's own `onsuccess` / `onerror` and nothing else, so a transaction that
+ * ABORTED without firing either left the promise pending FOREVER and an `await` on it
+ * stalled its holder with no error anywhere. Worth stating precisely, because the symptom
+ * was so unhelpful: a scan opened from the header chip stopped after three keys, the
+ * panel kept showing the PREVIOUS reading, `unhandledrejection` never fired, and a scan
+ * started a few seconds later over the same store completed normally.
*
- * A panel whose whole job is to report a number must not be able to hang silently, so a
- * read that does not come back inside the window is reported as an UNMEASURED row rather
- * than being waited on. It is the honest degradation: the row still appears, still says
- * what it is, and still offers to remove itself — only its size is missing, and it says
- * so. (The scan needs six of these now rather than one per file: see the blob branch.)
+ * 27-H FIXED THAT AT THE SOURCE — `idb.js` rejects on `onabort` and bounds every
+ * operation at 10s — and this stays anyway, for a reason that has not changed: a panel
+ * whose whole job is to report a number must not wait ten seconds per key for a store
+ * that is misbehaving. A read that does not come back inside THIS window is reported as
+ * an UNMEASURED row rather than being waited on. It is the honest degradation: the row
+ * still appears, still says what it is, and still offers to remove itself — only its size
+ * is missing, and it says so. (The scan needs six of these now rather than one per file:
+ * see the blob branch.)
* @param {string} key @returns {Promise<{value: any, measured: boolean}>}
*/
async function safeGet(key) {
diff --git a/src/lib/terrainSculpt.js b/src/lib/terrainSculpt.js
index 85f9366b..0b3c37c9 100644
--- a/src/lib/terrainSculpt.js
+++ b/src/lib/terrainSculpt.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup, lockedObjects, globalScene, TControls, gizmoSuppressed } from '../stores/sceneStore';
+import { objectsGroup, lockedObjects, globalScene, TControls, gizmoSuppressed, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { commitMeshGeoSnapshot } from './faceEdit';
import { MAX_SNAPSHOT, previewReplicable } from './meshBudget';
@@ -428,7 +428,7 @@ export function strokeMove(uuid, x, z, dt = 0.016, y = 0) {
});
}
}
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Stroke end: flush the pending preview + ONE snapshot commit + undo entry. */
diff --git a/src/lib/themes.js b/src/lib/themes.js
index dab42a29..69db7d59 100644
--- a/src/lib/themes.js
+++ b/src/lib/themes.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// UI themes (phase 89): a theme is a token block on :root[data-theme] (see
// styles/theme.css) — strictly LOCAL chrome, never replicated. 'light' also
@@ -62,7 +63,7 @@ export const THEME_TOKENS = [
function loadCustomThemes() {
if (typeof localStorage === 'undefined') return [];
try {
- const raw = localStorage.getItem('customThemes');
+ const raw = safeStorage.getItem('customThemes');
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
@@ -75,13 +76,13 @@ export const customThemes = writable(loadCustomThemes());
// must be initialized BEFORE the theme subscriber so a persisted custom id resolves on load
export const theme = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('theme') ?? 'dark' : 'dark'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('theme') ?? 'dark' : 'dark'
);
customThemes.subscribe((value) => {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem('customThemes', JSON.stringify(value));
+ safeStorage.setItem('customThemes', JSON.stringify(value));
} catch {}
});
@@ -103,7 +104,7 @@ function applyTheme(id) {
root.classList.toggle('dark', id !== 'light');
}
try {
- localStorage.setItem('theme', id);
+ safeStorage.setItem('theme', id);
} catch {}
}
diff --git a/src/lib/touchControls.js b/src/lib/touchControls.js
index d188f3b9..77557da6 100644
--- a/src/lib/touchControls.js
+++ b/src/lib/touchControls.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// W4: THE TOUCH PLAY CONTROLS LEAF — a virtual move stick and a look drag, plus the
// one local preference that tunes them.
@@ -61,7 +62,7 @@ function clamp(value, min, max) {
function storedSpeed() {
if (typeof localStorage === 'undefined') return 1;
- const raw = Number(localStorage.getItem(SPEED_KEY));
+ const raw = Number(safeStorage.getItem(SPEED_KEY));
if (!Number.isFinite(raw) || raw <= 0) return 1;
return clamp(raw, TOUCH_LOOK_SPEED_RANGE.min, TOUCH_LOOK_SPEED_RANGE.max);
}
@@ -78,7 +79,7 @@ export function setTouchLookSpeed(value) {
const next = clamp(Number(value) || 1, TOUCH_LOOK_SPEED_RANGE.min, TOUCH_LOOK_SPEED_RANGE.max);
touchLookSpeed.set(next);
try {
- if (typeof localStorage !== 'undefined') localStorage.setItem(SPEED_KEY, String(next));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(SPEED_KEY, String(next));
} catch {
/* private mode — the pref is a convenience, never a requirement */
}
diff --git a/src/lib/trackpadNav.js b/src/lib/trackpadNav.js
index 57faeae1..2aeeaa21 100644
--- a/src/lib/trackpadNav.js
+++ b/src/lib/trackpadNav.js
@@ -17,54 +17,55 @@ import { globalCamera, globalRenderer, orbitControls } from '../stores/sceneStor
// this one has to ask and stand down itself. proportional is a svelte/store-only
// leaf: no cycle.
import { proportionalWheelActive } from './proportional';
+import { safeStorage } from './safeStorage';
/** How two-finger swipes are treated: 'auto' (heuristic) | 'on' | 'off'.
* @type {import('svelte/store').Writable} */
export const trackpadMode = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('trackpadMode') || 'auto' : 'auto'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('trackpadMode') || 'auto' : 'auto'
);
trackpadMode.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadMode', value);
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadMode', value);
});
/** Accessibility escape hatch: let the BROWSER zoom the page again (pinch /
* ctrl+wheel over UI, mobile pinch). Off by default — pinch is an app gesture.
* @type {import('svelte/store').Writable} */
export const allowBrowserZoom = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('allowBrowserZoom') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('allowBrowserZoom') === 'true'
);
allowBrowserZoom.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('allowBrowserZoom', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('allowBrowserZoom', String(value));
});
/** Flip the two-finger pan direction. The DEFAULT (off) is content-follows-
* fingers, the user-picked direction; on = the opposite convention.
* @type {import('svelte/store').Writable} */
export const reversePan = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('trackpadReversePan') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('trackpadReversePan') === 'true'
);
reversePan.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadReversePan', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadReversePan', String(value));
});
/** Two-finger pan on/off (default ON). Off = trackpad swipes fall through to the
* wheel zoom and panning stays available via right-click drag (OrbitControls).
* @type {import('svelte/store').Writable} */
export const panEnabled = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('trackpadPanEnabled') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('trackpadPanEnabled') !== 'false'
);
panEnabled.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadPanEnabled', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadPanEnabled', String(value));
});
/** Pinch-to-zoom on/off (default ON). Off = pinch does nothing to the camera
* (the page-zoom guard still applies); zoom stays on the mouse wheel.
* @type {import('svelte/store').Writable} */
export const pinchZoomEnabled = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('trackpadPinchZoom') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('trackpadPinchZoom') !== 'false'
);
pinchZoomEnabled.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadPinchZoom', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadPinchZoom', String(value));
});
// ---- 24-A2: the wheel classifier ------------------------------------------------
@@ -216,8 +217,8 @@ function panBy(e) {
/** A2.3: once ever, the first time the classifier turns a wheel into a pan in auto
* mode, point at the one-click override. `wheelHintSeen` in localStorage. */
function maybeWheelHint() {
- if (typeof localStorage === 'undefined' || localStorage.getItem('wheelHintSeen')) return;
- localStorage.setItem('wheelHintSeen', '1');
+ if (typeof localStorage === 'undefined' || safeStorage.getItem('wheelHintSeen')) return;
+ safeStorage.setItem('wheelHintSeen', '1');
import('../stores/appStore').then((m) =>
m.showToast('Wheel panned instead of zooming? Viewport menu ▸ View ▸ Mouse wheel switches it')
);
diff --git a/src/lib/transientObjects.js b/src/lib/transientObjects.js
index 1c7a7145..70958a21 100644
--- a/src/lib/transientObjects.js
+++ b/src/lib/transientObjects.js
@@ -28,7 +28,7 @@
// reach it, and any of those edges through objectActions/history would be a cycle.
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
/** @param {any} object */
@@ -84,7 +84,7 @@ export function removeTransientObjects() {
if (!doomed.length) return [];
const uuids = doomed.map((object) => object.uuid);
uuids.forEach((uuid) => removeTransientObject(uuid, false));
- objectsGroup.update((value) => value);
+ pokeScene();
return uuids;
}
@@ -102,7 +102,7 @@ export function removeTransientObject(uuid, poke = true) {
const peer = get(peers);
object.parent?.remove(object);
if (peer) peer.send({ type: 'delete', uuid, peerId: peer.peer.id });
- if (poke) objectsGroup.update((value) => value);
+ if (poke) pokeScene();
return true;
}
diff --git a/src/lib/units.js b/src/lib/units.js
index b5177ee5..3a089957 100644
--- a/src/lib/units.js
+++ b/src/lib/units.js
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// #20 P3: display UNITS for numeric fields.
//
@@ -65,7 +66,9 @@ const ALIASES = {
};
ALIASES.angleDeg = ALIASES.angle;
-const ls = typeof localStorage !== 'undefined' ? localStorage : null;
+// 27-H: `safeStorage` is the alias now — it already answers when there is no storage at
+// all, so the `typeof` dance and the `?.` on every use below are what it replaces.
+const ls = safeStorage;
/** @param {string} key @param {string} fallback @param {string[]} allowed */
function storedUnit(key, fallback, allowed) {
diff --git a/src/lib/uvEditor.js b/src/lib/uvEditor.js
index ebdb1149..fe93ed42 100644
--- a/src/lib/uvEditor.js
+++ b/src/lib/uvEditor.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordEntry } from './history';
// UV3 painting commits through the EXISTING replicated texture path, so
@@ -10,6 +10,7 @@ import { applyMap, materialAt, recordMaterialChange, copyTextureParams } from '.
// the unwrap REGISTRY: built-in projections, plus whatever a module registers
import { unwrap } from './uvUnwrap';
import { MAX_SNAPSHOT } from './meshBudget';
+import { safeStorage } from './safeStorage';
// UV1: read-only reuse of the mesh snapshot pipeline. faceEdit owns the triangle
// <-> geometry conversion AND the 'meshgeo' history kind (which already accepts a
// {positions, groups, uvs} triple and re-broadcasts uvs on undo), so a UV commit
@@ -60,13 +61,13 @@ export const uvBrushSize = writable(24);
* @type {import('svelte/store').Writable<'size'|'opacity'|'off'>} */
export const uvPenPressure = writable(
/** @type {any} */ (
- typeof localStorage !== 'undefined' && ['size', 'opacity', 'off'].includes(localStorage.getItem('uvPenPressure') || '')
- ? localStorage.getItem('uvPenPressure')
+ typeof localStorage !== 'undefined' && ['size', 'opacity', 'off'].includes(safeStorage.getItem('uvPenPressure') || '')
+ ? safeStorage.getItem('uvPenPressure')
: 'size'
)
);
uvPenPressure.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('uvPenPressure', String(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('uvPenPressure', String(value));
});
/** a light touch still marks: the width/alpha factor at pressure 0 */
export const MIN_PRESSURE_FACTOR = 0.15;
@@ -85,12 +86,12 @@ const pressureFactor = (w) => MIN_PRESSURE_FACTOR + (1 - MIN_PRESSURE_FACTOR) *
* @type {import('svelte/store').Writable}
*/
export const uvFaceFilter = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('uvFaceFilter') ?? 'all' : 'all'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('uvFaceFilter') ?? 'all' : 'all'
);
if (typeof localStorage !== 'undefined')
uvFaceFilter.subscribe((value) => {
try {
- localStorage.setItem('uvFaceFilter', value);
+ safeStorage.setItem('uvFaceFilter', value);
} catch {}
});
@@ -384,7 +385,7 @@ export function transformUvCluster(object, indices, options = {}) {
uv.setXY(i, pivot.cu + du * cos - dv * sin, pivot.cv + du * sin + dv * cos);
}
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -469,7 +470,7 @@ export function applyUvSnapshot(object, snapshot, options = {}) {
uv.setXY(s.i, u + du, v + dv);
}
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -513,7 +514,7 @@ export function snapUvToPixels(object, indices, w, h) {
uv.setXY(i, Math.round(uv.getX(i) * w) / w, Math.round(uv.getY(i) * h) / h);
}
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -592,7 +593,7 @@ export function fitUvToSquare(object, indices, margin = 0.02) {
uv.setXY(i, u, v);
}
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -909,7 +910,7 @@ export function moveUvCluster(object, indices, du, dv) {
}
for (const i of indices) uv.setXY(i, uv.getX(i) + du, uv.getY(i) + dv);
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -1078,7 +1079,7 @@ function install(material, entry) {
material.needsUpdate = true;
}
entry.texture.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** A live texture identifies its seed by uuid+version, so a blank canvas (seed
@@ -1210,7 +1211,7 @@ export function paintMove(u, v, color, size, w) {
const point = pressured && typeof w === 'number' ? [u, v, Math.max(0, Math.min(1, Math.round(w * 1000) / 1000))] : [u, v];
paintStroke.points.push(point);
if (previous) strokeSegment(entry, previous, point, color, size, paintStroke.pmode);
- objectsGroup.update((value) => value);
+ pokeScene();
const now = performance.now();
if (now - lastPaintSend < PAINT_THROTTLE) return true;
lastPaintSend = now;
@@ -1294,7 +1295,7 @@ export function cancelPaintStroke() {
if (material.map === entry.texture) {
material.map = entry.previousMap ?? null;
material.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
}
// the canvas now disagrees with the material — drop it so the next stroke
// re-seeds from whatever is actually on the model
@@ -1324,7 +1325,7 @@ export async function applyUvPaint(data) {
for (let i = 1; i < seg.length; i++)
strokeSegment(entry, seg[i - 1], seg[i], data.color ?? '#000000', data.size ?? 16, pmode);
liveUvStrokes.set(data.id, { ts: Date.now() });
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Receive side: a peer finished a stroke. @param {any} data */
diff --git a/src/lib/viewPrefs.js b/src/lib/viewPrefs.js
index 3de5103a..6774f611 100644
--- a/src/lib/viewPrefs.js
+++ b/src/lib/viewPrefs.js
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// 18-A: viewport LINE colours — the wireframe view mode, the selection outline and
// the mesh-edit overlay. A LOCAL per-device view preference, never replicated and
@@ -35,7 +36,7 @@ export const DEFAULT_VIEW_PREFS = {
function load() {
try {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null;
// unknown/missing keys fall back to defaults, so old payloads keep working
const stored = raw ? JSON.parse(raw) : {};
return { ...DEFAULT_VIEW_PREFS, ...stored };
@@ -48,7 +49,7 @@ function load() {
export const viewPrefs = writable(load());
viewPrefs.subscribe((value) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value));
});
/** @param {Partial} patch */
diff --git a/src/lib/viewportOverrides.js b/src/lib/viewportOverrides.js
index 106cfd68..19e517cd 100644
--- a/src/lib/viewportOverrides.js
+++ b/src/lib/viewportOverrides.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// B — VIEWPORT OVERRIDES (this device).
//
@@ -56,9 +57,9 @@ function load() {
for (const def of OVERRIDES) state[def.key] = true;
if (typeof localStorage === 'undefined') return state;
try {
- const raw = localStorage.getItem(KEY);
+ const raw = safeStorage.getItem(KEY);
if (raw) Object.assign(state, JSON.parse(raw));
- else if (localStorage.getItem(LEGACY_POST_KEY) === 'false') state.post = false;
+ else if (safeStorage.getItem(LEGACY_POST_KEY) === 'false') state.post = false;
} catch {}
return state;
}
@@ -68,7 +69,7 @@ export const viewportOverrides = writable(load());
viewportOverrides.subscribe((state) => {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem(KEY, JSON.stringify(state));
+ safeStorage.setItem(KEY, JSON.stringify(state));
} catch {}
});
@@ -96,10 +97,10 @@ export function viewportOverridesDebug() {
* not a promise that it does. LOCAL, like every other override here.
*/
export const vrPostEnabled = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('vrPostEnabled') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('vrPostEnabled') === 'true'
);
vrPostEnabled.subscribe((value) => {
try {
- localStorage.setItem('vrPostEnabled', String(value));
+ safeStorage.setItem('vrPostEnabled', String(value));
} catch {}
});
diff --git a/src/lib/voiceChat.js b/src/lib/voiceChat.js
index 33a11315..4c3c3de7 100644
--- a/src/lib/voiceChat.js
+++ b/src/lib/voiceChat.js
@@ -7,6 +7,7 @@ import { ensureAudioContext as engineContext, bus, updateListener, resumeAudio }
// LOCALLY with a gain (see the colo stage below); nothing about what we transmit changes.
import { colocatedPeers, isColocatedWith } from './colocationPresence';
import { letterOf } from './keyOf';
+import { safeStorage } from './safeStorage';
// Voice chat over the existing peerjs mesh (MediaConnection).
// - mic toggle transmits continuously; while OFF, holding V is push-to-talk
@@ -20,7 +21,7 @@ export const micGranted = writable(false);
export const pttActive = writable(false);
// positional audio: voices come from the peer's avatar (PannerNode per peer)
export const spatialVoice = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('spatialVoice') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('spatialVoice') !== 'false'
);
/** @type {import('svelte/store').Writable<'ptt' | 'open' | 'off'>} VR mic mode (quick-menu tile) */
export const vrMicMode = writable('ptt');
@@ -37,6 +38,8 @@ let pttHeld = false;
/** @type {Record} */ const outgoingCalls = {};
/** @type {Record} */ const incomingCalls = {};
/** @type {Record} */ const analysers = {};
+/** @type {any} the speaking-detection interval, armed only while there is audio */
+let pollTimer = null;
/**
* The shared AudioContext. #22 A1 moved OWNERSHIP into `audioEngine` — the whole
@@ -51,6 +54,7 @@ export function ensureAudioContext() {
/** @param {any} call @param {'in'|'out'} direction */
function trackCall(call, direction) {
(direction === 'in' ? incomingCalls : outgoingCalls)[call.peer] = call;
+ syncPoll();
call.on('stream', (/** @type {MediaStream} */ stream) => {
remoteStreams.update((map) => ({ ...map, [call.peer]: stream }));
watchStream(call.peer, stream);
@@ -224,9 +228,11 @@ function cleanupCall(peerId, direction) {
delete analysers[peerId];
dropSpatialChain(peerId);
}
+ syncPoll();
}
async function ensureStream() {
+ clearTimeout(idleRelease);
if (localStream) return true;
try {
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -234,6 +240,7 @@ async function ensureStream() {
applyTrackState();
callEveryone();
watchStream('self', localStream);
+ syncPoll();
return true;
} catch (error) {
console.log('mic denied', error);
@@ -242,6 +249,105 @@ async function ensureStream() {
}
}
+/**
+ * 27-H (hardening audit M9) — GIVE THE MICROPHONE BACK.
+ *
+ * Mute only ever set `track.enabled = false`, and nothing in this module has ever
+ * called `stop()`. A disabled track is still a LIVE track: the tab keeps its recording
+ * indicator, the OS keeps the device claimed so nothing else can open it, and both
+ * stay that way for the life of the page after one press. That is a trust problem
+ * before it is a resource one — the indicator says "this page is listening" and it is
+ * not true.
+ *
+ * THE OUTGOING CALLS GO WITH IT, and they have to: a MediaConnection carries this
+ * stream, so leaving them up after stopping its tracks leaves peers holding a channel
+ * that can never carry audio again — `callPeer` skips a peer that already has one, so
+ * re-acquiring would reach nobody. Closing them means `ensureStream` re-calls
+ * everybody, which costs a renegotiation but is the only version that works.
+ *
+ * INCOMING calls are deliberately left alone: listening never needed a microphone,
+ * and turning your own mic off is not a request to stop hearing other people.
+ */
+export function releaseMic() {
+ clearTimeout(idleRelease);
+ if (!localStream) return false;
+ try {
+ localStream.getTracks().forEach((track) => track.stop());
+ } catch {}
+ localStream = null;
+ delete analysers['self'];
+ for (const peerId of Object.keys(outgoingCalls)) {
+ try {
+ outgoingCalls[peerId].close();
+ } catch {}
+ cleanupCall(peerId, 'out');
+ }
+ pttActive.set(false);
+ // THE STATE HAS TO AGREE WITH THE DEVICE. Leaving `micActive` true with no stream
+ // behind it leaves the toolbar claiming the mic is open while nothing is being
+ // transmitted, and the NEXT toggle then turns it "off" — measured as B never being
+ // called at all, because the press the suite meant as "on" was read as "off".
+ micActive.set(false);
+ syncPoll();
+ return true;
+}
+
+/**
+ * How long the mic stays claimed after a push-to-talk release.
+ *
+ * NOT zero, and this is the one piece of policy in the change. Re-acquiring costs a
+ * `getUserMedia` AND a renegotiation with every peer, so releasing the instant a key
+ * comes up would make the second sentence of a conversation arrive seconds late. A few
+ * seconds of indicator after you stop talking is active use; forever is the bug.
+ * An explicit voice-OFF releases immediately — you said so.
+ */
+const PTT_IDLE_MS = 3000;
+/** @type {any} */ let idleRelease = null;
+
+/** Arm the idle release, unless something is still transmitting. */
+function releaseWhenIdle() {
+ clearTimeout(idleRelease);
+ if (get(micActive) || pttHeld) return;
+ idleRelease = setTimeout(() => {
+ if (!get(micActive) && !pttHeld) releaseMic();
+ }, PTT_IDLE_MS);
+}
+
+/**
+ * 27-H (audit M9): the speaking poll used to be armed once at init and run at ~7Hz for
+ * the life of the tab — with no microphone, no peers and nothing to measure. It runs
+ * only while there is something to measure now: our own stream, or somebody on a call.
+ */
+function pollWanted() {
+ return !!localStream || Object.keys(incomingCalls).length > 0 || Object.keys(outgoingCalls).length > 0;
+}
+
+function syncPoll() {
+ const wanted = pollWanted();
+ if (wanted && !pollTimer) pollTimer = setInterval(pollSpeaking, 150);
+ else if (!wanted && pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ // nobody can be speaking when nothing is being measured
+ if (get(speakingPeers).length) speakingPeers.set([]);
+ }
+}
+
+/** Is the microphone claimed, and is the analyser loop running? (tests / diagnostics) */
+export function voiceDebug() {
+ const tracks = localStream ? localStream.getTracks() : [];
+ return {
+ stream: !!localStream,
+ live: tracks.filter((t) => t.readyState === 'live').length,
+ ended: tracks.filter((t) => t.readyState === 'ended').length,
+ enabled: tracks.filter((t) => t.enabled).length,
+ polling: !!pollTimer,
+ outgoing: Object.keys(outgoingCalls).length,
+ incoming: Object.keys(incomingCalls).length,
+ analysers: Object.keys(analysers).length
+ };
+}
+
function applyTrackState() {
const enabled = get(micActive) || pttHeld;
localStream?.getAudioTracks().forEach((track) => (track.enabled = enabled));
@@ -265,6 +371,9 @@ export async function toggleMic() {
if (next && !(await ensureStream())) return;
micActive.set(next);
applyTrackState();
+ // M9: turning the mic off is an explicit "I am done" — the device goes back now,
+ // not after a grace, because the indicator is what the user is watching
+ if (!next) releaseMic();
}
/** VR A-button push-to-talk (same track path as hold-V) @param {boolean} held */
@@ -274,7 +383,10 @@ export async function setPttHeld(held) {
if (held) {
if (await ensureStream()) applyTrackState();
else pttHeld = false;
- } else applyTrackState();
+ } else {
+ applyTrackState();
+ releaseWhenIdle();
+ }
}
/** Radial menu (74): jump straight to a mode, reusing the cycle transitions
@@ -294,6 +406,8 @@ export async function cycleMicMode() {
if (get(micActive)) await toggleMic();
pttHeld = false;
applyTrackState();
+ // M9: OFF means off — no stream, no device claim, no indicator
+ releaseMic();
} else {
vrMicMode.set('ptt');
}
@@ -336,6 +450,8 @@ function onKeyup(event) {
if (letterOf(event) !== 'v' || !pttHeld) return;
pttHeld = false;
applyTrackState();
+ // M9: hand the device back shortly after the hold ends
+ releaseWhenIdle();
}
// --- speaking detection ---
@@ -387,7 +503,7 @@ mutedPeers.subscribe((list) => {
// writes a store from inside a subscriber.
colocatedPeers.subscribe(() => applyColocationGains());
spatialVoice.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('spatialVoice', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('spatialVoice', String(on));
if (on) Object.entries(get(remoteStreams)).forEach(([peerId, stream]) => buildSpatialChain(peerId, stream));
else Object.keys(spatialChains).forEach(dropSpatialChain);
});
@@ -416,7 +532,9 @@ export function initVoiceChat(/** @type {any} */ pc) {
window.addEventListener('keyup', onKeyup);
// AudioContext starts suspended until a user gesture
window.addEventListener('pointerdown', () => resumeAudio(), { once: false });
- setInterval(pollSpeaking, 150);
+ // M9: NOT an unconditional interval any more — `syncPoll` arms it when there is
+ // audio to measure and stands it down when there is not
+ syncPoll();
}
/** A data connection to this peer just opened — call them if we transmit @param {string} peerId */
diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js
index 15173109..d826b3c3 100644
--- a/src/lib/vrControls.js
+++ b/src/lib/vrControls.js
@@ -39,8 +39,7 @@ import {
vrToolMode,
vrTargetHz,
vrSleeveEnabled,
- peerHandStyle
-} from '../stores/sceneStore';
+ peerHandStyle, pokeScene } from '../stores/sceneStore';
import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu';
import { paletteColorAt, barValueAt } from './vrPalette';
import { recordMaterialChange, setMaterialParam } from './materialsHandler';
@@ -128,6 +127,7 @@ import { setVRAxes, setVRButtons } from './inputRuntime';
import { suspendAnimation, resumeAnimation } from './flowRuntime';
import { drawMode, toggleDrawMode, addStrokePoint, endStroke } from './drawMode';
import { setPttHeld, cycleMicMode, vrMicMode, micActive, pttActive } from './voiceChat';
+import { safeStorage } from './safeStorage';
import {
HOLD_MS,
vrWindowAdjust,
@@ -1267,7 +1267,7 @@ export function raycastSettings(index) {
export function applySnapMode(mode) {
vrSnapMode.set(mode);
try {
- localStorage.setItem('vrSnapMode', mode);
+ safeStorage.setItem('vrSnapMode', mode);
} catch {}
snapEnabled.set(mode === 'grid' || mode === 'rotation');
surfaceSnap.set(mode === 'surface');
@@ -1294,7 +1294,7 @@ function nudgeTransform(object, kind, axis, sign) {
else object.scale[axis] = Math.max(0.01, object.scale[axis] + sign * step);
recordTransform({ uuid: object.uuid, before, after: transformStateOf(object) });
broadcastMove(object, true);
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Props panel actions ('props:' prefix in executeVRMenuAction) @param {string} action */
@@ -2439,7 +2439,7 @@ function updateGrab() {
}
grab.prevPos.copy(position);
grab.prevQuat.copy(quaternion);
- objectsGroup.update((value) => value);
+ pokeScene();
broadcastMove(object);
return;
}
@@ -2468,7 +2468,7 @@ function updateGrab() {
}
grab.prevPos.copy(position);
grab.prevQuat.copy(quaternion);
- objectsGroup.update((value) => value);
+ pokeScene();
broadcastMove(object);
}
@@ -2480,7 +2480,7 @@ function updateScaleGrab() {
factor = Math.max(Math.round(factorRaw / step) * step, step);
}
scaleGrab.object.scale.copy(scaleGrab.startScale).multiplyScalar(factor);
- objectsGroup.update((value) => value);
+ pokeScene();
broadcastMove(scaleGrab.object);
}
@@ -2510,7 +2510,7 @@ function spawnPrimitive(command) {
} else {
object.position.set(spawn.x, object.position.y, spawn.z);
}
- objectsGroup.update((value) => value);
+ pokeScene();
broadcastMove(object, true);
}
@@ -2691,19 +2691,19 @@ export function executeVRMenuAction(name) {
if (key === 'close') vrSettingsPanelOpen.set(false);
else if (key === 'teleport') {
vrTeleportEnabled.update((v) => !v);
- try { localStorage.setItem('vrTeleportEnabled', String(get(vrTeleportEnabled))); } catch {}
+ try { safeStorage.setItem('vrTeleportEnabled', String(get(vrTeleportEnabled))); } catch {}
} else if (key === 'mirror') {
vrMirrorSnapTurn.update((v) => !v);
- try { localStorage.setItem('vrMirrorSnapTurn', String(get(vrMirrorSnapTurn))); } catch {}
+ try { safeStorage.setItem('vrMirrorSnapTurn', String(get(vrMirrorSnapTurn))); } catch {}
} else if (key === 'vertexhold') {
vrVertexHold.update((v) => !v);
- try { localStorage.setItem('vrVertexHold', String(get(vrVertexHold))); } catch {}
+ try { safeStorage.setItem('vrVertexHold', String(get(vrVertexHold))); } catch {}
} else if (key === 'angle') {
// cycle Off -> 15 -> 30 -> 45 -> Off
const steps = [0, 15, 30, 45];
const next = steps[(steps.indexOf(get(vrSnapAngle)) + 1) % steps.length];
vrSnapAngle.set(next);
- try { localStorage.setItem('vrSnapAngle', String(next)); } catch {}
+ try { safeStorage.setItem('vrSnapAngle', String(next)); } catch {}
} else if (key === 'hz') {
// B2.1: cycle Auto(max) -> 90 -> 120 and apply live if presenting
const steps = ['auto', '90', '120'];
@@ -2718,12 +2718,12 @@ export function executeVRMenuAction(name) {
// WebXR can't hot-swap session modes — applies on the next VR entry
const next = !get(vrPassthrough);
vrPassthrough.set(next);
- try { localStorage.setItem('vrPassthrough', String(next)); } catch {}
+ try { safeStorage.setItem('vrPassthrough', String(next)); } catch {}
showToast('Passthrough ' + (next ? 'on' : 'off') + ' — takes effect on the next VR entry');
} else if (key === 'sleeve') {
// K1: experimental forearm sleeve palette (default off)
vrSleeveEnabled.update((v) => !v);
- try { localStorage.setItem('vrSleeveEnabled', String(get(vrSleeveEnabled))); } catch {}
+ try { safeStorage.setItem('vrSleeveEnabled', String(get(vrSleeveEnabled))); } catch {}
} else if (key === 'resetpanels') {
resetWindowPoses();
showToast('VR panel positions reset');
@@ -2758,7 +2758,7 @@ export function executeVRMenuAction(name) {
object.rotation.set(0, 0, 0);
recordTransform({ uuid: object.uuid, before, after: transformStateOf(object) });
broadcastMove(object, true);
- objectsGroup.update((v) => v);
+ pokeScene();
hapticPulse(0.3, 40);
return;
}
@@ -2845,7 +2845,7 @@ export function executeVRMenuAction(name) {
vrWireframeSelection.update((v) => {
const next = !v;
try {
- localStorage.setItem('vrWireframe', String(next));
+ safeStorage.setItem('vrWireframe', String(next));
} catch {}
return next;
});
@@ -2943,7 +2943,7 @@ export function executeVRMenuAction(name) {
vrStatsOpen.update((v) => {
const next = !v;
try {
- localStorage.setItem('vrStats', String(next));
+ safeStorage.setItem('vrStats', String(next));
} catch {}
return next;
});
@@ -2953,7 +2953,7 @@ export function executeVRMenuAction(name) {
const next = order[(order.indexOf(get(vrGrabStyle)) + 1) % order.length];
vrGrabStyle.set(next);
try {
- localStorage.setItem('vrGrabStyle', next);
+ safeStorage.setItem('vrGrabStyle', next);
} catch {}
showToast(
next === 'rigid'
@@ -2975,8 +2975,8 @@ export function executeVRMenuAction(name) {
}
} else if (name === 'grid') {
showGrid.update((v) => !v);
- if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid');
- else localStorage.setItem('showGrid', 'false');
+ if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid');
+ else safeStorage.setItem('showGrid', 'false');
} else if (name === 'undo') undo();
else if (name === 'redo') redo();
else if (name === 'box') spawnPrimitive('/create Box 1 1 1');
@@ -2988,7 +2988,7 @@ export function executeVRMenuAction(name) {
else if (name === 'hand') {
vrMenuHand.update((hand) => {
const next = hand === 'right' ? 'left' : 'right';
- localStorage.setItem('vrMenuHand', next);
+ safeStorage.setItem('vrMenuHand', next);
return next;
});
} else if (name === 'mic') {
diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js
index 414e3cea..f1aa98a6 100644
--- a/src/lib/vrRadialMenu.js
+++ b/src/lib/vrRadialMenu.js
@@ -14,6 +14,7 @@ import { simulating, remoteSimulating, toggleSimulation } from './physics';
import { setMicMode, vrMicMode } from './voiceChat';
import { duplicateSelection, deleteSelection, groupSelection, selectionUuids } from './objectActions';
import { savePrefab, savePrefabSelection } from './prefabs';
+import { safeStorage } from './safeStorage';
// D4 (roadmap 13): selection-set helpers for the Edit ring — counted labels
// act on the whole SET (parity with the desktop object menu, U-2)
@@ -286,7 +287,7 @@ function registerBuiltins() {
SNAP_ANGLES[(SNAP_ANGLES.indexOf(get(vrSnapAngle)) + 1) % SNAP_ANGLES.length];
vrSnapAngle.set(next);
try {
- localStorage.setItem('vrSnapAngle', String(next));
+ safeStorage.setItem('vrSnapAngle', String(next));
} catch {}
}
});
@@ -321,7 +322,7 @@ function registerBuiltins() {
const next = get(vrMenuHand) === 'left' ? 'right' : 'left';
vrMenuHand.set(/** @type {any} */ (next));
try {
- localStorage.setItem('vrMenuHand', next);
+ safeStorage.setItem('vrMenuHand', next);
} catch {}
}
});
diff --git a/src/lib/vrSleeve.js b/src/lib/vrSleeve.js
index c9c404f6..ef2d87a7 100644
--- a/src/lib/vrSleeve.js
+++ b/src/lib/vrSleeve.js
@@ -8,8 +8,7 @@ import {
vrMenuHand,
vrMenuOpen,
vrSnapMode,
- vrSleeveEnabled
-} from '../stores/sceneStore';
+ vrSleeveEnabled, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { parkEditOverlays } from './editOverlays';
import { snapEnabled, snapSettings, dropToSurface } from './snapping';
@@ -304,7 +303,7 @@ function applyPlacement(object, pose, scale) {
object.position.z = Math.round(object.position.z / step) * step;
}
object.updateMatrix();
- objectsGroup.update((v) => v);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer)
@@ -515,7 +514,7 @@ export function sleeveGripDrop(object, before) {
if (before?.pos) object.position.fromArray(before.pos);
if (before?.rot) object.rotation.set(before.rot[0], before.rot[1], before.rot[2]);
if (before?.scale) object.scale.fromArray(before.scale);
- objectsGroup.update((v) => v);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer)
diff --git a/src/lib/vrWindowPoses.js b/src/lib/vrWindowPoses.js
index a3747bd9..90ad56ce 100644
--- a/src/lib/vrWindowPoses.js
+++ b/src/lib/vrWindowPoses.js
@@ -1,6 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// VR window grab (111): every follower window (radial ring, objects panel,
// color palette, stats card) can be detached by holding the other hand's grip
@@ -23,7 +24,7 @@ export const vrWindowAdjust = writable(null);
function loadPoses() {
try {
- return JSON.parse(localStorage.getItem('vrWindowPoses') ?? '{}') ?? {};
+ return JSON.parse(safeStorage.getItem('vrWindowPoses') ?? '{}') ?? {};
} catch {
return {};
}
@@ -42,7 +43,7 @@ export function saveWindowPose(id, offset) {
windowPoses.update((poses) => {
const next = { ...poses, [id]: offset };
try {
- localStorage.setItem('vrWindowPoses', JSON.stringify(next));
+ safeStorage.setItem('vrWindowPoses', JSON.stringify(next));
} catch {}
return next;
});
@@ -52,7 +53,7 @@ export function saveWindowPose(id, offset) {
export function resetWindowPoses() {
windowPoses.set({});
try {
- localStorage.removeItem('vrWindowPoses');
+ safeStorage.removeItem('vrWindowPoses');
} catch {}
}
diff --git a/src/lib/whatsNew.js b/src/lib/whatsNew.js
index f119d685..460effb4 100644
--- a/src/lib/whatsNew.js
+++ b/src/lib/whatsNew.js
@@ -10,6 +10,7 @@ import { APP_VERSION, IS_DEV } from './version.js';
import { showToast } from '../stores/appStore.js';
// The changelog ships as the repo-root CHANGELOG.md (GitHub renders the same file).
import changelogRaw from '../../CHANGELOG.md?raw';
+import { safeStorage } from './safeStorage';
/** Raw markdown of the changelog, rendered by WhatsNew.svelte. */
export const CHANGELOG = String(changelogRaw || '');
@@ -22,12 +23,12 @@ const LAST_SEEN_VERSION = 'lastSeenVersion';
* @param {string} key @param {boolean} dflt
*/
function boolPref(key, dflt) {
- const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
+ const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(key) : null;
const store = writable(raw === null ? dflt : raw === 'true');
if (typeof localStorage !== 'undefined') {
store.subscribe((v) => {
try {
- localStorage.setItem(key, v ? 'true' : 'false');
+ safeStorage.setItem(key, v ? 'true' : 'false');
} catch {
/* storage disabled */
}
@@ -54,7 +55,7 @@ export const whatsNewUnseen = writable(false);
function markSeen() {
try {
- localStorage.setItem(LAST_SEEN_VERSION, APP_VERSION);
+ safeStorage.setItem(LAST_SEEN_VERSION, APP_VERSION);
} catch {
/* storage disabled */
}
@@ -80,7 +81,7 @@ export function openWelcome() {
export function closeWelcome() {
welcomeOpen.set(false);
try {
- localStorage.setItem(SEEN_WELCOME, 'true');
+ safeStorage.setItem(SEEN_WELCOME, 'true');
} catch {
/* storage disabled */
}
@@ -132,7 +133,7 @@ export function hasDeepLink() {
*/
export function startWhatsNew() {
if (typeof localStorage === 'undefined') return;
- const firstVisit = !localStorage.getItem(SEEN_WELCOME);
+ const firstVisit = !safeStorage.getItem(SEEN_WELCOME);
// R22 round 7 — DO NOT GREET AN INVITE. A URL with a peer id in its hash is somebody
// answering "join me", and the first thing they should see is the session, not an
// introduction to the app. The overlay is for a bare open; the version badge and its
@@ -150,7 +151,7 @@ export function startWhatsNew() {
// COMMITTED assertion — measured: whats-new went red on my machine and would have
// stayed green in CI, which is the worst shape a local override can take. The debug
// hook is the one reliable signal that this page is a test.
- const underTest = !!localStorage.getItem('debugStores');
+ const underTest = !!safeStorage.getItem('debugStores');
const skipEnv = !underTest && String(import.meta.env.VITE_SKIP_WELCOME ?? '') === 'true';
const welcomeThisBoot = !invited && !skipEnv && (firstVisit || get(showWelcomeOnStart));
if (welcomeThisBoot) welcomeOpen.set(true);
@@ -161,7 +162,7 @@ export function startWhatsNew() {
return;
}
if (!get(showWhatsNewNotice)) return;
- const lastSeen = localStorage.getItem(LAST_SEEN_VERSION);
+ const lastSeen = safeStorage.getItem(LAST_SEEN_VERSION);
// IS_DEV: the version string is constant across dev reloads, so this stays quiet
// after the first acknowledgement instead of nagging every HMR restart.
if (!lastSeen) {
diff --git a/src/lib/windowTabs.js b/src/lib/windowTabs.js
index 022ed670..b1e8100b 100644
--- a/src/lib/windowTabs.js
+++ b/src/lib/windowTabs.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
// Window tab groups (phase 83, floating windows only — docked splits stay in
// pending/81). Grouped windows share ONE rect; the active member is visible,
@@ -19,7 +20,7 @@ let nextId = 1;
function persist() {
try {
- localStorage.setItem(
+ safeStorage.setItem(
'windowTabGroups',
JSON.stringify(get(tabGroups).map(({ id, members, active, rect }) => ({ id, members, active, rect })))
);
@@ -43,7 +44,7 @@ const migrateKey = (key) => KEY_ALIASES[key] ?? key;
/** @type {any[]} groups waiting for their members to register+open again */
let pendingRestore = [];
try {
- pendingRestore = JSON.parse(localStorage.getItem('windowTabGroups') ?? '[]').map(
+ pendingRestore = JSON.parse(safeStorage.getItem('windowTabGroups') ?? '[]').map(
(/** @type {any} */ saved) => ({
...saved,
members: (saved.members ?? []).map(migrateKey),
diff --git a/src/lib/wireErrors.js b/src/lib/wireErrors.js
new file mode 100644
index 00000000..2b2009de
--- /dev/null
+++ b/src/lib/wireErrors.js
@@ -0,0 +1,105 @@
+import { writable, get } from 'svelte/store';
+import { log, registerDiagnosticsSection } from './diagnostics';
+import { showToast } from '../stores/appStore';
+
+// 27-A (hardening audit H1) — WHEN A PEER'S MESSAGES FAIL, SOMEBODY SHOULD KNOW.
+//
+// Before this, a malformed or unknown message threw out of `conn.on('data')` into peerjs,
+// where nothing caught it and nothing counted it. Two peers on different releases could
+// spend a whole session failing to exchange one domain, and the only symptom was a
+// feature that "did not work" for one of them.
+//
+// A SEPARATE LEAF from diagnostics.js on purpose: that module is deliberately
+// zero-dependency (version.js and svelte/store only) so ANY module can log without
+// thinking about cycles, and it must not grow an import of appStore for a toast. This
+// file is the consumer — it uses diagnostics' own registration seam to contribute a
+// section, which is exactly what that seam exists for.
+//
+// THE RATE LIMIT IS THE POINT. A peer sending a bad message per frame would otherwise
+// produce a toast per frame; the counters stay exact while the user is told once.
+
+/** How many failures from one (peer, type) pair before the user is told. */
+const TOAST_AFTER = 5;
+/** …and how long before that pair may raise another toast. */
+const TOAST_COOLDOWN_MS = 60_000;
+
+/** @typedef {{count: number, first: number, last: number, sample: string}} WireFailure */
+
+/** @type {Map} keyed `|` */
+const failures = new Map();
+/** @type {Map} last toast per peer, so one bad peer cannot spam */
+const lastToastAt = new Map();
+
+/** Bumped on every recorded failure, so a panel can react without reading the map. */
+export const wireErrorCount = writable(0);
+
+/** @param {string} peerId */
+const shortId = (peerId) => String(peerId || '?').slice(0, 6).toUpperCase();
+
+/**
+ * Record one failed message.
+ * @param {string} peerId
+ * @param {string} type the message type, or a pseudo-type: 'shape' (not an object),
+ * 'invalid' (failed its validator), 'unknown:' (no branch), 'threw' (applier threw)
+ * @param {unknown} [error]
+ */
+export function noteWireError(peerId, type, error) {
+ const key = peerId + '|' + type;
+ const now = Date.now();
+ const entry = failures.get(key) ?? { count: 0, first: now, last: now, sample: '' };
+ entry.count++;
+ entry.last = now;
+ if (error !== undefined && !entry.sample) entry.sample = String(error).slice(0, 200);
+ failures.set(key, entry);
+ wireErrorCount.update((n) => n + 1);
+
+ // The first three carry the detail; after that the counter is the record.
+ if (entry.count <= 3)
+ log('warn', 'wire', 'message from ' + shortId(peerId) + ' failed (' + type + ')', entry.sample || undefined);
+
+ if (entry.count === TOAST_AFTER) {
+ const since = lastToastAt.get(peerId) ?? 0;
+ if (now - since > TOAST_COOLDOWN_MS) {
+ lastToastAt.set(peerId, now);
+ showToast(
+ 'Messages from ' + shortId(peerId) + ' are failing (' + type + ') - you may be on different versions.'
+ );
+ }
+ }
+}
+
+/** Everything recorded, newest-first. Read by the diagnostics bundle and by tests. */
+export function wireErrors() {
+ return [...failures.entries()]
+ .map(([key, v]) => {
+ const [peerId, type] = key.split('|');
+ return { peerId, type, ...v };
+ })
+ .sort((a, b) => b.last - a.last);
+}
+
+/** Total failures recorded (tests read this rather than the store). */
+export function wireErrorTotal() {
+ return get(wireErrorCount);
+}
+
+/** Drop a departed peer's rows — golden rule 3's cleanup obligation. @param {string} peerId */
+export function dropWireErrors(peerId) {
+ for (const key of [...failures.keys()]) if (key.startsWith(peerId + '|')) failures.delete(key);
+ lastToastAt.delete(peerId);
+}
+
+/** Tests, and a fresh session. */
+export function clearWireErrors() {
+ failures.clear();
+ lastToastAt.clear();
+ wireErrorCount.set(0);
+}
+
+let registered = false;
+/** Contribute the counters to the diagnostics bundle (idempotent). */
+export function startWireErrors() {
+ if (registered) return;
+ registered = true;
+ registerDiagnosticsSection('wire', () => ({ total: get(wireErrorCount), failures: wireErrors().slice(0, 20) }));
+}
diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js
new file mode 100644
index 00000000..9f5276de
--- /dev/null
+++ b/src/lib/wireValidate.js
@@ -0,0 +1,142 @@
+// 27-A (hardening audit H1 + M7) — WHAT A MESSAGE MUST LOOK LIKE BEFORE IT IS APPLIED.
+//
+// The dispatcher trusted every payload's SHAPE. `data.hosts.forEach`, `data.forEach` in
+// the userdata applier, `lockeditems.filter`, `moveGeometry(data.pos[0], …)` — each one
+// throws on a malformed message, and the dispatcher had no try/catch, so ONE bad message
+// from ONE peer took down that connection's entire handler. The A1 comment in
+// commandsHandler already records an instance of exactly that ("one stray message takes
+// the whole connection handler down").
+//
+// A ZERO-IMPORT LEAF, so the dispatcher can validate before touching any applier, and so
+// this is unit-testable with no browser, no peer and no scene.
+//
+// THE RULE THAT KEEPS IT ADDITIVE: an ABSENT entry means ALLOW. A peer one release ahead
+// sends types this table has never heard of, and the correct answer to "I do not know
+// this message" is to pass it to a dispatcher that counts it as unknown — never to
+// reject it on shape. So this table only ever describes types we DO know, and a new
+// message type needs no entry to work.
+//
+// NaN IS THE OTHER HALF. A non-finite transform is worse than a malformed one: it applies
+// cleanly, poisons the object's matrix, and from there every consumer that measures the
+// scene (Box3 for bounds, frame-to-fit, the physics body's next step) reads NaN forever
+// with nothing pointing back at the message that did it.
+
+/** @param {unknown} v */
+export function isUuid(v) {
+ return typeof v === 'string' && v.length > 0 && v.length <= 64;
+}
+
+/** Every element finite, exactly `n` of them. @param {unknown} v @param {number} n */
+export function isFiniteArray(v, n) {
+ return Array.isArray(v) && v.length === n && v.every((x) => typeof x === 'number' && Number.isFinite(x));
+}
+
+/** @param {unknown} v */
+export function isVec3(v) {
+ return isFiniteArray(v, 3);
+}
+
+/** A rotation on the wire is an Euler triple or a quaternion. @param {unknown} v */
+export function isQuatOrEuler(v) {
+ return isFiniteArray(v, 3) || isFiniteArray(v, 4);
+}
+
+/** @param {unknown} v */
+export function isArray(v) {
+ return Array.isArray(v);
+}
+
+/**
+ * Keep a transform APPLICABLE: every non-finite component falls back to the value the
+ * object already has, so a partly-broken message moves what it can and poisons nothing.
+ * Returns null when there is nothing usable at all, so the caller can skip the write.
+ * @param {any} pos @param {any} rot @param {any} scale
+ * @param {{pos: number[], rot: number[], scale: number[]}} current
+ * @returns {{pos: number[], rot: number[], scale: number[], repaired: boolean} | null}
+ */
+export function sanitizeTransform(pos, rot, scale, current) {
+ if (!Array.isArray(pos) && !Array.isArray(rot) && !Array.isArray(scale)) return null;
+ let repaired = false;
+ /** @param {any} src @param {number[]} fallback @param {number} n */
+ const fix = (src, fallback, n) => {
+ /** @type {number[]} */
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const v = Array.isArray(src) ? src[i] : undefined;
+ if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
+ else {
+ out.push(fallback[i] ?? 0);
+ repaired = true;
+ }
+ }
+ return out;
+ };
+ return {
+ pos: fix(pos, current.pos, 3),
+ rot: fix(rot, current.rot, 3),
+ scale: fix(scale, current.scale, 3),
+ repaired
+ };
+}
+
+/**
+ * Per-type shape tests. ABSENT MEANS ALLOW — see the header. Deliberately shallow: this
+ * is the difference between "will this throw inside an applier" and "is this message
+ * semantically right", and only the first is the dispatcher's business.
+ * @type {Record boolean>}
+ */
+export const VALIDATORS = {
+ hosts: (d) => isArray(d.hosts),
+ userdata: (d) => isArray(d.userdata),
+ locked: (d) => isArray(d.lockeditems),
+ lock: (d) => isUuid(d.uuid) && (d.uuids === undefined || isArray(d.uuids)),
+ unlock: (d) => d.peerId === undefined || typeof d.peerId === 'string',
+ clearscene: (d) => typeof d.peerId === 'string',
+ delete: (d) => isUuid(d.uuid),
+ name: (d) => isUuid(d.uuid) && typeof d.name === 'string',
+ move: (d) => isUuid(d.uuid) && isVec3(d.pos) && isQuatOrEuler(d.rot) && isVec3(d.scale),
+ throw: (d) => isUuid(d.uuid),
+ simulate: (d) => typeof d.running === 'boolean' || typeof d.paused === 'boolean',
+ loading: (d) => isArray(d.uuids),
+ object: (d) => d.element !== undefined,
+ group: (d) => d.uuid !== undefined,
+ duplicate: (d) => isUuid(d.sourceUuid) && isArray(d.uuids),
+ nodes: (d) => isArray(d.nodes) && isArray(d.edges),
+ nodesync: (d) => typeof d.hash === 'string' && typeof d.count === 'number',
+ nodecreate: (d) => !!d.node && typeof d.node === 'object',
+ nodedata: (d) => typeof d.id === 'string' && !!d.data && typeof d.data === 'object',
+ nodedelete: (d) => isArray(d.ids),
+ edgecreate: (d) => !!d.edge && typeof d.edge === 'object',
+ edgedelete: (d) => isArray(d.ids),
+ nodedefs: (d) => isArray(d.defs),
+ verts: (d) => isUuid(d.uuid) && isArray(d.indices),
+ meshgeo: (d) => isUuid(d.uuid) && d.positions !== undefined,
+ assetstart: (d) => typeof d.hash === 'string' && typeof d.chunks === 'number' && typeof d.size === 'number',
+ assetchunk: (d) => typeof d.hash === 'string' && Number.isInteger(d.seq),
+ assetfile: (d) => typeof d.hash === 'string',
+ manifest: (d) => !!d.manifest && typeof d.manifest === 'object',
+ environment: (d) => !!d && typeof d === 'object',
+ atscene: (d) => typeof d.peerId === 'string',
+ disconnected: (d) => typeof d.peerId === 'string',
+ annotations: (d) => isArray(d.annotations),
+ joints: (d) => isArray(d.joints),
+ triggers: (d) => !!d.triggers && typeof d.triggers === 'object',
+ peervars: (d) => typeof d.peerId === 'string',
+ playmode: (d) => typeof d.peerId === 'string',
+ camera: (d) => typeof d.peerId === 'string' && isVec3(d.position) && isFiniteArray(d.rotation, 3)
+};
+
+/**
+ * @param {any} data a message already known to be a non-null object with a `type`
+ * @returns {boolean} true when it is safe to hand to the appliers
+ */
+export function validateWireMessage(data) {
+ const check = VALIDATORS[data.type];
+ if (!check) return true; // unknown to this table = a newer peer's type = allow
+ try {
+ return !!check(data);
+ } catch {
+ // a validator that throws on a hostile shape is itself a rejection
+ return false;
+ }
+}
diff --git a/src/stores/appStore.js b/src/stores/appStore.js
index ef0de1d7..c24c802d 100644
--- a/src/stores/appStore.js
+++ b/src/stores/appStore.js
@@ -1,4 +1,5 @@
import { writable, derived, get } from 'svelte/store';
+import { safeStorage } from '../lib/safeStorage';
/** @type {import('svelte/store').Writable} */
export const settingsOpen = writable(null);
@@ -19,12 +20,12 @@ export const inspectorKind = writable('selection');
* it). LOCAL preference.
*/
export const inspectorPinned = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('inspectorPinned') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('inspectorPinned') === 'true'
);
if (typeof localStorage !== 'undefined')
inspectorPinned.subscribe((v) => {
try {
- localStorage.setItem('inspectorPinned', String(v));
+ safeStorage.setItem('inspectorPinned', String(v));
} catch {}
});
export const flowGraphClose = writable(true);
@@ -124,7 +125,7 @@ export const username = writable(null);
// local player's avatar configuration (userdata slot 5, replicated to peers)
const storedAvatarConfig =
- typeof localStorage !== 'undefined' ? localStorage.getItem('avatarConfig') : null;
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('avatarConfig') : null;
/** @type {import('svelte/store').Writable<{body: string, hat: string, face: string}>} */
export const avatarConfig = writable(
storedAvatarConfig ? JSON.parse(storedAvatarConfig) : { body: '#4f83cc', hat: 'none', face: 'label' }
@@ -340,46 +341,46 @@ export const viewportMenuOpener = writable(null);
/** @type {import('svelte/store').Writable} */
export const objectSearch = writable(null);
export const objectSearchEnabled = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('objectSearchEnabled') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('objectSearchEnabled') === 'true'
);
objectSearchEnabled.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('objectSearchEnabled', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('objectSearchEnabled', String(on));
});
// advanced mode: reveals system objects (module content, environment rig)
// in the object list behind a System filter chip
export const advancedMode = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('advancedMode') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('advancedMode') === 'true'
);
advancedMode.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('advancedMode', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('advancedMode', String(on));
});
// object list: reveal the environment group behind an Environment chip (70.4)
export const showEnvInList = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('showEnvInList') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('showEnvInList') === 'true'
);
showEnvInList.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('showEnvInList', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('showEnvInList', String(on));
});
// A3 (roadmap #13): show the physics simulation transport (SimControls HUD).
// Default OFF — the standalone ▶/⏸/⏹ HUD confuses with the main play button in
// Controls; the P shortcut still starts/stops the sim when this is hidden.
export const showSimControls = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('showSimControls') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('showSimControls') === 'true'
);
showSimControls.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('showSimControls', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('showSimControls', String(on));
});
// N4: Explorer 3D model preview — a rotatable inline preview in Properties + a
// popup on open. Global (all of Explorer), persisted; off by default.
export const enable3dPreview = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('enable3dPreview') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('enable3dPreview') === 'true'
);
enable3dPreview.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('enable3dPreview', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('enable3dPreview', String(on));
});
// 21-H3: dropping a MULTI-selection into the viewport. OFF = the N objects SPREAD in
@@ -388,10 +389,10 @@ enable3dPreview.subscribe((on) => {
// stack. A LOCAL pref like every other Explorer setting — `explorerDrop` reads it and
// nothing about it goes on the wire (each placement replicates through its own path).
export const stackOnDrop = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('explorerStackOnDrop') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('explorerStackOnDrop') === 'true'
);
stackOnDrop.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('explorerStackOnDrop', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('explorerStackOnDrop', String(on));
});
// 21-I3 (locked answer 6): "Update from selection" REPLACES a prefab's bytes instantly
@@ -400,20 +401,20 @@ stackOnDrop.subscribe((on) => {
// can undo does not need a dialog in front of it, and the Undo is the safety net. A
// LOCAL pref like every other Explorer setting; nothing about it goes on the wire.
export const confirmPrefabUpdate = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('confirmPrefabUpdate') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('confirmPrefabUpdate') === 'true'
);
confirmPrefabUpdate.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('confirmPrefabUpdate', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('confirmPrefabUpdate', String(on));
});
// Shift+A quick-add (the cursor-anchored Add popover). Opt-in, persisted; OFF by
// default — Shift is a camera-strafe modifier in fly mode, so the shortcut only
// exists for users who ask for it in Settings.
export const enableShiftAdd = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('enableShiftAdd') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('enableShiftAdd') === 'true'
);
enableShiftAdd.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('enableShiftAdd', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('enableShiftAdd', String(on));
});
/**
@@ -433,7 +434,7 @@ enableShiftAdd.subscribe((on) => {
export const touchTools = writable(
(() => {
if (typeof localStorage === 'undefined') return false;
- const stored = localStorage.getItem('touchTools');
+ const stored = safeStorage.getItem('touchTools');
if (stored !== null) return stored === 'true';
const coarse =
typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
@@ -442,7 +443,7 @@ export const touchTools = writable(
})()
);
touchTools.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('touchTools', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('touchTools', String(on));
});
// The sticky additive-selection MODE the cluster toggles. Touch cannot hold a modifier,
@@ -459,32 +460,32 @@ export const multiSelectMode = writable(false);
// what MY copy command does is not scene data.
export const duplicateCarriesAnimation = writable(
typeof localStorage === 'undefined' ||
- localStorage.getItem('duplicateCarriesAnimation') !== 'false'
+ safeStorage.getItem('duplicateCarriesAnimation') !== 'false'
);
duplicateCarriesAnimation.subscribe((on) => {
if (typeof localStorage !== 'undefined')
- localStorage.setItem('duplicateCarriesAnimation', String(on));
+ safeStorage.setItem('duplicateCarriesAnimation', String(on));
});
export const duplicateCarriesFlow = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('duplicateCarriesFlow') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('duplicateCarriesFlow') !== 'false'
);
duplicateCarriesFlow.subscribe((on) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('duplicateCarriesFlow', String(on));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('duplicateCarriesFlow', String(on));
});
export const duplicateCarriesShader = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('duplicateCarriesShader') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('duplicateCarriesShader') !== 'false'
);
duplicateCarriesShader.subscribe((on) => {
if (typeof localStorage !== 'undefined')
- localStorage.setItem('duplicateCarriesShader', String(on));
+ safeStorage.setItem('duplicateCarriesShader', String(on));
});
export const noteDoubleClickToOpen = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('noteDoubleClickToOpen') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('noteDoubleClickToOpen') === 'true'
);
noteDoubleClickToOpen.subscribe((on) => {
if (typeof localStorage !== 'undefined')
- localStorage.setItem('noteDoubleClickToOpen', String(on));
+ safeStorage.setItem('noteDoubleClickToOpen', String(on));
});
// E1 (roadmap #13): notification center — a persisted history of everything that
@@ -495,7 +496,7 @@ export const notifications = writable(
(() => {
if (typeof localStorage === 'undefined') return [];
try {
- return JSON.parse(localStorage.getItem('notifications') || '[]');
+ return JSON.parse(safeStorage.getItem('notifications') || '[]');
} catch {
return [];
}
@@ -504,7 +505,7 @@ export const notifications = writable(
notifications.subscribe((list) => {
if (typeof localStorage === 'undefined') return;
try {
- localStorage.setItem('notifications', JSON.stringify(list.slice(-50)));
+ safeStorage.setItem('notifications', JSON.stringify(list.slice(-50)));
} catch {
/* storage full / disabled */
}
@@ -546,11 +547,11 @@ export const connectBarHeight = writable(0);
* hidden. Toggle in Settings; a `.allow-undock` root class drives the CSS, and the
* panels read this to decide whether to force-dock on load. Persisted. */
export const mobileUndockAllowed = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('mobileUndockAllowed') === 'true' : false
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('mobileUndockAllowed') === 'true' : false
);
if (typeof localStorage !== 'undefined') {
mobileUndockAllowed.subscribe((v) => {
- try { localStorage.setItem('mobileUndockAllowed', v ? 'true' : 'false'); } catch { /* */ }
+ try { safeStorage.setItem('mobileUndockAllowed', v ? 'true' : 'false'); } catch { /* */ }
if (typeof document !== 'undefined') document.documentElement.classList.toggle('allow-undock', !!v);
});
}
@@ -567,11 +568,11 @@ if (typeof localStorage !== 'undefined') {
* shipped default-off, because the subscriber writes on the first flush — would be
* pinned OFF forever with no way to tell that from never having chosen. Absent = ON. */
export const floatingToolbar = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('floatingToolbar') !== 'false' : true
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('floatingToolbar') !== 'false' : true
);
if (typeof localStorage !== 'undefined') {
floatingToolbar.subscribe((v) => {
- try { localStorage.setItem('floatingToolbar', v ? 'true' : 'false'); } catch { /* */ }
+ try { safeStorage.setItem('floatingToolbar', v ? 'true' : 'false'); } catch { /* */ }
});
}
@@ -596,43 +597,43 @@ if (typeof localStorage !== 'undefined') {
* fresh key makes absent mean "never chose" again. The pref never shipped in a tagged
* release, so there is nothing real to migrate. */
export const toolbarAlwaysOnTop = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('toolbarOnTop') === 'true' : false
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('toolbarOnTop') === 'true' : false
);
if (typeof localStorage !== 'undefined') {
toolbarAlwaysOnTop.subscribe((v) => {
- try { localStorage.setItem('toolbarOnTop', v ? 'true' : 'false'); } catch { /* */ }
+ try { safeStorage.setItem('toolbarOnTop', v ? 'true' : 'false'); } catch { /* */ }
});
}
/** PINNED: keep the drawer's tab bar (+ status) visible even when the body is
* collapsed, so it acts as a persistent mini-bar under the pill. Persisted. */
export const connectDrawerPinned = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('connectDrawerPinned') === 'true' : false
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('connectDrawerPinned') === 'true' : false
);
/** Route toasts into the drawer's Toasts tab only — hide the viewport pop-ups even
* when the drawer is closed (they still live in the Toasts tab + notification bell).
* Persisted. */
export const toastsInDrawerOnly = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('toastsInDrawerOnly') === 'true' : false
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('toastsInDrawerOnly') === 'true' : false
);
if (typeof localStorage !== 'undefined') {
connectDrawerPinned.subscribe((v) => {
- try { localStorage.setItem('connectDrawerPinned', v ? 'true' : 'false'); } catch { /* */ }
+ try { safeStorage.setItem('connectDrawerPinned', v ? 'true' : 'false'); } catch { /* */ }
});
toastsInDrawerOnly.subscribe((v) => {
- try { localStorage.setItem('toastsInDrawerOnly', v ? 'true' : 'false'); } catch { /* */ }
+ try { safeStorage.setItem('toastsInDrawerOnly', v ? 'true' : 'false'); } catch { /* */ }
});
}
/** Show the "Local objects" section in the object list (viewer WIP / editor-shareable
* objects). OFF by default — auto-enabled when the first local object is made; also
* togglable under the object-list filter cog. Persisted. */
export const showLocalObjects = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('showLocalObjects') === 'true' : false
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('showLocalObjects') === 'true' : false
);
if (typeof localStorage !== 'undefined') {
showLocalObjects.subscribe((v) => {
try {
- localStorage.setItem('showLocalObjects', v ? 'true' : 'false');
+ safeStorage.setItem('showLocalObjects', v ? 'true' : 'false');
} catch {
/* storage disabled */
}
@@ -643,12 +644,12 @@ if (typeof localStorage !== 'undefined') {
* cloud plugin is present). Default ON for discoverability; users can hide it and
* still reach rooms via the chevron drawer's Rooms tab. Persisted. */
export const showRoomsButton = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('showRoomsButton') !== 'false' : true
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('showRoomsButton') !== 'false' : true
);
if (typeof localStorage !== 'undefined') {
showRoomsButton.subscribe((v) => {
try {
- localStorage.setItem('showRoomsButton', v ? 'true' : 'false');
+ safeStorage.setItem('showRoomsButton', v ? 'true' : 'false');
} catch {
/* storage disabled */
}
@@ -723,8 +724,15 @@ export function dismissToastById(id) {
toastStore.update((list) => list.filter((entry) => !(entry && entry.id === id)));
}
+/** 26-B: the uuids of an inbound object batch still outstanding. Typed, because it is
+ * now WRITTEN as an array (the old code spliced in place and re-assigned), and
+ * `writable([])` alone infers `never[]`. */
+/** @type {import('svelte/store').Writable} */
export const loading = writable([]);
-export const loadingcount = writable([]);
+/** How many the batch announced. It was initialised to `[]` and only ever held a
+ * number; `[] > 0` is false and `[] - n` is `-n`, so 0 reads identically. */
+/** @type {import('svelte/store').Writable} */
+export const loadingcount = writable(0);
export const loadingFile = writable([]);
export const messages = writable([]);
diff --git a/src/stores/flowStore.js b/src/stores/flowStore.js
index 86961931..9c0a1928 100644
--- a/src/stores/flowStore.js
+++ b/src/stores/flowStore.js
@@ -1,4 +1,5 @@
import { writable, get } from 'svelte/store';
+import { safeStorage } from '../lib/safeStorage';
// Shared node graph state, replicated between peers.
//
@@ -194,6 +195,16 @@ export function clearGraphs() {
// scene object uuids whose flow effects (animations/colors) are muted locally
/** @type {import('svelte/store').Writable} */
+/**
+ * 27-C: the flow runtime has STOPPED ticking after repeated failures (audit top-10 #3).
+ * It lives HERE rather than in flowRuntime because 27-D's safe-mode boot sets it before
+ * the runtime starts, and because flowRuntime sits inside the documented history cycle.
+ * A store, so the Resume toast and any future indicator read one truth.
+ * @type {import('svelte/store').Writable<{paused: boolean, reason: string}>}
+ */
+export const flowPaused = writable({ paused: false, reason: '' });
+
+/** @type {import('svelte/store').Writable} */
export const mutedFlowObjects = writable([]);
// live output value of each value/logic node (133), for the on-card readouts --
@@ -214,7 +225,7 @@ export const flowCursors = writable({});
// animations use wall-clock time so phases match across peers (NTP keeps
// machines within tens of ms); off = local page time like before
export const syncedAnimations = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('syncedAnimations') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('syncedAnimations') !== 'false'
);
// user-designed node definitions ({id, name, params, code}), replicated
diff --git a/src/stores/sceneStore.js b/src/stores/sceneStore.js
index 59f5167b..4ffec3b0 100644
--- a/src/stores/sceneStore.js
+++ b/src/stores/sceneStore.js
@@ -1,6 +1,7 @@
import { writable } from 'svelte/store';
// dependency-free helper, so importing it keeps this store a leaf
import { coarsePointer } from '../lib/inputDevice';
+import { safeStorage } from '../lib/safeStorage';
/** @type {import('svelte/store').Writable} */
export const globalScene = writable(null);
@@ -51,6 +52,15 @@ export const globalCamera = writable(null);
export const camSave = writable(null);
/** @type {import('svelte/store').Writable} */
export const globalRenderer = writable(null);
+
+/**
+ * 27-G (audit M13): the WebGL context has been lost. A lost context is SILENT — the
+ * canvas simply stops updating while every other part of the app keeps responding, so it
+ * reads to a user as "it froze" with nothing to act on. This drives the overlay that says
+ * what happened and offers a way out.
+ * @type {import('svelte/store').Writable}
+ */
+export const contextLost = writable(false);
/** @type {import('svelte/store').Writable} */
export const orbitControls = writable(null);
/**
@@ -70,45 +80,45 @@ export const peerHands = writable({});
// --- VR control suite ---
// which hand carries the quick-menu (the other hand is the pointer)
export const vrMenuHand = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('vrMenuHand') || 'right' : 'right'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('vrMenuHand') || 'right' : 'right'
);
export const vrMenuOpen = writable(false);
// snap-turn angle in degrees (15 / 30 / 45, or 0 = off — 155)
export const vrSnapAngle = writable(
- typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('vrSnapAngle') || '45') : 45
+ typeof localStorage !== 'undefined' ? parseInt(safeStorage.getItem('vrSnapAngle') || '45') : 45
);
// mirror snap-turn direction (155): left flick turns right and vice-versa
export const vrMirrorSnapTurn = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('vrMirrorSnapTurn') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('vrMirrorSnapTurn') === 'true'
);
// teleport locomotion (157): default ON; off disables the right-stick-up arc
export const vrTeleportEnabled = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('vrTeleportEnabled') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('vrTeleportEnabled') !== 'false'
);
// VR sleeve palette (K1, experimental): a forearm strip of ghost primitives on
// the LEFT controller (mirrors right when the menu owns the left hand) —
// trigger-drag a ghost out to place it. DEFAULT OFF.
export const vrSleeveEnabled = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('vrSleeveEnabled') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('vrSleeveEnabled') === 'true'
);
// vertex grab style (182): default HOLD (trigger held = carry, release = drop);
// OFF = the toggle style (press to grab, press again to drop)
export const vrVertexHold = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('vrVertexHold') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('vrVertexHold') !== 'false'
);
// VR flying: left-stick movement follows the controller aim (pitch included)
export const vrFlying = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('vrFlying') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('vrFlying') === 'true'
);
// passthrough preference (90): the VR button requests immersive-ar instead of
// immersive-vr on the NEXT session start (WebXR can't hot-swap modes)
export const vrPassthrough = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('vrPassthrough') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('vrPassthrough') === 'true'
);
// radial menu open style (74): false = B/Y toggles (default), true = hold B/Y
// and release over a sector to activate it
export const vrMenuHold = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('vrMenuHold') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('vrMenuHold') === 'true'
);
// native VR objects panel (101), opened from the radial Objects sector
export const vrObjectsPanelOpen = writable(false);
@@ -141,18 +151,18 @@ export const vrApprovePanelOpen = writable(false);
export const vrToolMode = writable('select');
// B2.1 (roadmap 9): target VR refresh rate — 'auto' picks the highest supported
export const vrTargetHz = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('vrTargetHz') || 'auto' : 'auto'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('vrTargetHz') || 'auto' : 'auto'
);
vrTargetHz.subscribe((v) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('vrTargetHz', String(v));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('vrTargetHz', String(v));
});
// B2.3: how everyone's hand-tracked peers render LOCALLY — 'hands' (cuboid bones)
// or 'spheres' (joint dots). A per-viewer preference, never replicated.
export const peerHandStyle = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('peerHandStyle') || 'hands' : 'hands'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('peerHandStyle') || 'hands' : 'hands'
);
peerHandStyle.subscribe((v) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('peerHandStyle', String(v));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('peerHandStyle', String(v));
});
// Viewport render mode (V-2): LOCAL per-viewer, never replicated —
// 'shaded' | 'shaded-ao' (default on desktop) | 'wireframe' | 'custom'
@@ -163,7 +173,7 @@ peerHandStyle.subscribe((v) => {
// scenePost.adoptCustomView(), which only ever promotes a viewer who has not
// explicitly picked a mode (see chooseViewMode).
function defaultViewMode() {
- const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('viewMode') : null;
+ const stored = typeof localStorage !== 'undefined' ? safeStorage.getItem('viewMode') : null;
if (stored) return stored;
// AO is a FULLSCREEN pass: a poor default on a phone GPU even when it works,
// and several mobile drivers mis-compile it (the viewport then keeps showing a
@@ -174,21 +184,21 @@ function defaultViewMode() {
}
export const viewMode = writable(defaultViewMode());
viewMode.subscribe((v) => {
- if (typeof localStorage !== 'undefined') localStorage.setItem('viewMode', String(v));
+ if (typeof localStorage !== 'undefined') safeStorage.setItem('viewMode', String(v));
});
// VR snap MODE (156): 'off' | 'grid' | 'surface' | 'rotation'
export const vrSnapMode = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('vrSnapMode') || 'off' : 'off'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('vrSnapMode') || 'off' : 'off'
);
// 115: true = the prefabs window is world-fixed (📌), false = lazy-follows the view
export const vrPrefabsPinned = writable(false);
// VR selection indicator style (110): wireframe (default) or the shell
export const vrWireframeSelection = writable(
- typeof localStorage === 'undefined' || localStorage.getItem('vrWireframe') !== 'false'
+ typeof localStorage === 'undefined' || safeStorage.getItem('vrWireframe') !== 'false'
);
// stats card on the pointer controller (102) — persisted so it re-attaches
export const vrStatsOpen = writable(
- typeof localStorage !== 'undefined' && localStorage.getItem('vrStats') === 'true'
+ typeof localStorage !== 'undefined' && safeStorage.getItem('vrStats') === 'true'
);
// true while an AR (passthrough) session presents — a LOCAL view mode: the
// scene background/fog go transparent so the room shows through; the
@@ -211,7 +221,104 @@ export const gizmoSuppressed = writable(false);
export const vrTransformMode = writable('move');
/** grab style (100): 'rigid' = controller-as-handle (default); 'move'/'rotate' = legacy gizmo grabs */
export const vrGrabStyle = writable(
- typeof localStorage !== 'undefined' ? localStorage.getItem('vrGrabStyle') ?? 'rigid' : 'rigid'
+ typeof localStorage !== 'undefined' ? safeStorage.getItem('vrGrabStyle') ?? 'rigid' : 'rigid'
);
/** handedness currently holding a grab ('left'|'right'|null) — gates that hand's stick */
export const vrGrabbedHand = writable(null);
+
+// ---------------------------------------------------------------------------
+// 26-B (hardening audit M6) — THE ONE PLACE A SCENE MUTATION IS ANNOUNCED.
+//
+// THE FINDING: `objectsGroup.update((v) => v)` sat at 117 call sites and eighteen
+// subscribers hang off it, several of which TRAVERSE the whole tree (the Controls
+// status-line walk, `refreshFilter`, `shadowDefaults.sweep`, the collider / camera /
+// light helper sweeps, the shader reconcile, `sceneAssets.schedule`). A 1,000-object
+// handshake therefore ran 1,000 pokes x ~8 traversals x 1,000 nodes — about 8M node
+// visits, synchronously, on the receive path — which IS the reported "the window
+// freezes while a big scene arrives". The same shape on `/clear` + restore and on any
+// bulk import.
+//
+// The mutation itself is unchanged: `pokeScene()` still ends in the same identity
+// update and every subscriber still sees the same value. What changes is HOW MANY
+// times: at most one flush per microtask normally, and at most one per frame while an
+// INGEST BATCH is open. N pokes inside one task become one.
+//
+// WHY A MICROTASK AND NOT rAF as the default: a microtask lands before the browser
+// paints and before any `await` continuation, so nothing that reads a subscriber's
+// output after yielding can observe a stale tree — and it still runs in a backgrounded
+// tab, which rAF does not. The batch mode uses a TIMER for the same reason: a hidden
+// tab throttles it to ~1Hz instead of stopping, so an ingest that starts and then loses
+// focus still converges.
+//
+// This lives in the STORE and not in a new leaf on purpose: all 37 files that poke
+// already import from here, so the seam costs no import edge anywhere — which matters,
+// because the pokers include peerHandler, flowRuntime, autosave and history, i.e. every
+// module inside the documented import cycles.
+// ---------------------------------------------------------------------------
+
+/** Bumped on every flush. A subscriber that caches an expensive traversal can key it
+ * off this instead of re-walking; it is also what the 26-A meter samples. LOCAL — it
+ * never replicates, saves or undoes. */
+export const sceneRevision = writable(0);
+
+/** One poke per this many ms while an ingest batch is open (~one frame at 60Hz). */
+const POKE_BATCH_MS = 16;
+
+let pokePending = false;
+/** @type {any} */
+let pokeTimer = null;
+let batchDepth = 0;
+
+function flushScenePoke() {
+ pokePending = false;
+ if (pokeTimer !== null) {
+ clearTimeout(pokeTimer);
+ pokeTimer = null;
+ }
+ sceneRevision.update((n) => n + 1);
+ objectsGroup.update((value) => value);
+}
+
+/**
+ * Announce that the THREE tree under `objectsGroup` changed. Coalesced — see above.
+ * Every former `objectsGroup.update((v) => v)` call site calls this instead.
+ */
+export function pokeScene() {
+ if (batchDepth > 0) {
+ // batch mode: a timer already armed means this poke is already covered
+ if (pokeTimer !== null) return;
+ pokePending = true;
+ pokeTimer = setTimeout(flushScenePoke, POKE_BATCH_MS);
+ return;
+ }
+ if (pokePending) return;
+ pokePending = true;
+ queueMicrotask(flushScenePoke);
+}
+
+/**
+ * Open an ingest batch: while one is open, pokes flush at most once per frame instead
+ * of once per microtask. Refcounted, so nested batches (an import inside a handshake)
+ * compose. ALWAYS pair with `endSceneBatch` in a `finally`.
+ */
+export function beginSceneBatch() {
+ batchDepth++;
+}
+
+/** Close an ingest batch and flush immediately, so the last object of a batch is on
+ * screen without waiting out a frame. */
+export function endSceneBatch() {
+ batchDepth = Math.max(0, batchDepth - 1);
+ if (batchDepth === 0 && pokePending) flushScenePoke();
+}
+
+/** Flush any pending poke right now. For the paths that must not yield first (a
+ * serializer about to read the tree) and for the suite. */
+export function flushScenePokes() {
+ if (pokePending) flushScenePoke();
+}
+
+/** Is an ingest batch open? Read by the suite and by the 26-A meter. */
+export function sceneBatchOpen() {
+ return batchDepth > 0;
+}
diff --git a/tests/e2e/approval-timeout.test.cjs b/tests/e2e/approval-timeout.test.cjs
new file mode 100644
index 00000000..0b77f8e0
--- /dev/null
+++ b/tests/e2e/approval-timeout.test.cjs
@@ -0,0 +1,222 @@
+// 27-E (roadmap 25, audit H3 + H7 + M10 + L7) — A REQUEST THAT ENDS, AND A ROOM WITH A SIZE.
+//
+// Before this, an approval could hang forever on BOTH sides. The joiner sat on
+// "Requesting AB12" with no countdown and no end; the host collected a card per dial with
+// nothing ever dropping them; `peer-unavailable` toasted "unreachable" while the pill
+// still said "Requesting"; an approval MUTATED the waitingForApproval row in place and
+// discarded the filter, so the array grew one dead row per join for the tab's lifetime;
+// and nothing bounded how many peers a full mesh would accept.
+//
+// What this suite pins:
+// 1. the pill counts down, from the SAME clock the host's card ages against
+// 2. an expired request cancels itself, un-whitelists the peer, and offers Retry
+// 3. `peer-unavailable` ends the request instead of contradicting it
+// 4. a host's card shows its age and STAYS approvable past the window
+// 5. the pending queue is bounded, dropping EXPIRED cards before live ones
+// 6. approval REMOVES the row rather than mutating it
+// 7. the camera stream is rate-gated (audit H7), measured, not asserted by reading code
+// 8. approval is refused at the hard cap, with the reason on the button
+//
+// Time is driven by writing the shared clock rather than by sleeping 90 real seconds: the
+// guard under test is the WINDOW and what happens at its end, not the wall clock.
+//
+// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- approval-timeout
+const h = require('./helpers.cjs');
+
+const waiting = (page) =>
+ page.evaluate(() => {
+ let v = [];
+ window.__stores.waitingForApproval.subscribe((x) => (v = x))();
+ return v;
+ });
+
+const approvals = (page) =>
+ page.evaluate(() => {
+ let v = [];
+ window.__stores.pendingApprovals.subscribe((x) => (v = x))();
+ return v;
+ });
+
+h.run(async () => {
+ const browser = await h.launch();
+ const peer = await h.setupPage(browser, 'approval');
+ const page = peer.page;
+ await page.waitForFunction(() => !!window.__stores?.connectionState?.APPROVAL_WINDOW_MS, {
+ timeout: 30000
+ });
+
+ const WINDOW = await page.evaluate(() => window.__stores.connectionState.APPROVAL_WINDOW_MS);
+ h.check(WINDOW === 90000, `premise: one approval window constant, 90s (${WINDOW})`);
+
+ // ---- 1. the pill counts down -------------------------------------------------------
+ // Stub the dial so no signaling is needed: the state machine is what is under test.
+ await page.evaluate(() => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ Object.defineProperty(pc.peer, 'open', { value: true, configurable: true });
+ pc.peer.connect = (id) => ({ peer: id, open: false, on() {}, close() {}, send() {} });
+ });
+ await page.locator('input[placeholder="Enter peer ID to connect"]').fill('aaaa1');
+ await page.getByRole('button', { name: 'Connect', exact: true }).click();
+ await page.waitForTimeout(600);
+
+ const pending = await waiting(page);
+ h.check(pending.some((w) => w[0] === 'aaaa1' && w[1] === 'pending'), 'the request is pending');
+ const pillText = await page.locator('.cx-input').first().inputValue().catch(() => '');
+ h.check(/1:2\d|1:3\d/.test(pillText), `the pill shows a countdown (${pillText})`);
+ // Report the neighbouring state too: an empty map beside a live pending row means the
+ // dial took its stamping branch and the write went somewhere else, which is a module
+ // identity problem rather than a logic one.
+ const started = await page.evaluate(() => {
+ const s = window.__stores;
+ const read = (store) => {
+ let v;
+ store.subscribe((x) => (v = x))();
+ return v;
+ };
+ const map = read(s.connectionState.approvalStartedAt);
+ return {
+ keys: Object.keys(map || {}),
+ whitelist: (read(s.userdata) || []).map((u) => u[0]),
+ waiting: (read(s.waitingForApproval) || []).map((w) => w[0] + ':' + w[1])
+ };
+ });
+ h.check(
+ started.keys.includes('aaaa1'),
+ `the shared clock was stamped, which the host card reads too (stamped=[${started.keys}] whitelist=[${started.whitelist}] waiting=[${started.waiting}])`
+ );
+
+ // ---- 2. it expires: cancelled, un-whitelisted, Retry offered -------------------------
+ // Wind the clock back past the window rather than waiting 90s.
+ await page.evaluate((w) => {
+ window.__stores.connectionState.approvalStartedAt.update((m) => ({ ...m, aaaa1: Date.now() - w - 1000 }));
+ }, WINDOW);
+ await page.waitForTimeout(400);
+ const expiredPill = await page.locator('.cx-input').first().inputValue().catch(() => '');
+ h.check(!/·\s*\d/.test(expiredPill) || /0:0\d/.test(expiredPill), `the countdown reaches zero (${expiredPill})`);
+
+ // the timer itself is armed for the real window, so fire the expiry path directly
+ await page.evaluate(() => window.__stores.peerApproval.cancelOutboundRequest('aaaa1'));
+ await page.waitForTimeout(300);
+ const afterCancel = await waiting(page);
+ const roster = await page.evaluate(() => {
+ let v = [];
+ window.__stores.userdata.subscribe((x) => (v = x))();
+ return v.map((u) => u[0]);
+ });
+ h.check(!afterCancel.some((w) => w[0] === 'aaaa1'), 'the pending row is gone');
+ h.check(!roster.includes('aaaa1'), 'and the optimistic whitelist row was taken back');
+
+ // ---- 3. peer-unavailable ends the request --------------------------------------------
+ await page.locator('input[placeholder="Enter peer ID to connect"]').fill('bbbb2');
+ await page.getByRole('button', { name: 'Connect', exact: true }).click();
+ await page.waitForTimeout(400);
+ h.check((await waiting(page)).some((w) => w[0] === 'bbbb2'), 'premise: a second request is pending');
+ await page.evaluate(() => window.__stores.peerApproval.abandonOutboundRequest('bbbb2'));
+ await page.waitForTimeout(300);
+ h.check(
+ !(await waiting(page)).some((w) => w[0] === 'bbbb2'),
+ 'an unreachable peer ends the request instead of contradicting it'
+ );
+
+ // ---- 4+5+6. the host side: age, expiry, bounds, and the row --------------------------
+ const bounded = await page.evaluate(async (w) => {
+ const s = window.__stores;
+ const cs = s.connectionState;
+ s.pendingApprovals.set([]);
+ // 20 requests, the first ten already expired
+ const rows = [];
+ for (let i = 0; i < 20; i++) rows.push({ peerId: 'p' + i });
+ s.pendingApprovals.set(rows);
+ const now = Date.now();
+ const stamps = {};
+ rows.forEach((r, i) => (stamps[r.peerId] = i < 10 ? now - w - 5000 : now - 1000));
+ cs.approvalStartedAt.set(stamps);
+ return { max: cs.MAX_PENDING_APPROVALS, seeded: rows.length };
+ }, WINDOW);
+ h.check(bounded.max === 12, `premise: the queue bound is a constant (${bounded.max})`);
+
+ // the bound is applied where requests ARRIVE, so drive one more through the real path
+ await page.evaluate(() => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ const handlers = {};
+ const conn = { peer: 'newcomer', open: true, on: (e, f) => (handlers[e] = f), close() {}, send() {} };
+ pc.peer.emit('connection', conn);
+ });
+ await page.waitForTimeout(500);
+ const after = await approvals(page);
+ h.check(
+ after.length <= bounded.max,
+ `the pending queue is bounded at ${bounded.max} (was 20, now ${after.length})`
+ );
+ const survivors = after.map((a) => a.peerId);
+ const expiredLeft = survivors.filter((id) => /^p[0-9]$/.test(id)).length;
+ h.check(
+ expiredLeft < 10,
+ `EXPIRED cards are dropped before live ones (${expiredLeft} of the 10 expired remain)`
+ );
+
+ // ---- 7. the camera stream is rate-gated ----------------------------------------------
+ const rate = await page.evaluate(async () => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ let sent = 0;
+ const realSend = pc.send.bind(pc);
+ pc.send = (d) => {
+ if (d && d.type === 'camera') sent++;
+ return realSend(d);
+ };
+ let cam = null;
+ window.__stores.globalCamera.subscribe((c) => (cam = c))();
+ const t0 = performance.now();
+ // move the camera every frame for a second; the gate decides how many go out
+ await new Promise((done) => {
+ const step = () => {
+ if (cam) cam.position.x += 0.5;
+ if (performance.now() - t0 > 1000) return done();
+ requestAnimationFrame(step);
+ };
+ step();
+ });
+ pc.send = realSend;
+ return { sent, ms: Math.round(performance.now() - t0) };
+ });
+ h.check(
+ rate.sent <= 25,
+ `the camera stream is gated to ~20/s, not one per frame (${rate.sent} in ${rate.ms}ms)`
+ );
+ h.check(rate.sent > 0, 'and it still sends — the gate bounds the rate, it does not mute it');
+
+ // ---- 8. the hard cap refuses an approval ----------------------------------------------
+ // Seed the OPEN CONNECTIONS, not `userdata`. The whitelist is written at dial time, so
+ // a suite that filled it would pass against a cap counting the wrong thing — which is
+ // the defect this section exists to catch.
+ const capped = await page.evaluate(() => {
+ const s = window.__stores;
+ const HARD = s.connectionState.HARD_PEER_CAP;
+ let pc = null;
+ s.peers.subscribe((v) => (pc = v))();
+ for (let i = 0; i < HARD - 1; i++) pc.openedPeers.add('full' + i);
+ s.peers.update((v) => v); // the store ticks on every open/close
+ return { HARD, size: s.connectionState.sessionSize(pc), roster: 0 };
+ });
+ h.check(
+ capped.size === capped.HARD,
+ `the session counts ${capped.HARD} people from the OPEN connections, self included`
+ );
+ await page.waitForTimeout(400);
+ const cardCount = await page.locator('.cxreq-btn.cxreq-editor').count();
+ h.check(cardCount > 0, `premise: a request card is on screen to approve (${cardCount})`);
+ const fullCardButtons = await page
+ .locator('.cxreq-btn.cxreq-editor')
+ .first()
+ .isDisabled()
+ .catch(() => null);
+ h.check(
+ fullCardButtons === true,
+ `at the hard cap of ${capped.HARD} the approve button is disabled rather than silently failing (disabled=${fullCardButtons})`
+ );
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/connect-states.test.cjs b/tests/e2e/connect-states.test.cjs
index b4ede834..592cb57d 100644
--- a/tests/e2e/connect-states.test.cjs
+++ b/tests/e2e/connect-states.test.cjs
@@ -47,7 +47,7 @@ h.run(async () => {
const pendingInput = A.page.locator('.cx-connect input[disabled]').first();
const pendingValue = await pendingInput.inputValue();
h.check(
- (await pendingInput.isVisible()) && /^Requesting FFFF1$/i.test(pendingValue),
+ (await pendingInput.isVisible()) && /^Requesting FFFF1( · \d+:\d{2})?$/i.test(pendingValue),
`CN: pending shows the waiting-for-approval status ("${pendingValue}")`
);
diff --git a/tests/e2e/diagnostics.test.cjs b/tests/e2e/diagnostics.test.cjs
new file mode 100644
index 00000000..9235d4a6
--- /dev/null
+++ b/tests/e2e/diagnostics.test.cjs
@@ -0,0 +1,151 @@
+// 27-B (hardening audit H4) — A FAILURE LEAVES A TRACE, AND THE USER CAN HAND IT OVER.
+//
+// Before this, `src/lib` held 135 `console.log` calls against 17 console.error/warn, and
+// there was no `window.onerror` or `unhandledrejection` handler anywhere in src. So an
+// uncaught error inside a store subscriber silently broke that subscriber chain, and a
+// user had no way to say what happened beyond "it stopped working".
+//
+// What this suite pins:
+// 1. the ring holds the LAST 300 lines (oldest dropped, newest kept)
+// 2. the bundle carries version/time/agent AND the session section that App.svelte
+// registers — the seam that keeps diagnostics.js a zero-store leaf
+// 3. an uncaught ERROR reaches the ring, the `lastUncaught` store and ONE sticky toast
+// 4. an unhandled REJECTION takes the same path
+// 5. the toast's "Copy diagnostics" button is wired (it answers either way: the
+// clipboard is not granted in headless, and the fallback path still reports)
+// 6. Settings ▸ About offers the same button
+//
+// The deliberate throws are safe for the runner: helpers' FATAL_ERROR only matches
+// svelte RENDER crashes (each_key_duplicate, effect_update_depth_exceeded, …), and a
+// plain Error message matches none of them.
+//
+// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- diagnostics
+const h = require('./helpers.cjs');
+
+h.run(async () => {
+ const browser = await h.launch();
+ const peer = await h.setupPage(browser, 'diagnostics');
+ const page = peer.page;
+ await page.waitForFunction(() => !!window.__stores?.diagnostics, { timeout: 30000 });
+ h.check(true, 'premise: the diagnostics module is live in the app');
+
+ // ---- 1. the ring caps, dropping the OLDEST ------------------------------------
+ const ring = await page.evaluate(() => {
+ const d = window.__stores.diagnostics;
+ d.clearDiagnostics();
+ for (let i = 0; i < 320; i++) d.log('info', 'test', 'line ' + i);
+ const lines = d.lines();
+ return { n: lines.length, first: lines[0], last: lines[lines.length - 1] };
+ });
+ h.check(ring.n === 300, `the ring holds 300 lines, not 320 (${ring.n})`);
+ h.check(/line 20\b/.test(ring.first), `the OLDEST line is dropped first: ${ring.first}`);
+ h.check(/line 319\b/.test(ring.last), `the NEWEST line is kept: ${ring.last}`);
+
+ // ---- 2. the bundle, and the registered section --------------------------------
+ const bundle = await page.evaluate(() => window.__stores.diagnostics.bundle());
+ h.check(
+ !!bundle.version && !!bundle.at && typeof bundle.ua === 'string',
+ `the bundle carries version (${bundle.version}), time and user agent`
+ );
+ h.check(Array.isArray(bundle.lines) && bundle.lines.length === 300, 'the bundle carries the ring');
+ const session = bundle.sections?.session;
+ h.check(
+ !!session && 'peerId' in session && 'objects' in session && 'roster' in session,
+ `App.svelte's session section is registered and readable: ${JSON.stringify(session)}`
+ );
+ h.check(
+ session && typeof session.peerId === 'string' && session.peerId.length > 0,
+ 'the section reads the live peer id through the store, not an import of it'
+ );
+
+ // a section that throws must not be able to break the bundle
+ const resilient = await page.evaluate(() => {
+ const d = window.__stores.diagnostics;
+ const off = d.registerDiagnosticsSection('broken', () => {
+ throw new Error('section-boom');
+ });
+ const b = d.bundle();
+ off();
+ return { broken: b.sections.broken, stillHasSession: !!b.sections.session };
+ });
+ h.check(
+ JSON.stringify(resilient.broken ?? '').includes('section-boom') && resilient.stillHasSession,
+ 'a section that throws is recorded as failed and the rest of the bundle survives'
+ );
+
+ // ---- 3. an uncaught error ------------------------------------------------------
+ await page.evaluate(() => {
+ window.__stores.diagnostics.clearDiagnostics();
+ setTimeout(() => {
+ throw new Error('boom-diagnostics');
+ }, 0);
+ });
+ await page.waitForTimeout(800);
+ const caught = await page.evaluate(() => {
+ const d = window.__stores.diagnostics;
+ let last = null;
+ d.lastUncaught.subscribe((/** @type {any} */ v) => (last = v))();
+ return { lines: d.lines(), last };
+ });
+ h.check(
+ caught.lines.some((/** @type {string} */ l) => l.includes('boom-diagnostics')),
+ 'an uncaught error lands in the ring'
+ );
+ h.check(
+ !!caught.last && String(caught.last.message).includes('boom-diagnostics'),
+ '…and in the lastUncaught store the toast mirrors'
+ );
+ const toast = page.locator('text=Something went wrong').first();
+ await toast.waitFor({ state: 'visible', timeout: 8000 }).catch(() => {});
+ h.check(await toast.isVisible().catch(() => false), 'one sticky toast says something went wrong');
+
+ // ---- 4. an unhandled rejection takes the same path -----------------------------
+ await page.evaluate(() => {
+ window.__stores.diagnostics.clearDiagnostics();
+ Promise.reject(new Error('rejected-diagnostics'));
+ });
+ await page.waitForTimeout(600);
+ const rejected = await page.evaluate(() => window.__stores.diagnostics.lines());
+ h.check(
+ rejected.some((/** @type {string} */ l) => l.includes('rejected-diagnostics') && l.includes('[promise]')),
+ 'an unhandled rejection lands in the ring, scoped to promise'
+ );
+
+ // ---- 5. the toast's button is wired --------------------------------------------
+ // Headless grants no clipboard permission, so the honest assertion is that pressing
+ // it REPORTS — copied, or could not copy. Either proves the action ran.
+ const copyButton = page.getByRole('button', { name: 'Copy diagnostics' }).first();
+ if (await copyButton.isVisible().catch(() => false)) {
+ await copyButton.click();
+ await page.waitForTimeout(500);
+ const reported = await page.evaluate(() =>
+ document.body.innerText.includes('Diagnostics copied') || document.body.innerText.includes('Could not copy')
+ );
+ h.check(reported, 'the toast button assembles the bundle and reports the outcome');
+ } else {
+ h.check(false, 'the sticky toast offers a Copy diagnostics button');
+ }
+
+ // the bundle text is valid JSON a user can paste into an issue
+ const text = await page.evaluate(() => window.__stores.diagnostics.bundleText());
+ let parsed = null;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ /* left null */
+ }
+ h.check(!!parsed && !!parsed.version, 'the clipboard payload is valid JSON carrying the version');
+
+ // ---- 6. Settings ▸ About offers it too ------------------------------------------
+ await page.evaluate(() => window.__stores.settingsOpen.set(true));
+ await page.waitForTimeout(700);
+ await page.getByText('About', { exact: true }).first().click().catch(() => {});
+ await page.waitForTimeout(500);
+ h.check(
+ await page.locator('#about-copy-diagnostics').isVisible().catch(() => false),
+ 'Settings ▸ About offers Copy diagnostics'
+ );
+ await page.evaluate(() => window.__stores.settingsOpen.set(false));
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/dispose.test.cjs b/tests/e2e/dispose.test.cjs
new file mode 100644
index 00000000..3f6107b6
--- /dev/null
+++ b/tests/e2e/dispose.test.cjs
@@ -0,0 +1,193 @@
+// 27-G (audit H6, M13) — GPU MEMORY COMES BACK, AND A LOST CONTEXT IS VISIBLE.
+//
+// The unit suite (tests/unit/disposeTree) covers the hard part — what may and may not be
+// freed when resources are shared — with no renderer at all. This suite covers the two
+// things it cannot see:
+// 1. `renderer.info.memory` really falls back after deletes, so the leak is gone in the
+// place a user pays for it rather than only in a function's return value
+// 2. a REAL lost context (WEBGL_lose_context) raises the overlay, and restoring brings
+// the scene back
+//
+// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- dispose
+const h = require('./helpers.cjs');
+
+const memory = (peer) =>
+ peer.page.evaluate(() => {
+ let r = null;
+ window.__stores.globalRenderer.subscribe((v) => (r = v))();
+ return r?.info?.memory ? { geometries: r.info.memory.geometries, textures: r.info.memory.textures } : null;
+ });
+
+const objectCount = (peer) =>
+ peer.page.evaluate(() => {
+ let g = null;
+ window.__stores.objectsGroup.subscribe((v) => (g = v))();
+ return g ? g.children.length : -1;
+ });
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- 1. deleting gives the memory back ---------------------------------------------
+ // WARM UP FIRST. Creating and selecting an object allocates one-time machinery — the
+ // transform gizmo's own geometry most of all — which is not a leak and never comes
+ // back. Measuring the floor before any of it existed calls it one: the first run of
+ // this check read 2 -> 28 -> 18 and failed, while the very next section showed
+ // 18 -> 26 -> 18, i.e. disposal returning to the real floor exactly.
+ const warmUuid = await A.page.evaluate(() => {
+ let g = null;
+ window.__stores.objectsGroup.subscribe((v) => (g = v))();
+ window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [0, 0.5, -6]);
+ return g.children[g.children.length - 1].uuid;
+ });
+ // It has to RENDER before being deleted. The transform gizmo's geometries are uploaded
+ // the first time they are actually DRAWN, not when an object is selected — so creating
+ // and deleting inside one evaluate leaves them for the next section to allocate, and
+ // the floor reads 4 when the true floor is 18.
+ await A.page.waitForTimeout(2000);
+ await A.page.evaluate((id) => window.__stores.commandsHandler.deleteObject(id), warmUuid);
+ await A.page.waitForTimeout(1500);
+ const before = await memory(A);
+ h.check(!!before, `premise: the renderer reports its memory (${JSON.stringify(before)})`);
+
+ const uuids = await A.page.evaluate(() => {
+ const made = [];
+ let g = null;
+ window.__stores.objectsGroup.subscribe((v) => (g = v))();
+ for (let i = 0; i < 10; i++) {
+ window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [i * 2 - 9, 0.5, -3]);
+ made.push(g.children[g.children.length - 1].uuid);
+ }
+ return made;
+ });
+ h.check(uuids.length === 10 && new Set(uuids).size === 10, `premise: ten distinct objects (${new Set(uuids).size})`);
+ await A.page.waitForTimeout(1500);
+
+ const loaded = await memory(A);
+ h.check(
+ loaded.geometries > before.geometries,
+ `ten objects cost GPU memory (${before.geometries} -> ${loaded.geometries} geometries)`
+ );
+
+ await A.page.evaluate((ids) => {
+ for (const id of ids) window.__stores.commandsHandler.deleteObject(id);
+ }, uuids);
+ await A.page.waitForTimeout(1500);
+
+ h.check((await objectCount(A)) === 0, 'the objects are gone from the scene');
+ const after = await memory(A);
+
+ // THE INVARIANT, rather than a baseline number. A geometry still referenced by a live
+ // scene object is not a leak: the grid, the transform gizmo and the environment rig
+ // all legitimately keep theirs, and what the floor sits at depends on what has been
+ // touched. A geometry the RENDERER still holds that NOTHING in the scene refers to is
+ // the leak this phase is about — and that is the thing worth asserting.
+ const residual = await A.page.evaluate(() => {
+ let s = null;
+ window.__stores.globalScene.subscribe((v) => (s = v))();
+ const seen = new Set();
+ /** @type {Record} */
+ const byOwner = {};
+ s?.traverse((o) => {
+ if (!o.geometry || seen.has(o.geometry)) return;
+ seen.add(o.geometry);
+ const k = o.name || o.type;
+ byOwner[k] = (byOwner[k] || 0) + 1;
+ });
+ return { referenced: seen.size, byOwner };
+ });
+ h.check(
+ after.geometries <= before.geometries + 2,
+ `the memory came back to the floor (floor ${before.geometries}, peak ${loaded.geometries}, now ${after.geometries}) — still held by live helpers: ${JSON.stringify(residual.byOwner)}`
+ );
+ h.check(
+ after.geometries < loaded.geometries,
+ `and the deleted objects' geometry really went (peak ${loaded.geometries} -> ${after.geometries})`
+ );
+
+ // ---- 2. clearing a scene frees it too ------------------------------------------------
+ await A.page.evaluate(() => {
+ for (let i = 0; i < 8; i++)
+ window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [i - 4, 0.5, 2]);
+ });
+ await A.page.waitForTimeout(1200);
+ const filled = await memory(A);
+ h.check(filled.geometries > after.geometries, `premise: eight more objects are resident (${filled.geometries})`);
+
+ await A.page.evaluate(() => window.__stores.commandsHandler.clearSceneLocal());
+ await A.page.waitForTimeout(1200);
+ const cleared = await memory(A);
+ h.check(
+ cleared.geometries <= after.geometries + 2,
+ `clearing the scene frees what it held (${filled.geometries} -> ${cleared.geometries})`
+ );
+
+ // ---- 3. a real lost context raises the overlay ----------------------------------------
+ const canLose = await A.page.evaluate(() => {
+ let r = null;
+ window.__stores.globalRenderer.subscribe((v) => (r = v))();
+ const gl = r?.getContext?.();
+ // HOLD the extension. Once the context is lost, getExtension returns null, so
+ // fetching it again in order to RESTORE throws — which it did, on the first run.
+ window.__loseCtx = gl?.getExtension?.('WEBGL_lose_context') ?? null;
+ return !!window.__loseCtx;
+ });
+ h.check(canLose === true, 'premise: WEBGL_lose_context is available, so a REAL context loss can be driven');
+
+ if (canLose) {
+ // premise: the overlay is NOT on screen yet. Without this, "a lost context raises
+ // the overlay" would pass just as well against an overlay that is always rendered.
+ h.check(
+ !(await A.page.locator('.gl-lost').isVisible().catch(() => false)),
+ 'premise: the overlay is hidden while the context is healthy'
+ );
+ await A.page.evaluate(() => window.__loseCtx.loseContext());
+ await A.page.waitForTimeout(800);
+
+ const overlay = await A.page.locator('.gl-lost').isVisible().catch(() => false);
+ h.check(overlay, 'a lost context raises the overlay instead of looking like a freeze');
+ h.check(
+ await A.page.locator('.gl-lost-primary').isVisible().catch(() => false),
+ 'and it offers to save the scene, which still exists in the page'
+ );
+
+ await A.page.evaluate(() => window.__loseCtx.restoreContext());
+ await h.eventually(
+ () => A.page.locator('.gl-lost').isVisible().catch(() => false),
+ (v) => v === false,
+ 'restoring the context dismisses the overlay',
+ 20000
+ );
+
+ // Measure what the RENDERER did, not how often requestAnimationFrame was serviced.
+ // three bumps info.render.frame inside render(), so a rising counter is direct
+ // evidence that the restored context is being drawn into. The tick count stays in
+ // the message as context only: this box runs SwiftShader at four or five frames a
+ // second, so a threshold picked for 60Hz reads a healthy page as frozen — which is
+ // exactly what the first version of this check did, at 3 frames against a bar of 3.
+ const drawing = await A.page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ let r = null;
+ window.__stores.globalRenderer.subscribe((v) => (r = v))();
+ const first = r?.info?.render?.frame ?? -1;
+ let ticks = 0;
+ const t0 = performance.now();
+ const step = () => {
+ ticks++;
+ if (performance.now() - t0 > 1500)
+ return resolve({ first, last: r?.info?.render?.frame ?? -1, ticks });
+ requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+ })
+ );
+ h.check(
+ drawing.last > drawing.first,
+ `and the restored context is being drawn into (renderer frame ${drawing.first} -> ${drawing.last}, ${drawing.ticks} rAF ticks in 1.5s)`
+ );
+ }
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/helpers.cjs b/tests/e2e/helpers.cjs
index 5285433b..ca8d20c0 100644
--- a/tests/e2e/helpers.cjs
+++ b/tests/e2e/helpers.cjs
@@ -119,7 +119,11 @@ async function setupPage(browser, name, options = {}) {
page.__errors.push(err.message ?? String(err));
console.log(`[${name} pageerror] ` + err.stack);
});
- await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
+ // 27-D: `options.hash` loads the app WITH a hash (`{ hash: '#safe' }`). It has to be
+ // on the initial navigation, not set afterwards: safe mode is read once during
+ // onMount, so a hash assigned to a live page arrives long after the decision.
+ // Absent means an unchanged URL, so every existing caller is untouched.
+ await page.goto(URL + (options.hash ?? ''), { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(4000);
await page.waitForFunction(() => window.__stores && !!window.__stores.moduleSDK, { timeout: 30000 });
const id = await page.evaluate(
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);
+});
diff --git a/tests/e2e/join-result.test.cjs b/tests/e2e/join-result.test.cjs
new file mode 100644
index 00000000..07ce3c83
--- /dev/null
+++ b/tests/e2e/join-result.test.cjs
@@ -0,0 +1,218 @@
+// 25-F (roadmap 25 section 2c) — A REAL "NO", AND A FULL ROOM THAT SAYS SO.
+//
+// An incoming connection from the host WAS the approval signal, and a refusal had no
+// channel at all: the host closes a stranger's conn before it opens. So Reject left the
+// joiner on "Requesting" for the whole 90 s window and then told it the host "did not
+// answer", and a full session said exactly the same thing.
+//
+// The answer now rides the METADATA of a short dial from the host (arriving through
+// signaling, no ICE needed), gated on the joiner having advertised `jr` — an older joiner
+// would read ANY incoming conn from the host as an approval.
+//
+// What this suite pins:
+// 1-5 the JOINER: denied and full end the request and are told apart (toast + chip);
+// an older host's plain dial-back is still an approval; a refusal nobody is
+// waiting for is ignored; the `joinresult` MESSAGE carries the same answer
+// 6 `joinresult` is on the capability floor
+// 7-10 the HOST: Reject tells a joiner that can hear it and stays silent to one that
+// cannot; at the cap the card offers "Tell them it's full"; an approval dial-back
+// says it is one, in its metadata AND as the first handshake message
+// 11 two real peers over signaling: declined, then full
+//
+// Run: APP_URL=https://theprototype.app:5175/ PEER_CONFIG=... npm run e2e -- join-result
+const h = require('./helpers.cjs');
+
+/** run a snippet with `s = window.__stores` and `pc` (the PeerConnection) in scope */
+const inPage = (peer, body, arg) =>
+ peer.page.evaluate(
+ ([src, a]) => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ return Object.getPrototypeOf(async function () {}).constructor('s', 'pc', 'arg', src)(window.__stores, pc, a);
+ },
+ [body, arg ?? null]
+ );
+const read = (peer, store) => inPage(peer, `let v; s.${store}.subscribe((x) => (v = x))(); return v;`);
+const notes = (peer) => inPage(peer, 'let v = []; s.notifications.subscribe((x) => (v = x))(); return v.map((n) => String(n.text));');
+
+/** an incoming conn as peerjs hands it to the `connection` event */
+const EMIT = `
+ const fake = { peer: arg.peer, metadata: arg.metadata, open: false, closed: false, handlers: {},
+ on(ev, fn) { this.handlers[ev] = fn; }, close() { this.closed = true; }, send() {} };
+ window.__lastFake = fake;
+ pc.peer.emit('connection', fake);
+ return { closed: fake.closed, wired: Object.keys(fake.handlers) };`;
+
+/** dial through the real pill, against a stubbed peer.connect that records every call */
+const DIAL_STUB = `
+ window.__dials = [];
+ Object.defineProperty(pc.peer, 'open', { value: true, configurable: true });
+ pc.peer.connect = (id, opts) => {
+ const conn = { peer: id, open: false, sent: [], handlers: {}, on(ev, fn) { (this.handlers[ev] ??= []).push(fn); }, close() { this.closed = true; }, send(m) { this.sent.push(m); } };
+ window.__dials.push({ id, opts: JSON.parse(JSON.stringify(opts ?? null)), conn });
+ return conn;
+ };`;
+
+/** element reads that answer null instead of throwing, so one missing element is one red
+ * check rather than the end of the suite */
+const attr = (loc, name) => loc.getAttribute(name, { timeout: 3000 }).catch(() => null);
+const text = (loc) => loc.textContent({ timeout: 3000 }).catch(() => '');
+const click = (loc, timeout = 5000) => loc.click({ timeout }).then(() => true, () => false);
+
+async function dialVia(peer, id) {
+ await peer.page.locator('input[placeholder="Enter peer ID to connect"]').fill(id);
+ await peer.page.getByRole('button', { name: 'Connect', exact: true }).click();
+ await peer.page.waitForTimeout(400);
+}
+
+h.run(async () => {
+ const browser = await h.launch();
+ const J = await h.setupPage(browser, 'joiner');
+ await J.page.waitForFunction(() => !!window.__stores?.connectionState?.isRefusal, { timeout: 30000 });
+ await inPage(J, DIAL_STUB);
+
+ // ---- 1. declined -----------------------------------------------------------------
+ console.log('\n=== 1. declined ===');
+ await dialVia(J, 'aaaa1');
+ const dial = await inPage(J, 'return window.__dials.map((d) => ({ id: d.id, opts: d.opts }))');
+ h.check(dial.some((d) => d.id === 'aaaa1' && d.opts?.metadata?.jr === 1), `the joiner's dial says it can hear a join result (${JSON.stringify(dial)})`);
+ h.check((await read(J, 'waitingForApproval')).some((w) => w[0] === 'aaaa1'), 'premise: the request is pending');
+ const refused = await inPage(J, EMIT, { peer: 'aaaa1', metadata: { joinresult: 'denied' } });
+ h.check(refused.closed && refused.wired.length === 0, `the refusal dial is closed at once and never wired (${JSON.stringify(refused)})`);
+ h.check(!(await read(J, 'waitingForApproval')).some((w) => w[0] === 'aaaa1'), 'the request is over — no pending row');
+ h.check(!(await read(J, 'userdata')).some((u) => u[0] === 'aaaa1'), 'the optimistic whitelist row is taken back');
+ h.check((await read(J, 'connectionState.sessionHost')) === null, 'a refusal is NOT an approval: no session host');
+ const r1 = await read(J, 'connectionState.joinRefusal');
+ h.check(r1?.peerId === 'aaaa1' && r1?.result === 'denied', `the refusal is recorded as denied (${JSON.stringify(r1)})`);
+ h.check((await notes(J)).some((t) => t.includes('AAAA1 declined your connection request')), 'the joiner is TOLD it was declined');
+ h.check(!(await notes(J)).some((t) => /AAAA1 has approved/.test(t)), '…and never told it was approved');
+ const chip1 = J.page.locator('#connect-refusal-chip');
+ await chip1.waitFor({ timeout: 5000 }).catch(() => {});
+ h.check((await chip1.count()) === 1 && /AAAA1 declined/.test(await text(chip1)) && (await attr(chip1, 'data-result')) === 'denied', 'the pill shows "declined" beside the idle input');
+
+ // ---- 2. full ---------------------------------------------------------------------
+ console.log('\n=== 2. full ===');
+ await dialVia(J, 'bbbb1');
+ h.check((await J.page.locator('#connect-refusal-chip').count()) === 0, 'a new dial clears the last answer from the pill');
+ await inPage(J, EMIT, { peer: 'bbbb1', metadata: { jr: 1, joinresult: 'full' } });
+ const r2 = await read(J, 'connectionState.joinRefusal');
+ h.check(r2?.result === 'full', `a full room is recorded as FULL, not denied (${r2?.result})`);
+ h.check((await notes(J)).some((t) => /BBBB1's session is full \(16 people\)/.test(t)), 'the toast says the session is full, with its size');
+ const chip2 = J.page.locator('#connect-refusal-chip');
+ h.check((await attr(chip2, 'data-result')) === 'full' && /session is full \(16\)/.test(await text(chip2)), 'the chip says full, told apart from declined');
+ await click(chip2);
+ h.check((await J.page.locator('#connect-refusal-chip').count()) === 0 && (await read(J, 'connectionState.joinRefusal')) === null, 'the chip dismisses');
+
+ // ---- 3. an older host ------------------------------------------------------------
+ console.log('\n=== 3. an older host (no result on its dial-back) ===');
+ await dialVia(J, 'cccc1');
+ const plain = await inPage(J, EMIT, { peer: 'cccc1', metadata: undefined });
+ h.check(!plain.closed, 'a plain dial-back is not closed');
+ h.check((await read(J, 'connectionState.sessionHost')) === 'cccc1', 'a plain dial-back from the host is still the approval');
+ h.check((await read(J, 'connectionState.joinRefusal')) === null, '…and records no refusal');
+ await inPage(J, 's.connectionState.resetSession(); s.userdata.set([]); s.waitingForApproval.set([]);');
+
+ // ---- 4. nobody is waiting ----------------------------------------------------------
+ console.log('\n=== 4. a refusal nobody is waiting for ===');
+ const before = (await notes(J)).length;
+ const stray = await inPage(J, EMIT, { peer: 'zzzz1', metadata: { joinresult: 'denied' } });
+ h.check(stray.closed, 'a stray refusal dial is closed');
+ h.check((await notes(J)).length === before && (await read(J, 'connectionState.joinRefusal')) === null, 'and tells nobody anything — there was no request to end');
+
+ // ---- 5. the message --------------------------------------------------------------
+ console.log('\n=== 5. the joinresult MESSAGE ===');
+ await dialVia(J, 'dddd1');
+ await inPage(J, `
+ const fake = { peer: 'dddd1', open: true, handlers: {}, on(ev, fn) { this.handlers[ev] = fn; }, close() {}, send() {} };
+ pc.wireData(fake);
+ fake.handlers.data({ type: 'joinresult', result: 'denied' });`);
+ const r5 = await read(J, 'connectionState.joinRefusal');
+ h.check(r5?.peerId === 'dddd1' && r5?.result === 'denied', `a joinresult message ends the request the same way (${JSON.stringify(r5)})`);
+
+ // ---- 6. the floor ------------------------------------------------------------------
+ const floor = await inPage(J, 's.cloudHooks.setCapabilityProvider(() => false); const r = { jr: s.cloudHooks.canApply("x", "joinresult"), other: s.cloudHooks.canApply("x", "environment") }; s.cloudHooks.setCapabilityProvider(null); return r');
+ h.check(floor.jr && !floor.other, `joinresult sits on the ALWAYS_ALLOWED floor (${JSON.stringify(floor)})`);
+ await J.ctx.close();
+
+ // ---- 7. host: Reject tells them -----------------------------------------------------
+ console.log('\n=== 7. host: Reject ===');
+ const H = await h.setupPage(browser, 'host');
+ await H.page.waitForFunction(() => !!window.__stores?.connectionState?.isRefusal, { timeout: 30000 });
+ await inPage(H, DIAL_STUB);
+ await inPage(H, EMIT, { peer: 'eeee1', metadata: { jr: 1 } });
+ const cards = await read(H, 'pendingApprovals');
+ h.check(cards.some((c) => c.peerId === 'eeee1' && c.hearsNo === true), `the card remembers the dial can hear a refusal (${JSON.stringify(cards)})`);
+ const card = H.page.locator('.tp-toast--req', { hasText: 'EEEE1' });
+ await click(card.locator('.cxreq-reject'));
+ await H.page.waitForTimeout(300);
+ const dials7 = await inPage(H, 'return window.__dials.map((d) => ({ id: d.id, opts: d.opts }))');
+ h.check(dials7.some((d) => d.id === 'eeee1' && d.opts?.metadata?.joinresult === 'denied'), `Reject dials back with joinresult: denied (${JSON.stringify(dials7)})`);
+ h.check(!(await read(H, 'pendingApprovals')).some((c) => c.peerId === 'eeee1'), 'the card is gone');
+ h.check(!(await inPage(H, 'return Object.keys(pc.connections)')).includes('eeee1'), 'the refusal dial never joins the mesh');
+
+ // ---- 8. host: an older joiner hears nothing ------------------------------------------
+ console.log('\n=== 8. host: an older joiner ===');
+ await inPage(H, EMIT, { peer: 'ffff1', metadata: undefined });
+ h.check((await read(H, 'pendingApprovals')).some((c) => c.peerId === 'ffff1' && c.hearsNo === false), 'a dial without jr makes a card that cannot hear a refusal');
+ await click(H.page.locator('.tp-toast--req', { hasText: 'FFFF1' }).locator('.cxreq-reject'));
+ await H.page.waitForTimeout(300);
+ const dials8 = await inPage(H, 'return window.__dials.filter((d) => d.id === "ffff1").length');
+ h.check(dials8 === 0, `an older joiner is NOT dialled — it would read the refusal as an approval (${dials8} dials)`);
+
+ // ---- 9. host: full -------------------------------------------------------------------
+ console.log('\n=== 9. host: the cap ===');
+ await inPage(H, EMIT, { peer: 'gggg1', metadata: { jr: 1 } });
+ await inPage(H, 'window.__realOpened = pc.openedPeers; pc.openedPeers = new Set(Array.from({ length: 15 }, (_, i) => "fake" + i)); s.peers.update((v) => v);');
+ const gcard = H.page.locator('.tp-toast--req', { hasText: 'GGGG1' });
+ const fullBtn = gcard.locator('.cxreq-full');
+ await fullBtn.waitFor({ timeout: 5000 }).catch(() => {});
+ h.check((await fullBtn.count()) === 1, 'at the cap the card offers "Tell them it\'s full"');
+ h.check(await gcard.locator('button', { hasText: 'Approve' }).isDisabled({ timeout: 3000 }).catch(() => false), 'and Approve stays disabled (27-E)');
+ await click(fullBtn);
+ await H.page.waitForTimeout(300);
+ const dials9 = await inPage(H, 'return window.__dials.filter((d) => d.id === "gggg1").map((d) => d.opts)');
+ h.check(dials9.some((o) => o?.metadata?.joinresult === 'full'), `…which dials back with joinresult: full (${JSON.stringify(dials9)})`);
+ // the VR panel's yes goes through peerApproval.approvePeer, which used to approve past the cap
+ await inPage(H, EMIT, { peer: 'gggg2', metadata: { jr: 1 } });
+ await inPage(H, 's.peerApproval.approvePeer("gggg2")');
+ const dials9b = await inPage(H, 'return window.__dials.filter((d) => d.id === "gggg2").map((d) => d.opts)');
+ h.check(dials9b.length === 1 && dials9b[0]?.metadata?.joinresult === 'full', `the shared approve refuses past the cap and says full (${JSON.stringify(dials9b)})`);
+ await inPage(H, 'pc.openedPeers = window.__realOpened; s.peers.update((v) => v);');
+
+ // ---- 10. host: approval says so -------------------------------------------------------
+ console.log('\n=== 10. host: an approval says it is one ===');
+ await inPage(H, EMIT, { peer: 'hhhh1', metadata: { jr: 1 } });
+ await click(H.page.locator('.tp-toast--req', { hasText: 'HHHH1' }).getByRole('button', { name: 'Approve' }));
+ await H.page.waitForTimeout(300);
+ const first = await inPage(H, `
+ const d = window.__dials.find((x) => x.id === 'hhhh1');
+ if (!d) return null;
+ d.conn.open = true;
+ try { for (const fn of d.conn.handlers.open ?? []) fn(); } catch (e) { return { opts: d.opts, error: String(e), sent: d.conn.sent.map((m) => m?.type) }; }
+ return { opts: d.opts, sent: d.conn.sent.map((m) => ({ type: m?.type, result: m?.result })) };`);
+ console.log(' ' + JSON.stringify(first));
+ h.check(first?.opts?.metadata?.joinresult === 'approved', 'the approve dial-back carries joinresult: approved in its metadata');
+ h.check(first?.sent?.[0]?.type === 'joinresult' && first?.sent?.[0]?.result === 'approved', 'and its handshake OPENS with the joinresult message');
+ await H.ctx.close();
+
+ // ---- 11. two real peers -----------------------------------------------------------------
+ console.log('\n=== 11. two real peers ===');
+ const A = await h.setupPage(browser, 'A');
+ const B = await h.setupPage(browser, 'B');
+ await dialVia(B, A.id);
+ const acard = A.page.locator('.tp-toast--req', { hasText: String(B.id).slice(0, 6).toUpperCase() });
+ h.check(await click(acard.locator('.cxreq-reject'), 30000), "premise: A gets B's request card and rejects it");
+ await h.eventually(() => read(B, 'connectionState.joinRefusal'), (r) => r?.result === 'denied' && r?.peerId === A.id, 'B hears the real Reject as declined', 20000);
+ h.check(!(await read(B, 'waitingForApproval')).some((w) => w[0] === A.id), "B's request is over");
+ h.check(!(await notes(A)).some((t) => /unreachable/.test(t)), 'A is not told the refused joiner is unreachable');
+
+ await dialVia(B, A.id);
+ await inPage(A, 'window.__realOpened = pc.openedPeers; pc.openedPeers = new Set(Array.from({ length: 15 }, (_, i) => "fake" + i)); s.peers.update((v) => v);');
+ h.check(await click(acard.locator('.cxreq-full'), 30000), 'premise: at the cap A tells B it is full');
+ await inPage(A, 'pc.openedPeers = window.__realOpened; s.peers.update((v) => v);');
+ await h.eventually(() => read(B, 'connectionState.joinRefusal'), (r) => r?.result === 'full', 'B hears the full room as FULL', 20000);
+ const chip = B.page.locator('#connect-refusal-chip');
+ h.check((await attr(chip, 'data-result')) === 'full', 'and its pill says so');
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/localSignal.cjs b/tests/e2e/localSignal.cjs
new file mode 100644
index 00000000..2d918b67
--- /dev/null
+++ b/tests/e2e/localSignal.cjs
@@ -0,0 +1,74 @@
+// A LOCAL PeerJS signaling server for the multi-peer stress runs (net-stress rig and its
+// regression suite). Flooding the production signaling box with a mesh sweep is abuse,
+// and a shared box on a saturated machine is also the most common source of a two-peer
+// red that has nothing to do with the diff — so these runs bring their own.
+//
+// Port 9001 is MACHINE-WIDE: two lanes share it. Everything that starts it runs under the
+// e2e flock, and a server already listening is REUSED rather than fought over.
+//
+// Pages reach it through `peerServerConfig = {mode:'local'}` (peerServer.js), seeded with
+// `LOCAL_PEER_STORAGE` — never by guessing from the page's hostname.
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+const { spawn } = require('child_process');
+
+const SIGNAL_PORT = 9001;
+const ROOT = path.resolve(__dirname, '..', '..');
+const LOCAL_PEER_STORAGE = { peerServerConfig: JSON.stringify({ mode: 'local' }) };
+
+/** @param {number} ms */
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function signalUp() {
+ return new Promise((resolve) => {
+ const req = https.get(
+ { host: 'localhost', port: SIGNAL_PORT, path: '/', rejectUnauthorized: false, timeout: 1500 },
+ (res) => {
+ res.resume();
+ resolve(res.statusCode === 200);
+ }
+ );
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ });
+}
+
+/** Start the server unless one already answers. Returns the child to stop, or null. */
+async function ensureSignalServer() {
+ if (await signalUp()) return null;
+ const bin = path.join(ROOT, 'node_modules', 'peer', 'dist', 'bin', 'peerjs.js');
+ const key = path.join(ROOT, 'certs', 'localhost.key');
+ const crt = path.join(ROOT, 'certs', 'localhost.crt');
+ if (!fs.existsSync(bin)) throw new Error('the `peer` devDependency is missing — run npm ci');
+ if (!fs.existsSync(key)) throw new Error('certs/localhost.key missing — copy certs/ from another checkout');
+ const child = spawn(process.execPath, [bin, '--port', String(SIGNAL_PORT), '--sslkey', key, '--sslcert', crt], {
+ cwd: ROOT,
+ stdio: 'ignore'
+ });
+ for (let i = 0; i < 40; i++) {
+ await sleep(250);
+ if (await signalUp()) return child;
+ }
+ try {
+ child.kill();
+ } catch {
+ /* already gone */
+ }
+ throw new Error('local PeerJS server did not come up on :' + SIGNAL_PORT);
+}
+
+/** @param {any} child */
+function stopSignalServer(child) {
+ if (!child) return;
+ try {
+ child.kill();
+ } catch {
+ /* already gone */
+ }
+}
+
+module.exports = { SIGNAL_PORT, LOCAL_PEER_STORAGE, signalUp, ensureSignalServer, stopSignalServer };
diff --git a/tests/e2e/net-backoff.test.cjs b/tests/e2e/net-backoff.test.cjs
index 2d7ddb1c..a4fc5302 100644
--- a/tests/e2e/net-backoff.test.cjs
+++ b/tests/e2e/net-backoff.test.cjs
@@ -35,6 +35,36 @@ function check(ok, label) {
// deterministic: same inputs -> identical output (no Date/random)
check(JSON.stringify(backoffSchedule()) === JSON.stringify(backoffSchedule()), 'schedule is deterministic across calls');
+ // ---- 27-F: jitter and an unbounded retry (hardening audit H2) ----------------
+ // The signaling reconnect used to stop after 5 attempts and tell the user to reload,
+ // which drops every live DataConnection AND the invite id. Unbounded is the fix; the
+ // CAP is what protects the server, and jitter stops a room of tabs returning together.
+ check(backoffDelay(1, { base: 1000 }) === 1000, 'jitter defaults to OFF (defaults byte-identical)');
+ check(
+ JSON.stringify(backoffSchedule()) === JSON.stringify([500, 1000, 2000, 4000]),
+ 'the default schedule is unchanged by the new options'
+ );
+
+ // injectable rng = the schedule stays testable; +/-25% of 1000 is 750..1250
+ check(backoffDelay(1, { base: 1000, jitter: 0.25, rng: () => 0 }) === 750, 'jitter at rng 0 is -25%');
+ check(backoffDelay(1, { base: 1000, jitter: 0.25, rng: () => 0.5 }) === 1000, 'jitter at rng 0.5 is the plain delay');
+ check(backoffDelay(1, { base: 1000, jitter: 0.25, rng: () => 1 }) === 1250, 'jitter at rng 1 is +25%');
+ check(
+ backoffDelay(1, { base: 100, jitter: 4, rng: () => 0 }) === 0,
+ 'a jitter big enough to go negative CLAMPS at 0 (a negative wait would hammer the server)'
+ );
+
+ // unbounded: every attempt has a delay, saturated at the cap
+ const unbounded = { base: 800, cap: 8000, max: Infinity };
+ check(backoffDelay(5, unbounded) !== null, 'attempt 5 still has a delay when max is Infinity');
+ check(backoffDelay(99, unbounded) === 8000, 'attempt 99 saturates at the cap instead of giving up');
+ check(backoffDelay(1, unbounded) === 800, 'the first unbounded attempt is the base');
+
+ // ...and the schedule helper must TERMINATE on an unbounded max
+ const un = backoffSchedule(unbounded);
+ check(un.length === 10, `an unbounded schedule is bounded by \`limit\` (${un.length} entries)`);
+ check(backoffSchedule({ ...unbounded, limit: 3 }).length === 3, 'limit is honoured');
+
console.log(failures === 0 ? 'ALL PASS' : failures + ' FAILURES');
process.exit(failures === 0 ? 0 : 1);
})().catch((e) => {
diff --git a/tests/e2e/net-stress.cjs b/tests/e2e/net-stress.cjs
index 3c776ab2..e80de918 100644
--- a/tests/e2e/net-stress.cjs
+++ b/tests/e2e/net-stress.cjs
@@ -1,7 +1,7 @@
// B5 — mesh network stress harness (LOCAL PeerJS ONLY).
//
-// node tests/e2e/net-stress.cjs [--peers 4,6,8,10] [--load 20] [--objects 20]
-// [--out docs/net-stress.md] [--hz 10]
+// node tests/e2e/net-stress.cjs [--peers 8,10,12,16] [--load 20] [--objects 20]
+// [--out docs/net-stress.md] [--hz 10] [--presence 10]
//
// NOT a .test.cjs on purpose: a full sweep runs for many minutes, well past the
// runner's per-suite timeout. `npm run e2e -- net-stress` runs the small
@@ -14,20 +14,25 @@
// - message loss — sequence numbers over a synthetic mutation load
// - fan-out cost — wall time of one PeerConnection.send() across N-1 conns
// - renderer FPS — idle baseline vs under load (relative; see the caveat below)
+// - long tasks/min — main-thread blocks over 50ms per peer under the load (25-G)
+// - presence — with --presence N, every peer orbits its camera for N seconds and
+// each counts the `camera` messages it RECEIVES per sender: the
+// audit-H7 stream, now rate-gated (25-C), at mesh scale (25-G)
//
// HARD RULE: local signaling server only. Pointing a 10-peer flood at the public
-// or self-hosted production box is abuse, so the harness refuses any APP_URL that
-// isn't localhost and spawns its own `peer` server on :9001 (the same one the
-// .vscode "peerjs" task starts).
+// or self-hosted production box is abuse, so the harness spawns its own `peer` server on
+// :9001 (localSignal.cjs) and SEEDS every page with `peerServerConfig = {mode:'local'}` —
+// which is what actually keeps the pages off production, whatever the app's hostname.
+// The APP_URL must still resolve to this machine (a lane serves theprototype.app via
+// /etc/hosts), so the dev server being flooded is our own.
//
// CAVEAT on FPS: N headless Chromium contexts each render a WebGL scene on the
-// same machine (SwiftShader, no GPU), so absolute FPS says more about the host
-// than about the protocol. Only the idle-vs-load DELTA at a given N is meaningful.
+// same machine, so absolute FPS says more about the host than about the protocol.
+// Only the idle-vs-load DELTA at a given N is meaningful. The rig launches with
+// GPU_ARGS; on a box without a GPU that silently falls back to SwiftShader.
const fs = require('fs');
const path = require('path');
-const https = require('https');
-const { spawn } = require('child_process');
// ---------------------------------------------------------------- arguments
const argv = process.argv.slice(2);
@@ -36,7 +41,7 @@ function arg(name, fallback) {
const i = argv.indexOf('--' + name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
}
-const SIZES = arg('peers', '4,6,8,10')
+const SIZES = arg('peers', '8,10,12,16')
.split(',')
.map((n) => parseInt(n, 10))
.filter((n) => n >= 2);
@@ -44,6 +49,8 @@ const LOAD_SECS = parseInt(arg('load', '20'), 10);
const HZ = parseInt(arg('hz', '10'), 10);
const OBJECTS = parseInt(arg('objects', '20'), 10);
const OUT = arg('out', '');
+// 25-G: seconds of continuous camera motion on every peer; 0 = skip the presence phase
+const PRESENCE_SECS = parseInt(arg('presence', '0'), 10);
// --logs echoes each page's own console (peerHandler is chatty about the connect
// dance) with a ms stamp, which is the only way to see WHY a join stalls
const LOGS = argv.includes('--logs');
@@ -53,52 +60,17 @@ const T0 = Date.now();
const APP_URL = process.env.APP_URL || 'https://localhost:5185/';
process.env.APP_URL = APP_URL;
const host = new URL(APP_URL).hostname;
-if (!/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(host)) {
- console.error(
- 'REFUSING to run: APP_URL host is "' + host + '".\n' +
- 'The stress harness floods the signaling server and must only ever point at a\n' +
- 'LOCAL dev server (which routes PeerJS to localhost:9001). See the file header.'
- );
- process.exit(2);
-}
-
const h = require('./helpers.cjs');
-
-// ------------------------------------------------------- local peerjs server
-const SIGNAL_PORT = 9001;
-
-function signalUp() {
- return new Promise((resolve) => {
- const req = https.get(
- { host: 'localhost', port: SIGNAL_PORT, path: '/', rejectUnauthorized: false, timeout: 1500 },
- (res) => {
- res.resume();
- resolve(res.statusCode === 200);
- }
- );
- req.on('error', () => resolve(false));
- req.on('timeout', () => { req.destroy(); resolve(false); });
- });
-}
-
-async function ensureSignalServer() {
- if (await signalUp()) return null;
- const bin = path.join(ROOT, 'node_modules', 'peer', 'dist', 'bin', 'peerjs.js');
- const key = path.join(ROOT, 'certs', 'localhost.key');
- const crt = path.join(ROOT, 'certs', 'localhost.crt');
- if (!fs.existsSync(bin)) throw new Error('the `peer` devDependency is missing — run npm ci');
- if (!fs.existsSync(key)) throw new Error('certs/localhost.key missing — run npm run certs');
- console.log('starting local PeerJS server on :' + SIGNAL_PORT);
- const child = spawn(process.execPath, [bin, '--port', String(SIGNAL_PORT), '--sslkey', key, '--sslcert', crt], {
- cwd: ROOT,
- stdio: 'ignore'
- });
- for (let i = 0; i < 40; i++) {
- await sleep(250);
- if (await signalUp()) return child;
- }
- try { child.kill(); } catch { /* already gone */ }
- throw new Error('local PeerJS server did not come up on :' + SIGNAL_PORT);
+const { SIGNAL_PORT, LOCAL_PEER_STORAGE, ensureSignalServer } = require('./localSignal.cjs');
+
+/** Does the APP_URL host resolve to this machine? @param {string} name */
+function isLoopback(name) {
+ if (/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(name)) return Promise.resolve(true);
+ return new Promise((resolve) =>
+ require('dns').lookup(name, { all: true }, (err, addrs) =>
+ resolve(!err && addrs.length > 0 && addrs.every((a) => a.address === '127.0.0.1' || a.address === '::1'))
+ )
+ );
}
// ------------------------------------------------------------------- utils
@@ -147,9 +119,43 @@ function installProbe(peer) {
// per-type traffic accounting — ON during joins (where the interesting
// asymmetry is), OFF under load so the sizing cost can't skew FPS
accounting: true,
- traffic: { count: 0, bytes: 0, byType: {} }
+ traffic: { count: 0, bytes: 0, byType: {} },
+ // 25-G: camera messages received per SENDER, and the main thread's long tasks
+ cam: {},
+ tasks: []
});
ns.pc = pc;
+ if (!ns.taskObserver) {
+ try {
+ ns.taskObserver = new PerformanceObserver((list) => {
+ for (const e of list.getEntries()) ns.tasks.push(e.startTime);
+ });
+ ns.taskObserver.observe({ entryTypes: ['longtask'] });
+ } catch {
+ ns.taskObserver = null;
+ }
+ }
+ /** long tasks that started in the last `ms` */
+ ns.tasksIn = (/** @type {number} */ ms) => ns.tasks.filter((/** @type {number} */ t) => t >= performance.now() - ms).length;
+ /** orbit the editor camera every frame until stopped — the presence stream's source */
+ ns.orbitStart = () => {
+ let controls;
+ w.__stores.orbitControls.subscribe((/** @type {any} */ c) => (controls = c))();
+ ns.orbitFrames = 0;
+ ns.orbiting = true;
+ const tick = () => {
+ if (!ns.orbiting) return;
+ if (controls?._rotateLeft) controls._rotateLeft(0.03);
+ controls?.update?.();
+ ns.orbitFrames++;
+ requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+ };
+ ns.orbitStop = () => {
+ ns.orbiting = false;
+ return ns.orbitFrames;
+ };
/** rough wire size; binarypack is compact but relative sizes are what matter */
ns.sizeOf = (/** @type {any} */ d) => {
@@ -196,6 +202,7 @@ function installProbe(peer) {
ns.hooked.add(c);
added++;
c.on('data', (/** @type {any} */ d) => {
+ if (d && d.type === 'camera' && d.peerId) ns.cam[d.peerId] = (ns.cam[d.peerId] || 0) + 1;
if (ns.accounting && d) {
const t = typeof d === 'string' ? 'string' : d.type || 'unknown';
const tr = ns.traffic;
@@ -364,7 +371,7 @@ async function runSize(N) {
// measures render starvation. We want the NETWORK to be the bottleneck.
const p = await h.setupPage(browser, 'P' + i, {
context: { viewport: { width: 800, height: 600 } },
- storage: { viewMode: 'shaded' }
+ storage: { viewMode: 'shaded', ...LOCAL_PEER_STORAGE }
});
if (LOGS) {
const tag = 'P' + i + '/' + p.id;
@@ -499,12 +506,65 @@ async function runSize(N) {
// --- load: every peer broadcasts `move` at hz for `secs`, then a ramp to
// find where the mesh actually starts hurting
+ /**
+ * 25-G: every peer orbits its camera for `secs`, and each counts the `camera` messages
+ * it RECEIVES per sender. The rate is per sender per receiver, stated beside the
+ * sender's own frame count — a 50ms gate at 60fps is ~0.33 messages a frame.
+ * @param {number} secs
+ */
+ const presencePhase = async (secs) => {
+ for (const p of peers) await p.page.evaluate(() => window.__ns.hook());
+ for (const p of peers) await p.page.evaluate(() => { window.__ns.cam = {}; });
+ for (const p of peers) await p.page.evaluate(() => window.__ns.orbitStart());
+ await sleep(secs * 1000);
+ const frames = [];
+ for (const p of peers) frames.push(await p.page.evaluate(() => window.__ns.orbitStop()));
+ const longPerMin = [];
+ for (const p of peers) longPerMin.push(await p.page.evaluate((ms) => window.__ns.tasksIn(ms), secs * 1000));
+ await sleep(1000);
+ /** received camera msgs/s per peer, summed over every sender */
+ const receivedPerPeer = [];
+ /** per sender->receiver pair, msgs per sender frame */
+ const perFrame = [];
+ let pairsSilent = 0;
+ for (let i = 0; i < N; i++) {
+ const cam = await peers[i].page.evaluate(() => ({ ...window.__ns.cam }));
+ let total = 0;
+ for (let j = 0; j < N; j++) {
+ if (i === j) continue;
+ const got = cam[peers[j].id] || 0;
+ total += got;
+ if (!got) pairsSilent++;
+ if (frames[j]) perFrame.push(got / frames[j]);
+ }
+ receivedPerPeer.push(total / secs);
+ }
+ const out = {
+ secs,
+ senderFps: median(frames.map((f) => f / secs)),
+ receivedPerPeerPerSec: median(receivedPerPeer),
+ maxReceivedPerPeerPerSec: Math.max(...receivedPerPeer),
+ msgsPerSenderFrame: stats(perFrame),
+ pairsSilent,
+ longTasksPerMin: median(longPerMin.map((n) => (n * 60) / secs)),
+ maxLongTasksPerMin: Math.max(...longPerMin.map((n) => (n * 60) / secs))
+ };
+ console.log(
+ ' presence: ' + r(out.receivedPerPeerPerSec) + ' camera msgs/s received per peer (max ' + r(out.maxReceivedPerPeerPerSec) + ')' +
+ ', ' + r(out.msgsPerSenderFrame.p50, 2) + ' msgs per sender frame, sender fps ' + r(out.senderFps) +
+ ', silent pairs ' + pairsSilent + ', long tasks/min ' + r(out.longTasksPerMin) + ' (max ' + r(out.maxLongTasksPerMin) + ')'
+ );
+ return out;
+ };
+
/** @param {number} hz @param {number} secs */
const loadPhase = async (hz, secs) => {
for (const p of peers) await p.page.evaluate(() => window.__ns.hook());
for (const p of peers) await p.page.evaluate(() => window.__ns.fpsStart());
for (const p of peers) await p.page.evaluate(([u, z]) => window.__ns.startLoad(u, z), [uuid, hz]);
await sleep(secs * 1000);
+ const longPerMin = [];
+ for (const p of peers) longPerMin.push(await p.page.evaluate((ms) => window.__ns.tasksIn(ms), secs * 1000));
const sent = [];
for (const p of peers) sent.push(await p.page.evaluate(() => window.__ns.stopLoad()));
const fps = [];
@@ -549,6 +609,7 @@ async function runSize(N) {
sendMs: stats(sendMs),
oneWay: stats(lat),
fps: median(fps),
+ longTasksPerMin: median(longPerMin.map((n) => (n * 60) / secs)),
msgs: { expected, got, lossPct: expected ? (100 * (expected - got)) / expected : 0 },
meshMsgsPerSec: hz * N * (N - 1)
};
@@ -564,6 +625,7 @@ async function runSize(N) {
};
row.steady = await loadPhase(HZ, LOAD_SECS);
+ if (PRESENCE_SECS > 0) row.presence = await presencePhase(PRESENCE_SECS);
row.ramp = [];
for (const hz of [30, 60, 120]) row.ramp.push(await loadPhase(hz, 8));
@@ -624,8 +686,8 @@ function report(rows) {
lines.push('');
lines.push('## Load ramp (8s per step; "emitted" = what the send timer actually managed)');
lines.push('');
- lines.push('| N | Hz/peer | mesh msgs/s | loss | one-way p50/p95 | send() p95 | fps | emitted/wanted |');
- lines.push('|---|---|---|---|---|---|---|---|');
+ lines.push('| N | Hz/peer | mesh msgs/s | loss | one-way p50/p95 | send() p95 | fps | long tasks/min | emitted/wanted |');
+ lines.push('|---|---|---|---|---|---|---|---|---|');
for (const w of rows) {
for (const s of [w.steady, ...(w.ramp || [])]) {
if (!s) continue;
@@ -634,10 +696,29 @@ function report(rows) {
' | ' + r(s.oneWay.p50) + ' / ' + r(s.oneWay.p95) +
' | ' + r(s.sendMs.p95, 2) +
' | ' + r(s.fps) +
+ ' | ' + r(s.longTasksPerMin) +
' | ' + r(s.sentPerPeer, 0) + '/' + s.wantedPerPeer + ' |'
);
}
}
+ if (rows.some((w) => w.presence)) {
+ lines.push('');
+ lines.push('## Presence (25-G): every peer orbiting for ' + PRESENCE_SECS + 's');
+ lines.push('');
+ lines.push('| N | sender fps | camera msgs/s received per peer (median / max) | msgs per sender frame p50/max | silent pairs | long tasks/min (median / max) |');
+ lines.push('|---|---|---|---|---|---|');
+ for (const w of rows) {
+ const p = w.presence;
+ if (!p) continue;
+ lines.push(
+ '| ' + w.N + ' | ' + r(p.senderFps) +
+ ' | ' + r(p.receivedPerPeerPerSec) + ' / ' + r(p.maxReceivedPerPeerPerSec) +
+ ' | ' + r(p.msgsPerSenderFrame.p50, 2) + ' / ' + r(p.msgsPerSenderFrame.max, 2) +
+ ' | ' + p.pairsSilent +
+ ' | ' + r(p.longTasksPerMin) + ' / ' + r(p.maxLongTasksPerMin) + ' |'
+ );
+ }
+ }
lines.push('');
lines.push('```json');
lines.push(JSON.stringify(rows, null, 1));
@@ -649,6 +730,13 @@ function report(rows) {
(async () => {
let server = null;
try {
+ if (!(await isLoopback(host))) {
+ console.error(
+ 'REFUSING to run: APP_URL host "' + host + '" does not resolve to this machine.\n' +
+ 'The rig floods its dev server and must only ever point at a LOCAL one. See the file header.'
+ );
+ process.exit(2);
+ }
server = await ensureSignalServer();
console.log('app: ' + APP_URL + ' signaling: https://localhost:' + SIGNAL_PORT);
const rows = [];
diff --git a/tests/e2e/net-stress.test.cjs b/tests/e2e/net-stress.test.cjs
new file mode 100644
index 00000000..ef68d23b
--- /dev/null
+++ b/tests/e2e/net-stress.test.cjs
@@ -0,0 +1,286 @@
+// 27-I + 25-G — THE MESH REGRESSION SUITE, on FOUR peers and a LOCAL signaling server.
+//
+// `net-stress.cjs` beside this file is the MEASUREMENT RIG (a many-minute sweep across
+// mesh sizes). This is the quick check that would catch a real regression in what the rig
+// measures. 27-I shipped it on three peers against the shared signaling box; 25-G makes it
+// what that brief asked for:
+// - N=4, because with three peers the host's `hosts` roster only ever names ONE other
+// peer, so a fill that mishandled a list longer than one (only the first id, only the
+// last) would still pass. With four, each fill has to reach two peers that never
+// dialled each other — six of the twelve links come from the fill alone.
+// - a LOCAL `peer` server on :9001 (localSignal.cjs), so a signaling hiccup on a shared
+// box can no longer masquerade as a mesh regression, and nothing floods production.
+//
+// What this pins:
+// 1. the mesh FILLS — every one of the 12 ordered pairs is open (pair-complete: a link
+// that never formed is exactly the loss a user feels)
+// 2. a broadcast reaches every peer with NO loss, by sequence number
+// 3. all FOUR broadcasting at once: every ordered pair delivers whole — nobody starves
+// 4. one send's fan-out cost stays bounded
+// 5. the PRESENCE stream (roadmap 25 3c/3d, audit H7): four peers orbiting at display
+// rate send `camera` at the gated rate (20/s desktop), NOT once per frame — with a
+// premise that every sender drew well above the gate, so per-frame would be visible
+// 6. the main thread under that load: long tasks per peer are recorded and bounded
+//
+// The probe rides a REAL `move` payload with additive `__ns` fields: 27-A validates every
+// incoming message, so a made-up uuid would be rejected — the probe carries a real uuid.
+//
+// Run: APP_URL=https://theprototype.app:5180/ npm run e2e -- net-stress.test
+const h = require('./helpers.cjs');
+const { LOCAL_PEER_STORAGE, ensureSignalServer, stopSignalServer } = require('./localSignal.cjs');
+
+/** Hook every conn; count probe messages per sender by seq, and presence per sender. */
+const installProbe = (peer) =>
+ peer.page.evaluate((myId) => {
+ const w = window;
+ let pc;
+ w.__stores.peers.subscribe((p) => (pc = p))();
+ const ns = (w.__probe = w.__probe || { myId, hooked: new WeakSet(), rx: {}, cam: {}, sendMs: [], tasks: [] });
+ ns.pc = pc;
+ if (!ns.observer) {
+ try {
+ ns.observer = new PerformanceObserver((list) => {
+ for (const e of list.getEntries()) ns.tasks.push({ at: e.startTime, ms: e.duration });
+ });
+ ns.observer.observe({ entryTypes: ['longtask'] });
+ } catch {
+ ns.observer = null;
+ }
+ }
+ // the app's outgoing map AND peerjs's own, which also holds INBOUND conns
+ ns.allConns = () => {
+ const seen = new Set();
+ const out = [];
+ const push = (c) => {
+ if (!c || typeof c.send !== 'function' || c.type !== 'data' || seen.has(c)) return;
+ seen.add(c);
+ out.push(c);
+ };
+ for (const k of Object.keys(pc.connections || {})) push(pc.connections[k]);
+ const raw = (pc.peer && pc.peer.connections) || {};
+ for (const k of Object.keys(raw)) (raw[k] || []).forEach(push);
+ return out;
+ };
+ ns.hook = () => {
+ for (const c of ns.allConns()) {
+ if (ns.hooked.has(c)) continue;
+ ns.hooked.add(c);
+ c.on('data', (d) => {
+ if (!d) return;
+ if (d.type === 'camera' && d.peerId) ns.cam[d.peerId] = (ns.cam[d.peerId] || 0) + 1;
+ if (d.__ns !== 'probe') return;
+ const s = ns.rx[d.__from] || (ns.rx[d.__from] = { count: 0, maxSeq: -1 });
+ s.count++;
+ if (d.__seq > s.maxSeq) s.maxSeq = d.__seq;
+ });
+ }
+ return ns.allConns().length;
+ };
+ ns.hook();
+ if (!ns.auto) ns.auto = setInterval(() => ns.hook(), 250);
+ ns.send = (uuid, seq) => {
+ const t = performance.now();
+ pc.send({
+ type: 'move',
+ uuid,
+ pos: [Math.sin(seq / 10), 0.5, Math.cos(seq / 10)],
+ rot: [0, seq / 50, 0],
+ scale: [1, 1, 1],
+ __ns: 'probe',
+ __from: ns.myId,
+ __seq: seq
+ });
+ ns.sendMs.push(performance.now() - t);
+ };
+ // `maxSeq` is a RUNNING MAXIMUM and `count` accumulates, so a later section that
+ // sends fewer messages than an earlier one cannot lower either — every section that
+ // counts starts from a reset, or it passes on numbers left over from the last one.
+ ns.reset = () => {
+ ns.rx = {};
+ ns.cam = {};
+ };
+ ns.blast = async (uuid, count, gapMs) => {
+ ns.sendMs = [];
+ for (let i = 0; i < count; i++) {
+ ns.send(uuid, i);
+ await new Promise((r) => setTimeout(r, gapMs));
+ }
+ return { sent: count, maxSendMs: Math.max(...ns.sendMs) };
+ };
+ // orbit the editor camera every frame for `ms`, counting our own frames — the
+ // presence stream's send rate is stated against THIS number
+ ns.orbit = async (ms) => {
+ let controls;
+ w.__stores.orbitControls.subscribe((c) => (controls = c))();
+ const started = performance.now();
+ let frames = 0;
+ const taskFrom = ns.tasks.length;
+ while (performance.now() - started < ms) {
+ await new Promise((r) => requestAnimationFrame(r));
+ if (controls?._rotateLeft) controls._rotateLeft(0.03);
+ controls?.update?.();
+ frames++;
+ }
+ const tasks = ns.tasks.slice(taskFrom);
+ return {
+ frames,
+ elapsed: performance.now() - started,
+ longTasks: tasks.length,
+ longest: tasks.reduce((m, t) => Math.max(m, t.ms), 0)
+ };
+ };
+ return true;
+ }, peer.id);
+
+const received = (peer, fromId) =>
+ peer.page.evaluate((from) => {
+ const s = window.__probe?.rx?.[from];
+ return s ? { count: s.count, maxSeq: s.maxSeq } : { count: 0, maxSeq: -1 };
+ }, fromId);
+
+const openPeers = (peer) =>
+ peer.page.evaluate(() => {
+ let pc;
+ window.__stores.peers.subscribe((p) => (pc = p))();
+ return [...(pc?.openedPeers ?? [])];
+ });
+
+h.run(async () => {
+ /** @type {any} */
+ let browserRef = null;
+ const signal = await ensureSignalServer();
+ try {
+ // GPU args: section 5 is a RATE claim against display frames, and a SwiftShader page
+ // at ~2.5fps can never exercise a 50ms gate (the e2e skill's rule)
+ const browser = (browserRef = await h.launch({ args: h.GPU_ARGS }));
+ const opts = { storage: LOCAL_PEER_STORAGE, context: { viewport: { width: 800, height: 600 } } };
+ const peers = [];
+ for (const name of ['A', 'B', 'C', 'D']) peers.push(await h.setupPage(browser, name, opts));
+ const [A, B, C, D] = peers;
+ const server = await A.page.evaluate(() => {
+ let s;
+ window.__stores.peerServer.peerServerStatus.subscribe((v) => (s = v))();
+ return s;
+ });
+ h.check(server?.kind === 'local', `premise: the peers signal through the LOCAL server (${JSON.stringify(server)})`);
+
+ // ---- 1. the mesh fills -----------------------------------------------------------
+ // a CONNECTED peer's pill has no dial input, so every joiner dials the HOST
+ await h.connect(B, A);
+ await h.connect(C, A);
+ await h.connect(D, A);
+ /** every ordered pair (i sees j open) */
+ const pairState = async () => {
+ const lists = [];
+ for (const p of peers) lists.push(await openPeers(p));
+ const missing = [];
+ peers.forEach((p, i) =>
+ peers.forEach((q, j) => {
+ if (i !== j && !lists[i].includes(q.id)) missing.push(`${'ABCD'[i]}->${'ABCD'[j]}`);
+ })
+ );
+ return missing;
+ };
+ await h.eventually(pairState, (m) => m.length === 0, 'all 12 ordered pairs of a four-peer mesh are open', 45000);
+ const missing = await pairState();
+ h.check(
+ missing.length === 0,
+ `the mesh is FULL — B, C and D each dialled only the host (missing: ${JSON.stringify(missing)})`
+ );
+
+ // a REAL object, so the probe's `move` survives the 27-A wire validator
+ const uuid = await A.page.evaluate(() => {
+ window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [3, 0.5, -2]);
+ return new Promise((resolve) =>
+ window.__stores.objectsGroup.subscribe((g) => {
+ const o = g.children[g.children.length - 1];
+ resolve(o ? o.uuid : null);
+ })()
+ );
+ });
+ h.check(!!uuid, `premise: a real object to address, so the probe is not rejected as malformed (${uuid})`);
+ await h.eventually(
+ () => D.page.evaluate((u) => !!window.__stores.objectsGroup && (() => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); return !!g.getObjectByProperty('uuid', u); })(), uuid),
+ (ok) => ok,
+ 'premise: the object reached the last joiner',
+ 15000
+ );
+
+ for (const p of peers) await installProbe(p);
+ await A.page.waitForTimeout(600);
+
+ // ---- 2. a broadcast reaches everyone, with no loss -------------------------------
+ const blast = await A.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 60, 25]);
+ h.check(blast.sent === 60, `premise: the host sent 60 probe messages (${blast.sent})`);
+ await A.page.waitForTimeout(1200);
+ for (const p of [B, C, D]) {
+ const got = await received(p, A.id);
+ h.check(got.count === 60 && got.maxSeq === 59, `${p === B ? 'B' : p === C ? 'C' : 'D'} got every host message, tail included (${got.count}/60, maxSeq ${got.maxSeq})`);
+ }
+
+ // ---- 4. fan-out cost stays bounded ------------------------------------------------
+ h.check(blast.maxSendMs < 250, `one broadcast's fan-out stays bounded (worst send ${blast.maxSendMs.toFixed(1)}ms across 3 conns)`);
+
+ // ---- 3. all four at once: every ordered pair whole --------------------------------
+ for (const p of peers) await p.page.evaluate(() => window.__probe.reset());
+ await Promise.all(peers.map((p) => p.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 40, 25])));
+ await A.page.waitForTimeout(1800);
+ let pairsWhole = 0;
+ const broken = [];
+ for (let i = 0; i < 4; i++) {
+ for (let j = 0; j < 4; j++) {
+ if (i === j) continue;
+ const got = await received(peers[i], peers[j].id);
+ if (got.count === 40 && got.maxSeq === 39) pairsWhole++;
+ else broken.push(`${'ABCD'[j]}->${'ABCD'[i]} ${got.count}/40`);
+ }
+ }
+ h.check(pairsWhole === 12, `under four-way load every ordered pair delivered whole (${pairsWhole}/12 ${JSON.stringify(broken)})`);
+
+ // ---- 5. the presence stream is throttled, not per frame ---------------------------
+ for (const p of peers) await p.page.evaluate(() => window.__probe.reset());
+ const runs = await Promise.all(peers.map((p) => p.page.evaluate((ms) => window.__probe.orbit(ms), 3000)));
+ // read at once: OrbitControls damping keeps the camera drifting (and sending) after the
+ // orbit loop stops, and those messages belong to no measured frame window
+ const cams = [];
+ for (const p of peers) cams.push(await p.page.evaluate(() => ({ ...window.__probe.cam })));
+ // the claim is only testable when a per-frame sender would EXCEED the gate: 25-C gates
+ // the desktop camera at 50ms (20/s), so every peer must be drawing well above that
+ h.check(
+ runs.every((r) => (r.frames * 1000) / r.elapsed >= 40),
+ `premise: every peer ran well above the 20/s gate while orbiting (${runs.map((r) => Math.round((r.frames * 1000) / r.elapsed)).join(', ')} fps)`
+ );
+ // per SENDER, as seen by every other peer, in messages per second of orbit
+ const rates = [];
+ let flowing = true;
+ peers.forEach((sender, j) => {
+ peers.forEach((_, i) => {
+ if (i === j) return;
+ const got = cams[i][sender.id] || 0;
+ if (got < 10) flowing = false;
+ rates.push(Math.round((got / (runs[j].elapsed / 1000)) * 10) / 10);
+ });
+ });
+ h.check(flowing, `premise: the camera stream flows between every pair while orbiting (${JSON.stringify(cams.map((c) => Object.values(c)))})`);
+ const worst = Math.max(...rates);
+ const slowestFps = Math.min(...runs.map((r) => (r.frames * 1000) / r.elapsed));
+ // 20/s plus slack for in-flight messages at the cut; a per-frame sender
+ // would read at its frame rate, which the premise put at 40 or more
+ h.check(
+ worst <= 25 && worst < slowestFps * 0.65,
+ `presence is gated, not per frame: worst ${worst} msgs/s per sender against >= ${Math.round(slowestFps)} fps (all ${JSON.stringify(rates)})`
+ );
+
+ // ---- 6. the main thread under the load --------------------------------------------
+ const longest = Math.max(...runs.map((r) => r.longest));
+ console.log('long tasks while four peers orbit: ' + JSON.stringify(runs.map((r) => ({ n: r.longTasks, longest: Math.round(r.longest) }))));
+ h.check(longest < 1000, `no peer froze while four orbit and stream presence (longest task ${Math.round(longest)}ms)`);
+ } catch (error) {
+ stopSignalServer(signal);
+ throw error;
+ }
+ // `finish` exits the process, so the server we started is stopped BEFORE it — a
+ // leftover listener on the machine-wide :9001 would be reused by the next lane's run
+ stopSignalServer(signal);
+ await h.finish(browserRef);
+});
diff --git a/tests/e2e/overload-guard.test.cjs b/tests/e2e/overload-guard.test.cjs
new file mode 100644
index 00000000..dd24d41f
--- /dev/null
+++ b/tests/e2e/overload-guard.test.cjs
@@ -0,0 +1,301 @@
+// 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)');
+
+ // 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;
+ 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;
+ // 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;
+ 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'
+ );
+ // 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.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);
+});
diff --git a/tests/e2e/perf-governor.test.cjs b/tests/e2e/perf-governor.test.cjs
new file mode 100644
index 00000000..cabb9997
--- /dev/null
+++ b/tests/e2e/perf-governor.test.cjs
@@ -0,0 +1,370 @@
+// 26-D — the adaptive quality governor (roadmap 26 section 4, Stage 1).
+//
+// The decision RULE is unit-tested (tests/unit/qualityGovernor.test.js). This suite proves
+// the WIRING against the live app, in the order it matters:
+// 1. a light scene on a slow machine is never governed (the 26-G ruling, held)
+// 2. a heavy slow scene takes the first step: shadows go off on the renderer, the user's
+// saved shadowQuality is NOT written, the chip appears, the baseline is recorded
+// 3. THE 26-G INTERACTION: a heavy scene whose draw calls the governor has brought down to
+// green still reads as heavy to the freeze guard, and a frozen streak still pauses
+// 4. every step reaches its consumer (the real pixel ratio, the composer's buffer size) and
+// walking back restores it exactly
+// 5. recovery on good frames, the chip's pin and release (with the release snooze), and the
+// opt-out
+// 6. the INGEST DRAW GAP (26-E's finding): while a batch drains through slow frames the
+// renderer draws at most ~4 frames a second, sticky for the drain, off when it ends
+// 7. END TO END on real frames: 3,000 real boxes on a real GPU engage the governor on their
+// own, and the measured draw calls fall
+//
+// Sections 1-6 stop the budget sampler so the metrics and frames are the suite's to write
+// (the overload-guard suite's pattern); section 7 turns it back on.
+//
+// Run: APP_URL=https://theprototype.app:5180/ npm run e2e -- perf-governor
+const h = require('./helpers.cjs');
+const { installProbe } = require('./sceneStressProbe.cjs');
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+ await installProbe(A.page);
+
+ // shared in-page helpers
+ await A.page.evaluate(() => {
+ const s = window.__stores;
+ const read = (store) => {
+ let v;
+ store.subscribe((x) => (v = x))();
+ return v;
+ };
+ window.__gov = {
+ read,
+ /** synthetic frames of `ms` for `durationMs`, ending at a time we choose */
+ frames(ms, durationMs) {
+ const q = s.qualityGovernor.governorForTest;
+ let t = window.__gov.clock;
+ const end = t + durationMs;
+ while (t < end) {
+ t += ms;
+ q.frame(ms, t);
+ }
+ window.__gov.clock = t;
+ return t;
+ },
+ decide() {
+ return s.qualityGovernor.decideNow(window.__gov.clock);
+ },
+ metrics(m) {
+ s.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', ...m });
+ },
+ clock: 1e7
+ };
+ });
+
+ const snap = () =>
+ A.page.evaluate(() => {
+ const s = window.__stores;
+ const { read } = window.__gov;
+ const r = read(s.globalRenderer);
+ return {
+ state: read(s.qualityGovernor.qualityState),
+ overrides: read(s.qualityGovernor.qualityOverrides),
+ baseline: read(s.sceneBudget.qualityBaseline),
+ shadowMap: r.shadowMap.enabled,
+ pixelRatio: r.getPixelRatio(),
+ bufferWidth: r.getDrawingBufferSize(new s.THREE.Vector2()).x,
+ canvasCss: r.domElement.clientWidth,
+ storedShadowPref: localStorage.getItem('shadowQuality'),
+ post: window.__postDebug ? (({ kinds, composerBufferWidth }) => ({ kinds, composerBufferWidth }))(window.__postDebug()) : null,
+ chip: document.querySelector('#quality-chip')
+ ? { level: document.querySelector('#quality-chip').getAttribute('data-level'), pinned: document.querySelector('#quality-chip').getAttribute('data-pinned'), title: document.querySelector('#quality-chip').title }
+ : null
+ };
+ });
+
+ const base = await A.page.evaluate(() => {
+ const s = window.__stores;
+ s.sceneBudget.stopSceneMetrics();
+ s.qualityGovernor.governorForTest.reset();
+ return { dpr: window.devicePixelRatio, stored: localStorage.getItem('shadowQuality') };
+ });
+ const start = await snap();
+ h.check(start.state.level === 0 && start.overrides.dprScale === 1 && !start.overrides.shadowsOff, `boots at full quality (${JSON.stringify(start.overrides)})`);
+ h.check(start.chip === null, 'no chip at full quality');
+ h.check(start.shadowMap === true, 'premise: the renderer draws shadows at full quality');
+
+ // ---- 1. a light scene on a slow machine is never governed --------------------------
+ const light = await A.page.evaluate(() => {
+ const g = window.__gov;
+ g.metrics({ objects: 12, meshes: 12, triangles: 144, calls: 24 });
+ g.frames(400, 4000);
+ return g.decide();
+ });
+ h.check(light.level === 0 && light.moved === null, `400ms frames on a 12-box scene change nothing (${JSON.stringify(light)})`);
+
+ // ---- 2. a heavy slow scene: the first step --------------------------------------------
+ // heavy by DRAW CALLS alone (objects green): the case where the governor's own shadows
+ // step could talk 26-G out of a scene — the one section 3 is about
+ const HEAVY = { objects: 1000, meshes: 1000, triangles: 60000, calls: 4800 };
+ const first = await A.page.evaluate((m) => {
+ const g = window.__gov;
+ g.metrics(m);
+ g.frames(50, 2200);
+ return g.decide();
+ }, HEAVY);
+ h.check(first.moved === 'up' && first.level === 1, `50ms frames on a heavy scene take ONE step (${JSON.stringify(first)})`);
+ await A.page.waitForTimeout(400);
+ const afterFirst = await snap();
+ h.check(afterFirst.overrides.shadowsOff === true && afterFirst.overrides.dprScale === 1, `the first step is SHADOWS (26-E: calls are the cost) (${JSON.stringify(afterFirst.overrides)})`);
+ h.check(afterFirst.shadowMap === false, 'the renderer really stopped drawing shadows');
+ h.check(
+ afterFirst.storedShadowPref === base.stored,
+ `…without touching the user's saved shadow preference (stored ${afterFirst.storedShadowPref} === ${base.stored})`
+ );
+ h.check(afterFirst.baseline?.calls === 4800 && afterFirst.baseline?.objects === 1000, `the pre-reduction readings are recorded (${JSON.stringify(afterFirst.baseline)})`);
+ h.check(afterFirst.chip?.level === '1' && /shadows off/i.test(afterFirst.chip?.title ?? ''), `the chip appears and names what was reduced (${JSON.stringify(afterFirst.chip)})`);
+
+ // ---- 3. the 26-G interaction --------------------------------------------------------
+ // the SAME scene as the governor has made it: same objects, shadows off halved the calls
+ // to green — by its LIVE readings this scene is light
+ const guard = await A.page.evaluate(() => {
+ const s = window.__stores;
+ const g = window.__gov;
+ g.metrics({ objects: 1000, meshes: 1000, triangles: 30000, calls: 1900 });
+ const liveOnly = s.sceneBudget.isHeavy(s.sceneBudget.sceneMetrics ? g.read(s.sceneBudget.sceneMetrics) : {}, 'desktop', null);
+ const guardSays = s.overloadGuard.sceneIsHeavy();
+ let paused = null;
+ for (let i = 0; i < 12; i++) s.overloadGuard.noteFrameForFreeze(300);
+ paused = g.read(s.overloadGuard.renderPaused);
+ s.overloadGuard.resumeRendering();
+ return { liveOnly, guardSays, paused };
+ });
+ h.check(guard.liveOnly === false, 'premise: by its live readings alone the reduced scene is NOT heavy');
+ h.check(guard.guardSays === true, "26-G still judges the scene by what it cost BEFORE the governor reduced it");
+ h.check(!!guard.paused, `…so a frozen streak still pauses rendering (${JSON.stringify(guard.paused)})`);
+ // a scene that really shrank (under 70% of the baseline objects) is not held heavy
+ const shrunk = await A.page.evaluate(() => {
+ const s = window.__stores;
+ window.__gov.metrics({ objects: 400, meshes: 400, triangles: 5000, calls: 800 });
+ return s.overloadGuard.sceneIsHeavy();
+ });
+ h.check(shrunk === false, 'a scene that really shrank is not held heavy by a stale baseline');
+ await A.page.evaluate((m) => window.__gov.metrics(m), HEAVY);
+ await A.page.waitForTimeout(3500); // the resume grace, so nothing below re-pauses
+
+ // ---- 4. every step reaches its consumer, and walking back restores it ---------------
+ const top = await A.page.evaluate(async () => {
+ const s = window.__stores;
+ s.qualityGovernor.governorForTest.setLevel(s.qualityGovernor.governorForTest.level() + 100);
+ await new Promise((r) => setTimeout(r, 800));
+ return true;
+ });
+ void top;
+ const atTop = await snap();
+ h.check(
+ atTop.overrides.dprScale === 0.5 && atTop.overrides.aoOff && atTop.overrides.postOff && atTop.overrides.particlesCapped && atTop.overrides.presenceSlow,
+ `the last level holds every step (${JSON.stringify(atTop.overrides)})`
+ );
+ h.check(
+ Math.abs(atTop.pixelRatio - base.dpr * 0.5) < 1e-6,
+ `the renderer's pixel ratio follows the resolution step (${atTop.pixelRatio} = ${base.dpr} x 0.5)`
+ );
+ h.check(
+ Math.abs(atTop.bufferWidth - Math.round(atTop.canvasCss * base.dpr * 0.5)) <= 1,
+ `…so the drawing buffer really is half size (${atTop.bufferWidth}px for ${atTop.canvasCss} CSS px)`
+ );
+ h.check(
+ start.post?.kinds?.includes('ao') && !atTop.post?.kinds?.includes('ao'),
+ `the AO pass really leaves the compiled chain (${JSON.stringify(start.post?.kinds)} -> ${JSON.stringify(atTop.post?.kinds)})`
+ );
+ h.check(
+ Math.abs((atTop.post?.composerBufferWidth ?? 0) - atTop.bufferWidth) <= 1 && atTop.post.composerBufferWidth < start.post.composerBufferWidth,
+ `the composer's buffer follows the resolution step, not just the canvas (${start.post?.composerBufferWidth} -> ${atTop.post?.composerBufferWidth}px)`
+ );
+ await A.page.evaluate(async () => {
+ window.__stores.qualityGovernor.governorForTest.setLevel(0);
+ await new Promise((r) => setTimeout(r, 800));
+ });
+ const back = await snap();
+ h.check(
+ back.state.level === 0 && Math.abs(back.pixelRatio - base.dpr) < 1e-6 && back.shadowMap === true && back.baseline === null,
+ `walking back restores everything exactly (dpr ${back.pixelRatio}, shadows ${back.shadowMap}, baseline ${JSON.stringify(back.baseline)})`
+ );
+ h.check(
+ back.post?.kinds?.includes('ao') && back.post?.composerBufferWidth === start.post?.composerBufferWidth,
+ `…including AO and the composer size (${JSON.stringify(back.post)})`
+ );
+ h.check(back.chip === null, 'the chip leaves at full quality');
+
+ // ---- 5. recovery, pin/release, opt-out ----------------------------------------------
+ const recovery = await A.page.evaluate((m) => {
+ const g = window.__gov;
+ g.metrics(m);
+ g.frames(50, 2200);
+ const up = g.decide();
+ g.frames(16.7, 9000);
+ const early = g.decide();
+ g.frames(16.7, 1600);
+ const down = g.decide();
+ return { up: up.moved, early: early.moved, down: down.moved, level: down.level };
+ }, HEAVY);
+ h.check(recovery.up === 'up' && recovery.early === null && recovery.down === 'down' && recovery.level === 0, `recovers after 10s of good frames and not before (${JSON.stringify(recovery)})`);
+
+ await A.page.evaluate(() => {
+ const g = window.__gov;
+ g.frames(50, 3200);
+ g.decide();
+ });
+ await A.page.waitForTimeout(300);
+ await A.page.evaluate(() => document.querySelector('#quality-chip').click());
+ await A.page.waitForTimeout(200);
+ const pinned = await A.page.evaluate(() => {
+ const g = window.__gov;
+ g.frames(16.7, 25000);
+ const d = g.decide();
+ return { d, chip: document.querySelector('#quality-chip')?.getAttribute('data-pinned') };
+ });
+ h.check(pinned.chip === 'true' && pinned.d.level === 1 && pinned.d.moved === null, `a click on the chip HOLDS the level through good frames (${JSON.stringify(pinned)})`);
+ await A.page.evaluate(() => document.querySelector('#quality-chip').click());
+ await A.page.waitForTimeout(300);
+ const released = await snap();
+ h.check(released.state.level === 0 && released.chip === null && released.shadowMap === true, 'a second click restores full quality');
+ const snoozed = await A.page.evaluate(() => {
+ const g = window.__gov;
+ g.frames(50, 3200);
+ return g.decide();
+ });
+ h.check(snoozed.level === 0, `…and it STICKS: no automatic step during the release snooze (${JSON.stringify(snoozed)})`);
+
+ const off = await A.page.evaluate(() => {
+ const s = window.__stores;
+ s.qualityGovernor.governorForTest.reset(); // clears the snooze
+ s.qualityGovernor.setAutoQuality(false);
+ const g = window.__gov;
+ g.frames(50, 3200);
+ const d = g.decide();
+ const stored = localStorage.getItem('autoQuality');
+ s.qualityGovernor.setAutoQuality(true);
+ localStorage.removeItem('autoQuality');
+ return { level: d.level, stored };
+ });
+ h.check(off.level === 0 && off.stored === 'false', `turning it off stops it and is remembered locally (${JSON.stringify(off)})`);
+ // the real entry point: Settings binds the same store
+ await A.page.evaluate(() => window.__stores.settingsOpen.set(true));
+ await A.page.waitForTimeout(500);
+ await A.page.locator('#settings-search').fill('heavy');
+ await A.page.waitForTimeout(400);
+ const box = A.page.locator('#auto-quality');
+ const found = (await box.count()) > 0 && (await box.isVisible());
+ const had = found ? await box.isChecked() : null;
+ if (found) await box.click();
+ await A.page.waitForTimeout(250);
+ const afterSetting = await A.page.evaluate(() => ({
+ store: window.__gov.read(window.__stores.qualityGovernor.autoQuality),
+ stored: localStorage.getItem('autoQuality')
+ }));
+ if (found) await box.click();
+ await A.page.waitForTimeout(250);
+ const restoredSetting = await A.page.evaluate(() => {
+ const on = window.__gov.read(window.__stores.qualityGovernor.autoQuality);
+ localStorage.removeItem('autoQuality');
+ window.__stores.settingsOpen.set(false);
+ return on;
+ });
+ h.check(
+ found && had === true && afterSetting.store === false && afterSetting.stored === 'false' && restoredSetting === true,
+ `Settings > "Reduce quality when the scene is heavy" drives the same switch (found ${found}, checked ${had}, then ${JSON.stringify(afterSetting)}, back ${restoredSetting})`
+ );
+ await A.page.waitForTimeout(400);
+
+ // ---- 6. the ingest draw gap ----------------------------------------------------------
+ const gap = await A.page.evaluate(async () => {
+ const s = window.__stores;
+ const g = window.__gov;
+ const r = g.read(s.globalRenderer);
+ s.qualityGovernor.governorForTest.reset();
+ const framesIn = async (ms) => {
+ const a = r.info.render.frame;
+ await new Promise((res) => setTimeout(res, ms));
+ return r.info.render.frame - a;
+ };
+ const normal = await framesIn(1000);
+ // a big batch draining through slow frames
+ g.metrics({ objects: 200, meshes: 200, triangles: 3000, calls: 400, ingestBacklog: 800 });
+ s.beginSceneBatch();
+ try {
+ g.frames(50, 2200);
+ g.decide();
+ const engaged = g.read(s.qualityGovernor.ingestDrawGap);
+ const throttled = await framesIn(2000);
+ // the throttled frames are FAST; the gap must not switch itself off on them
+ g.frames(16.7, 2200);
+ g.decide();
+ const sticky = g.read(s.qualityGovernor.ingestDrawGap);
+ // render() calls, not display frames: the composer makes several per frame, so the
+ // claim is the RATIO against the same counter a second earlier
+ return { normal, engaged, throttledPerSec: throttled / 2, ratio: throttled / 2 / Math.max(1, normal), sticky, stillLevel: g.read(s.qualityGovernor.qualityState).level };
+ } finally {
+ s.endSceneBatch();
+ g.decide();
+ }
+ });
+ const gapAfter = await A.page.evaluate(() => window.__gov.read(window.__stores.qualityGovernor.ingestDrawGap));
+ h.check(gap.normal > 30, `premise: the renderer draws at display rate normally (${gap.normal} render() calls in 1s)`);
+ h.check(gap.engaged === 250, `a draining batch through slow frames engages the draw gap (${gap.engaged}ms)`);
+ h.check(
+ gap.throttledPerSec > 0 && gap.ratio < 0.15,
+ `…and the renderer really draws ~4 frames a second of 60 (${gap.throttledPerSec} render() calls/s against ${gap.normal}, ratio ${gap.ratio.toFixed(3)})`
+ );
+ h.check(gap.sticky === 250, 'the gap is sticky for the drain: its own cheap frames do not switch it off');
+ h.check(gap.stillLevel === 0, 'a light scene receiving objects is not a quality step, only a drain');
+ h.check(gapAfter === 0, 'the drain ends, the gap ends');
+
+ // ---- 7. end to end on real frames --------------------------------------------------
+ const real = await A.page.evaluate(async () => {
+ const s = window.__stores;
+ const read = window.__gov.read;
+ s.qualityGovernor.governorForTest.reset();
+ // OFF while the scene is built and measured at full quality, so "before" is clean
+ s.qualityGovernor.setAutoQuality(false);
+ s.sceneBudget.startSceneMetrics();
+ await window.__stress.seedCubes(3000);
+ window.__stress.frameAll(3000);
+ await new Promise((r) => setTimeout(r, 2500));
+ const before = s.sceneBudget.sampleSceneMetrics();
+ const beforeRun = await window.__stress.frames(2000);
+ s.qualityGovernor.setAutoQuality(true);
+ localStorage.removeItem('autoQuality');
+ // let it act: steps are held 3s apart
+ const deadline = performance.now() + 20000;
+ while (performance.now() < deadline && read(s.qualityGovernor.qualityState).level === 0) await new Promise((r) => setTimeout(r, 250));
+ const engagedAfterMs = Math.round(20000 - (deadline - performance.now()));
+ await new Promise((r) => setTimeout(r, 2500));
+ const after = s.sceneBudget.sampleSceneMetrics();
+ const afterRun = await window.__stress.frames(2000);
+ const pct = (d) => {
+ const x = [...d].sort((a, b) => a - b);
+ return x[Math.min(x.length - 1, Math.ceil(0.95 * x.length) - 1)];
+ };
+ const state = read(s.qualityGovernor.qualityState);
+ return {
+ engagedAfterMs,
+ state,
+ beforeCalls: before.calls,
+ afterCalls: after.calls,
+ beforeP95: pct(beforeRun.deltas),
+ afterP95: pct(afterRun.deltas),
+ heavyStill: s.overloadGuard.sceneIsHeavy()
+ };
+ });
+ console.log('real 3,000 boxes: ' + JSON.stringify(real));
+ h.check(real.beforeP95 > 35, `premise: 3,000 real boxes miss 30fps on this GPU (p95 ${real.beforeP95}ms)`);
+ h.check(real.state.level >= 1, `the governor engaged ON ITS OWN from real frames (level ${real.state.level} after ${real.engagedAfterMs}ms: ${real.state.labels.join(', ')})`);
+ h.check(real.afterCalls < real.beforeCalls * 0.75, `…and the measured draw calls fell (${real.beforeCalls} -> ${real.afterCalls})`);
+ h.check(real.heavyStill === true, '…while 26-G still judges the scene heavy');
+
+ await A.page.evaluate(() => {
+ window.__stores.qualityGovernor.governorForTest.reset();
+ });
+ h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`);
+ await h.finish(browser);
+});
diff --git a/tests/e2e/runtime-resilience.test.cjs b/tests/e2e/runtime-resilience.test.cjs
new file mode 100644
index 00000000..8cb42368
--- /dev/null
+++ b/tests/e2e/runtime-resilience.test.cjs
@@ -0,0 +1,177 @@
+// 27-C (hardening audit, top-10 #3 and M7) — ONE THROW USED TO END THE SESSION.
+//
+// `tick` called `runTick(now)` and THEN re-armed the frame, so an exception escaped
+// before `requestAnimationFrame` ever ran: no further frame was scheduled, every flow
+// animation and every physics step stopped for the rest of the session, and nothing
+// said so. A module frame task, a post-tick hook or one bad node evaluator was enough.
+//
+// What this suite pins:
+// 1. a throwing module frame task does NOT stop the frame loop, and a spin node
+// carries on animating
+// 2. a throwing post-tick hook is survived the same way
+// 3. per-frame failures are RATE-LIMITED (first three, then one per 300) — a 60Hz
+// log buries the first failure, which is the only one that says what broke
+// 4. a persistently throwing TICK pauses the runtime with a Resume card rather than
+// burning a core forever, and Resume restarts it
+// 5. physics: a throwing step stops the simulation ONCE, with the scene intact
+//
+// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- runtime-resilience
+const h = require('./helpers.cjs');
+
+const paused = (page) =>
+ page.evaluate(() => {
+ let v = null;
+ window.__stores.flowPaused.subscribe((x) => (v = x))();
+ return v;
+ });
+
+const ringLines = (page) =>
+ page.evaluate(() => window.__stores.diagnostics.lines());
+
+h.run(async () => {
+ const browser = await h.launch();
+ const peer = await h.setupPage(browser, 'resilience');
+ const page = peer.page;
+ await page.waitForFunction(() => !!window.__stores?.flowRuntime && !!window.__stores?.flowPaused, {
+ timeout: 30000
+ });
+ h.check(true, 'premise: the flow runtime and its paused store are live');
+
+ // ---- 1. a throwing module frame task must not stop the loop ----------------------
+ // A spin node is the visible half: it is a pure function of (base pose, time), so if
+ // frames keep coming its rotation keeps changing.
+ await page.evaluate(() => {
+ const s = window.__stores;
+ s.commandsHandler.sceneCommand('/create box');
+ });
+ await page.waitForTimeout(600);
+ const uuid = await page.evaluate(() => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ return group.children[group.children.length - 1].uuid;
+ });
+ await page.evaluate((id) => {
+ const s = window.__stores;
+ s.updateGraph(s.SCENE_GRAPH, () => ({
+ nodes: [
+ { id: 'spin1', type: 'spin', position: { x: 40, y: 40 }, data: { type: 'spin', axis: 'y', speed: 2 } },
+ { id: 'sel1', type: 'objectselector', position: { x: 240, y: 40 }, data: { type: 'objectselector', selected: id } }
+ ],
+ edges: [{ id: 'e-spin1-sel1', source: 'spin1', target: 'sel1' }]
+ }));
+ }, uuid);
+ await page.waitForTimeout(500);
+
+ const readRot = () =>
+ page.evaluate((id) => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ return group.getObjectByProperty('uuid', id)?.rotation.y ?? null;
+ }, uuid);
+
+ const spinBefore = await readRot();
+ await page.waitForTimeout(500);
+ const spinAfter = await readRot();
+ h.check(
+ spinBefore !== null && spinAfter !== null && spinBefore !== spinAfter,
+ `premise: the spin node is animating (${spinBefore} -> ${spinAfter})`
+ );
+
+ await page.evaluate(() => {
+ window.__rt = { taskCalls: 0 };
+ // `moduleFrameTasks` is the exported array `api.registerFrameTask` pushes onto —
+ // the same list a real module's task lands in, so this is the real path.
+ window.__stores.moduleSDK.moduleFrameTasks.push(() => {
+ window.__rt.taskCalls++;
+ throw new Error('frame-task-boom');
+ });
+ });
+ await page.waitForTimeout(900);
+ const afterTask = await readRot();
+ const taskCalls = await page.evaluate(() => window.__rt.taskCalls);
+ // Headless SwiftShader renders at ~5 fps here (CLAUDE.md measures ~4.5), so 900ms is a
+ // handful of frames, not sixty. The claim is "it ran repeatedly", not a frame rate.
+ h.check(taskCalls >= 3, `the throwing frame task really ran repeatedly (${taskCalls} calls)`);
+ h.check(
+ afterTask !== spinAfter,
+ `the frame loop survived it — the spin node is still animating (${spinAfter} -> ${afterTask})`
+ );
+ const stillTicking = await paused(page);
+ h.check(stillTicking?.paused === false, 'a throwing FRAME TASK does not pause the runtime (it is contained)');
+
+ // ---- 3. and its log is rate-limited ----------------------------------------------
+ const lines = await ringLines(page);
+ const taskLines = lines.filter((l) => /frame task failed/i.test(l));
+ h.check(
+ taskLines.length > 0 && taskLines.length <= 4,
+ `the per-frame failure is rate-limited, not one line per frame (${taskLines.length} lines for ${taskCalls} calls)`
+ );
+
+ // ---- 4. a persistently throwing TICK pauses, and Resume restarts it ---------------
+ // Drive the counter directly at its own entry point rather than waiting out 120 real
+ // frames: the guard under test is the threshold and the re-arm, not the clock.
+ // Drive the XR PUMP directly rather than waiting out 120 real frames: at ~5 fps that is
+ // ~25s of wall clock, and pumping also proves the XR path shares the guard — Scene.svelte
+ // calls pumpFlowTick while presenting, where window.rAF is suspended.
+ await page.evaluate(() => {
+ const rt = window.__stores.flowRuntime;
+ rt.failTicksForTest(130);
+ for (let i = 0; i < 130; i++) rt.pumpFlowTick(performance.now());
+ });
+ await page.waitForTimeout(300);
+ const nowPaused = await paused(page);
+ h.check(nowPaused?.paused === true, `a tick that keeps throwing pauses the runtime (${JSON.stringify(nowPaused)})`);
+ const toastShown = await page.evaluate(() =>
+ document.body.innerText.includes('Flow runtime paused')
+ );
+ h.check(toastShown, 'and says so, with a Resume card');
+
+ const frozen = await readRot();
+ await page.waitForTimeout(400);
+ h.check((await readRot()) === frozen, 'while paused, nothing ticks');
+
+ await page.evaluate(() => {
+ // Clear the forced failures FIRST. Resume restores ticking, and a tick that still
+ // throws re-pauses at once — which is exactly what made this section red before.
+ window.__stores.flowRuntime.failTicksForTest(0);
+ window.__stores.flowRuntime.resumeFlowRuntime();
+ });
+ await page.waitForTimeout(900);
+ const resumed = await paused(page);
+ h.check(resumed?.paused === false, 'Resume clears the paused state');
+ const spinResumed = await readRot();
+ await page.waitForTimeout(900);
+ h.check((await readRot()) !== spinResumed, 'and the animation runs again');
+
+ // ---- 5. physics: a throwing step stops the run ONCE -------------------------------
+ const physics = await page.evaluate(async () => {
+ const s = window.__stores;
+ // there is no startSimulation export — `toggleSimulation` is the entry point, and
+ // it is async because it warms rapier's wasm on the first run.
+ await s.physics.toggleSimulation();
+ await new Promise((r) => setTimeout(r, 1200));
+ let running = false;
+ s.physics.simulating.subscribe((v) => (running = v))();
+ return { running };
+ });
+ h.check(physics.running === true, 'premise: a simulation is running');
+
+ await page.evaluate(() => window.__stores.physics.throwOnNextStepForTest());
+ await page.waitForTimeout(2000);
+ const afterThrow = await page.evaluate(() => {
+ let running = true;
+ window.__stores.physics.simulating.subscribe((v) => (running = v))();
+ return { running, said: document.body.innerText.includes('Physics stopped after an error') };
+ });
+ h.check(afterThrow.running === false, 'a throwing physics step stops the simulation');
+ h.check(afterThrow.said, 'and says so once, rather than logging 60 times a second in silence');
+
+ const afterPhysics = await readRot();
+ await page.waitForTimeout(900);
+ h.check(
+ (await readRot()) !== afterPhysics,
+ 'the FLOW loop survived the physics failure (they share one frame)'
+ );
+
+ await h.finish(browser);
+});
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);
+});
diff --git a/tests/e2e/scene-poke.test.cjs b/tests/e2e/scene-poke.test.cjs
new file mode 100644
index 00000000..56803c4b
--- /dev/null
+++ b/tests/e2e/scene-poke.test.cjs
@@ -0,0 +1,330 @@
+// 26-B — Stage 0: poke coalescing, time-sliced ingest, list virtualisation.
+// (hardening audit M6, M1, M2; roadmap 26 section 4 "Stage 0".)
+//
+// THE FINDING: `objectsGroup.update((v) => v)` sat at 117 call sites with eighteen
+// subscribers hanging off it, several of which traverse the whole tree. A 1,000-object
+// handshake therefore ran ~8M node visits synchronously on the receive path, the object
+// list re-rendered every row on each one, and the "Receiving objects" bar walked the
+// tree TWICE per outstanding uuid per poke. That is the reported freeze.
+//
+// What is asserted, in the order it matters:
+// 1. N pokes in one task produce ONE store notification (the mechanism), and a batch
+// drops that to one per frame — with the counterfactual measured in the SAME run;
+// 2. incoming objects are applied IN ORDER and the drainer YIELDS, so a big scene
+// lands without holding the thread;
+// 3. the object list virtualises above the threshold and stays byte-identical below;
+// 4. audit M1: a send builds its OWN uuid list and resolves its connection late;
+// 5. audit M2: the progress bar is cleared by a sender leaving, by a parse failure
+// and by a clear — none of which could clear it before.
+const h = require('./helpers.cjs');
+
+h.run(async () => {
+ // GPU args: section 2 counts rAF frames, and a SwiftShader page runs at ~2.5fps,
+ // where a one-poke-per-frame claim cannot be told from doing nothing (the e2e
+ // skill's rate-assertion rule).
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- 1. the coalescer ---------------------------------------------------
+ const seam = await A.page.evaluate(() => {
+ const s = window.__stores;
+ return {
+ poke: typeof s.pokeScene === 'function',
+ begin: typeof s.beginSceneBatch === 'function',
+ end: typeof s.endSceneBatch === 'function',
+ rev: !!s.sceneRevision
+ };
+ });
+ h.check(seam.poke && seam.begin && seam.end && seam.rev, 'the pokeScene seam is on the debug hook (premise)');
+
+ const coalesced = await A.page.evaluate(async () => {
+ const { objectsGroup, pokeScene } = window.__stores;
+ let hits = 0;
+ const stop = objectsGroup.subscribe(() => hits++);
+ hits = 0; // the subscribe itself fires once
+ for (let i = 0; i < 500; i++) pokeScene();
+ const duringTask = hits;
+ await new Promise((r) => setTimeout(r, 50));
+ const afterFlush = hits;
+ stop();
+ return { duringTask, afterFlush };
+ });
+ h.check(coalesced.duringTask === 0, `500 pokes notify nothing inside the task (${coalesced.duringTask})`);
+ h.check(coalesced.afterFlush === 1, `…and exactly ONE notification lands after it (${coalesced.afterFlush})`);
+
+ // THE COUNTERFACTUAL, measured in the same page: the raw identity update this
+ // replaced notifies once per call. 500 vs 1 is the whole of Stage 0.
+ const raw = await A.page.evaluate(async () => {
+ const { objectsGroup } = window.__stores;
+ let hits = 0;
+ const stop = objectsGroup.subscribe(() => hits++);
+ hits = 0;
+ for (let i = 0; i < 500; i++) objectsGroup.update((v) => v);
+ stop();
+ return hits;
+ });
+ h.check(raw === 500, `the old bare update notifies once per call (${raw}) — the counterfactual`);
+
+ // ---- 2. batch mode: one poke per frame, not per microtask ----------------
+ const batched = await A.page.evaluate(async () => {
+ const { objectsGroup, pokeScene, beginSceneBatch, endSceneBatch, sceneBatchOpen } = window.__stores;
+ let hits = 0;
+ const stop = objectsGroup.subscribe(() => hits++);
+ hits = 0;
+ beginSceneBatch();
+ const open = sceneBatchOpen();
+ // 40 pokes spread over ~200ms of REAL time: microtask coalescing would give 40
+ // (one per task), the frame rule gives about 200/16
+ let frames = 0;
+ const tick = () => { frames++; requestAnimationFrame(tick); };
+ requestAnimationFrame(tick);
+ for (let i = 0; i < 40; i++) {
+ pokeScene();
+ await new Promise((r) => setTimeout(r, 5));
+ }
+ const duringBatch = hits;
+ endSceneBatch();
+ await new Promise((r) => setTimeout(r, 30));
+ stop();
+ return { open, duringBatch, after: hits, frames, closed: !sceneBatchOpen() };
+ });
+ h.check(batched.open, 'beginSceneBatch opens the batch (premise)');
+ h.check(batched.closed, 'endSceneBatch closes it');
+ h.check(batched.frames > 6, `the page really rendered frames in the window (${batched.frames} — GPU premise)`);
+ h.check(
+ batched.duringBatch > 0 && batched.duringBatch < 25,
+ `40 pokes over ~200ms flush ~one per frame, not one per task (${batched.duringBatch})`
+ );
+ h.check(batched.after >= batched.duringBatch, 'closing the batch flushes what is pending');
+
+ // ---- 3. time-sliced ingest ----------------------------------------------
+ const ingest = await A.page.evaluate(() => {
+ const c = window.__stores.commandsHandler;
+ return { backlog: typeof c.ingestBacklog === 'function', drop: typeof c.dropIngestQueue === 'function' };
+ });
+ h.check(ingest.backlog && ingest.drop, 'the ingest queue is on the debug hook (premise)');
+
+ // Feed real objects through the RECEIVE entry point (`createObject` with a toJSON
+ // element, exactly what the `object` message carries) and measure the LONGEST the
+ // main thread was held — which is what a user feels, and what "the window freezes"
+ // names. THE COUNTERFACTUAL is the shape this replaced, measured in the same page on
+ // the same payloads: parse, add and poke each object in ONE uninterrupted task.
+ // 400 is deliberately just UNDER the virtualisation threshold, so this section
+ // measures the coalescer and the queue alone and not the windowed list.
+ const N = 400;
+ const sliced = await A.page.evaluate(async (N) => {
+ const { THREE, objectsGroup, pokeScene, commandsHandler } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ const payloads = [];
+ const names = [];
+ for (let i = 0; i < N; i++) {
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 18), new THREE.MeshStandardMaterial());
+ mesh.name = 'ingest-' + String(i).padStart(3, '0');
+ names.push(mesh.name);
+ payloads.push({ element: mesh.toJSON() });
+ }
+ /** longest gap between two consecutive rAF callbacks = the worst hitch */
+ const watch = () => {
+ const state = { max: 0, frames: 0, done: false, last: performance.now() };
+ const tick = () => {
+ const now = performance.now();
+ state.max = Math.max(state.max, now - state.last);
+ state.last = now;
+ state.frames++;
+ if (!state.done) requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+ return state;
+ };
+ const frame = () => new Promise((r) => requestAnimationFrame(() => r(undefined)));
+ const reset = async () => {
+ group.clear();
+ pokeScene();
+ await new Promise((r) => setTimeout(r, 250));
+ };
+
+ // a) THE OLD SHAPE: everything in one task, one bare poke per object
+ await reset();
+ const loader = new THREE.ObjectLoader();
+ let w = watch();
+ await frame();
+ const rawStart = performance.now();
+ for (const p of payloads) {
+ group.add(loader.parse(p.element));
+ objectsGroup.update((v) => v);
+ }
+ const rawMs = performance.now() - rawStart;
+ await frame();
+ w.done = true;
+ const inline = { max: w.max, frames: w.frames };
+
+ // b) …and through the queue
+ await reset();
+ w = watch();
+ await frame();
+ const started = performance.now();
+ const all = payloads.map((p) => commandsHandler.createObject(p, null));
+ const backlogSeen = commandsHandler.ingestBacklog();
+ await Promise.all(all);
+ const ms = performance.now() - started;
+ await frame();
+ w.done = true;
+ const queued = { max: w.max, frames: w.frames };
+ const landed = group.children.map((/** @type {any} */ o) => o.name).filter((/** @type {string} */ n) => n.startsWith('ingest-'));
+ await reset();
+
+ return {
+ ms, rawMs, backlogSeen,
+ maxGap: queued.max, frames: queued.frames,
+ rawMaxGap: inline.max, rawFrames: inline.frames,
+ ordered: landed.join(',') === names.join(','),
+ count: landed.length
+ };
+ }, N);
+ h.check(sliced.backlogSeen > 1, `the queue really parks work rather than parsing inline (${sliced.backlogSeen} parked)`);
+ h.check(sliced.count === N, `all ${N} objects landed (${sliced.count})`);
+ h.check(sliced.ordered, 'they landed in the order they were received — the queue preserves it');
+ h.check(
+ sliced.rawMaxGap > 150,
+ `the old shape holds the thread for ${Math.round(sliced.rawMaxGap)}ms on ${N} objects (premise: this is the freeze)`
+ );
+ h.check(
+ sliced.maxGap < sliced.rawMaxGap * 0.6,
+ `the queued ingest's worst hitch is far shorter (${Math.round(sliced.maxGap)}ms vs ${Math.round(sliced.rawMaxGap)}ms) — the counterfactual`
+ );
+ h.check(
+ sliced.frames > sliced.rawFrames,
+ `the page rendered more frames during the queued ingest (${sliced.frames}) than during the old one (${sliced.rawFrames})`
+ );
+
+ // dropping the queue: a clear must not let a half-sent scene trickle in after it
+ const dropped = await A.page.evaluate(async () => {
+ const { THREE, commandsHandler } = window.__stores;
+ const payloads = [];
+ for (let i = 0; i < 40; i++) {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial());
+ mesh.name = 'dropme-' + i;
+ payloads.push({ element: mesh.toJSON() });
+ }
+ const all = payloads.map((p) => commandsHandler.createObject(p, null));
+ const n = commandsHandler.dropIngestQueue();
+ await Promise.all(all);
+ await new Promise((r) => setTimeout(r, 200));
+ const group = window.__stores.objectsGroup;
+ let live = 0;
+ const stop = group.subscribe((/** @type {any} */ g) => {
+ live = g.children.filter((/** @type {any} */ o) => String(o.name).startsWith('dropme-')).length;
+ });
+ stop();
+ return { n, live };
+ });
+ h.check(dropped.n > 0, `dropIngestQueue reports what it dropped (${dropped.n})`);
+ h.check(dropped.live < 40, `the dropped objects never reached the scene (${dropped.live} of 40 landed)`);
+
+ // ---- 4. audit M1: a send owns its uuid list, and resolves its conn late ---
+ const m1 = await A.page.evaluate(() => {
+ // the send path bails instead of throwing when the conn is gone — the old code
+ // read `conn.send` inside a timer, where an uncaught TypeError kills the reply
+ // with no trace at all
+ try {
+ window.__stores.commandsHandler.sendObjects('nobody-is-here');
+ return { threw: false };
+ } catch (e) {
+ return { threw: true, message: String(e) };
+ }
+ });
+ h.check(!m1.threw, `sendObjects to an absent peer does not throw (${m1.message ?? ''})`);
+ await A.page.waitForTimeout(900);
+ h.check(
+ h.pageErrors(A).length === 0,
+ `…and nothing is thrown 500ms later inside the timer either (${JSON.stringify(h.pageErrors(A))})`
+ );
+
+ // ---- 5. audit M2: the progress bar can be cleared -------------------------
+ const m2 = await A.page.evaluate(async () => {
+ const s = window.__stores;
+ const read = () => { let v; const stop = s.loading.subscribe((/** @type {any} */ x) => (v = x)); stop(); return v; };
+ s.commandsHandler.createLoader(3, ['ghost-a', 'ghost-b', 'ghost-c'], 'peer-who-left');
+ const armed = read().length;
+ // a parse that never produces an object still counts as an arrival
+ s.commandsHandler.noteLoadFailed(['ghost-a']);
+ const afterFail = read().length;
+ // the sender leaving clears the rest
+ s.commandsHandler.handleDisconnected('peer-who-left');
+ const afterLeave = read().length;
+ // and a clear drops any batch outright
+ s.commandsHandler.createLoader(2, ['x', 'y'], 'someone');
+ const armedAgain = read().length;
+ s.commandsHandler.clearSceneLocal();
+ return { armed, afterFail, afterLeave, armedAgain, afterClear: read().length };
+ });
+ h.check(m2.armed === 3, `a loading batch arms with its uuids (${m2.armed})`);
+ h.check(m2.afterFail === 2, `a failed parse counts as an arrival (${m2.afterFail})`);
+ h.check(m2.afterLeave === 0, 'the sender disconnecting clears the batch — it used to stick forever');
+ h.check(m2.armedAgain === 2 && m2.afterClear === 0, 'a scene clear drops the batch too');
+
+ // ---- 6. list virtualisation ---------------------------------------------
+ // Below the threshold the recursive tree renders as it always did.
+ const small = await A.page.evaluate(async () => {
+ const { THREE, objectsGroup, pokeScene, objectListNav, expandedObjects } = window.__stores;
+ let group; const stop = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); stop();
+ group.clear();
+ for (let i = 0; i < 20; i++) {
+ const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial());
+ m.name = 'small-' + i;
+ group.add(m);
+ }
+ pokeScene();
+ await new Promise((r) => setTimeout(r, 300));
+ let exp; const s2 = expandedObjects.subscribe((/** @type {any} */ v) => (exp = v)); s2();
+ return objectListNav.visibleObjectRows(group, exp, null).length;
+ });
+ h.check(small === 20, `20 objects flatten to 20 rows (${small})`);
+ // the list is open by default in this app; assert on what it actually rendered
+ await A.page.waitForTimeout(400);
+ const smallRows = await A.page.locator('#object-tree [role="treeitem"]').count();
+ h.check(smallRows === 20, `…and all 20 rows are in the DOM below the threshold (${smallRows})`);
+
+ const big = await A.page.evaluate(async () => {
+ const { THREE, objectsGroup, pokeScene } = window.__stores;
+ let group; const stop = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); stop();
+ const geo = new THREE.BoxGeometry(1, 1, 1);
+ const mat = new THREE.MeshStandardMaterial();
+ for (let i = 0; i < 700; i++) {
+ const m = new THREE.Mesh(geo, mat);
+ m.name = 'big-' + i;
+ group.add(m);
+ }
+ pokeScene();
+ return group.children.length;
+ });
+ h.check(big === 720, `the scene holds ${big} objects — past the 500-row threshold (premise)`);
+ await A.page.waitForTimeout(900);
+ const bigRows = await A.page.locator('#object-tree [role="treeitem"]').count();
+ h.check(
+ bigRows > 0 && bigRows < 200,
+ `the list draws a WINDOW, not 720 rows (${bigRows} in the DOM) — the virtualisation`
+ );
+ const mode = await A.page.getAttribute('[data-object-rows]', 'data-object-rows');
+ h.check(mode === 'window', `the list says which mode it is in (${mode})`);
+ // …and the scroll height still covers all of them, so the scrollbar tells the truth
+ const spacers = await A.page.evaluate(() => {
+ const host = document.querySelector('[data-object-rows]');
+ const kids = host ? [...host.children] : [];
+ // the two spacers carry their height INLINE, which is readable whether or not the
+ // panel is laid out at this instant
+ const px = kids
+ .map((el) => /height:\s*([\d.]+)px/.exec(el.getAttribute('style') ?? '')?.[1])
+ .filter(Boolean)
+ .map(Number);
+ return { spacerPx: px.reduce((a, b) => a + b, 0), spacers: px.length, kids: kids.length };
+ });
+ h.check(spacers.spacers === 2, `the window has its two spacers (${spacers.spacers} of ${spacers.kids} children)`);
+ h.check(
+ spacers.spacerPx > 700 * 12,
+ `the spacers stand in for every off-screen row, so the scrollbar tells the truth (${Math.round(spacers.spacerPx)}px)`
+ );
+
+ h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`);
+ await h.finish(browser);
+});
diff --git a/tests/e2e/scene-stress.cjs b/tests/e2e/scene-stress.cjs
new file mode 100644
index 00000000..d4cb0ca5
--- /dev/null
+++ b/tests/e2e/scene-stress.cjs
@@ -0,0 +1,256 @@
+// 26-E — THE SCENE-STRESS RIG (roadmap 26 section 6). A MEASUREMENT, run by hand.
+//
+// APP_URL=https://theprototype.app:5180/ node tests/e2e/scene-stress.cjs \
+// [--sizes 100,1000,3000,10000] [--dense 1,5,15] [--dense-tris 200000] \
+// [--physics 100,300,1000] [--sync 1000,3000] [--window 4000] \
+// [--view shaded-ao|shaded] [--out path.md]
+//
+// NOT a .test.cjs on purpose: a full sweep runs for many minutes. `npm run e2e --
+// scene-stress` runs the small REGRESSION suite instead (scene-stress.test.cjs), which
+// drives the same probe (sceneStressProbe.cjs) at a tiny size.
+//
+// WHY IT EXISTS: roadmap 26 section 2's budget numbers were starting points reasoned from
+// WebGL practice. The governor (26-D) and the auto-stops (26-G) steer by them, so they
+// have to be MEASURED. Per scene size this records:
+// - seed / import cost and the long tasks it caused
+// - frame p50/p95/p99 idle and while ORBITING (navigation is when a heavy scene hurts)
+// - draw calls and triangles per DISPLAY frame (see sceneBudget's render-totals note —
+// the raw `renderer.info` reads one fullscreen pass and cannot be used)
+// - GPU proxies (geometries/textures) and the JS heap
+// - object-list render ms (and whether 26-B windowed it)
+// - one autosave export: ms and bytes
+// - optionally, physics over the same scene: body count, step p50/p95, whether 26-G's
+// slow-step stop fired
+// - optionally, a second peer JOINING: time-to-synced as the joiner's own
+// `syncMs` reads it (announcement -> last object landed), plus the joiner's long tasks
+//
+// CAVEATS worth printing with every table:
+// - the numbers belong to ONE GPU; the report names it (WEBGL_debug_renderer_info). A
+// SwiftShader row (no GPU) measures the CPU rasteriser, not the app — the rig refuses
+// to treat one as data and says so.
+// - headless Chromium has no compositor pressure from other windows; a real desktop is
+// worse, never better.
+// - the two-peer sync uses whatever signaling the helpers use (PEER_CONFIG). It is ONE
+// joiner and one handshake — not a flood.
+
+const fs = require('fs');
+const path = require('path');
+const h = require('./helpers.cjs');
+const { measureScene, installProbe, summarize } = require('./sceneStressProbe.cjs');
+
+const argv = process.argv.slice(2);
+/** @param {string} name @param {string} fallback */
+function arg(name, fallback) {
+ const i = argv.indexOf('--' + name);
+ return i >= 0 && argv[i + 1] != null ? argv[i + 1] : fallback;
+}
+/** @param {string} value */
+const list = (value) =>
+ value
+ .split(',')
+ .map((n) => parseInt(n, 10))
+ .filter((n) => Number.isFinite(n) && n > 0);
+
+const SIZES = list(arg('sizes', '100,1000,3000,10000'));
+const DENSE = list(arg('dense', '1,5,15'));
+const DENSE_TRIS = parseInt(arg('dense-tris', '200000'), 10);
+const PHYSICS = list(arg('physics', ''));
+const SYNC = list(arg('sync', ''));
+const WINDOW_MS = parseInt(arg('window', '4000'), 10);
+const VIEW = arg('view', '');
+const OUT = arg('out', '');
+const storage = VIEW ? { viewMode: VIEW } : undefined;
+
+/** @param {any} x @param {number} [d] */
+const r = (x, d = 1) => (x == null || !Number.isFinite(Number(x)) ? '—' : Number(Number(x).toFixed(d)));
+
+/**
+ * A second peer joins a host already holding `size` boxes. The joiner's own `syncMs`
+ * metric (commandsHandler, 26-E) is the answer: announcement to last object, measured on
+ * one clock.
+ * @param {any} browser @param {number} size
+ */
+async function measureSync(browser, size) {
+ const host = await h.setupPage(browser, 'host-' + size, { storage });
+ const joiner = await h.setupPage(browser, 'join-' + size, { storage });
+ try {
+ await installProbe(host.page);
+ await installProbe(joiner.page);
+ await host.page.evaluate((n) => window.__stress.seedCubes(n), size);
+ const t0 = Date.now();
+ await joiner.page.evaluate(() => (window.__stress.joinStarted = performance.now()));
+ await h.connect(joiner, host, 0);
+ // wait for the joiner to hold the scene AND for its batch to have closed
+ const deadline = Date.now() + 240000;
+ /** @type {any} */
+ let got = null;
+ while (Date.now() < deadline) {
+ got = await joiner.page.evaluate(() => ({
+ count: window.__stress.count(),
+ sync: window.__stores.commandsHandler.lastSyncStats(),
+ tasks: window.__stress.tasksSince(window.__stress.joinStarted)
+ }));
+ if (got.sync && got.count >= size) break;
+ await joiner.page.waitForTimeout(250);
+ }
+ return {
+ size,
+ wallMs: Date.now() - t0,
+ objects: got?.count ?? 0,
+ syncMs: got?.sync?.complete ? got.sync.ms : null,
+ complete: !!got?.sync?.complete,
+ joinerLongTasks: got?.tasks?.count ?? null,
+ joinerLongestTask: got?.tasks ? Math.round(got.tasks.longest) : null,
+ joinerBusyMs: got?.tasks ? Math.round(got.tasks.busy) : null
+ };
+ } finally {
+ await host.ctx.close();
+ await joiner.ctx.close();
+ }
+}
+
+/** @param {any[]} rows @param {any[]} dense @param {any[]} physics @param {any[]} sync */
+function report(rows, dense, physics, sync) {
+ const gpu = rows[0]?.gpu ?? dense[0]?.gpu ?? physics[0]?.gpu ?? 'unknown';
+ const L = [];
+ L.push('# 26-E — scene stress, measured');
+ L.push('');
+ L.push('GPU: `' + gpu + '` · window ' + WINDOW_MS + 'ms per reading · 1280x720 · view ' + (VIEW || 'default'));
+ if (/swiftshader|llvmpipe|software/i.test(gpu))
+ L.push('\n**WARNING: software rasteriser — these rows measure the CPU renderer, not the app. Do not fold them into the budget.**');
+ L.push('');
+ L.push('## Boxes (the real `/create box` path)');
+ L.push('');
+ L.push('| objects | seed ms | seed longest task | idle p50/p95/p99 | orbit p50/p95/p99 | orbit long tasks | calls/frame | tris/frame | renders/frame | geoms | textures | heap MB | list ms (rows, mode) | autosave ms / MB |');
+ L.push('|---|---|---|---|---|---|---|---|---|---|---|---|---|---|');
+ for (const w of rows) {
+ L.push(
+ '| ' + w.objects +
+ ' | ' + r(w.seedMs, 0) +
+ ' | ' + r(w.seedLongestTask, 0) +
+ ' | ' + r(w.idle.p50) + ' / ' + r(w.idle.p95) + ' / ' + r(w.idle.p99) +
+ ' | ' + r(w.orbit.p50) + ' / ' + r(w.orbit.p95) + ' / ' + r(w.orbit.p99) +
+ ' | ' + w.orbitLongTasks + ' (max ' + r(w.orbitLongestTask, 0) + ')' +
+ ' | ' + r(w.calls, 0) +
+ ' | ' + r(w.triangles, 0) +
+ ' | ' + r(w.rendersPerFrame) +
+ ' | ' + r(w.geometries, 0) +
+ ' | ' + r(w.textures, 0) +
+ ' | ' + r(w.heapMB, 0) +
+ ' | ' + r(w.listMs, 0) + ' (' + w.listRows + ', ' + w.listMode + ')' +
+ ' | ' + r(w.autosaveExportMs, 0) + ' / ' + r((w.autosaveBytes ?? 0) / 1048576, 2) +
+ (w.autosaveError ? ' ERR' : '') +
+ ' |'
+ );
+ }
+ if (dense.length) {
+ L.push('');
+ L.push('## Dense models (a ' + DENSE_TRIS + '-triangle GLB through the real import path)');
+ L.push('');
+ L.push('| models | import p50/max ms | import longest task | idle p50/p95/p99 | orbit p50/p95/p99 | tris/frame | calls/frame | heap MB | autosave ms / MB |');
+ L.push('|---|---|---|---|---|---|---|---|---|');
+ for (const w of dense) {
+ L.push(
+ '| ' + w.objects +
+ ' | ' + r(w.importMsP50, 0) + ' / ' + r(w.importMsMax, 0) +
+ ' | ' + r(w.importLongestTask, 0) +
+ ' | ' + r(w.idle.p50) + ' / ' + r(w.idle.p95) + ' / ' + r(w.idle.p99) +
+ ' | ' + r(w.orbit.p50) + ' / ' + r(w.orbit.p95) + ' / ' + r(w.orbit.p99) +
+ ' | ' + r(w.triangles, 0) +
+ ' | ' + r(w.calls, 0) +
+ ' | ' + r(w.heapMB, 0) +
+ ' | ' + r(w.autosaveExportMs, 0) + ' / ' + r((w.autosaveBytes ?? 0) / 1048576, 2) +
+ ' |'
+ );
+ }
+ }
+ if (physics.length) {
+ L.push('');
+ L.push('## Physics over N dynamic boxes (26-G stops a run at ' + '30 steps over 24ms)');
+ L.push('');
+ L.push('| boxes | bodies | step p50 / p95 ms | frame p50/p95 while simulating | auto-stopped |');
+ L.push('|---|---|---|---|---|');
+ for (const w of physics) {
+ L.push(
+ '| ' + w.size +
+ ' | ' + (w.physicsAutoStopped ? 'stopped' : r(w.bodies, 0)) +
+ ' | ' + r(w.stepP50) + ' / ' + r(w.stepP95) +
+ ' | ' + (w.physicsFrame ? r(w.physicsFrame.p50) + ' / ' + r(w.physicsFrame.p95) : '—') +
+ ' | ' + (w.physicsStarted ? (w.physicsAutoStopped ? 'YES' : 'no') : 'did not start') +
+ ' |'
+ );
+ }
+ }
+ if (sync.length) {
+ L.push('');
+ L.push('## A joiner receiving the scene (two peers, one handshake)');
+ L.push('');
+ L.push('| objects | joiner syncMs | wall ms (dial -> synced) | joiner long tasks | longest | busy ms |');
+ L.push('|---|---|---|---|---|---|');
+ for (const w of sync) {
+ L.push(
+ '| ' + w.objects + '/' + w.size +
+ ' | ' + (w.complete ? r(w.syncMs, 0) : 'INCOMPLETE') +
+ ' | ' + r(w.wallMs, 0) +
+ ' | ' + r(w.joinerLongTasks, 0) +
+ ' | ' + r(w.joinerLongestTask, 0) +
+ ' | ' + r(w.joinerBusyMs, 0) +
+ ' |'
+ );
+ }
+ }
+ L.push('');
+ L.push('```json');
+ L.push(JSON.stringify({ rows, dense, physics, sync }, null, 1));
+ L.push('```');
+ return L.join('\n');
+}
+
+(async () => {
+ // precise-memory: without it performance.memory is bucketed and every size reads the same heap
+ const browser = await h.launch({ args: [...h.GPU_ARGS, '--enable-precise-memory-info'] });
+ const rows = [];
+ const dense = [];
+ const physics = [];
+ const sync = [];
+ try {
+ for (const size of SIZES) {
+ console.log('\n==== ' + size + ' boxes ====');
+ const row = await measureScene(h, browser, { kind: 'cubes', size, windowMs: WINDOW_MS, storage });
+ console.log(JSON.stringify({ ...row, gpu: undefined }));
+ rows.push(row);
+ }
+ for (const size of DENSE) {
+ console.log('\n==== ' + size + ' dense models ====');
+ const row = await measureScene(h, browser, { kind: 'dense', size, windowMs: WINDOW_MS, denseTris: DENSE_TRIS, storage });
+ console.log(JSON.stringify({ ...row, gpu: undefined }));
+ dense.push(row);
+ }
+ for (const size of PHYSICS) {
+ console.log('\n==== physics over ' + size + ' boxes ====');
+ const row = await measureScene(h, browser, { kind: 'cubes', size, windowMs: WINDOW_MS, physics: true, autosave: false, storage });
+ console.log(JSON.stringify({ bodies: row.bodies, stepP50: row.stepP50, stepP95: row.stepP95, stopped: row.physicsAutoStopped, frame: row.physicsFrame }));
+ physics.push(row);
+ }
+ for (const size of SYNC) {
+ console.log('\n==== a joiner receiving ' + size + ' boxes ====');
+ const row = await measureSync(browser, size);
+ console.log(JSON.stringify(row));
+ sync.push(row);
+ }
+ const md = report(rows, dense, physics, sync);
+ console.log('\n' + md.split('```json')[0]);
+ if (OUT) {
+ const out = path.isAbsolute(OUT) ? OUT : path.resolve(process.cwd(), OUT);
+ fs.mkdirSync(path.dirname(out), { recursive: true });
+ fs.writeFileSync(out, md);
+ console.log('written to ' + out);
+ }
+ } catch (err) {
+ console.error('STRESS RUN FAILED:', err && err.stack ? err.stack : err);
+ process.exitCode = 1;
+ } finally {
+ await browser.close();
+ }
+ void summarize;
+})();
diff --git a/tests/e2e/scene-stress.test.cjs b/tests/e2e/scene-stress.test.cjs
new file mode 100644
index 00000000..34df9e67
--- /dev/null
+++ b/tests/e2e/scene-stress.test.cjs
@@ -0,0 +1,202 @@
+// 26-E — the scene-stress rig's REGRESSION suite (roadmap 26 section 6).
+//
+// `scene-stress.cjs` is the measurement rig, run by hand; this proves the machinery it
+// stands on still measures what it says:
+// 1. the percentile rule the rig reports is the meter's rule (pure, no browser)
+// 2. THE FINDING: draw calls and triangles are counted per DISPLAY frame across every
+// `renderer.render()` — the raw `renderer.info` reads one fullscreen pass (1 call,
+// 1 triangle with 150 boxes on screen), so the triangle and draw-call budgets could
+// never leave green
+// 3. stopping the sampler hands the renderer back unwrapped, and starting re-wraps it
+// 4. the metric sources the rig needed are registered from their own modules: physics
+// bodies + step time, autosave export ms/bytes, and the receive-side sync time
+// 4b. a loading stall is measured from the last ARRIVAL, not the announcement (the rig
+// found a 3,000-object join declared dead at 63s while still landing)
+// 5. the rig's per-size runner produces a COMPLETE row end to end at a tiny size, so
+// the manual rig cannot rot unnoticed between the runs that feed the roadmap
+//
+// Run: APP_URL=https://theprototype.app:5180/ npm run e2e -- scene-stress
+const h = require('./helpers.cjs');
+const { percentile, summarize, installProbe, measureScene } = require('./sceneStressProbe.cjs');
+
+h.run(async () => {
+ // ---- 1. the pure part --------------------------------------------------------------
+ const ring = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
+ h.check(percentile(ring, 0.5) === 50 && percentile(ring, 0.95) === 100, `nearest-rank percentiles (p50 ${percentile(ring, 0.5)}, p95 ${percentile(ring, 0.95)})`);
+ const s = summarize([5, 1, NaN, 3]);
+ h.check(s.n === 3 && s.p50 === 3 && s.max === 5, `summarize sorts, drops non-numbers and reports max (${JSON.stringify(s)})`);
+ h.check(summarize([]).p95 === null, 'an empty window reports null, never a zero that reads as a fast frame');
+
+ // GPU args: frame-time and per-frame render totals over real frames (the e2e skill's rule)
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+ const gpu = await installProbe(A.page);
+ console.log('renderer: ' + gpu);
+
+ // the meter's own rule, on the same numbers, in the page
+ const meterRule = await A.page.evaluate((values) => {
+ const b = window.__stores.sceneBudget;
+ for (let i = 0; i < 300; i++) b.noteFrame(1000); // push the ring out of the way
+ for (const v of values) for (let i = 0; i < 24; i++) b.noteFrame(v);
+ return b.frameStats();
+ }, ring);
+ h.check(
+ meterRule.p50 === percentile([...ring.flatMap((v) => Array(24).fill(v))], 0.5) &&
+ meterRule.p95 === percentile([...ring.flatMap((v) => Array(24).fill(v))], 0.95),
+ `the rig's percentile is the meter's percentile (meter p50 ${meterRule.p50} p95 ${meterRule.p95})`
+ );
+
+ // ---- 2. per-frame render totals ----------------------------------------------------
+ const seeded = await A.page.evaluate(() => window.__stress.seedCubes(150));
+ h.check(seeded.count === 150, `premise: 150 real boxes in the scene (${seeded.count})`);
+ await A.page.evaluate(() => window.__stress.frameAll(150));
+ const totals = await A.page.evaluate(async () => {
+ const { sceneBudget, globalRenderer } = window.__stores;
+ let r;
+ globalRenderer.subscribe((/** @type {any} */ v) => (r = v))();
+ await new Promise((res) => setTimeout(res, 1200));
+ const m = sceneBudget.sampleSceneMetrics();
+ // what one reader of renderer.info sees at an arbitrary moment: the last pass
+ const lastPass = { calls: r.info.render.calls, triangles: r.info.render.triangles };
+ return { calls: m.calls, triangles: m.triangles, rendersPerFrame: m.rendersPerFrame, lastPass, wrapped: !!r.__budgetRender };
+ });
+ h.check(totals.wrapped, 'the sampler wraps the live renderer');
+ h.check(
+ totals.rendersPerFrame > 1,
+ `premise: a desktop frame is SEVERAL render() calls, not one (${totals.rendersPerFrame} per frame)`
+ );
+ h.check(
+ totals.lastPass.calls < 150,
+ `premise: raw renderer.info reads only the last pass (${totals.lastPass.calls} calls, ${totals.lastPass.triangles} triangles)`
+ );
+ h.check(totals.calls >= 150, `draw calls per frame count every box (${totals.calls} for 150 boxes)`);
+ h.check(totals.triangles >= 150 * 12, `triangles per frame count every box (${totals.triangles} >= ${150 * 12})`);
+
+ // more objects must move the reading — the axis is live, not a constant
+ await A.page.evaluate(() => window.__stress.seedCubes(150));
+ await A.page.evaluate(() => window.__stress.frameAll(300));
+ const doubled = await A.page.evaluate(async () => {
+ await new Promise((res) => setTimeout(res, 1200));
+ return window.__stores.sceneBudget.sampleSceneMetrics();
+ });
+ h.check(
+ doubled.calls > totals.calls * 1.5 && doubled.triangles > totals.triangles * 1.5,
+ `doubling the boxes roughly doubles the reading (calls ${totals.calls} -> ${doubled.calls}, tris ${totals.triangles} -> ${doubled.triangles})`
+ );
+
+ // ---- 3. stop hands the renderer back, start re-wraps ------------------------------
+ const cycle = await A.page.evaluate(async () => {
+ const { sceneBudget, globalRenderer } = window.__stores;
+ let r;
+ globalRenderer.subscribe((/** @type {any} */ v) => (r = v))();
+ // three defines `render` as an OWN property in its constructor, so "restored" means the
+ // very same function object is back, not that the property is gone
+ const original = r.__budgetRender;
+ sceneBudget.stopSceneMetrics();
+ const stopped = { wrapped: !!r.__budgetRender, same: !!original && r.render === original };
+ sceneBudget.startSceneMetrics();
+ await new Promise((res) => setTimeout(res, 800));
+ return { stopped, restarted: !!r.__budgetRender };
+ });
+ h.check(!cycle.stopped.wrapped && cycle.stopped.same, `stopping the sampler restores the renderer's own render (${JSON.stringify(cycle.stopped)})`);
+ h.check(cycle.restarted, 'starting it again re-wraps the renderer');
+
+ // ---- 4. the metric sources ---------------------------------------------------------
+ const save = await A.page.evaluate(async () => {
+ await window.__stores.autosave.saveNow();
+ const m = window.__stores.sceneBudget.sampleSceneMetrics();
+ return { exportMs: m.autosaveExportMs, bytes: m.autosaveBytes };
+ });
+ h.check(save.exportMs > 0 && save.bytes > 1000, `autosave export ms and bytes reach the sampler (${save.exportMs}ms, ${save.bytes} bytes)`);
+
+ const phys = await A.page.evaluate(async () => {
+ const { physics, sceneBudget } = window.__stores;
+ const before = sceneBudget.sampleSceneMetrics();
+ const run = await window.__stress.physics(1500);
+ const after = sceneBudget.sampleSceneMetrics();
+ return { beforeBodies: before.bodies, beforeStep: before.physicsStepMs, run, afterBodies: after.bodies, afterStep: after.physicsStepMs, stats: physics.physicsStepStats() };
+ });
+ h.check(phys.beforeBodies === 0 && phys.beforeStep === null, `no simulation: 0 bodies and no step time (${phys.beforeBodies}, ${phys.beforeStep})`);
+ h.check(phys.run.startedOk, 'premise: the simulation started');
+ h.check(phys.run.bodies === 300, `while simulating the sampler counts the bodies (${phys.run.bodies} for 300 dynamic boxes)`);
+ h.check(phys.run.step && phys.run.step.n > 20 && phys.run.step.p95 > 0, `…and the step time (${JSON.stringify(phys.run.step)})`);
+ h.check(
+ phys.afterBodies === 0 && phys.afterStep === null && phys.stats === null,
+ `a stopped run reads as no run, never a stale cost (${phys.afterBodies}, ${phys.afterStep})`
+ );
+
+ const sync = await A.page.evaluate(async () => {
+ const { commandsHandler, sceneBudget } = window.__stores;
+ // a batch that FINISHES: every announced uuid counted as arrived
+ await commandsHandler.createLoader(2, ['stress-a', 'stress-b'], 'nobody');
+ await new Promise((res) => setTimeout(res, 300));
+ commandsHandler.noteLoadFailed(['stress-a', 'stress-b']);
+ const finished = { stats: commandsHandler.lastSyncStats(), metric: sceneBudget.sampleSceneMetrics().syncMs };
+ // a batch that is CLOSED before it finishes (the sender left, a scene clear)
+ await commandsHandler.createLoader(2, ['stress-c', 'stress-d'], 'nobody');
+ await new Promise((res) => setTimeout(res, 100));
+ commandsHandler.clearLoadingBatch();
+ const closed = { stats: commandsHandler.lastSyncStats(), metric: sceneBudget.sampleSceneMetrics().syncMs };
+ return { finished, closed };
+ });
+ h.check(
+ sync.finished.stats?.complete === true && sync.finished.metric >= 280 && sync.finished.stats.objects === 2,
+ `a finished batch reports its sync time, on one clock (${JSON.stringify(sync.finished)})`
+ );
+ h.check(
+ sync.closed.stats?.complete === false && sync.closed.metric === null,
+ `a batch closed before it finished reports NO sync time, not a fast one (${JSON.stringify(sync.closed)})`
+ );
+ // ---- 4b. a stall is SILENCE, not duration ------------------------------------------
+ // The rig's finding: the 60s stall timer was armed once at the announcement, so a
+ // 3,000-object join still landing ~10 objects a second was declared dead at 63s. Here
+ // the stall is 800ms and the batch keeps making progress past it.
+ const stall = await A.page.evaluate(async () => {
+ const { commandsHandler, loading } = window.__stores;
+ const read = () => {
+ let v;
+ loading.subscribe((/** @type {any} */ x) => (v = x))();
+ return v.length;
+ };
+ const sleep = (/** @type {number} */ ms) => new Promise((r) => setTimeout(r, ms));
+ commandsHandler.setLoadingStallMsForTest(800);
+ try {
+ await commandsHandler.createLoader(4, ['st-1', 'st-2', 'st-3', 'st-4'], 'nobody');
+ const trace = [];
+ // one arrival every 500ms: total 1.5s, never 800ms of silence
+ for (const uuid of ['st-1', 'st-2', 'st-3']) {
+ await sleep(500);
+ commandsHandler.noteLoadFailed([uuid]);
+ trace.push(read());
+ }
+ const aliveAfter1500 = read();
+ // …then silence: the stall must still fire
+ await sleep(1300);
+ return { trace, aliveAfter1500, afterSilence: read(), sync: commandsHandler.lastSyncStats() };
+ } finally {
+ commandsHandler.setLoadingStallMsForTest();
+ }
+ });
+ h.check(
+ stall.aliveAfter1500 === 1,
+ `a batch still arriving past the stall window is NOT given up on (${JSON.stringify(stall.trace)} left after 1.5s)`
+ );
+ h.check(
+ stall.afterSilence === 0 && stall.sync?.complete === false,
+ `…and real silence still ends it, reported as incomplete (${stall.afterSilence} left, ${JSON.stringify(stall.sync)})`
+ );
+
+ h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`);
+ await A.ctx.close();
+
+ // ---- 5. the rig's runner, end to end, at a tiny size -------------------------------
+ const row = await measureScene(h, browser, { kind: 'cubes', size: 40, windowMs: 1000, physics: true });
+ const numeric = ['seedMs', 'objects', 'triangles', 'calls', 'rendersPerFrame', 'geometries', 'textures', 'listMs', 'autosaveExportMs', 'autosaveBytes', 'bodies', 'stepP95'];
+ const missing = numeric.filter((key) => !Number.isFinite(row[key]));
+ h.check(missing.length === 0, `the rig produces a complete row (missing: ${JSON.stringify(missing)})`);
+ h.check(row.idle.n > 20 && row.orbit.n > 20 && row.idle.p95 > 0, `…with real frame windows (idle ${row.idle.n} frames, orbit ${row.orbit.n})`);
+ h.check(row.objects === 40 && row.listRows === 40 && row.bodies === 40, `…measuring the scene it built (${row.objects} objects, ${row.listRows} rows, ${row.bodies} bodies)`);
+ h.check(row.pageErrors === 0, `…with no page errors (${row.pageErrors})`);
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/sceneStressProbe.cjs b/tests/e2e/sceneStressProbe.cjs
new file mode 100644
index 00000000..569da473
--- /dev/null
+++ b/tests/e2e/sceneStressProbe.cjs
@@ -0,0 +1,340 @@
+// 26-E — THE SCENE-STRESS PROBE, shared by the manual rig and its regression suite.
+//
+// `scene-stress.cjs` is the measurement rig (a many-minute sweep, run by hand, like
+// `net-stress.cjs`). `scene-stress.test.cjs` is the quick regression that proves the rig
+// still measures what it says it measures. Both drive THIS file, so the suite covers the
+// real measurement code rather than a copy of it that can drift.
+//
+// Everything that is timed is timed INSIDE the page. A CDP round trip is several
+// milliseconds on this box, which is a third of a frame — a frame time measured across
+// the bridge is a measurement of the bridge.
+//
+// Not a `.test.cjs`, so the runner never picks it up on its own.
+
+/**
+ * Nearest-rank percentile — the SAME rule `sceneBudget.frameStats` uses, so a number the
+ * rig reports and a number the meter reports mean the same thing. PURE.
+ * @param {number[]} sorted ascending @param {number} q 0..1
+ */
+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];
+}
+
+/** @param {number[]} values */
+function summarize(values) {
+ const sorted = [...values].filter(Number.isFinite).sort((a, b) => a - b);
+ return {
+ n: sorted.length,
+ p50: percentile(sorted, 0.5),
+ p95: percentile(sorted, 0.95),
+ p99: percentile(sorted, 0.99),
+ max: sorted.length ? sorted[sorted.length - 1] : null
+ };
+}
+
+/**
+ * Install `window.__stress` in the page. Idempotent. Returns the renderer string, so a
+ * report can say what GPU its numbers came from (they are meaningless without it).
+ * @param {any} page
+ */
+async function installProbe(page) {
+ return page.evaluate(() => {
+ /** @type {any} */
+ const w = window;
+ const s = w.__stores;
+ /** @param {any} store */
+ const read = (store) => {
+ let v;
+ store.subscribe((/** @type {any} */ x) => (v = x))();
+ return v;
+ };
+ /** @param {number} ms */
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const nextFrame = () => new Promise((r) => requestAnimationFrame(r));
+
+ if (!w.__stress) {
+ /** @type {any} */
+ const ns = (w.__stress = { tasks: [] });
+ try {
+ ns.observer = new PerformanceObserver((list) => {
+ for (const e of list.getEntries()) ns.tasks.push({ at: e.startTime, ms: e.duration });
+ });
+ ns.observer.observe({ entryTypes: ['longtask'] });
+ ns.longTasksAvailable = true;
+ } catch {
+ ns.longTasksAvailable = false;
+ }
+
+ /** Long tasks that STARTED inside [from, now]. */
+ ns.tasksSince = (/** @type {number} */ from) => {
+ const hit = ns.tasks.filter((/** @type {any} */ t) => t.at >= from);
+ return {
+ count: hit.length,
+ longest: hit.reduce((m, /** @type {any} */ t) => Math.max(m, t.ms), 0),
+ busy: hit.reduce((m, /** @type {any} */ t) => m + t.ms, 0)
+ };
+ };
+
+ /** Frame deltas for `ms`, optionally doing `each(dt)` every frame. */
+ ns.frames = async (/** @type {number} */ ms, /** @type {any} */ each) => {
+ /** @type {number[]} */
+ const deltas = [];
+ const started = performance.now();
+ let last = await nextFrame();
+ while (performance.now() - started < ms) {
+ const now = /** @type {number} */ (await nextFrame());
+ deltas.push(now - last);
+ if (each) each(now - last);
+ last = now;
+ }
+ return { deltas, tasks: ns.tasksSince(started), elapsed: performance.now() - started };
+ };
+
+ ns.renderer = () => {
+ const r = read(s.globalRenderer);
+ try {
+ const gl = r.getContext();
+ const dbg = gl.getExtension('WEBGL_debug_renderer_info');
+ return dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
+ } catch {
+ return 'unknown';
+ }
+ };
+
+ ns.count = () => read(s.objectsGroup)?.children?.length ?? 0;
+
+ /**
+ * Seed `n` boxes through the REAL create command (history, palette colour,
+ * shadow defaults, the poke) laid out on a square grid so every one is in
+ * frame. In chunks, yielding between them, because the thing being measured
+ * is the scene afterwards — not how badly a 10,000-iteration loop blocks.
+ */
+ ns.seedCubes = async (/** @type {number} */ n, /** @type {number} */ chunk = 250) => {
+ const started = performance.now();
+ const side = Math.ceil(Math.sqrt(n));
+ const gap = 1.6;
+ const base = ns.count();
+ for (let i = 0; i < n; i++) {
+ s.commandsHandler.sceneCommand('/create box');
+ const o = read(s.selectedObject);
+ if (o?.position) o.position.set((i % side) * gap - (side * gap) / 2, 0.5, Math.floor(i / side) * gap - (side * gap) / 2);
+ if (i % chunk === chunk - 1) await sleep(0);
+ }
+ // the creations are synchronous, but the palette/shadow sweeps ride pokes
+ for (let t = 0; t < 200 && ns.count() < base + n; t++) await sleep(50);
+ s.selectedObjects?.set?.([]);
+ s.flushScenePokes?.();
+ await nextFrame();
+ return { ms: performance.now() - started, tasks: ns.tasksSince(started), count: ns.count() - base };
+ };
+
+ /** Point the editor camera at the whole grid, from above and to one side. */
+ ns.frameAll = (/** @type {number} */ n) => {
+ const side = Math.ceil(Math.sqrt(Math.max(1, n))) * 1.6;
+ const cam = read(s.globalCamera);
+ const controls = read(s.orbitControls);
+ const d = Math.max(12, side * 0.9);
+ cam.position.set(d * 0.6, d * 0.7, d * 0.8);
+ cam.far = Math.max(cam.far, d * 6);
+ cam.updateProjectionMatrix();
+ controls?.target?.set?.(0, 0, 0);
+ controls?.update?.();
+ };
+
+ /** A continuous orbit: what "the scene is heavy" feels like while navigating. */
+ ns.orbit = (/** @type {number} */ ms) => {
+ const controls = read(s.orbitControls);
+ return ns.frames(ms, () => {
+ if (controls?._rotateLeft) controls._rotateLeft(0.02);
+ else if (controls?.rotateLeft) controls.rotateLeft(0.02);
+ controls?.update?.();
+ });
+ };
+
+ /** The budget sampler's own reading, after it has seen at least one window. */
+ ns.metrics = async () => {
+ await sleep(600);
+ return s.sceneBudget.sampleSceneMetrics();
+ };
+
+ /** One autosave snapshot through the real writer. */
+ ns.autosave = async () => {
+ const started = performance.now();
+ await s.autosave.saveNow();
+ const status = read(s.autosave.autosaveStatus);
+ return {
+ wallMs: performance.now() - started,
+ exportMs: status.lastExportMs,
+ bytes: status.lastBytes,
+ error: status.lastError,
+ tasks: ns.tasksSince(started)
+ };
+ };
+
+ /**
+ * Object list: close it, reopen it, and time until rows are in the DOM plus
+ * one painted frame. Above 500 rows 26-B windows the list, so the row count is
+ * reported too — a small count at 10k is the virtualisation working.
+ */
+ ns.listRender = async () => {
+ s.objectListClose.set(true);
+ for (let t = 0; t < 40 && document.querySelector('#object-tree [role="treeitem"]'); t++) await sleep(25);
+ await nextFrame();
+ const started = performance.now();
+ s.objectListClose.set(false);
+ let rows = 0;
+ for (let t = 0; t < 400; t++) {
+ rows = document.querySelectorAll('#object-tree [role="treeitem"]').length;
+ if (rows > 0) break;
+ await new Promise((r) => setTimeout(r, 0));
+ }
+ await nextFrame();
+ return { ms: performance.now() - started, rows, mode: document.querySelector('[data-object-rows]')?.getAttribute('data-object-rows') ?? null };
+ };
+
+ /**
+ * Import a dense model through the real GLB import path: a UV sphere of about
+ * `tris` triangles, exported to binary glTF in the page, handed to
+ * `fileHandler.importFile`. Timed until the object is in the scene.
+ */
+ ns.importDense = async (/** @type {number} */ tris) => {
+ const THREE = s.THREE;
+ const Exporter = s.GLTFExporterModule.GLTFExporter;
+ // a UV sphere of w x h segments has about 2*w*(h-1) triangles
+ const h = Math.max(4, Math.round(Math.sqrt(tris / 2)));
+ const wSeg = Math.max(4, Math.round(tris / (2 * (h - 1))));
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, wSeg, h), new THREE.MeshStandardMaterial({ color: 0x8899aa }));
+ mesh.name = 'dense';
+ const glb = await new Promise((resolve, reject) =>
+ new Exporter().parse(mesh, resolve, reject, { binary: true })
+ );
+ mesh.geometry.dispose();
+ const file = new File([/** @type {any} */ (glb)], 'dense.glb', { type: 'model/gltf-binary' });
+ const before = ns.count();
+ const started = performance.now();
+ await s.fileHandler.importFile(file, 'dense', 'glb', [ (before % 6) * 2.5 - 6, 1, Math.floor(before / 6) * 2.5 - 6 ]);
+ for (let t = 0; t < 600 && ns.count() <= before; t++) await sleep(20);
+ s.selectedObjects?.set?.([]);
+ return { ms: performance.now() - started, bytes: /** @type {any} */ (glb).byteLength, landed: ns.count() > before, tasks: ns.tasksSince(started) };
+ };
+
+ /** Run the simulation over what is in the scene for `ms`. */
+ ns.physics = async (/** @type {number} */ ms) => {
+ const physics = s.physics;
+ if (!read(physics.simulating)) await physics.toggleSimulation();
+ for (let t = 0; t < 100 && !read(physics.simulating); t++) await sleep(50);
+ const startedOk = !!read(physics.simulating);
+ await sleep(400); // the first steps build the world
+ const run = await ns.frames(ms);
+ const step = physics.physicsStepStats?.() ?? null;
+ const metrics = s.sceneBudget.sampleSceneMetrics();
+ const stillRunning = !!read(physics.simulating);
+ if (stillRunning) physics.stopSimulation();
+ return { startedOk, stillRunning, step, bodies: metrics.bodies ?? null, run };
+ };
+ }
+ return w.__stress.renderer();
+ });
+}
+
+/**
+ * The per-size measurement, on a FRESH page so one size's heap and GPU state never
+ * colours the next. Returns one report row. Every field is a number or null; nothing is
+ * a string that a table would have to parse.
+ * @param {any} h helpers.cjs
+ * @param {any} browser
+ * @param {{kind: 'cubes'|'dense', size: number, windowMs?: number, physics?: boolean, autosave?: boolean, denseTris?: number, storage?: Record, viewport?: {width: number, height: number}}} opts
+ */
+async function measureScene(h, browser, opts) {
+ const windowMs = opts.windowMs ?? 4000;
+ const peer = await h.setupPage(browser, opts.kind + '-' + opts.size, {
+ context: { viewport: opts.viewport ?? { width: 1280, height: 720 } },
+ storage: opts.storage
+ });
+ try {
+ const gpu = await installProbe(peer.page);
+ /** @type {any} */
+ const row = { kind: opts.kind, size: opts.size, gpu };
+ const emptyIdle = await peer.page.evaluate((ms) => window.__stress.frames(ms), Math.min(2000, windowMs));
+ row.emptyFrame = summarize(emptyIdle.deltas);
+
+ if (opts.kind === 'cubes') {
+ const seed = await peer.page.evaluate((n) => window.__stress.seedCubes(n), opts.size);
+ row.seedMs = Math.round(seed.ms);
+ row.seedLongTasks = seed.tasks.count;
+ row.seedLongestTask = Math.round(seed.tasks.longest);
+ row.objects = seed.count;
+ await peer.page.evaluate((n) => window.__stress.frameAll(n), opts.size);
+ } else {
+ const tris = opts.denseTris ?? 200000;
+ /** @type {number[]} */
+ const imports = [];
+ let bytes = 0;
+ let landed = 0;
+ let longest = 0;
+ for (let i = 0; i < opts.size; i++) {
+ const one = await peer.page.evaluate((t) => window.__stress.importDense(t), tris);
+ imports.push(one.ms);
+ bytes = one.bytes;
+ if (one.landed) landed++;
+ longest = Math.max(longest, one.tasks.longest);
+ }
+ row.importMsP50 = Math.round(summarize(imports).p50 ?? 0);
+ row.importMsMax = Math.round(summarize(imports).max ?? 0);
+ row.importLongestTask = Math.round(longest);
+ row.glbBytes = bytes;
+ row.objects = landed;
+ await peer.page.evaluate(() => window.__stress.frameAll(36));
+ }
+ await peer.page.waitForTimeout(1200);
+
+ const idle = await peer.page.evaluate((ms) => window.__stress.frames(ms), windowMs);
+ row.idle = summarize(idle.deltas);
+ row.idleLongTasks = idle.tasks.count;
+ const orbit = await peer.page.evaluate((ms) => window.__stress.orbit(ms), windowMs);
+ row.orbit = summarize(orbit.deltas);
+ row.orbitLongTasks = orbit.tasks.count;
+ row.orbitLongestTask = Math.round(orbit.tasks.longest);
+
+ const m = await peer.page.evaluate(() => window.__stress.metrics());
+ row.triangles = m.triangles;
+ row.calls = m.calls;
+ row.rendersPerFrame = m.rendersPerFrame ?? null;
+ row.geometries = m.geometries;
+ row.textures = m.textures;
+ row.heapMB = m.heap ? Math.round(m.heap / 1048576) : null;
+ row.meterP95 = m.frameP95;
+
+ const list = await peer.page.evaluate(() => window.__stress.listRender());
+ row.listMs = Math.round(list.ms);
+ row.listRows = list.rows;
+ row.listMode = list.mode;
+
+ if (opts.autosave !== false) {
+ const save = await peer.page.evaluate(() => window.__stress.autosave());
+ row.autosaveExportMs = save.exportMs ? Math.round(save.exportMs) : null;
+ row.autosaveWallMs = Math.round(save.wallMs);
+ row.autosaveBytes = save.bytes || null;
+ row.autosaveLongestTask = Math.round(save.tasks.longest);
+ row.autosaveError = save.error || null;
+ }
+
+ if (opts.physics) {
+ const run = await peer.page.evaluate((ms) => window.__stress.physics(ms), windowMs);
+ row.physicsStarted = run.startedOk;
+ row.physicsAutoStopped = run.startedOk && !run.stillRunning;
+ row.bodies = run.bodies;
+ row.stepP50 = run.step ? Math.round(run.step.p50 * 10) / 10 : null;
+ row.stepP95 = run.step ? Math.round(run.step.p95 * 10) / 10 : null;
+ row.physicsFrame = summarize(run.run.deltas);
+ }
+ row.pageErrors = h.pageErrors(peer).length;
+ return row;
+ } finally {
+ await peer.ctx.close();
+ }
+}
+
+module.exports = { percentile, summarize, installProbe, measureScene };
diff --git a/tests/e2e/script-guard.test.cjs b/tests/e2e/script-guard.test.cjs
new file mode 100644
index 00000000..bb344135
--- /dev/null
+++ b/tests/e2e/script-guard.test.cjs
@@ -0,0 +1,175 @@
+// 27-D (audit C1) — A RUNAWAY SCRIPT NO LONGER TAKES THE SESSION WITH IT.
+//
+// A Script node runs on EVERY peer, every frame, on the main thread. So `while (true)`
+// in one node is not one person's mistake: it freezes the tab of everyone in the room,
+// with no way out but closing it. That is the audit's only CRITICAL finding, and this
+// suite is the proof that it is fixed.
+//
+// What it pins:
+// 1. a `while (true)` node reports an error badge instead of hanging
+// 2. THE PAGE IS STILL ALIVE afterwards — the check that actually matters, and the one
+// a store read alone cannot make, so it is measured by driving the real UI
+// 3. the scene keeps rendering and other nodes keep running
+// 4. a SLOW-but-terminating node is paused after a sustained run, not on one bad frame
+// 5. `#safe` boots with the runtime paused, which is how a hanging scene gets repaired
+//
+// The node setup mirrors `script-nodes`: a `script` node needs an `objectselector` and an
+// edge, or the runtime resolves no target and the script never runs at all — which would
+// make every check here pass while testing nothing.
+//
+// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- script-guard
+const h = require('./helpers.cjs');
+
+const makeBox = (peer) =>
+ peer.page.evaluate(() => {
+ window.__stores.commandsHandler.sceneCommand('/create box');
+ return new Promise((resolve) =>
+ window.__stores.objectsGroup.subscribe((g) =>
+ resolve(g.children[g.children.length - 1].uuid)
+ )()
+ );
+ });
+
+/** the script-nodes idiom: script -> objectselector, both stores written, both broadcast */
+const addScript = (peer, id, code, uuid) =>
+ peer.page.evaluate(
+ ([nodeId, src, target]) => {
+ const nodes = [
+ {
+ id: nodeId,
+ type: 'script',
+ position: { x: 0, y: 0 },
+ data: { type: 'script', code: src },
+ class: 'w-[150px]'
+ },
+ {
+ id: nodeId + '-sel',
+ type: 'objectselector',
+ position: { x: 300, y: 0 },
+ data: { type: 'objectselector', selected: target },
+ class: 'w-[150px]'
+ }
+ ];
+ const edge = { id: 'e-' + nodeId, source: nodeId, target: nodeId + '-sel' };
+ window.__stores.flowNodes.update((n) => [...n, ...nodes]);
+ window.__stores.flowEdges.update((e) => [...e, edge]);
+ },
+ [id, code, uuid]
+ );
+
+const badge = (peer, id) =>
+ peer.page.evaluate((nodeId) => {
+ let v = {};
+ window.__stores.scriptErrors.subscribe((m) => (v = m))();
+ return v[nodeId] ?? null;
+ }, id);
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- 1. a runaway loop reports instead of hanging ----------------------------------
+ const uuid = await makeBox(A);
+ h.check(!!uuid, `premise: an object for the script to target (${uuid})`);
+ await addScript(A, 'runaway', 'while (true) { object.position.x += 0.001; }', uuid);
+
+ await h.eventually(
+ () => badge(A, 'runaway'),
+ (b) => !!b && /loop limit/i.test(String(b)),
+ 'a while(true) node reports the loop limit instead of freezing',
+ 15000
+ );
+
+ // ---- 2. THE PAGE IS STILL ALIVE ----------------------------------------------------
+ // The load-bearing check. With the guard removed this is where the suite dies: the
+ // page stops answering and every later call times out.
+ const alive = await A.page.evaluate(() => 1 + 1).catch(() => null);
+ h.check(alive === 2, 'the page still answers after the runaway ran');
+
+ const clicked = await A.page
+ .locator('#logo-button, .logo, header')
+ .first()
+ .isVisible()
+ .catch(() => null);
+ h.check(clicked !== null, 'and the real UI is still there to be driven');
+
+ // the frame loop kept going: rAF still fires
+ const frames = await A.page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ let n = 0;
+ const t0 = performance.now();
+ const step = () => {
+ n++;
+ if (performance.now() - t0 > 600) return resolve(n);
+ requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+ })
+ );
+ h.check(frames > 3, `the render loop is still running (${frames} frames in 600ms)`);
+
+ // ---- 3. a healthy node beside it still works ---------------------------------------
+ const uuid2 = await makeBox(A);
+ await addScript(A, 'healthy', 'object.position.y = base.pos[1] + Math.sin(time * 3);', uuid2);
+ await A.page.waitForTimeout(1200);
+ const moved = await A.page.evaluate((id) => {
+ let g;
+ window.__stores.objectsGroup.subscribe((v) => (g = v))();
+ const o = g.getObjectByProperty('uuid', id);
+ return o ? o.position.y : null;
+ }, uuid2);
+ h.check(
+ moved !== null && Math.abs(moved) > 0.0001,
+ `a healthy script node beside the runaway still animates (y=${moved})`
+ );
+ h.check(!(await badge(A, 'healthy')), 'and it carries no error badge of its own');
+
+ // ---- 4. a SLOW node is paused, and only after a sustained run ------------------------
+ const uuid3 = await makeBox(A);
+ // Slow but TERMINATING, so only the time budget can catch it — the loop counter never
+ // trips. That constrains the fixture in a way worth stating: the guard stops every
+ // script at a million iterations, so it cannot buy time by looping MORE, it has to do
+ // more work per iteration. 900k plain additions measured about a millisecond here and
+ // the check failed for the fixture's sake rather than the feature's.
+ const SLOW_SRC =
+ 'let s = 0; for (let i = 0; i < 500000; i++) { s += Math.sin(i) * Math.cos(i); } data.s = s;';
+ const bodyMs = await A.page.evaluate((src) => {
+ const fn = new Function('data', src);
+ const t0 = performance.now();
+ fn({});
+ return performance.now() - t0;
+ }, SLOW_SRC);
+ h.check(
+ bodyMs > 8,
+ `premise: the slow fixture really is over the 8ms budget on this machine (${bodyMs.toFixed(1)}ms)`
+ );
+ await addScript(A, 'slow', SLOW_SRC, uuid3);
+ const slowBadge = await A.page
+ .waitForFunction(
+ () => {
+ let v = {};
+ window.__stores.scriptErrors.subscribe((m) => (v = m))();
+ return /too slow/i.test(String(v['slow'] ?? '')) ? v['slow'] : false;
+ },
+ { timeout: 30000 }
+ )
+ .then((r) => r.jsonValue())
+ .catch(() => null);
+ h.check(!!slowBadge, `a slow node is paused rather than left to eat the frame (${slowBadge})`);
+ h.check(
+ await A.page.evaluate(() => 1 + 1).then((v) => v === 2),
+ 'and the page is still responsive after it'
+ );
+
+ // ---- 5. safe mode boots paused -------------------------------------------------------
+ const S = await h.setupPage(browser, 'S', { hash: '#safe' });
+ const paused = await S.page.evaluate(() => {
+ let v = { paused: false, reason: '' };
+ window.__stores.flowPaused.subscribe((p) => (v = p))();
+ return v;
+ });
+ h.check(paused.paused === true, `#safe boots with the flow runtime paused (${paused.reason})`);
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/session-clock.test.cjs b/tests/e2e/session-clock.test.cjs
new file mode 100644
index 00000000..7fdb6a92
--- /dev/null
+++ b/tests/e2e/session-clock.test.cjs
@@ -0,0 +1,185 @@
+// 25-E (roadmap 25 section 4, audit M8) — ONE CLOCK FOR THE SESSION.
+//
+// Every stamp another peer compares used to be that machine's own Date.now(). A joiner
+// whose clock runs 90 s fast therefore WON every latest-wins merge for the next 90 s —
+// a host's LATER edit to the sky was refused on the joiner and overwritten on the host —
+// its flow clock ran 90 s out of phase, and its game timer read a round 90 s older.
+//
+// What this suite pins, with C's Date.now pushed +90 s by an init script (the music-clock
+// 6b recipe) and A its honest host:
+// 1. premise: the skew is real, and a lone peer's session clock is its own
+// 2. the joiner ADOPTS the host's clock (sessionNow agrees across the two machines)
+// 3. a LATER edit wins on both sides even though the earlier one came from the fast clock
+// 4. the synced flow clock and a game's elapsed time agree across the two
+// 5. one toast per skewed peer, and the round trip is on the capability floor
+// 6. the wire is additive (a pong carries so/ref; an older pong without them still folds)
+// 7. leaving the session hands the joiner its own clock back; the host drops the samples
+//
+// Measured against the REAL clock (`new Date().getTime()`, which the init script leaves
+// alone), so evaluate lag between two pages cannot pass or fail a check by itself.
+//
+// Run: APP_URL=https://theprototype.app:5175/ PEER_CONFIG=... npm run e2e -- session-clock
+const h = require('./helpers.cjs');
+
+const SKEW = 90000;
+
+/** run a snippet with `s = window.__stores` in scope */
+const inPage = (peer, body, arg) =>
+ peer.page.evaluate(([src, a]) => Object.getPrototypeOf(async function () {}).constructor('s', 'arg', src)(window.__stores, a), [body, arg ?? null]);
+
+/** the session clock minus the REAL clock, in ms — 0 on a machine keeping true time */
+const sessionError = (peer) => inPage(peer, 'return s.connectionState.sessionNow() - new Date().getTime()');
+const debug = (peer) => inPage(peer, 'return s.connectionState.sessionClockDebug()');
+const notes = (peer) =>
+ inPage(peer, 'let v = []; s.notifications.subscribe((x) => (v = x))(); return v.map((n) => String(n.text ?? n.message ?? n))');
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+ const C = await h.setupPage(browser, 'C');
+ await C.ctx.addInitScript((skew) => {
+ const real = Date.now;
+ Date.now = () => real() + skew;
+ }, SKEW);
+ await h.freshReload(C);
+ C.id = await C.page.evaluate(() => new Promise((r) => window.__stores.peers.subscribe((p) => r(p?.peer?.id))()));
+ console.log('A id: ' + A.id + ' C id (skewed +90s): ' + C.id);
+
+ // ---- 1. premise ------------------------------------------------------------------------
+ console.log('\n=== 1. premise ===');
+ const raw = await inPage(C, 'return Date.now() - new Date().getTime()');
+ h.check(raw >= SKEW - 5 && raw <= SKEW + 5, `C's raw Date.now runs ${SKEW} ms ahead of the real clock (${raw})`);
+ const alone = await debug(C);
+ h.check(alone.offset === 0 && alone.reference === null, `a peer on its own keeps its own clock (offset ${alone.offset}, reference ${alone.reference})`);
+ const aloneErr = await sessionError(C);
+ h.check(Math.abs(aloneErr - SKEW) < 50, `…so an unconnected C reads its own fast clock (${aloneErr})`);
+
+ // ---- 2. the joiner adopts the host's clock ------------------------------------------------
+ console.log('\n=== 2. adoption ===');
+ await h.connect(C, A, 3000);
+ await h.eventually(
+ () => sessionError(C),
+ (e) => Math.abs(e) < 250,
+ "C's session clock lands on A's (the host keeps true time here)",
+ 20000
+ );
+ const cDbg = await debug(C);
+ const aDbg = await debug(A);
+ console.log(' C: ' + JSON.stringify({ offset: cDbg.offset, reference: cDbg.reference, state: cDbg.state }));
+ console.log(' A: ' + JSON.stringify({ offset: aDbg.offset, reference: aDbg.reference }));
+ h.check(cDbg.reference === A.id, `C keeps time by the peer whose session it joined (${cDbg.reference})`);
+ h.check(Math.abs(cDbg.offset + SKEW) < 250, `C's offset is the negative of its skew (${cDbg.offset})`);
+ h.check(aDbg.offset === 0 && aDbg.reference === null, `the HOST never moves its clock toward a joiner (offset ${aDbg.offset})`);
+ const [ea, ec] = await Promise.all([sessionError(A), sessionError(C)]);
+ h.check(Math.abs(ea - ec) < 250, `sessionNow agrees across the two machines (A ${ea}, C ${ec})`);
+
+ // ---- 3. the later edit wins -------------------------------------------------------------
+ console.log('\n=== 3. a later edit wins everywhere ===');
+ // C edits FIRST; A edits a beat later. With raw clocks C's stamp is ~90 s newer, so A's
+ // later edit is refused on C and C's earlier one overwrites A. On the session clock the
+ // order of the stamps is the order things happened in.
+ await inPage(C, 'const e = s.environment; let st; e.environment.subscribe((v) => (st = v))(); e.setEnvironment("sunset", 1)');
+ await h.eventually(
+ () => inPage(A, 'let st; s.environment.environment.subscribe((v) => (st = v))(); return st.preset'),
+ (p) => p === 'sunset',
+ "premise: C's edit reaches A",
+ 10000
+ );
+ await A.page.waitForTimeout(400);
+ await inPage(A, 's.environment.setEnvironment("night", 1)');
+ await A.page.waitForTimeout(2500);
+ const presets = await Promise.all(
+ [A, C].map((p) => inPage(p, 'let st; s.environment.environment.subscribe((v) => (st = v))(); return { preset: st.preset, changedAt: st.changedAt }'))
+ );
+ console.log(' A ' + JSON.stringify(presets[0]) + ' C ' + JSON.stringify(presets[1]));
+ h.check(presets[0].preset === 'night', `A keeps its own later edit (${presets[0].preset})`);
+ h.check(presets[1].preset === 'night', `C takes A's later edit over its own earlier one (${presets[1].preset})`);
+
+ // ---- 4. the shared runtime clocks --------------------------------------------------------
+ console.log('\n=== 4. the flow clock and the game timer ===');
+ const [ta, tc] = await Promise.all([A, C].map((p) => inPage(p, 'return { t: s.moduleSDK.runtimeNow(), real: new Date().getTime() }')));
+ // correct for the two evaluations landing at different real instants
+ const flowDiff = tc.t - ta.t - (tc.real - ta.real) / 1000;
+ h.check(Math.abs(flowDiff) < 0.3, `the synced flow time agrees to ${flowDiff.toFixed(3)} s (it was 90 s apart)`);
+
+ // The history epoch and every action node's first-seen time are SESSION seconds taken
+ // as local cutoffs, mostly during the joiner's handshake. Recorded on the fast clock and
+ // never corrected, they sit 90 s in the future and every live pulse is refused as stale.
+ const syncedS = 'return { epoch: s.flowRuntime.triggerHistoryEpoch(), now: (s.connectionState.sessionNow() % 86400000) / 1000 }';
+ const ep = await inPage(C, syncedS);
+ h.check(ep.epoch > 0, `premise: the joiner received trigger history and marked its epoch (${ep.epoch})`);
+ h.check(ep.epoch <= ep.now + 0.5, `the joiner's history epoch is not in the future of its corrected clock (epoch ${ep.epoch.toFixed(2)}, now ${ep.now.toFixed(2)})`);
+ // …and a jump that happens AFTER the epoch was taken moves it by exactly the jump. Force
+ // one by feeding C's ring zero-RTT samples 30 s away from the truth, then put it back.
+ const jump = await inPage(C, `
+ const mc = s.musicClock;
+ const est = s.connectionState.sessionClockDebug().peers[arg].offset;
+ const e0 = s.flowRuntime.triggerHistoryEpoch();
+ const o0 = s.connectionState.sessionClockDebug().offset;
+ for (let i = 0; i < 12; i++) mc.recordClockSample(arg, est + 30000, 0);
+ const e1 = s.flowRuntime.triggerHistoryEpoch();
+ const moved = s.connectionState.sessionClockDebug().offset;
+ for (let i = 0; i < 12; i++) mc.recordClockSample(arg, est, 0);
+ const o2 = s.connectionState.sessionClockDebug().offset;
+ return { d: e1 - e0, want: (moved - o0) / 1000, back: s.flowRuntime.triggerHistoryEpoch() - e0, wantBack: (o2 - o0) / 1000 };`, A.id);
+ h.check(Math.abs(jump.want - 30) < 0.1 && Math.abs(jump.d - jump.want) < 0.001, `a 30 s clock correction moves the epoch by exactly the jump (${jump.d.toFixed(3)} for ${jump.want.toFixed(3)})`);
+ h.check(Math.abs(jump.back - jump.wantBack) < 0.001, `…and putting the clock back puts the epoch back (${jump.back.toFixed(3)} for ${jump.wantBack.toFixed(3)})`);
+
+ await inPage(A, 's.gameState.setGameState("playing")');
+ await h.eventually(
+ () => inPage(C, 'let g; s.gameState.gameState.subscribe((v) => (g = v))(); return g.state'),
+ (st) => st === 'playing',
+ 'premise: the game start reaches C',
+ 10000
+ );
+ const [ga, gc] = await Promise.all([A, C].map((p) => inPage(p, 'return { e: s.gameState.gameElapsed(), real: new Date().getTime() }')));
+ const gameDiff = gc.e - ga.e - (gc.real - ga.real) / 1000;
+ h.check(Math.abs(gameDiff) < 0.3, `a round's elapsed time agrees to ${gameDiff.toFixed(3)} s on the fast joiner`);
+ await inPage(A, 's.gameState.setGameState("menu")');
+
+ // ---- 5. the toast and the floor ----------------------------------------------------------
+ console.log('\n=== 5. the skew toast and the capability floor ===');
+ await h.eventually(
+ () => notes(A),
+ (list) => list.some((t) => t.includes(String(C.id).slice(0, 6).toUpperCase()) && /clock is 90 s ahead/.test(t)),
+ "A is told C's clock is 90 s ahead",
+ 20000
+ );
+ await h.eventually(
+ () => notes(C),
+ (list) => list.some((t) => /clock is 90 s behind/.test(t)),
+ "C is told A's clock is 90 s behind",
+ 20000
+ );
+ const once = await notes(A);
+ h.check(once.filter((t) => /clock is 90 s ahead/.test(t)).length === 1, 'once per peer, not once per resync');
+ const floor = await inPage(A, 's.cloudHooks.setCapabilityProvider(() => false); const r = { ping: s.cloudHooks.canApply("x", "clockping"), pong: s.cloudHooks.canApply("x", "clockpong"), other: s.cloudHooks.canApply("x", "environment") }; s.cloudHooks.setCapabilityProvider(null); return r');
+ h.check(floor.ping && floor.pong && !floor.other, `clockping/clockpong sit on the ALWAYS_ALLOWED floor (${JSON.stringify(floor)})`);
+
+ // ---- 6. the wire is additive -------------------------------------------------------------
+ console.log('\n=== 6. additive wire ===');
+ const pong = await inPage(C, `
+ let pc; s.peers.subscribe((v) => (pc = v))();
+ const conn = pc.connections[arg];
+ const seen = [];
+ const orig = conn.send.bind(conn);
+ conn.send = (m) => { if (m?.type === 'clockpong') seen.push(m); return orig(m); };
+ s.musicClock.answerClockPing({ type: 'clockping', sender: arg, t0: Date.now() });
+ conn.send = orig;
+ return seen[0] ?? null;`, A.id);
+ h.check(!!pong && typeof pong.so === 'number' && pong.ref === A.id, `a pong carries the responder's session offset and whose clock it is (so ${pong?.so}, ref ${pong?.ref})`);
+ const old = await inPage(A, `
+ const before = s.connectionState.sessionClockDebug().samples[arg]?.offsets.length ?? 0;
+ const now = Date.now();
+ s.musicClock.applyClockPong({ type: 'clockpong', sender: arg, t0: now - 10, t1: now - 5, t2: now - 5 });
+ return { before, after: s.connectionState.sessionClockDebug().samples[arg]?.offsets.length ?? 0 };`, C.id);
+ h.check(old.after === Math.min(old.before + 1, 12), `an OLDER peer's pong (no so/ref) still folds into the estimate (${old.before} -> ${old.after})`);
+
+ // ---- 7. leaving -------------------------------------------------------------------------
+ console.log('\n=== 7. leaving ===');
+ await inPage(C, 'let p; s.peers.subscribe((v) => (p = v))(); p.leaveSession()');
+ await h.eventually(() => debug(C), (d) => d.offset === 0 && d.reference === null, 'leaving hands C its own clock back', 10000);
+ await h.eventually(() => debug(A), (d) => !(C.id in d.peers), "the host drops the departed joiner's samples", 15000);
+
+ return h.finish(browser);
+});
diff --git a/tests/e2e/signaling-reconnect.test.cjs b/tests/e2e/signaling-reconnect.test.cjs
new file mode 100644
index 00000000..49d6f559
--- /dev/null
+++ b/tests/e2e/signaling-reconnect.test.cjs
@@ -0,0 +1,202 @@
+// 27-F (hardening audit H2) — THE SIGNALING LINK NEVER GIVES UP.
+//
+// It used to stop after five attempts (~20s) and toast "Please reload the page". A
+// reload is the worst available answer: it drops every live DataConnection AND the
+// invite id, while the thing that failed is usually a lid closing, a phone locking or
+// a wifi hop. Worse, a peer whose link CLOSED was a dead end in a second way —
+// `reconnect()` cannot revive a spent Peer object, and nothing ever rebuilt one.
+//
+// What this suite pins:
+// 1. the first drop arms the chip and toasts ONCE (a chip is a state you can look at;
+// an unbounded retry that toasts per attempt is spam)
+// 2. attempt 7 is still retrying, and nothing ever says "reload"
+// 3. `open` clears the chip and says so once — only to somebody who saw it go away
+// 4. a CLOSED peer is REBUILT, on the same id, so the invite link still works
+// 5. `online` and `visibilitychange` retry NOW and reset the schedule
+//
+// Events are driven on the peer itself (peerjs extends eventemitter3, so `emit` is
+// available) — the alternative is unplugging a network in a headless browser.
+//
+// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- signaling-reconnect
+const h = require('./helpers.cjs');
+
+const readRetry = (page) =>
+ page.evaluate(() => {
+ let v = null;
+ window.__stores.connectionState.signalingRetry.subscribe((x) => (v = x))();
+ return v;
+ });
+
+const toastTexts = (page) =>
+ page.evaluate(() => {
+ let list = [];
+ window.__stores.toastStore.subscribe((v) => (list = v))();
+ return list.map((t) => (typeof t === 'string' ? t : (t && (t.text || t.message)) || ''));
+ });
+
+const emitOnPeer = (page, event) =>
+ page.evaluate((name) => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ pc.peer.emit(name, pc.peer.id);
+ }, event);
+
+h.run(async () => {
+ const browser = await h.launch();
+ const peer = await h.setupPage(browser, 'signaling');
+ const page = peer.page;
+ await page.waitForFunction(() => !!window.__stores?.connectionState?.signalingRetry, { timeout: 30000 });
+
+ // ---- 0. premise -----------------------------------------------------------------
+ const premise = await page.evaluate(() => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ return { open: !!pc?.peer?.open, id: pc?.peer?.id ?? '' };
+ });
+ h.check(premise.open && !!premise.id, `premise: the signaling link is open (${premise.id})`);
+ h.check(
+ (await page.locator('#connect-retry-chip').count()) === 0,
+ 'no retry chip while the link is up'
+ );
+
+ // ---- 1. the first drop: chip on, ONE toast ---------------------------------------
+ await emitOnPeer(page, 'disconnected');
+ await page.waitForTimeout(400);
+ const first = await readRetry(page);
+ h.check(first?.retrying === true && first.attempt === 1, `the chip arms on the first drop (${JSON.stringify(first)})`);
+ h.check(
+ await page.locator('#connect-retry-chip').isVisible().catch(() => false),
+ 'the Connect pill shows a Reconnecting chip'
+ );
+ const afterFirst = await toastTexts(page);
+ h.check(
+ afterFirst.filter((t) => /Lost the peer server/i.test(t)).length === 1,
+ 'exactly one toast on the way in'
+ );
+
+ // ---- 2. it never gives up ---------------------------------------------------------
+ for (let i = 0; i < 6; i++) {
+ await emitOnPeer(page, 'disconnected');
+ await page.waitForTimeout(120);
+ }
+ const many = await readRetry(page);
+ h.check(many?.retrying === true && many.attempt === 7, `attempt 7 is still retrying (${JSON.stringify(many)})`);
+ const afterMany = await toastTexts(page);
+ h.check(
+ afterMany.filter((t) => /Lost the peer server/i.test(t)).length === 1,
+ '…and it still said it only once — the chip carries the live state'
+ );
+ h.check(
+ !afterMany.some((t) => /reload/i.test(t)),
+ 'nothing tells the user to reload (a reload drops every live peer and the invite id)'
+ );
+
+ // ---- 3. recovery says so, once ----------------------------------------------------
+ await emitOnPeer(page, 'open');
+ await page.waitForTimeout(400);
+ const healed = await readRetry(page);
+ h.check(healed?.retrying === false && healed.attempt === 0, 'the chip clears when the link comes back');
+ h.check(
+ (await page.locator('#connect-retry-chip').count()) === 0,
+ '…and the chip leaves the pill'
+ );
+ const afterOpen = await toastTexts(page);
+ h.check(
+ afterOpen.filter((t) => /Reconnected to the peer server/i.test(t)).length === 1,
+ 'one "Reconnected" toast, said only to somebody who saw it go away'
+ );
+
+ // ---- 4. a CLOSED peer is rebuilt, on the same id -----------------------------------
+ const beforeClose = await page.evaluate(() => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ window.__sigOld = pc.peer;
+ // A real `close` leaves the peer NOT open, and the rebuild is guarded on exactly
+ // that — so a synthetic event on a live socket must say so, or the guard correctly
+ // skips and the two checks below pass against the object they were meant to replace.
+ Object.defineProperty(pc.peer, 'open', { get: () => false, configurable: true });
+ return pc.peer.id;
+ });
+ await emitOnPeer(page, 'close');
+ // attempt 1 of the signaling schedule is 800ms +/-25%
+ await page.waitForTimeout(2500);
+ const rebuilt = await page.evaluate(() => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ return { fresh: pc.peer !== window.__sigOld, id: pc.peer?.id ?? '', open: !!pc.peer?.open };
+ });
+ h.check(rebuilt.fresh, 'a closed peer is REBUILT rather than mourned (a new Peer object)');
+ h.check(
+ rebuilt.id === beforeClose,
+ `the rebuilt peer keeps the same id, so the invite link still works (${rebuilt.id})`
+ );
+ const reopened = await page
+ .waitForFunction(
+ () => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ return !!pc?.peer?.open;
+ },
+ { timeout: 20000 }
+ )
+ .then(() => true)
+ .catch(() => false);
+ h.check(reopened, 'the rebuilt link opens against the real signaling server');
+
+ // ---- 5. online / visibilitychange retry NOW and reset the schedule ------------------
+ // The peer is genuinely open here, and `retryNow` correctly does nothing for an open
+ // link — so shadow the three flags it reads to stage a down link, then restore them.
+ await page.evaluate(() => {
+ let pc = null;
+ window.__stores.peers.subscribe((v) => (pc = v))();
+ const p = pc.peer;
+ window.__sig = { calls: 0, pc };
+ Object.defineProperty(p, 'open', { get: () => false, configurable: true });
+ Object.defineProperty(p, 'disconnected', { get: () => true, configurable: true });
+ Object.defineProperty(p, 'destroyed', { get: () => false, configurable: true });
+ p.reconnect = () => window.__sig.calls++;
+ pc.reconnectAttempts = 5;
+ });
+ const onOnline = await page.evaluate(() => {
+ const pc = window.__sig.pc;
+ const p = pc.peer; // whatever the app holds NOW, not what was stubbed at setup
+ Object.defineProperty(p, 'open', { get: () => false, configurable: true });
+ Object.defineProperty(p, 'disconnected', { get: () => true, configurable: true });
+ Object.defineProperty(p, 'destroyed', { get: () => false, configurable: true });
+ let calls = 0;
+ p.reconnect = () => calls++;
+ pc.reconnectAttempts = 5;
+ window.dispatchEvent(new Event('online')); // listeners run synchronously
+ return { calls, attempts: pc.reconnectAttempts };
+ });
+ h.check(onOnline.calls === 1, 'an `online` event retries immediately instead of waiting out the backoff');
+ h.check(
+ onOnline.attempts === 0,
+ '…and RESETS the schedule (the wait is for a server that is down, not a link that just came back)'
+ );
+
+ const onVisible = await page.evaluate(() => {
+ const pc = window.__sig.pc;
+ const p = pc.peer;
+ Object.defineProperty(p, 'open', { get: () => false, configurable: true });
+ Object.defineProperty(p, 'disconnected', { get: () => true, configurable: true });
+ Object.defineProperty(p, 'destroyed', { get: () => false, configurable: true });
+ let calls = 0;
+ p.reconnect = () => calls++;
+ document.dispatchEvent(new Event('visibilitychange'));
+ return { calls, hidden: document.hidden };
+ });
+ h.check(
+ onVisible.calls === 1,
+ `a tab becoming visible retries too, a lid or a phone lock ends here (${onVisible.calls} retries, document.hidden=${onVisible.hidden})`
+ );
+
+ await page.evaluate(() => {
+ const p = window.__sig.pc.peer;
+ delete p.open;
+ delete p.disconnected;
+ delete p.destroyed;
+ });
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs
new file mode 100644
index 00000000..e5422261
--- /dev/null
+++ b/tests/e2e/storage-hardening.test.cjs
@@ -0,0 +1,566 @@
+// 27-H (hardening audit M3, M4, M5, M9) — STORAGE THAT FAILS OUT LOUD.
+//
+// Four things this covers, each of which used to fail silently:
+// 1. an IndexedDB transaction that ABORTS or STALLS now rejects, instead of leaving
+// its caller awaiting a promise that never settles
+// 2. autosave cannot re-enter itself, measures its own export, backs off when the
+// scene gets expensive, and raises a STICKY toast when the disk is full
+// 3. `safeStorage` keeps working when `localStorage` throws (Safari private mode, a
+// full quota), so a setting still applies for the session
+// 4. the microphone is released when voice goes off
+//
+// Run: APP_URL=https://theprototype.app:5176/ npm run e2e -- storage-hardening
+const h = require('./helpers.cjs');
+
+h.run(async () => {
+ // A FAKE CAPTURE DEVICE, for section 4: the microphone checks read `track.readyState`
+ // on a real MediaStream, which headless Chromium will not produce without it — and a
+ // stubbed stream would be asserting a mock rather than the release.
+ const browser = await h.launch({
+ args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream']
+ });
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- 1. a transaction always settles -----------------------------------------------
+ const seams = await A.page.evaluate(
+ () => typeof window.__stores.idb?.debugForceNextTx === 'function' && typeof window.__stores.idb?.debugTimeoutMs === 'function'
+ );
+ h.check(seams, 'premise: the idb test seams are reachable');
+
+ const wrote = await A.page.evaluate(async () => {
+ try {
+ await window.__stores.idb.idbPut('27h-probe', { hello: 'world' });
+ const back = await window.__stores.idb.idbGet('27h-probe');
+ return back?.hello ?? null;
+ } catch (e) {
+ return 'threw: ' + e;
+ }
+ });
+ h.check(wrote === 'world', `premise: an ordinary put/get round trip still works (${wrote})`);
+
+ // THE FINDING. `tx.abort()` fires `onabort` and NOTHING else — no `oncomplete`, no
+ // `onerror` — so the old wrapper's promise stayed pending forever. The assertion is
+ // that the put REJECTS, not that it resolves: an abort is a failure and has to reach
+ // the caller as one.
+ // The probe RACES a 5s timer so the counterfactual reads as a clean failure rather
+ // than a harness crash: with `tx.onabort` removed this promise never settles, and
+ // "still waiting" is exactly the bug's name.
+ const aborted = await A.page.evaluate(async () => {
+ const t0 = performance.now();
+ window.__stores.idb.debugForceNextTx('abort');
+ const put = window.__stores.idb
+ .idbPut('27h-abort', { n: 1 })
+ .then(() => ({ outcome: 'resolved', message: '' }))
+ .catch((error) => ({ outcome: 'rejected', message: String(error && error.message) }));
+ const result = await Promise.race([
+ put,
+ new Promise((resolve) => setTimeout(() => resolve({ outcome: 'still waiting', message: '' }), 5000))
+ ]);
+ return { ...result, ms: performance.now() - t0 };
+ });
+ h.check(
+ aborted.outcome === 'rejected',
+ `an aborted transaction REJECTS rather than hanging (${aborted.outcome} in ${Math.round(aborted.ms)}ms)`
+ );
+ h.check(
+ /abort/i.test(aborted.message || ''),
+ `and it says an abort is what happened ("${aborted.message}")`
+ );
+ h.check(
+ aborted.ms < 1000,
+ `and it says so immediately, not after the 10s bound (${Math.round(aborted.ms)}ms)`
+ );
+
+ // The other half: an operation the browser never reports on at all. `'stall'` removes
+ // every handler the transaction could settle through, which IS the original bug — the
+ // timeout is what turns it into a failure a caller can report.
+ const stalled = await A.page.evaluate(async () => {
+ window.__stores.idb.debugTimeoutMs(400);
+ const t0 = performance.now();
+ window.__stores.idb.debugForceNextTx('stall');
+ const put = window.__stores.idb
+ .idbPut('27h-stall', { n: 2 })
+ .then(() => ({ outcome: 'resolved', timedOut: false, message: '' }))
+ .catch((error) => ({
+ outcome: 'rejected',
+ timedOut: !!(error && error.timedOut),
+ message: String(error && error.message)
+ }));
+ const result = await Promise.race([
+ put,
+ new Promise((resolve) =>
+ setTimeout(() => resolve({ outcome: 'still waiting', timedOut: false, message: '' }), 5000)
+ )
+ ]);
+ window.__stores.idb.debugTimeoutMs(null);
+ return { ...result, ms: performance.now() - t0 };
+ });
+ h.check(
+ stalled.outcome === 'rejected' && stalled.timedOut === true,
+ `a transaction that never reports back is bounded and rejects (${stalled.outcome}, timedOut=${stalled.timedOut})`
+ );
+ h.check(
+ stalled.ms >= 350 && stalled.ms < 3000,
+ `and it waits the bound it was given, no more (${Math.round(stalled.ms)}ms for a 400ms bound)`
+ );
+
+ // A failure must not disable storage for the rest of the session — the abort and the
+ // stall above both went through the CACHED connection, so this is also the check that
+ // the cache is not poisoned by them.
+ const recovered = await A.page.evaluate(async () => {
+ try {
+ await window.__stores.idb.idbPut('27h-after', { n: 3 });
+ const back = await window.__stores.idb.idbGet('27h-after');
+ return back?.n ?? null;
+ } catch (e) {
+ return 'threw: ' + e;
+ }
+ });
+ h.check(recovered === 3, `storage still works after both failures (${recovered})`);
+
+ // The cache. Every op used to open its own connection, and a storage scan makes a few
+ // hundred in a burst. Counted at the source rather than inferred from timing.
+ const opens = await A.page.evaluate(async () => {
+ const real = indexedDB.open.bind(indexedDB);
+ let count = 0;
+ // @ts-ignore - deliberate instrumentation
+ indexedDB.open = (...args) => {
+ count++;
+ return real(...args);
+ };
+ try {
+ await window.__stores.idb.idbGet('27h-probe'); // warm, in case nothing had opened yet
+ const warm = count;
+ for (let i = 0; i < 20; i++) await window.__stores.idb.idbGet('27h-probe');
+ return { warm, after: count };
+ } finally {
+ // @ts-ignore
+ indexedDB.open = real;
+ }
+ });
+ h.check(
+ opens.after === opens.warm,
+ `20 reads reuse one connection instead of opening 20 (${opens.after - opens.warm} new opens)`
+ );
+
+ // WHY 10s IS THE RIGHT BOUND, measured rather than assumed: the largest write this app
+ // can make is an Explorer import at its own 25MB cap (the autosave ceiling is 50MB of
+ // JSON, which structured-clones comparably). If this ever approaches the bound, the
+ // timeout would start failing legitimate saves — so the margin is asserted, not hoped
+ // for.
+ const big = await A.page.evaluate(async () => {
+ const bytes = new Uint8Array(25 * 1024 * 1024);
+ for (let i = 0; i < bytes.length; i += 4096) bytes[i] = i & 255; // not all-zero
+ const t0 = performance.now();
+ await window.__stores.idb.idbPut('27h-big', bytes);
+ const ms = performance.now() - t0;
+ await window.__stores.idb.idbDelete('27h-big');
+ return { ms, bound: window.__stores.idb.OP_TIMEOUT_MS };
+ });
+ h.check(
+ big.ms * 5 < big.bound,
+ `a 25MB put has at least 5x headroom under the bound (${Math.round(big.ms)}ms of ${big.bound}ms)`
+ );
+
+ // ---- 2. autosave: one at a time, adaptive, and loud when it fails ------------------
+ // A snapshot needs something to snapshot: `saveSnapshot` refuses to overwrite a good
+ // snapshot with emptiness, so an empty scene never writes at all.
+ await A.page.evaluate(() => {
+ for (let i = 0; i < 6; i++)
+ window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [i * 2 - 5, 0.5, -4]);
+ });
+ await A.page.waitForTimeout(1200);
+
+ const cadence = await A.page.evaluate(() => {
+ const f = window.__stores.autosave.cadenceFor;
+ return { at0: f(0), at150: f(150), at151: f(151), at300: f(300), at700: f(700), huge: f(1e9) };
+ });
+ h.check(
+ cadence.at0 === 30_000 && cadence.at150 === 30_000,
+ `a cheap export leaves the 30s cadence alone (${cadence.at0} / ${cadence.at150})`
+ );
+ h.check(
+ cadence.at151 === 60_000 && cadence.at300 === 60_000 && cadence.at700 === 240_000,
+ `past 150ms it doubles per doubling of the cost (151ms -> ${cadence.at151}, 300 -> ${cadence.at300}, 700 -> ${cadence.at700})`
+ );
+ h.check(cadence.huge === 300_000, `and it caps at 5 minutes (${cadence.huge})`);
+
+ // The estimate. WHY IT EXISTS: the probe it replaces was a full `JSON.stringify` of
+ // everything, thrown away immediately, purely to learn a number — so the property that
+ // matters is not accuracy, it is COST.
+ const sizing = await A.page.evaluate(() => {
+ const big = 'A'.repeat(4 * 1024 * 1024);
+ const snapshot = {
+ scene: { buffers: [{ uri: big }], images: [], nodes: new Array(500).fill({ name: 'n' }) },
+ animated: [{ bytes: big }],
+ multiMaterial: [],
+ nodes: new Array(50).fill({ id: 'n' })
+ };
+ const t0 = performance.now();
+ let bytes = 0;
+ for (let i = 0; i < 20; i++) bytes = window.__stores.autosave.estimateSnapshotBytes(snapshot);
+ const estimateMs = (performance.now() - t0) / 20;
+ const t1 = performance.now();
+ const probe = JSON.stringify(snapshot).length;
+ const probeMs = performance.now() - t1;
+ return { bytes, probe, estimateMs, probeMs };
+ });
+ h.check(
+ sizing.bytes > 8 * 1024 * 1024 && sizing.bytes < sizing.probe * 1.5,
+ `the estimate is in the right neighbourhood (${sizing.bytes} vs a real ${sizing.probe})`
+ );
+ h.check(
+ sizing.estimateMs * 20 < sizing.probeMs,
+ `and it is at least 20x cheaper than the stringify it replaced (${sizing.estimateMs.toFixed(3)}ms vs ${sizing.probeMs.toFixed(1)}ms)`
+ );
+
+ // ONE EXPORT AT A TIME. `debugRequestSave` is what the debounce timer calls — including
+ // the re-entrancy refusal, which `saveNow` deliberately skips (it waits its turn).
+ const reentry = await A.page.evaluate(async () => {
+ const a = window.__stores.autosave;
+ let before = null;
+ a.autosaveStatus.subscribe((v) => (before = v))();
+ const all = [a.debugRequestSave(), a.debugRequestSave(), a.debugRequestSave()];
+ const duringFirst = a.isSaving();
+ await Promise.all(all);
+ let after = null;
+ a.autosaveStatus.subscribe((v) => (after = v))();
+ return {
+ duringFirst,
+ writes: after.writes - before.writes,
+ coalesced: after.coalesced - before.coalesced,
+ exportMs: after.lastExportMs,
+ debounceMs: after.debounceMs
+ };
+ });
+ h.check(reentry.duringFirst === true, 'premise: a save really was in flight');
+ h.check(
+ reentry.writes === 1,
+ `three ticks during one save write ONE snapshot, not three (${reentry.writes})`
+ );
+ h.check(
+ reentry.coalesced === 2,
+ `and the other two are folded into it rather than starting their own export (${reentry.coalesced})`
+ );
+
+ // The cadence is DERIVED from that measurement, so the relation holds whatever the
+ // host's speed — which is the only honest way to assert it on a machine whose export
+ // cost is not ours to fix.
+ const derived = await A.page.evaluate(() => {
+ const a = window.__stores.autosave;
+ let state = null;
+ a.autosaveStatus.subscribe((v) => (state = v))();
+ return { ms: state.lastExportMs, debounce: state.debounceMs, expected: a.cadenceFor(state.lastExportMs) };
+ });
+ h.check(
+ derived.ms > 0 && derived.debounce === derived.expected,
+ `the live cadence is the one that measurement implies (${Math.round(derived.ms)}ms -> ${derived.debounce}ms)`
+ );
+
+ // A change made DURING a save is NOT in the bytes that save wrote, so the save must
+ // not mark it saved (the held-body `lastWritten` rule, one domain over). The window is
+ // real and it is the GLTF export, which is the slow part — which is also why the
+ // pulse has to be read before the export rather than beside the write.
+ const duringSave = await A.page.evaluate(async () => {
+ const a = window.__stores.autosave;
+ const settle = a.saveNow();
+ // synchronously after the save has begun: `markAtStart` is already taken
+ a.markAnnotationsDirty();
+ await settle;
+ return { dirty: a.isDirty() };
+ });
+ h.check(
+ duringSave.dirty === true,
+ `an edit made while a snapshot is being written stays unsaved (${duringSave.dirty})`
+ );
+ // and the ordinary case still clears, or the flag would be stuck on forever
+ const afterSave = await A.page.evaluate(async () => {
+ await window.__stores.autosave.saveNow();
+ return window.__stores.autosave.isDirty();
+ });
+ h.check(afterSave === false, `...while a quiet save does clear it (${afterSave})`);
+
+ // A FAILED AUTOSAVE IS SAID OUT LOUD. This used to reach `console.log` and stop there,
+ // so a full disk meant crash recovery had silently switched itself off. The quota error
+ // is raised through the idb seam because a headless origin is granted tens of gigabytes
+ // and cannot honestly be filled.
+ const quota = await A.page.evaluate(async () => {
+ window.__stores.toastStore.set([]);
+ window.__stores.idb.debugForceNextTx('quota');
+ await window.__stores.autosave.saveNow();
+ let toasts = [];
+ window.__stores.toastStore.subscribe((v) => (toasts = v))();
+ let state = null;
+ window.__stores.autosave.autosaveStatus.subscribe((v) => (state = v))();
+ const card = toasts.find((t) => t && t.id === 'autosave-failed');
+ return {
+ found: !!card,
+ sticky: !!card?.sticky,
+ text: card?.text ?? '',
+ actions: (card?.actions ?? []).map((entry) => entry.label),
+ lastError: state.lastError
+ };
+ });
+ h.check(quota.found, 'a full disk raises a toast instead of a console line');
+ h.check(quota.sticky, '...and it is STICKY — a 5s toast about losing work is one nobody reads');
+ h.check(
+ /room left/i.test(quota.text) && /recovery/i.test(quota.text),
+ `...saying what it means for crash recovery ("${quota.text}")`
+ );
+ h.check(
+ quota.actions.includes('Manage storage'),
+ `...and carrying the way to act on it (${JSON.stringify(quota.actions)})`
+ );
+ h.check(
+ /Quota/i.test(String(quota.lastError)),
+ `...and the diagnostics bundle records why (${quota.lastError})`
+ );
+
+ // and it clears itself once a save works again, or it is a permanent scar
+ const cleared = await A.page.evaluate(async () => {
+ await window.__stores.autosave.saveNow();
+ let toasts = [];
+ window.__stores.toastStore.subscribe((v) => (toasts = v))();
+ let state = null;
+ window.__stores.autosave.autosaveStatus.subscribe((v) => (state = v))();
+ return { still: toasts.some((t) => t && t.id === 'autosave-failed'), lastError: state.lastError };
+ });
+ h.check(
+ !cleared.still && cleared.lastError === null,
+ 'a later successful save takes the warning back down'
+ );
+
+ // The Storage panel says what the cadence currently is — an adaptive interval nobody
+ // can see is indistinguishable from autosave being broken.
+ // NOT a page-side `import()` of the module path: once vite has timestamped the app's
+ // own copy that binds a SECOND instance, whose stores nothing is rendering — the
+ // documented HMR module-identity trap, which cost two runs here before it was spotted.
+ const panel = await A.page.evaluate(() => {
+ window.__stores.storageUsage.openStorageModal();
+ return true;
+ });
+ h.check(panel, 'premise: the Storage panel opens');
+ await A.page.waitForSelector('#storage-autosave', { timeout: 15000 });
+ const line = await A.page.evaluate(() => {
+ const el = document.querySelector('#storage-autosave');
+ return {
+ text: el ? el.textContent.replace(/\s+/g, ' ').trim() : '',
+ cadence: document.querySelector('#storage-autosave-cadence')?.textContent ?? '',
+ cost: document.querySelector('#storage-autosave-cost')?.textContent ?? ''
+ };
+ });
+ h.check(
+ /seconds|minute/.test(line.cadence),
+ `the panel names the current cadence in words ("${line.cadence}")`
+ );
+ h.check(/ms$/.test(line.cost), `...and what the last snapshot cost to prepare ("${line.cost}")`);
+ await A.page.evaluate(() => window.__stores.storageUsage.storageModalOpen.set(false));
+
+ // ---- 3. safeStorage: a broken localStorage no longer kills its caller ---------------
+ // SAFARI PRIVATE MODE, simulated where the browser really fails: `Storage.prototype
+ // .setItem` throws. Stubbing the PROTOTYPE rather than our own module is the point —
+ // everything downstream, including the ~500 codemodded call sites, meets the real
+ // failure. Restored immediately afterwards, or every later section runs degraded.
+ const priv = await A.page.evaluate(() => {
+ const store = window.__stores.safeStorage;
+ store.debugResetStorage();
+ const real = Storage.prototype.setItem;
+ let threw = 0;
+ Storage.prototype.setItem = function () {
+ threw++;
+ throw new DOMException('The quota has been exceeded.', 'QuotaExceededError');
+ };
+ let raised = null;
+ let wrote = null;
+ try {
+ wrote = store.setItem('27h-pref', 'chosen');
+ } catch (error) {
+ raised = String(error);
+ }
+ const readBack = store.getItem('27h-pref');
+ const state = store.storageDebug();
+ // and the counterfactual, in the same broken world: the bare call this replaced
+ let bareThrew = false;
+ try {
+ localStorage.setItem('27h-pref-bare', 'chosen');
+ } catch {
+ bareThrew = true;
+ }
+ Storage.prototype.setItem = real;
+ return { raised, wrote, readBack, state, threw, bareThrew };
+ });
+ h.check(priv.threw > 0, `premise: the stub really is in the write path (${priv.threw} throws)`);
+ h.check(priv.bareThrew, 'premise: a bare localStorage.setItem throws in that world — the bug');
+ h.check(priv.raised === null, 'safeStorage.setItem does not throw, so the caller survives');
+ h.check(priv.wrote === false, '...and it says the write did not reach the disk');
+ h.check(
+ priv.readBack === 'chosen',
+ `...while the setting still APPLIES for this session (read back "${priv.readBack}")`
+ );
+ h.check(
+ priv.state.degraded === true && priv.state.failures > 0,
+ `...and the app knows it is degraded (${JSON.stringify(priv.state)})`
+ );
+
+ // A real setting, driven the way the app drives it, in the same broken world: the
+ // subscriber that persists it must still run its OTHER work. This is the actual bug —
+ // a throw inside a store subscriber kills the subscriber for the session.
+ const setting = await A.page.evaluate(async () => {
+ const real = Storage.prototype.setItem;
+ Storage.prototype.setItem = function () {
+ throw new DOMException('The quota has been exceeded.', 'QuotaExceededError');
+ };
+ let raised = null;
+ try {
+ const { autosaveEnabled } = window.__stores.autosave;
+ autosaveEnabled.set(false);
+ autosaveEnabled.set(true);
+ } catch (error) {
+ raised = String(error);
+ }
+ Storage.prototype.setItem = real;
+ let value = null;
+ window.__stores.autosave.autosaveEnabled.subscribe((v) => (value = v))();
+ return { raised, value };
+ });
+ h.check(
+ setting.raised === null && setting.value === true,
+ `a setting toggled while storage is broken still applies (${setting.value}, raised ${setting.raised})`
+ );
+
+ // The whole codemod, asserted as a property rather than a diff: nothing in src/ calls
+ // localStorage directly any more, and CI fails on the next one that does.
+ const guard = await A.page.evaluate(() => ({
+ exposed: typeof window.__stores.safeStorage?.setItem === 'function',
+ diagnostics: window.__stores.diagnostics.bundle().sections?.storage ?? null
+ }));
+ h.check(guard.exposed, 'premise: safeStorage is the module the app is using');
+ h.check(
+ guard.diagnostics && typeof guard.diagnostics.degraded === 'boolean',
+ `the diagnostics bundle carries whether persistence is working (${JSON.stringify(guard.diagnostics)})`
+ );
+ await A.page.evaluate(() => window.__stores.safeStorage.debugResetStorage());
+
+ // ---- 4. the microphone is given back -----------------------------------------------
+ // A fake device, so a real MediaStream with real tracks exists to be stopped — the
+ // whole check is about `track.readyState`, and a stub would be asserting a mock.
+ const seam = await A.page.evaluate(() => typeof window.__stores.voiceChat?.voiceDebug === 'function');
+ h.check(seam, 'premise: the voice seam is reachable');
+
+ const idle = await A.page.evaluate(() => window.__stores.voiceChat.voiceDebug());
+ h.check(
+ idle.stream === false && idle.polling === false,
+ `with no mic and no peers nothing is claimed and nothing is polling (${JSON.stringify(idle)})`
+ );
+
+ const on = await A.page.evaluate(async () => {
+ await window.__stores.voiceChat.toggleMic();
+ return window.__stores.voiceChat.voiceDebug();
+ });
+ h.check(on.stream === true && on.live === 1, `premise: the mic really opened (${on.live} live track)`);
+ h.check(on.polling === true, 'the speaking poll runs while there is audio to measure');
+
+ const off = await A.page.evaluate(async () => {
+ const before = window.__stores.voiceChat.voiceDebug();
+ await window.__stores.voiceChat.toggleMic();
+ const after = window.__stores.voiceChat.voiceDebug();
+ return { before, after };
+ });
+ h.check(
+ off.after.stream === false,
+ `turning the mic off releases the stream rather than muting a live track (${JSON.stringify(off.after)})`
+ );
+ h.check(
+ off.after.live === 0,
+ "...so the tab's recording indicator goes out and the device is free for another app"
+ );
+ h.check(off.after.polling === false, '...and the analyser loop stands down with it');
+ h.check(
+ off.after.analysers === 0,
+ `...and the analyser it was feeding is dropped too (${off.after.analysers})`
+ );
+
+ // PTT re-acquires, and does NOT release the instant the key comes up: re-acquiring
+ // costs a getUserMedia and a renegotiation with every peer, so an immediate release
+ // would make the next sentence arrive late. A few seconds is active use; forever is
+ // the bug this section is about.
+ const ptt = await A.page.evaluate(async () => {
+ await window.__stores.voiceChat.setPttHeld(true);
+ const held = window.__stores.voiceChat.voiceDebug();
+ await window.__stores.voiceChat.setPttHeld(false);
+ await new Promise((r) => setTimeout(r, 400));
+ const justAfter = window.__stores.voiceChat.voiceDebug();
+ await new Promise((r) => setTimeout(r, 4200));
+ const settled = window.__stores.voiceChat.voiceDebug();
+ return { held, justAfter, settled };
+ });
+ h.check(ptt.held.stream === true && ptt.held.live === 1, 'push-to-talk re-acquires the device');
+ h.check(ptt.justAfter.stream === true, '...and does not drop it the instant the key comes up');
+ h.check(
+ ptt.settled.stream === false && ptt.settled.live === 0,
+ `...but hands it back once the hold is over (${JSON.stringify(ptt.settled)})`
+ );
+
+ // leaving a session is the other half of the report: nothing in the peer layer used to
+ // touch voice at all
+ const left = await A.page.evaluate(async () => {
+ await window.__stores.voiceChat.toggleMic();
+ const before = window.__stores.voiceChat.voiceDebug();
+ let peer = null;
+ window.__stores.peers.subscribe((/** @type {any} */ v) => (peer = v))();
+ peer.leaveSession();
+ return { before, after: window.__stores.voiceChat.voiceDebug() };
+ });
+ h.check(left.before.stream === true, 'premise: the mic was open when the session ended');
+ h.check(
+ left.after.stream === false && left.after.live === 0,
+ `leaving the session hands the microphone back (${JSON.stringify(left.after)})`
+ );
+
+ // ---- 5. releasing the device must not cost the session its voice --------------------
+ // THE RISK THIS CHANGE INTRODUCES, asserted rather than reasoned about: releasing the
+ // stream closes our OUTGOING MediaConnections (they carry it, and `callPeer` skips a
+ // peer that already has one, so leaving a dead channel up would make the next
+ // re-acquire reach nobody). So the thing to prove is that turning the mic back on
+ // really does call everybody again.
+ const B = await h.setupPage(browser, 'B');
+ await h.connect(B, A);
+
+ const voiceOf = (peer) => peer.page.evaluate(() => window.__stores.voiceChat.voiceDebug());
+
+ await A.page.evaluate(() => window.__stores.voiceChat.toggleMic());
+ await h.eventually(() => voiceOf(B), (v) => v.incoming > 0, "premise: A's first mic-on reaches B", 20000);
+
+ await A.page.evaluate(() => window.__stores.voiceChat.toggleMic());
+ const released = await voiceOf(A);
+ h.check(
+ released.stream === false && released.outgoing === 0,
+ `mic-off releases the device AND the channel that carried it (${JSON.stringify(released)})`
+ );
+
+ await A.page.evaluate(() => window.__stores.voiceChat.toggleMic());
+ await h.eventually(
+ () => voiceOf(A),
+ (v) => v.stream === true && v.outgoing > 0,
+ 'turning the mic back on re-establishes the call, so voice survives a release',
+ 20000
+ );
+ await h.eventually(
+ () => voiceOf(B),
+ (v) => v.incoming > 0,
+ '...and the peer has a live incoming call again',
+ 20000
+ );
+ const restored = await voiceOf(A);
+ h.check(
+ restored.stream === true && restored.live === 1 && restored.outgoing > 0,
+ `...with a live device behind it (${JSON.stringify(restored)})`
+ );
+ await A.page.evaluate(() => window.__stores.voiceChat.releaseMic());
+
+ await A.page.evaluate(async () => {
+ for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k);
+ });
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/wire-hardening.test.cjs b/tests/e2e/wire-hardening.test.cjs
new file mode 100644
index 00000000..bf7b3ed7
--- /dev/null
+++ b/tests/e2e/wire-hardening.test.cjs
@@ -0,0 +1,151 @@
+// 27-A (hardening audit H1, M7, M11, M12) — ONE BAD MESSAGE USED TO KILL A CONNECTION.
+//
+// The dispatcher had no try/catch and trusted every payload's SHAPE. Three consequences,
+// all of them silent:
+// · a null / string / number fell through 440 lines of `else if` to the final branch,
+// `data.startsWith('/')`, and threw `not a function` out of `conn.on('data')` — where
+// peerjs swallowed it and nothing counted it;
+// · a malformed structural message (`hosts` that is not an array, `userdata` likewise)
+// threw INSIDE an applier, leaving state half-applied;
+// · a `move` carrying NaN applied cleanly and poisoned the object's matrix, after which
+// every consumer that measures the scene reads NaN with nothing naming the cause.
+//
+// And the branch that caught a raw string routed it into `sceneCommand`, where "/clear
+// all" wipes the scene AND re-broadcasts — a receiver re-broadcasting, which is golden
+// rule 1 inverted. Nothing sends raw strings, so it stood armed and unreachable.
+//
+// Driven through a STUBBED conn: `wireData` is the real dispatcher, so handing it a fake
+// connection object exercises the true path with no peer and no signaling server.
+//
+// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- wire-hardening
+const h = require('./helpers.cjs');
+
+const errorsFor = (page) => page.evaluate(() => window.__stores.wireErrors.wireErrors());
+
+h.run(async () => {
+ const browser = await h.launch();
+ const peer = await h.setupPage(browser, 'wire');
+ const page = peer.page;
+ await page.waitForFunction(() => !!window.__stores?.wireErrors && !!window.__stores?.wireValidate, {
+ timeout: 30000
+ });
+
+ // a real object to aim `move` at
+ await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box'));
+ await page.waitForTimeout(700);
+ const uuid = await page.evaluate(() => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ return group.children[group.children.length - 1].uuid;
+ });
+ h.check(!!uuid, `premise: there is an object to move (${uuid})`);
+
+ // ---- feed the REAL dispatcher a hostile stream through a stubbed conn --------------
+ await page.evaluate(
+ ({ id }) => {
+ const s = window.__stores;
+ s.wireErrors.clearWireErrors();
+ let pc = null;
+ s.peers.subscribe((v) => (pc = v))();
+ /** a minimal DataConnection: wireData only needs `peer` and `on` */
+ const handlers = {};
+ const conn = { peer: 'badpeer1', open: true, on: (ev, fn) => (handlers[ev] = fn), send() {} };
+ pc.wireData(conn);
+ window.__wire = { deliver: (m) => handlers.data(m) };
+ // order matters only in that the VALID move comes last: if the handler had been
+ // taken down by any earlier message, that one could not land.
+ window.__wire.deliver(null);
+ window.__wire.deliver('/clear all');
+ window.__wire.deliver(42);
+ window.__wire.deliver({ type: 'zzz-not-a-real-type' });
+ window.__wire.deliver({ type: 'hosts', hosts: 'not-an-array' });
+ window.__wire.deliver({ type: 'userdata', userdata: 'not-an-array' });
+ window.__wire.deliver({ type: 'locked', lockeditems: 'not-an-array' });
+ window.__wire.deliver({ type: 'move', uuid: id, pos: [NaN, 0, 0], rot: [0, 0, 0], scale: [1, 1, 1] });
+ window.__wire.deliver({ type: 'move', uuid: id, pos: [3, 4, 5], rot: [0, 0, 0], scale: [1, 1, 1] });
+ },
+ { id: uuid }
+ );
+ await page.waitForTimeout(400);
+
+ // ---- 1. the handler survived, and the LAST message applied -------------------------
+ const moved = await page.evaluate((id) => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ const o = group.getObjectByProperty('uuid', id);
+ return { pos: o.position.toArray(), finite: o.position.toArray().every(Number.isFinite) };
+ }, uuid);
+ h.check(
+ moved.pos[0] === 3 && moved.pos[1] === 4 && moved.pos[2] === 5,
+ `the connection survived every hostile message — the valid move still applied (${moved.pos})`
+ );
+ h.check(moved.finite, 'and the object never holds a non-finite coordinate');
+
+ // ---- 2. a page error would mean it threw out of the handler ------------------------
+ h.check(
+ h.pageErrors(peer).length === 0,
+ `nothing threw out of conn.on('data') (${JSON.stringify(h.pageErrors(peer)).slice(0, 120)})`
+ );
+
+ // ---- 3. every rejection was COUNTED, by kind ---------------------------------------
+ const errs = await errorsFor(page);
+ const kinds = errs.map((e) => e.type);
+ const total = errs.reduce((n, e) => n + e.count, 0);
+ h.check(total >= 6, `every bad message was counted, not swallowed (${total} across ${errs.length} kinds)`);
+ h.check(
+ kinds.filter((k) => k === 'shape').length === 1 && errs.find((e) => e.type === 'shape').count === 3,
+ `null, a string and a number are all rejected on SHAPE (${JSON.stringify(errs.find((e) => e.type === 'shape'))})`
+ );
+ h.check(
+ kinds.includes('unknown:zzz-not-a-real-type'),
+ `an unknown type is counted rather than falling through (${kinds.filter((k) => k.startsWith('unknown')).join(',')})`
+ );
+ for (const t of ['invalid:hosts', 'invalid:userdata', 'invalid:locked'])
+ h.check(kinds.includes(t), `a malformed structural message is refused before its applier: ${t}`);
+ // `move-nan` is recorded by the sanitiser, which runs BELOW the dispatcher in
+ // geometries.js and has no peer in scope — so it is filed under a pseudo-peer. Every
+ // failure the DISPATCHER records must name the sender.
+ h.check(
+ errs.filter((e) => e.type !== 'move-nan').every((e) => e.peerId === 'badpeer1'),
+ `every dispatcher failure is attributed to the peer that sent it (${[...new Set(errs.map((e) => e.peerId))].join(',')})`
+ );
+
+ // ---- 4. the NaN move is REFUSED, and never reaches the matrix ----------------------
+ // Two defences exist for this hazard and only the first can fire: the validator
+ // requires finite components, so a NaN `move` is rejected at the gate and
+ // `sanitizeTransform` (geometries.js) never runs for wire traffic. Rejection is the
+ // right answer for a transform — a partially repaired pose is one nobody sent, and the
+ // next message in a stream corrects it — so the sanitiser stays only as the backstop
+ // for callers that do not pass through the validator.
+ h.check(
+ kinds.includes('invalid:move'),
+ `a NaN transform is refused at the gate, before any applier (${kinds.filter((k) => k.includes('move')).join(',') || 'none'})`
+ );
+
+ // ---- 5. the raw-string branch is GONE ----------------------------------------------
+ // '/clear all' was delivered above. Under the old branch it would have wiped the scene
+ // and re-broadcast it; the object surviving IS the assertion.
+ const survived = await page.evaluate((id) => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ return !!group.getObjectByProperty('uuid', id);
+ }, uuid);
+ h.check(survived, "a peer's raw string can no longer reach sceneCommand and clear the scene");
+
+ // ---- 6. the counters reach the diagnostics bundle ------------------------------------
+ const section = await page.evaluate(() => window.__stores.diagnostics.bundle().sections.wire);
+ h.check(
+ !!section && section.total >= 6 && Array.isArray(section.failures),
+ `the wire counters ride the diagnostics bundle (${JSON.stringify(section).slice(0, 120)})`
+ );
+
+ // ---- 7. a departed peer's rows are dropped (golden rule 3) ---------------------------
+ await page.evaluate(() => window.__stores.wireErrors.dropWireErrors('badpeer1'));
+ const after = await errorsFor(page);
+ h.check(
+ after.every((e) => e.peerId !== 'badpeer1'),
+ `a departed peer's counters are dropped, and only theirs (${after.length} row(s) left: ${after.map((e) => e.peerId + '/' + e.type).join(',')})`
+ );
+
+ await h.finish(browser);
+});
diff --git a/tests/unit/connectionState.test.js b/tests/unit/connectionState.test.js
new file mode 100644
index 00000000..199be1c8
--- /dev/null
+++ b/tests/unit/connectionState.test.js
@@ -0,0 +1,74 @@
+import { describe, it, expect } from 'vitest';
+import {
+ sessionSize,
+ roomIsFull,
+ approvalRemaining,
+ APPROVAL_WINDOW_MS,
+ MAX_PENDING_APPROVALS,
+ SOFT_PEER_CAP_DEFAULT,
+ HARD_PEER_CAP
+} from '../../src/lib/connectionState.js';
+
+// 27-E. These two functions decide whether a session may take one more person, and they
+// exist because the same arithmetic was written out four times against the WRONG store.
+// They are pure and take a peer-shaped argument, so they need no browser and no mesh.
+
+describe('sessionSize', () => {
+ it('counts you even when you are alone', () => {
+ expect(sessionSize(null)).toBe(1);
+ expect(sessionSize(undefined)).toBe(1);
+ expect(sessionSize({})).toBe(1);
+ expect(sessionSize({ openedPeers: new Set() })).toBe(1);
+ });
+
+ it('counts the OPEN connections plus you', () => {
+ expect(sessionSize({ openedPeers: new Set(['a', 'b']) })).toBe(3);
+ });
+
+ it('is unmoved by a whitelist full of people who never arrived', () => {
+ // the trap: `userdata` is written at DIAL time. A peer object carrying a long
+ // roster and no open channel is a host sitting alone, and must read as 1.
+ const dialledNobodyArrived = { openedPeers: new Set(), userdata: new Array(16).fill(['x']) };
+ expect(sessionSize(dialledNobodyArrived)).toBe(1);
+ expect(roomIsFull(dialledNobodyArrived)).toBe(false);
+ });
+});
+
+describe('roomIsFull', () => {
+ /** @param {number} n */
+ const withPeers = (n) => ({ openedPeers: new Set(Array.from({ length: n }, (_, i) => 'p' + i)) });
+
+ it('refuses at the hard cap and not one person before it', () => {
+ expect(roomIsFull(withPeers(HARD_PEER_CAP - 2))).toBe(false); // 15 in the room
+ expect(roomIsFull(withPeers(HARD_PEER_CAP - 1))).toBe(true); // 16 in the room
+ });
+
+ it('never refuses an empty session', () => {
+ expect(roomIsFull(null)).toBe(false);
+ });
+});
+
+describe('the constants the UI and the wire both read', () => {
+ it('keeps the soft cap under the hard one, or the warning could never fire', () => {
+ expect(SOFT_PEER_CAP_DEFAULT).toBeLessThan(HARD_PEER_CAP);
+ expect(SOFT_PEER_CAP_DEFAULT).toBeGreaterThanOrEqual(2);
+ });
+
+ it('bounds the pending queue below the hard cap', () => {
+ expect(MAX_PENDING_APPROVALS).toBeGreaterThan(0);
+ expect(MAX_PENDING_APPROVALS).toBeLessThan(HARD_PEER_CAP);
+ });
+});
+
+describe('approvalRemaining', () => {
+ it('starts at the full window and floors at zero', () => {
+ expect(approvalRemaining(Date.now())).toBeGreaterThan(APPROVAL_WINDOW_MS - 1000);
+ expect(approvalRemaining(Date.now() - APPROVAL_WINDOW_MS - 5000)).toBe(0);
+ });
+
+ it('treats a missing stamp as expired rather than as forever', () => {
+ // an absent stamp used to read as epoch 0, which is the safe direction: expired.
+ expect(approvalRemaining(0)).toBe(0);
+ expect(approvalRemaining(undefined)).toBe(0);
+ });
+});
diff --git a/tests/unit/disposeTree.test.js b/tests/unit/disposeTree.test.js
new file mode 100644
index 00000000..d0bfdb3c
--- /dev/null
+++ b/tests/unit/disposeTree.test.js
@@ -0,0 +1,164 @@
+import { describe, it, expect } from 'vitest';
+import * as THREE from 'three';
+import { disposeTree, keepSet, removeAndDispose } from '../../src/lib/disposeTree.js';
+
+// 27-G (audit H6). Freeing GPU memory is easy; freeing memory that something ELSE still
+// draws with is the bug, and it does not throw — the other object just renders black,
+// later, with nothing to connect it to the delete that caused it. So these tests are
+// mostly about SHARING, and they need no renderer: a geometry, a material and a texture
+// are ordinary objects with a dispose() method and a disposal event.
+
+/** a mesh with its own geometry, material and texture
+ * @param {string} name */
+const mesh = (name) => {
+ const g = new THREE.BoxGeometry(1, 1, 1);
+ const t = new THREE.Texture();
+ const m = new THREE.MeshStandardMaterial({ map: t });
+ const o = new THREE.Mesh(g, m);
+ o.name = name;
+ return o;
+};
+
+/** record what actually got disposed, by listening for three's own event
+ * @param {...any} resources */
+const watch = (...resources) => {
+ const gone = new Set();
+ for (const r of resources) r.addEventListener('dispose', () => gone.add(r));
+ return gone;
+};
+
+describe('it frees what only the doomed object was using', () => {
+ it('disposes geometry, material and texture, and says so', () => {
+ const scene = new THREE.Scene();
+ const a = mesh('a');
+ scene.add(a);
+ const gone = watch(a.geometry, a.material, a.material.map);
+
+ const freed = removeAndDispose(scene, a);
+
+ expect(freed).toEqual({ geometries: 1, materials: 1, textures: 1 });
+ expect(gone.size).toBe(3);
+ expect(a.parent).toBe(null);
+ });
+
+ it('walks the whole subtree, not just the root', () => {
+ const scene = new THREE.Scene();
+ const parent = new THREE.Group();
+ const child = mesh('child');
+ parent.add(child);
+ scene.add(parent);
+
+ const freed = removeAndDispose(scene, parent);
+ expect(freed.geometries).toBe(1);
+ expect(freed.textures).toBe(1);
+ });
+
+ it('counts a material referenced twice only once', () => {
+ const scene = new THREE.Scene();
+ const shared = new THREE.MeshStandardMaterial();
+ const o = new THREE.Mesh(new THREE.BoxGeometry(), [shared, shared]);
+ scene.add(o);
+
+ const freed = removeAndDispose(scene, o);
+ expect(freed.materials).toBe(1);
+ });
+});
+
+describe('it refuses to free what the scene still holds', () => {
+ it('keeps a MATERIAL two objects share', () => {
+ const scene = new THREE.Scene();
+ const shared = new THREE.MeshStandardMaterial({ map: new THREE.Texture() });
+ const a = new THREE.Mesh(new THREE.BoxGeometry(), shared);
+ const b = new THREE.Mesh(new THREE.BoxGeometry(), shared);
+ scene.add(a, b);
+ const gone = watch(shared, shared.map);
+
+ const freed = removeAndDispose(scene, a);
+
+ expect(freed.geometries).toBe(1); // its own geometry goes
+ expect(freed.materials).toBe(0); // the shared material does NOT
+ expect(freed.textures).toBe(0); // nor the texture hanging off it
+ expect(gone.size).toBe(0);
+ });
+
+ it('keeps a GEOMETRY a clone shares — the clone() rule this repo already lives by', () => {
+ const scene = new THREE.Scene();
+ const a = mesh('a');
+ const ghost = a.clone(); // shares geometry AND material
+ scene.add(a, ghost);
+ const gone = watch(a.geometry, a.material);
+
+ const freed = removeAndDispose(scene, a);
+ expect(freed.geometries).toBe(0);
+ expect(freed.materials).toBe(0);
+ expect(gone.size).toBe(0);
+ });
+
+ it('keeps a TEXTURE shared by two different materials', () => {
+ const scene = new THREE.Scene();
+ const tex = new THREE.Texture();
+ const a = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial({ map: tex }));
+ const b = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial({ map: tex }));
+ scene.add(a, b);
+ const gone = watch(tex);
+
+ const freed = removeAndDispose(scene, a);
+ expect(freed.materials).toBe(1); // a's own material is not shared
+ expect(freed.textures).toBe(0); // the texture is
+ expect(gone.size).toBe(0);
+ });
+
+ it('THE COUNTERFACTUAL: with no keep set, the shared texture IS destroyed', () => {
+ // this is the bug the keep set exists to prevent, written down so the guard
+ // cannot quietly stop working
+ const tex = new THREE.Texture();
+ const a = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial({ map: tex }));
+ const gone = watch(tex);
+
+ disposeTree(a); // no keep set
+ expect(gone.has(tex)).toBe(true);
+ });
+});
+
+describe('it finds textures it was never told about', () => {
+ it('disposes any map-like slot, not a hardcoded list', () => {
+ // three grows new map slots release to release; a hardcoded list silently stops
+ // covering the newest one, and a leak that covers 90% looks like no leak
+ const scene = new THREE.Scene();
+ const m = new THREE.MeshStandardMaterial();
+ m.map = new THREE.Texture();
+ m.normalMap = new THREE.Texture();
+ m.roughnessMap = new THREE.Texture();
+ m.emissiveMap = new THREE.Texture();
+ const o = new THREE.Mesh(new THREE.BoxGeometry(), m);
+ scene.add(o);
+
+ const freed = removeAndDispose(scene, o);
+ expect(freed.textures).toBe(4);
+ });
+});
+
+describe('keepSet', () => {
+ it('excludes the doomed subtree, so its own resources are not protected from it', () => {
+ const scene = new THREE.Scene();
+ const a = mesh('a');
+ scene.add(a);
+ const keep = keepSet(scene, a);
+ expect(keep.has(a.geometry)).toBe(false);
+ });
+
+ it('takes an ARRAY of doomed roots, which is what clear-scene needs', () => {
+ const scene = new THREE.Scene();
+ const a = mesh('a');
+ const b = mesh('b');
+ scene.add(a, b);
+ const keep = keepSet(scene, [a, b]);
+ expect(keep.has(a.geometry)).toBe(false);
+ expect(keep.has(b.geometry)).toBe(false);
+ });
+
+ it('survives a null scene and a null target rather than throwing mid-delete', () => {
+ expect(() => keepSet(null, null)).not.toThrow();
+ expect(disposeTree(null)).toEqual({ geometries: 0, materials: 0, textures: 0 });
+ });
+});
diff --git a/tests/unit/hudRichText.test.js b/tests/unit/hudRichText.test.js
new file mode 100644
index 00000000..c3cacaf3
--- /dev/null
+++ b/tests/unit/hudRichText.test.js
@@ -0,0 +1,118 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parseHudRichText,
+ hudRichTextPlain,
+ RICH_RUN_LIMIT,
+ RICH_SOURCE_LIMIT
+} from '../../src/lib/hudRichText.js';
+
+// 27-I (audit L9). This module turns authored text into runs a HUD renders, and the text
+// can arrive in a REPLICATED document — so "it is simply never markup" is a security
+// property, not an implementation detail. The module's own header says as much:
+// "`` matches no token, so it comes out as one".
+//
+// The structural guarantee is in the type union itself: a run is text, icon or br. There
+// is no html kind, so there is nothing for a hostile string to become. These tests pin
+// that, plus the two limits that stop one element re-rendering 10k nodes per runtime tick.
+
+/** @param {string} s */
+const kinds = (s) => [...new Set(parseHudRichText(s).map((r) => r.kind))];
+/** Only text runs carry bold/italic/colour — narrow ONCE here rather than at every
+ * assertion, since `br` has no such fields and reading them off the union is an error.
+ * @param {string} s @returns {{kind: string, text: string, bold: boolean, italic: boolean, color: string}[]} */
+const textRuns = (s) => /** @type {any[]} */ (parseHudRichText(s).filter((r) => r.kind === 'text'));
+
+/** @param {string} s */
+const textOf = (s) =>
+ parseHudRichText(s)
+ .filter((r) => r.kind === 'text')
+ .map((r) => r.text)
+ .join('');
+
+describe('it is a total function', () => {
+ it('produces a valid run list for every input, including nonsense', () => {
+ for (const v of ['', ' ', '***', '<', '&', '{', '{}', '{color:}', null, undefined, 42])
+ expect(Array.isArray(parseHudRichText(/** @type {any} */ (v))), String(v)).toBe(true);
+ });
+
+ it('gives plain text exactly ONE text run', () => {
+ const runs = parseHudRichText('Score: 12');
+ expect(runs).toHaveLength(1);
+ expect(runs[0]).toMatchObject({ kind: 'text', text: 'Score: 12', bold: false, italic: false });
+ });
+});
+
+describe('hostile input can only ever be text', () => {
+ const hostile = [
+ '',
+ '',
+ 'hi',
+ '',
+ '{color:url(javascript:alert(1))}x{/color}',
+ '{color:var(--secret)}x',
+ '{icon:../../etc/passwd}'
+ ];
+
+ for (const input of hostile)
+ it('keeps as text: ' + input.slice(0, 28), () => {
+ const runs = parseHudRichText(input);
+ // no run may be anything but the three known kinds…
+ expect(runs.every((r) => r.kind === 'text' || r.kind === 'icon' || r.kind === 'br')).toBe(true);
+ // …and nothing here names a real icon or a valid colour, so it is all text
+ expect(runs.every((r) => r.kind === 'text')).toBe(true);
+ // the angle brackets survive AS CHARACTERS rather than being consumed as markup
+ if (input.startsWith('<')) expect(textOf(input)).toContain('<');
+ });
+
+ it('refuses a colour that is not a hex literal or a token name', () => {
+ expect(textRuns('{color:url(evil)}danger{/color}').every((r) => r.color === '')).toBe(true);
+ expect(textOf('{color:url(evil)}danger{/color}')).toContain('danger');
+ });
+
+ it('accepts the two colour forms it documents, and pops the stack', () => {
+ const runs = textRuns('{color:#f00}a{/color}b');
+ expect(runs.find((r) => r.text === 'a')?.color).toBe('#f00');
+ // the stack popped, so the colour does not leak onward
+ expect(runs.find((r) => r.text === 'b')?.color).toBe('');
+ expect(textRuns('{color:accent}a')[0]?.color).toBe('accent');
+ });
+
+ it('treats an unpartnered or unknown brace as literal characters', () => {
+ expect(textOf('{not-a-tag}hello')).toContain('{not-a-tag}');
+ expect(textOf('a { b')).toContain('{');
+ });
+});
+
+describe('the markup it DOES understand', () => {
+ it('reads ** as bold before * as italic', () => {
+ expect(textRuns('**x**')[0]).toMatchObject({ text: 'x', bold: true, italic: false });
+ expect(textRuns('*x*')[0]).toMatchObject({ text: 'x', italic: true, bold: false });
+ });
+
+ it('turns a newline into a br run', () => {
+ expect(kinds('a\nb')).toContain('br');
+ });
+});
+
+describe('the limits that keep one element cheap', () => {
+ it('caps the run list', () => {
+ const runs = parseHudRichText('*a*'.repeat(RICH_RUN_LIMIT * 3));
+ expect(runs.length).toBeLessThanOrEqual(RICH_RUN_LIMIT);
+ });
+
+ it('caps the source it reads at all', () => {
+ const total = textRuns('x'.repeat(RICH_SOURCE_LIMIT * 3)).reduce((n, r) => n + r.text.length, 0);
+ expect(total).toBeLessThanOrEqual(RICH_SOURCE_LIMIT);
+ });
+});
+
+describe('hudRichTextPlain', () => {
+ it('returns a string and keeps the words while dropping the markup', () => {
+ const plain = hudRichTextPlain('**Score**: {color:#f00}12{/color}');
+ expect(typeof plain).toBe('string');
+ expect(plain).toContain('Score');
+ expect(plain).toContain('12');
+ expect(plain).not.toContain('**');
+ expect(plain).not.toContain('{color');
+ });
+});
diff --git a/tests/unit/idbTimeout.test.js b/tests/unit/idbTimeout.test.js
new file mode 100644
index 00000000..faa9c1ea
--- /dev/null
+++ b/tests/unit/idbTimeout.test.js
@@ -0,0 +1,74 @@
+import { describe, it, expect, vi } from 'vitest';
+import { withTimeout, OP_TIMEOUT_MS } from '../../src/lib/idb.js';
+
+// 27-H (audit M3). `withTimeout` is the pure half of the IndexedDB wrapper — the half
+// that decides whether a caller ever hears back — so it is tested here, with no browser
+// and no IndexedDB at all. The e2e suite covers the parts that need a real transaction
+// (an abort rejecting, a stalled one hitting this bound).
+//
+// THE THING THAT MATTERS is the last describe: a promise that never settles must still
+// reject, because that is the exact shape of the bug this phase exists to fix.
+
+describe('a settled promise passes straight through', () => {
+ it('resolves with its own value', async () => {
+ await expect(withTimeout(Promise.resolve(7), 1000, 'get')).resolves.toBe(7);
+ });
+
+ it('rejects with its own error, not a timeout', async () => {
+ const boom = new Error('aborted');
+ await expect(withTimeout(Promise.reject(boom), 1000, 'put')).rejects.toBe(boom);
+ });
+});
+
+describe('a promise that never settles is rejected anyway', () => {
+ it('rejects with a labelled, marked timeout', async () => {
+ const never = new Promise(() => {});
+ const error = await withTimeout(never, 5, 'put').catch((e) => e);
+ expect(error).toBeInstanceOf(Error);
+ expect(error.timedOut).toBe(true);
+ expect(String(error.message)).toContain('put');
+ expect(String(error.message)).toContain('5ms');
+ });
+
+ // The counterfactual for the fix itself: without the bound, awaiting the same promise
+ // produces nothing at all. `Promise.race` against a short timer is how the test says
+ // "this never came back" without hanging the run.
+ it('would hang forever without it', async () => {
+ const never = new Promise(() => {});
+ const outcome = await Promise.race([
+ never.then(() => 'settled'),
+ new Promise((resolve) => setTimeout(() => resolve('still waiting'), 30))
+ ]);
+ expect(outcome).toBe('still waiting');
+ });
+});
+
+describe('the timer never outlives the operation', () => {
+ it('is cleared when the promise resolves first', async () => {
+ vi.useFakeTimers();
+ try {
+ await withTimeout(Promise.resolve('ok'), 10_000, 'get');
+ // a leaked 10s handle per read would keep a few hundred timers alive across
+ // one storage scan, and hold a node process open
+ expect(vi.getTimerCount()).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('is cleared when the promise rejects first', async () => {
+ vi.useFakeTimers();
+ try {
+ await withTimeout(Promise.reject(new Error('nope')), 10_000, 'put').catch(() => {});
+ expect(vi.getTimerCount()).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe('the default bound is a contract', () => {
+ it('is ten seconds — enough for any write this app can make', () => {
+ expect(OP_TIMEOUT_MS).toBe(10_000);
+ });
+});
diff --git a/tests/unit/loopGuard.test.js b/tests/unit/loopGuard.test.js
new file mode 100644
index 00000000..c715c453
--- /dev/null
+++ b/tests/unit/loopGuard.test.js
@@ -0,0 +1,121 @@
+import { describe, it, expect } from 'vitest';
+import { instrument, LOOP_LIMIT, GUARD_VAR } from '../../src/lib/loopGuard.js';
+
+// 27-D (audit C1). This is the guard that stops one peer's `while (true)` hanging every
+// peer in the session, and it is a pure string transform — so it is tested by RUNNING
+// its output, not by matching its text. A shape assertion would pass on code that throws
+// a SyntaxError the moment a user's script reaches it.
+
+/** Narrow the union ONCE here. `instrument` returns the transformed code OR a refusal,
+ * and an `expect('error' in out)` does not narrow it for the type checker — so every
+ * later `.code` read would be an error while passing perfectly at runtime.
+ * @param {string} src @returns {{ code: string, loops: number }} */
+const ok = (src) => {
+ const out = instrument(src);
+ if ('error' in out) throw new Error('refused: ' + out.error);
+ return out;
+};
+
+/** @param {string} src */
+const build = (src) => new Function(ok(src).code);
+
+describe('it produces code that still runs', () => {
+ it('leaves an ordinary loop result untouched', () => {
+ const fn = build('let n=0; for (let i=0;i<1000;i++) { n+=i; } return n;');
+ expect(fn()).toBe(499500);
+ });
+
+ it('handles an UNBRACED body by giving it braces', () => {
+ const fn = build('let n=0; for (let i=0;i<10;i++) n+=i; return n;');
+ expect(fn()).toBe(45);
+ });
+
+ it('guards nested loops without crossing their braces', () => {
+ const fn = build('let n=0; for(let i=0;i<3;i++) for(let j=0;j<3;j++) n++; return n;');
+ expect(fn()).toBe(9);
+ });
+
+ it('leaves code with no loops alone apart from the declaration', () => {
+ const out = ok('return 1 + 1;');
+ expect(out.loops).toBe(0);
+ expect(new Function(out.code)()).toBe(2);
+ });
+});
+
+describe('it stops a hang', () => {
+ it('throws out of a while(true) instead of freezing the session', () => {
+ expect(() => build('while (true) { }')()).toThrow(/Script loop limit/);
+ });
+
+ it('throws out of an unbraced runaway too', () => {
+ expect(() => build('let n=0; while (true) n++;')()).toThrow(/Script loop limit/);
+ });
+
+ it('throws out of a do/while', () => {
+ expect(() => build('do { } while (true)')()).toThrow(/Script loop limit/);
+ });
+
+ it('counts per RUN, so a fresh call starts from zero', () => {
+ const fn = build('let n=0; for(let i=0;i<10;i++){n++;} return n;');
+ expect(fn()).toBe(10);
+ expect(fn()).toBe(10); // not 20 — the declaration is inside the function body
+ });
+});
+
+describe('it knows code from text', () => {
+ it('does not instrument a loop keyword inside a string', () => {
+ const out = ok('return "while (true) {";');
+ expect(out.loops).toBe(0);
+ expect(new Function(out.code)()).toBe('while (true) {');
+ });
+
+ it('does not instrument one inside a comment', () => {
+ const out = ok('// while (true) { }\n/* for (;;) */\nreturn 7;');
+ expect(out.loops).toBe(0);
+ expect(new Function(out.code)()).toBe(7);
+ });
+
+ it('does not instrument one inside a template literal', () => {
+ const out = ok('return `for (;;) ${1 + 1}`;');
+ expect(out.loops).toBe(0);
+ expect(new Function(out.code)()).toBe('for (;;) 2');
+ });
+
+ it('reads a / as division, not as the start of a regex', () => {
+ const out = ok('const a = 10; const b = 2; return a / b / 1;');
+ expect(new Function(out.code)()).toBe(5);
+ });
+
+ it('reads a real regex as a regex, loop keywords and all', () => {
+ const out = ok('return /while (true)/.source;');
+ expect(out.loops).toBe(0);
+ expect(new Function(out.code)()).toBe('while (true)');
+ });
+
+ it('does not mistake an identifier ENDING in a keyword', () => {
+ const out = ok('const meanwhile = 1; const format = (x) => x; return meanwhile;');
+ expect(out.loops).toBe(0);
+ expect(new Function(out.code)()).toBe(1);
+ });
+});
+
+describe('it refuses what it cannot read', () => {
+ it('refuses an unterminated string rather than guessing', () => {
+ expect('error' in instrument('const s = "oops; while(true){}')).toBe(true);
+ });
+
+ it('refuses an unterminated block comment', () => {
+ expect('error' in instrument('/* while (true) {}')).toBe(true);
+ });
+
+ it('refuses an unbalanced loop header', () => {
+ expect('error' in instrument('while (true {}')).toBe(true);
+ });
+});
+
+describe('the constants the badge and the injected code share', () => {
+ it('keeps a limit high enough for real work and low enough to catch a hang', () => {
+ expect(LOOP_LIMIT).toBeGreaterThanOrEqual(100000);
+ expect(GUARD_VAR.startsWith('__')).toBe(true);
+ });
+});
diff --git a/tests/unit/meshBudget.test.js b/tests/unit/meshBudget.test.js
new file mode 100644
index 00000000..dd735b82
--- /dev/null
+++ b/tests/unit/meshBudget.test.js
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest';
+import * as budget from '../../src/lib/meshBudget.js';
+
+// 27-I (audit L9). meshBudget is the app's size policy for geometry work: what may be
+// committed, what may be streamed as a live preview, and how much undo memory the history
+// may hold. Its own header carries the MEASUREMENTS those numbers came from (12 MB over
+// the wire in 4.9 s; 66-83 ms to commit at the ceiling; 11.4 MB per history entry), which
+// is exactly the kind of constant that gets "tidied" by someone who has not read them.
+//
+// The point of a unit test here is not to re-assert the numbers — it is to pin the
+// RELATIONSHIPS between them, because those are what silently break: a live preview
+// ceiling above the commit ceiling would stream what can never be committed, and a history
+// budget smaller than one entry would evict the edit you just made.
+
+describe('the ceilings exist and are numbers', () => {
+ it('exports the four budgets', () => {
+ expect(typeof budget.MAX_SNAPSHOT).toBe('number');
+ expect(typeof budget.MAX_LIVE_PREVIEW).toBe('number');
+ expect(typeof budget.MAX_FACE_TRIS).toBe('number');
+ expect(typeof budget.HISTORY_BYTES).toBe('number');
+ });
+});
+
+describe('the relationships between them are the real contract', () => {
+ it('a LIVE PREVIEW ceiling is below the COMMIT ceiling', () => {
+ // otherwise a gesture streams previews of an edit that can never be committed —
+ // and the preview is the per-frame cost, so it is the one that must stay small.
+ expect(budget.MAX_LIVE_PREVIEW).toBeLessThan(budget.MAX_SNAPSHOT);
+ });
+
+ it('the face-partition budget scales WITH the snapshot ceiling, not below it', () => {
+ expect(budget.MAX_FACE_TRIS).toBeGreaterThanOrEqual(budget.MAX_SNAPSHOT);
+ });
+
+ it('the history budget holds more than one ceiling-sized entry', () => {
+ // a meshgeo entry stores a BEFORE and an AFTER, so one edit at the ceiling is
+ // ~2 x 4 bytes x MAX_SNAPSHOT. A budget under that would evict the newest step.
+ const oneEntryBytes = budget.MAX_SNAPSHOT * 4 * 2;
+ expect(budget.HISTORY_BYTES).toBeGreaterThan(oneEntryBytes);
+ });
+
+ it('every budget is positive and finite', () => {
+ for (const [name, v] of Object.entries(budget))
+ if (typeof v === 'number') {
+ expect(Number.isFinite(v), name).toBe(true);
+ expect(v, name).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/tests/unit/netBackoff.test.js b/tests/unit/netBackoff.test.js
new file mode 100644
index 00000000..60625ca6
--- /dev/null
+++ b/tests/unit/netBackoff.test.js
@@ -0,0 +1,61 @@
+import { describe, it, expect } from 'vitest';
+import { backoffDelay, backoffSchedule } from '../../src/lib/netBackoff.js';
+
+// 27-I (audit L9). netBackoff's own header calls itself "pure and deterministic … so it
+// unit-tests cleanly", and until now there was no unit layer to test it in — only an e2e
+// suite that spawns a browser to exercise arithmetic. 27-F then gave it jitter and an
+// unbounded max, which are exactly the options a wrong edit breaks silently.
+
+describe('the default schedule is a contract', () => {
+ it('is 500/1000/2000/4000 and then exhausted', () => {
+ expect(backoffSchedule()).toEqual([500, 1000, 2000, 4000]);
+ expect(backoffDelay(1)).toBe(500);
+ expect(backoffDelay(4)).toBe(4000);
+ expect(backoffDelay(5)).toBe(null);
+ expect(backoffDelay(0)).toBe(null);
+ });
+
+ it('is deterministic, because every peer computes the same one', () => {
+ expect(backoffSchedule()).toEqual(backoffSchedule());
+ });
+
+ it('never schedules a shorter wait than the attempt before it', () => {
+ const s = backoffSchedule({ max: 6, cap: 100000 });
+ expect(s.every((d, i) => i === 0 || d >= s[i - 1])).toBe(true);
+ });
+});
+
+describe('27-F: jitter is additive and inert by default', () => {
+ it('changes nothing unless asked for', () => {
+ expect(backoffDelay(1, { base: 1000 })).toBe(1000);
+ expect(backoffDelay(3, { base: 1000 })).toBe(4000);
+ });
+
+ it('spreads +/- a fraction, using an INJECTED rng so the test is not a coin toss', () => {
+ expect(backoffDelay(1, { base: 1000, jitter: 0.25, rng: () => 0 })).toBe(750);
+ expect(backoffDelay(1, { base: 1000, jitter: 0.25, rng: () => 0.5 })).toBe(1000);
+ expect(backoffDelay(1, { base: 1000, jitter: 0.25, rng: () => 1 })).toBe(1250);
+ });
+
+ it('clamps at zero, because a negative wait would hammer the server', () => {
+ expect(backoffDelay(1, { base: 100, jitter: 4, rng: () => 0 })).toBe(0);
+ });
+});
+
+describe('27-F: an unbounded retry', () => {
+ const unbounded = { base: 800, cap: 8000, max: Infinity };
+
+ it('always has a delay, and saturates at the cap rather than giving up', () => {
+ expect(backoffDelay(1, unbounded)).toBe(800);
+ expect(backoffDelay(5, unbounded)).not.toBe(null);
+ expect(backoffDelay(99, unbounded)).toBe(8000);
+ expect(backoffDelay(10_000, unbounded)).toBe(8000);
+ });
+
+ it('TERMINATES when asked for its schedule', () => {
+ // the pre-27-F module looped forever here — measured as RangeError: Invalid array
+ // length — because the loop bound was the unbounded max itself.
+ expect(backoffSchedule(unbounded)).toHaveLength(10);
+ expect(backoffSchedule({ ...unbounded, limit: 3 })).toHaveLength(3);
+ });
+});
diff --git a/tests/unit/qualityGovernor.test.js b/tests/unit/qualityGovernor.test.js
new file mode 100644
index 00000000..45508121
--- /dev/null
+++ b/tests/unit/qualityGovernor.test.js
@@ -0,0 +1,192 @@
+import { describe, it, expect } from 'vitest';
+import {
+ createGovernor,
+ overridesAt,
+ stepLabelsAt,
+ GOVERNOR_STEPS,
+ MAX_LEVEL,
+ FULL_QUALITY,
+ drawGapFor,
+ INGEST_DRAW_GAP_MS
+} from '../../src/lib/qualityGovernorCore.js';
+
+/**
+ * feed `ms`-long frames from `from` for `durationMs`; returns the time reached
+ * @param {ReturnType} gov @param {number} from @param {number} durationMs @param {number} ms
+ */
+function run(gov, from, durationMs, ms) {
+ let t = from;
+ while (t < from + durationMs) {
+ t += ms;
+ gov.noteFrame(ms, t);
+ }
+ return t;
+}
+
+describe('overridesAt', () => {
+ it('level 0 is full quality and every level is the sum of the steps before it', () => {
+ expect(overridesAt(0)).toEqual(FULL_QUALITY);
+ expect(overridesAt(1)).toEqual({ ...FULL_QUALITY, shadowsOff: true });
+ const top = overridesAt(MAX_LEVEL);
+ expect(top.dprScale).toBe(0.5);
+ expect(top.shadowsOff && top.aoOff && top.postOff && top.particlesCapped && top.presenceSlow).toBe(true);
+ });
+ it('a later resolution step replaces an earlier one, never compounds it', () => {
+ const at = GOVERNOR_STEPS.findIndex((s) => s.key === 'res72') + 1;
+ expect(overridesAt(at).dprScale).toBe(0.72);
+ });
+ it('SHADOWS come first: 26-E measured draw calls, not fill, as the cost', () => {
+ expect(GOVERNOR_STEPS[0].key).toBe('shadows');
+ });
+ it('clamps silly levels and names every step in effect', () => {
+ expect(overridesAt(-3)).toEqual(FULL_QUALITY);
+ expect(overridesAt(999)).toEqual(overridesAt(MAX_LEVEL));
+ expect(stepLabelsAt(2)).toEqual([GOVERNOR_STEPS[0].label, GOVERNOR_STEPS[1].label]);
+ });
+});
+
+describe('createGovernor', () => {
+ it('a slow HEAVY scene steps down once, then holds', () => {
+ const g = createGovernor();
+ let t = run(g, 0, 2100, 50);
+ expect(g.decide(t, { heavy: true }).moved).toBe('up');
+ expect(g.level()).toBe(1);
+ // the very next moment: no fresh window, no second step
+ t = run(g, t, 500, 50);
+ expect(g.decide(t, { heavy: true }).moved).toBe(null);
+ // after the hold with frames still slow: the next step
+ t = run(g, t, 2600, 50);
+ expect(g.decide(t, { heavy: true }).moved).toBe('up');
+ expect(g.level()).toBe(2);
+ });
+ it('the hitch a change itself causes is not evidence for the next step', () => {
+ const g = createGovernor();
+ let t = run(g, 0, 2100, 50);
+ expect(g.decide(t, { heavy: true }).moved).toBe('up');
+ // the recompile right after the step: a burst of slow frames and THREE long tasks —
+ // more than the long-task trigger allows, still inside its 5s window at the next
+ // decision, so without the settle window this alone would take another step
+ t = run(g, t, 500, 120);
+ g.noteLongTask(t - 400);
+ g.noteLongTask(t - 250);
+ g.noteLongTask(t - 100);
+ // then the reduced scene runs at a steady 30fps
+ t = run(g, t, 2600, 33.4);
+ expect(g.decide(t, { heavy: true }).moved).toBe(null);
+ expect(g.level()).toBe(1);
+ });
+ it('a slow LIGHT scene is never governed (the 26-G ruling: a slow machine is not an overloaded scene)', () => {
+ const g = createGovernor();
+ const t = run(g, 0, 5000, 400);
+ expect(g.decide(t, { heavy: false }).moved).toBe(null);
+ expect(g.level()).toBe(0);
+ });
+ it('a steady 30fps is NOT overloaded: vsync quantises it to 33.3-33.4ms (26-E)', () => {
+ const g = createGovernor();
+ let t = 0;
+ for (let i = 0; i < 150; i++) {
+ const ms = i % 2 ? 33.4 : 33.3;
+ t += ms;
+ g.noteFrame(ms, t);
+ }
+ expect(g.decide(t, { heavy: true }).moved).toBe(null);
+ });
+ it('a smooth heavy scene is left alone', () => {
+ const g = createGovernor();
+ const t = run(g, 0, 5000, 16.7);
+ expect(g.decide(t, { heavy: true }).moved).toBe(null);
+ });
+ it('decides nothing on a window that is not full yet', () => {
+ const g = createGovernor();
+ const t = run(g, 0, 600, 60);
+ expect(g.decide(t, { heavy: true }).moved).toBe(null);
+ });
+ it('long tasks alone can trigger a step', () => {
+ const g = createGovernor();
+ const t = run(g, 0, 3100, 16.7);
+ for (const at of [t - 3000, t - 2000, t - 1000]) g.noteLongTask(at);
+ const d = g.decide(t, { heavy: true });
+ expect(d.moved).toBe('up');
+ expect(d.reason).toBe('long tasks');
+ });
+ it('recovers one step after 10s of good frames, and not before', () => {
+ const g = createGovernor();
+ let t = run(g, 0, 2100, 50);
+ g.decide(t, { heavy: true });
+ t = run(g, t, 9000, 16.7);
+ expect(g.decide(t, { heavy: true }).moved).toBe(null);
+ t = run(g, t, 1500, 16.7);
+ expect(g.decide(t, { heavy: true }).moved).toBe('down');
+ expect(g.level()).toBe(0);
+ });
+ it('a PINNED level never walks back', () => {
+ const g = createGovernor();
+ let t = run(g, 0, 2100, 50);
+ g.decide(t, { heavy: true });
+ t = run(g, t, 20000, 16.7);
+ expect(g.decide(t, { heavy: true, pinned: true }).moved).toBe(null);
+ expect(g.level()).toBe(1);
+ });
+ it('a scene that becomes light is handed back without waiting for good frames', () => {
+ const g = createGovernor();
+ let t = run(g, 0, 2100, 50);
+ g.decide(t, { heavy: true });
+ t = run(g, t, 10500, 60);
+ expect(g.decide(t, { heavy: false }).reason).toBe('scene is light');
+ });
+ it('FLAPPING doubles the next recovery hold, and a later honest step resets it', () => {
+ const g = createGovernor();
+ let t = run(g, 0, 2100, 50);
+ g.decide(t, { heavy: true }); // up
+ t = run(g, t, 10500, 16.7);
+ g.decide(t, { heavy: true }); // down
+ // a walk down is a change too: the next step still waits out the 3s hold
+ t = run(g, t, 3100, 50);
+ expect(g.decide(t, { heavy: true }).moved).toBe('up'); // back up within 20s
+ expect(g.recoverHoldMs()).toBe(20000);
+ t = run(g, t, 10500, 16.7);
+ expect(g.decide(t, { heavy: true }).moved).toBe(null); // 10s is no longer enough
+ t = run(g, t, 10000, 16.7);
+ expect(g.decide(t, { heavy: true }).moved).toBe('down');
+ // much later, a heavier scene: not a flap
+ t = run(g, t + 60000, 2100, 50);
+ expect(g.decide(t, { heavy: true }).moved).toBe('up');
+ expect(g.recoverHoldMs()).toBe(10000);
+ });
+ it('forget() discards the evidence (a hidden tab, a pause)', () => {
+ const g = createGovernor();
+ const t = run(g, 0, 2100, 50);
+ g.forget();
+ expect(g.decide(t, { heavy: true }).moved).toBe(null);
+ });
+ it('never climbs past the last step', () => {
+ const g = createGovernor();
+ let t = 0;
+ for (let i = 0; i < MAX_LEVEL + 3; i++) {
+ t = run(g, t, 3100, 50);
+ g.decide(t, { heavy: true });
+ }
+ expect(g.level()).toBe(MAX_LEVEL);
+ });
+ it('uses the VR thresholds on the vr profile', () => {
+ const g = createGovernor();
+ const t = run(g, 0, 2100, 16.7);
+ expect(g.decide(t, { heavy: true, profile: 'vr' }).moved).toBe('up');
+ });
+});
+
+describe('drawGapFor (the ingest rule)', () => {
+ const base = { engaged: false, draining: true, backlog: 500, p95: 50 };
+ it('throttles drawing while a big batch drains through slow frames', () => {
+ expect(drawGapFor(base)).toBe(INGEST_DRAW_GAP_MS);
+ });
+ it('leaves a fast frame, a small backlog, or no drain alone', () => {
+ expect(drawGapFor({ ...base, p95: 16.7 })).toBe(0);
+ expect(drawGapFor({ ...base, backlog: 10 })).toBe(0);
+ expect(drawGapFor({ ...base, draining: false })).toBe(0);
+ });
+ it('is STICKY for the drain: throttled frames are cheap, and must not switch it off', () => {
+ expect(drawGapFor({ ...base, engaged: true, p95: 5, backlog: 1 })).toBe(INGEST_DRAW_GAP_MS);
+ expect(drawGapFor({ ...base, engaged: true, draining: false })).toBe(0);
+ });
+});
diff --git a/tests/unit/safeStorage.test.js b/tests/unit/safeStorage.test.js
new file mode 100644
index 00000000..25d61dee
--- /dev/null
+++ b/tests/unit/safeStorage.test.js
@@ -0,0 +1,202 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+ getItem,
+ setItem,
+ removeItem,
+ keys,
+ clear,
+ storageDebug,
+ debugResetStorage,
+ safeStorage,
+ get,
+ set,
+ remove
+} from '../../src/lib/safeStorage.js';
+
+// 27-H (audit M4). The whole value of this module is what it does when storage is
+// BROKEN, and every one of those states is reachable here with no browser: node has no
+// `localStorage` at all, and the two failure modes are a `setItem` that throws (Safari
+// private mode, a full quota) and a `localStorage` property that throws on ACCESS (a
+// sandboxed iframe, some enterprise policies) — the second of which every
+// `typeof localStorage === 'undefined'` guard in this codebase misses.
+
+/** a working stand-in, so the happy path is testable too @param {any} overrides */
+function fakeStorage(overrides = {}) {
+ /** @type {Map} */
+ const map = new Map();
+ return Object.assign(
+ {
+ /** @param {string} k */
+ getItem: (k) => (map.has(k) ? map.get(k) : null),
+ /** @param {string} k @param {any} v */
+ setItem: (k, v) => map.set(k, String(v)),
+ /** @param {string} k */
+ removeItem: (k) => map.delete(k),
+ clear: () => map.clear(),
+ get length() {
+ return map.size;
+ },
+ /** @param {number} i */
+ key: (i) => [...map.keys()][i] ?? null,
+ __map: map
+ },
+ overrides
+ );
+}
+
+/** @param {any} value */
+function install(value) {
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ get() {
+ if (typeof value === 'function') return value();
+ return value;
+ }
+ });
+}
+
+afterEach(() => {
+ // @ts-ignore - installed by `install()` above; node has no localStorage to begin with
+ delete globalThis.localStorage;
+ debugResetStorage();
+});
+
+beforeEach(() => debugResetStorage());
+
+describe('with no storage at all (SSR, or a browser that has none)', () => {
+ it('still remembers what you set, for this session', () => {
+ expect(setItem('theme', 'light')).toBe(false);
+ expect(getItem('theme')).toBe('light');
+ });
+
+ it('says so, rather than pretending', () => {
+ setItem('theme', 'light');
+ const state = storageDebug();
+ expect(state.available).toBe(false);
+ expect(state.degraded).toBe(true);
+ expect(state.fallbackKeys).toBe(1);
+ });
+
+ it('reads a key nobody set as null, not undefined', () => {
+ expect(getItem('never-set')).toBe(null);
+ });
+});
+
+describe('with working storage', () => {
+ it('writes through and keeps nothing in memory', () => {
+ const store = fakeStorage();
+ install(store);
+ expect(setItem('theme', 'dark')).toBe(true);
+ expect(store.__map.get('theme')).toBe('dark');
+ expect(storageDebug().fallbackKeys).toBe(0);
+ expect(storageDebug().degraded).toBe(false);
+ expect(getItem('theme')).toBe('dark');
+ });
+
+ it('coerces like localStorage does', () => {
+ install(fakeStorage());
+ setItem('count', 3);
+ expect(getItem('count')).toBe('3');
+ });
+
+ it('removes from both sides', () => {
+ const store = fakeStorage();
+ install(store);
+ setItem('theme', 'dark');
+ removeItem('theme');
+ expect(getItem('theme')).toBe(null);
+ expect(store.__map.has('theme')).toBe(false);
+ });
+});
+
+describe("Safari private mode: setItem throws, and that used to kill the caller's subscriber", () => {
+ it('does not throw, and the setting still applies', () => {
+ install(
+ fakeStorage({
+ setItem() {
+ throw new DOMException('QuotaExceededError', 'QuotaExceededError');
+ }
+ })
+ );
+ expect(() => setItem('theme', 'light')).not.toThrow();
+ expect(getItem('theme')).toBe('light');
+ const state = storageDebug();
+ expect(state.degraded).toBe(true);
+ expect(state.failures).toBe(1);
+ expect(state.lastError).toBe('QuotaExceededError');
+ });
+
+ it('the counterfactual: a bare call in the same place does throw', () => {
+ install(
+ fakeStorage({
+ setItem() {
+ throw new Error('nope');
+ }
+ })
+ );
+ expect(() => globalThis.localStorage.setItem('theme', 'light')).toThrow();
+ });
+
+ it('a later successful write makes real storage the truth again', () => {
+ let broken = true;
+ const store = fakeStorage({
+ /** @param {string} k @param {any} v */
+ setItem(k, v) {
+ if (broken) throw new Error('nope');
+ store.__map.set(k, String(v));
+ }
+ });
+ install(store);
+ setItem('theme', 'light');
+ expect(storageDebug().fallbackKeys).toBe(1);
+ broken = false;
+ setItem('theme', 'dark');
+ // the shadow is dropped, or it would outvote the real value forever
+ expect(storageDebug().fallbackKeys).toBe(0);
+ expect(getItem('theme')).toBe('dark');
+ });
+});
+
+describe('a sandboxed iframe: touching localStorage throws on ACCESS', () => {
+ it('is survived, which no `typeof localStorage` guard manages', () => {
+ install(() => {
+ throw new DOMException('The operation is insecure.', 'SecurityError');
+ });
+ expect(() => setItem('theme', 'light')).not.toThrow();
+ expect(() => getItem('theme')).not.toThrow();
+ expect(() => keys()).not.toThrow();
+ // the value is still readable — that is the promise — so read it BEFORE the two
+ // calls that legitimately drop the fallback
+ expect(getItem('theme')).toBe('light');
+ expect(storageDebug().available).toBe(false);
+ expect(() => removeItem('theme')).not.toThrow();
+ expect(() => clear()).not.toThrow();
+ });
+});
+
+describe('keys() is the union of both sides', () => {
+ it('lists real keys and fallen-back ones together', () => {
+ const store = fakeStorage({
+ /** @param {string} k @param {any} v */
+ setItem(k, v) {
+ if (k === 'bad') throw new Error('nope');
+ store.__map.set(k, String(v));
+ }
+ });
+ install(store);
+ setItem('win:a', '1');
+ setItem('bad', '2');
+ expect(keys().sort()).toEqual(['bad', 'win:a']);
+ });
+});
+
+describe('the shapes callers use', () => {
+ it('the drop-in object and the short names are the same functions', () => {
+ expect(safeStorage.getItem).toBe(getItem);
+ expect(safeStorage.setItem).toBe(setItem);
+ expect(safeStorage.removeItem).toBe(removeItem);
+ expect(get).toBe(getItem);
+ expect(set).toBe(setItem);
+ expect(remove).toBe(removeItem);
+ });
+});
diff --git a/tests/unit/sessionClock.test.js b/tests/unit/sessionClock.test.js
new file mode 100644
index 00000000..d7f3344b
--- /dev/null
+++ b/tests/unit/sessionClock.test.js
@@ -0,0 +1,165 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ sessionNow,
+ sessionOffset,
+ setClockReference,
+ setClockSelf,
+ recordClockSample,
+ noteRemoteSessionClock,
+ dropPeerClock,
+ resetSessionClock,
+ estimateFromSamples,
+ targetOffset,
+ onSessionClockJump,
+ ADOPT_THRESHOLD_MS,
+ GROSS_SKEW_MS,
+ MIN_SAMPLES
+} from '../../src/lib/sessionClock.js';
+
+// 25-E. The session clock is a pure decision over a handful of samples: WHICH peer we
+// keep time by, WHEN an estimate is trustworthy enough to move the clock, and the two
+// traps — a reference that keeps time by us, and a departure that must not snap every
+// later stamp back onto a clock nobody else uses.
+
+let n = 0;
+/** a fresh peer id per test, because the sample rings are module state */
+const fresh = () => 'peer' + ++n;
+
+beforeEach(() => {
+ resetSessionClock();
+ setClockSelf('me');
+});
+
+describe('sessionNow', () => {
+ it('is our own clock while we host', () => {
+ const a = Date.now();
+ const s = sessionNow();
+ expect(s - a).toBeGreaterThanOrEqual(0);
+ expect(s - a).toBeLessThan(20);
+ expect(sessionOffset()).toBe(0);
+ });
+
+ it('adopts a GROSS skew from its reference on the very first sample', () => {
+ const host = fresh();
+ setClockReference(host);
+ recordClockSample(host, 90_000, 20);
+ expect(sessionOffset()).toBe(90_000);
+ expect(Math.abs(sessionNow() - (Date.now() + 90_000))).toBeLessThan(20);
+ });
+
+ it('waits for MIN_SAMPLES before moving on a small skew', () => {
+ const host = fresh();
+ setClockReference(host);
+ for (let i = 0; i < MIN_SAMPLES - 1; i++) recordClockSample(host, 300, 10);
+ expect(sessionOffset()).toBe(0);
+ recordClockSample(host, 300, 10);
+ expect(sessionOffset()).toBe(300);
+ });
+
+ it('ignores samples from anybody but the reference', () => {
+ setClockReference(fresh());
+ const other = fresh();
+ for (let i = 0; i < 6; i++) recordClockSample(other, 90_000, 10);
+ expect(sessionOffset()).toBe(0);
+ });
+
+ it('does not chase noise under the adoption threshold', () => {
+ const host = fresh();
+ setClockReference(host);
+ for (let i = 0; i < 6; i++) recordClockSample(host, ADOPT_THRESHOLD_MS - 5, 10);
+ expect(sessionOffset()).toBe(0);
+ });
+
+ it('is TRANSITIVE: a reference that follows its own host hands that clock on', () => {
+ const joiner = fresh();
+ setClockReference(joiner);
+ // the joiner's raw clock is 10 s ahead of ours, and it keeps time by a host that
+ // is 5 s behind IT — so the session is 5 s ahead of us
+ noteRemoteSessionClock(joiner, -5_000, 'the-host');
+ for (let i = 0; i < MIN_SAMPLES; i++) recordClockSample(joiner, 10_000, 10);
+ expect(sessionOffset()).toBe(5_000);
+ expect(targetOffset()).toBe(5_000);
+ });
+
+ it('refuses a clock handed back by a reference that follows US (the loop guard)', () => {
+ const loop = fresh();
+ setClockReference(loop);
+ noteRemoteSessionClock(loop, 7_000, 'me');
+ for (let i = 0; i < MIN_SAMPLES; i++) recordClockSample(loop, 2_000, 10);
+ // its RAW clock is still used — only the part it copied from us is dropped
+ expect(sessionOffset()).toBe(2_000);
+ });
+
+ it('an older peer (no `so` on the pong) reads as its raw clock', () => {
+ const old = fresh();
+ setClockReference(old);
+ noteRemoteSessionClock(old, undefined, undefined);
+ for (let i = 0; i < MIN_SAMPLES; i++) recordClockSample(old, 4_000, 10);
+ expect(sessionOffset()).toBe(4_000);
+ });
+
+ it('KEEPS the offset when the reference departs, and drops it only on leaving', () => {
+ const host = fresh();
+ setClockReference(host);
+ recordClockSample(host, 60_000, 10);
+ dropPeerClock(host);
+ setClockReference(null);
+ expect(sessionOffset()).toBe(60_000);
+ resetSessionClock();
+ expect(sessionOffset()).toBe(0);
+ });
+
+ it('the gross-skew fast path is exactly GROSS_SKEW_MS', () => {
+ const host = fresh();
+ setClockReference(host);
+ recordClockSample(host, GROSS_SKEW_MS - 1, 10);
+ expect(sessionOffset()).toBe(0);
+ const other = fresh();
+ setClockReference(other);
+ recordClockSample(other, GROSS_SKEW_MS + 1, 10);
+ expect(sessionOffset()).toBe(GROSS_SKEW_MS + 1);
+ });
+});
+
+describe('onSessionClockJump', () => {
+ it('reports each adoption as the jump it made, and a reset as the jump back', () => {
+ /** @type {number[]} */
+ const jumps = [];
+ const off = onSessionClockJump((d) => jumps.push(d));
+ const host = fresh();
+ setClockReference(host);
+ recordClockSample(host, -90_000, 10);
+ recordClockSample(host, -90_000, 10);
+ recordClockSample(host, -89_900, 10); // median unchanged: no second jump
+ resetSessionClock();
+ off();
+ expect(jumps).toEqual([-90_000, 90_000]);
+ });
+
+ it('a throwing listener does not stop the clock or the others', () => {
+ /** @type {number[]} */
+ const seen = [];
+ const a = onSessionClockJump(() => {
+ throw new Error('boom');
+ });
+ const b = onSessionClockJump((d) => seen.push(d));
+ const host = fresh();
+ setClockReference(host);
+ recordClockSample(host, 5_000, 10);
+ a();
+ b();
+ expect(sessionOffset()).toBe(5_000);
+ expect(seen).toEqual([5_000]);
+ });
+});
+
+describe('estimateFromSamples (moved from musicClock)', () => {
+ it('takes the median of the lowest-RTT half', () => {
+ const est = estimateFromSamples({ offsets: [300, 310, 900, 305], rtts: [10, 12, 400, 11] });
+ expect(est?.offset).toBe(302.5);
+ expect(est?.samples).toBe(4);
+ });
+ it('answers null for an empty ring', () => {
+ expect(estimateFromSamples({ offsets: [], rtts: [] })).toBe(null);
+ });
+});
diff --git a/tests/unit/wireValidate.test.js b/tests/unit/wireValidate.test.js
new file mode 100644
index 00000000..1bc684aa
--- /dev/null
+++ b/tests/unit/wireValidate.test.js
@@ -0,0 +1,116 @@
+import { describe, it, expect } from 'vitest';
+import {
+ isUuid,
+ isFiniteArray,
+ isVec3,
+ isQuatOrEuler,
+ sanitizeTransform,
+ validateWireMessage,
+ VALIDATORS
+} from '../../src/lib/wireValidate.js';
+
+// 27-I (audit L9) + 27-A. The wire validator is the one module in this batch whose whole
+// job is deciding what a hostile peer may hand the appliers, and it imports NOTHING — so
+// it is exactly what a unit layer is for: no browser, no peer, no scene.
+
+describe('shape predicates', () => {
+ it('accepts a plausible uuid and rejects the rest', () => {
+ expect(isUuid('2bfe3770-7caa-4037-87ee-ca9c557993a7')).toBe(true);
+ expect(isUuid('')).toBe(false);
+ expect(isUuid(null)).toBe(false);
+ expect(isUuid(42)).toBe(false);
+ expect(isUuid('x'.repeat(65))).toBe(false); // unbounded strings are not identifiers
+ });
+
+ it('treats NaN and Infinity as NOT numbers, which is the whole point', () => {
+ expect(isVec3([1, 2, 3])).toBe(true);
+ expect(isVec3([1, 2, NaN])).toBe(false);
+ expect(isVec3([1, 2, Infinity])).toBe(false);
+ expect(isVec3([1, 2])).toBe(false);
+ expect(isVec3('1,2,3')).toBe(false);
+ expect(isFiniteArray([1, 2, 3, 4], 4)).toBe(true);
+ expect(isFiniteArray([1, 2, 3], 4)).toBe(false);
+ });
+
+ it('takes a rotation as either an Euler triple or a quaternion', () => {
+ expect(isQuatOrEuler([0, 0, 0])).toBe(true);
+ expect(isQuatOrEuler([0, 0, 0, 1])).toBe(true);
+ expect(isQuatOrEuler([0, 0])).toBe(false);
+ });
+});
+
+describe('validateWireMessage', () => {
+ it('ALLOWS a type it has never heard of — the additive rule', () => {
+ // A peer one release ahead sends types this table cannot know. Rejecting them on
+ // shape would make every forward-compatible message a dropped message.
+ expect(validateWireMessage({ type: 'something-from-2027', whatever: true })).toBe(true);
+ });
+
+ it('refuses the structural messages whose appliers would throw', () => {
+ expect(validateWireMessage({ type: 'hosts', hosts: 'not-an-array' })).toBe(false);
+ expect(validateWireMessage({ type: 'hosts', hosts: ['a', 'b'] })).toBe(true);
+ expect(validateWireMessage({ type: 'userdata', userdata: 'nope' })).toBe(false);
+ expect(validateWireMessage({ type: 'locked', lockeditems: {} })).toBe(false);
+ });
+
+ it('refuses a transform that would poison the matrix', () => {
+ const good = { type: 'move', uuid: 'abc', pos: [1, 2, 3], rot: [0, 0, 0], scale: [1, 1, 1] };
+ expect(validateWireMessage(good)).toBe(true);
+ expect(validateWireMessage({ ...good, pos: [NaN, 2, 3] })).toBe(false);
+ expect(validateWireMessage({ ...good, scale: [1, 1] })).toBe(false);
+ expect(validateWireMessage({ ...good, uuid: 123 })).toBe(false);
+ });
+
+ it('cannot itself be made to throw by a hostile shape', () => {
+ // a validator that throws IS a rejection, never an escape into the dispatcher
+ const nasty = {
+ type: 'move',
+ get uuid() {
+ throw new Error('boom');
+ }
+ };
+ expect(() => validateWireMessage(nasty)).not.toThrow();
+ expect(validateWireMessage(nasty)).toBe(false);
+ });
+
+ it('has no validator that rejects its own well-formed message', () => {
+ // a cheap guard against a typo in the table silently dropping a whole domain
+ const samples = {
+ hosts: { hosts: [] },
+ userdata: { userdata: [] },
+ locked: { lockeditems: [] },
+ delete: { uuid: 'a' },
+ disconnected: { peerId: 'a' },
+ atscene: { peerId: 'a' },
+ assetchunk: { hash: 'h', seq: 0 },
+ assetstart: { hash: 'h', chunks: 2, size: 10 }
+ };
+ for (const [type, body] of Object.entries(samples))
+ expect(validateWireMessage({ type, ...body }), type).toBe(true);
+ expect(Object.keys(VALIDATORS).length).toBeGreaterThan(20);
+ });
+});
+
+describe('sanitizeTransform', () => {
+ const current = { pos: [9, 9, 9], rot: [1, 1, 1], scale: [2, 2, 2] };
+
+ it('keeps the current value for each non-finite component, and says it repaired', () => {
+ const out = sanitizeTransform([NaN, 5, 6], [0, 0, 0], [1, 1, 1], current);
+ // null is a REAL answer from this function (nothing usable at all), so assert it
+ // away first — the test should state which case it is in, not assume one.
+ expect(out).not.toBeNull();
+ expect(out?.pos).toEqual([9, 5, 6]); // the broken axis falls back, the good ones apply
+ expect(out?.repaired).toBe(true);
+ });
+
+ it('reports no repair when everything is finite', () => {
+ const out = sanitizeTransform([1, 2, 3], [0, 0, 0], [1, 1, 1], current);
+ expect(out).not.toBeNull();
+ expect(out?.repaired).toBe(false);
+ expect(out?.pos).toEqual([1, 2, 3]);
+ });
+
+ it('returns null when there is nothing usable at all', () => {
+ expect(sanitizeTransform(undefined, undefined, undefined, current)).toBe(null);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000..8f983a7b
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vitest/config';
+
+// 27-I (audit L9): the unit layer the project never had. Deliberately NARROW — only
+// modules that import NOTHING run here, so a unit run needs no browser, no jsdom, no
+// svelte compiler and no three.js. That is what makes it fast enough to be a required
+// CI job, and it is why throwVelocity (imports three) and transferLedger (imports
+// svelte/store) are NOT in this first cut: padding the list with modules that need a
+// runtime is how a "unit" suite turns into a slow, flaky second e2e suite.
+export default defineConfig({
+ test: {
+ include: ['tests/unit/**/*.test.js'],
+ // node, not jsdom: every module in this layer imports NOTHING, which is the
+ // entry requirement. A test that needs a DOM belongs in tests/e2e.
+ environment: 'node'
+ }
+});