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 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'}
- + 0 ? ' · ' + Math.floor(pendingLeft / 60) + ':' + String(pendingLeft % 60).padStart(2, '0') : '')} + /> + {/if} + @@ -444,6 +520,37 @@ } /* the chevron is a lucide component's svg — the class lands OUTSIDE this component's scope hash, so these selectors must be :global to reach it */ + /* 27-F: the signaling retry chip. Amber like the pending state, compact, and only + present while the link is down — so it costs the pill no width the rest of the time. */ + .cx-retry { + align-self: center; + white-space: nowrap; + border-radius: 9999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + color: #78350f; + background: #fbbf24; + } + + /* 25-F: the refusal chip — red for declined, amber for a full room (a wait, not a no) */ + .cx-refused { + align-self: center; + white-space: nowrap; + border: 0; + border-radius: 9999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + color: #fff; + background: #dc2626; + cursor: pointer; + } + .cx-refused[data-result='full'] { + color: #78350f; + background: #fbbf24; + } + .cx-toggle :global(.cx-chevron) { transition: transform 0.2s ease; } diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index bc500bda..104282e5 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -5,6 +5,9 @@ import { chatHidden, flowGraphClose, flowCodeClose, animationClose, uvEditorClose, shaderEditorClose, hudEditorClose, explorerClose, objectListClose, objectContextMenu, renamingObject, advancedMode, showEnvInList, showLocalObjects, floatingToolbar, toolbarAlwaysOnTop, showSimControls, expandedObjects } from '../../stores/appStore.js'; // 24-B2: keyboard navigation in the object list (the Explorer's gridKeydown shape) import { visibleObjectRows, withExpanded, typeAheadIndex } from '$lib/objectListNav'; + import { sceneMetrics, statsOpen, worstTier, budgetRows } from '$lib/sceneBudget'; + import { qualityState, pinQuality, releaseQuality } from '$lib/qualityGovernor'; + import { showToast as showQualityToast } from '../../stores/appStore'; import { keyOf } from '$lib/keyOf'; import { systemGroupNames } from '$lib/moduleSDK'; import { ENV_ROOT } from '$lib/environment'; @@ -19,9 +22,9 @@ import { sendPing } from '$lib/ping'; import { buildObjectMenuItems } from '$lib/objectMenu'; import * as THREE from 'three'; - import { onMount, setContext, tick } from 'svelte'; + import { onMount, setContext, tick, untrack } from 'svelte'; import { createGesture } from '$lib/modalGrab'; - import { writable } from 'svelte/store'; + import { writable, get } from 'svelte/store'; import { shareObject } from '$lib/objectPermissions'; import Objects from './Objects.svelte'; import LocalObjects from './LocalObjects.svelte'; @@ -37,6 +40,7 @@ import { togglePanel } from '$lib/panelToggles'; import { requestPlay, willEnterXR, willEnterAR, vrSupported, arSupported, xrSessionFailed } from '$lib/playMode'; import { DOCK_VIEWS } from '$lib/dockMenu'; + import { safeStorage } from '$lib/safeStorage'; import { VRButton, XRButton } from '@threlte/xr' // A panel is "shown" when it is open AND either the visible dock tab OR floating @@ -313,7 +317,7 @@ let hiddenChips: Set = $state( new Set( typeof localStorage !== 'undefined' - ? JSON.parse(localStorage.getItem('hiddenListChips') ?? '[]') + ? JSON.parse(safeStorage.getItem('hiddenListChips') ?? '[]') : [] ) ); @@ -327,7 +331,7 @@ if (viewMode === value) viewMode = ''; } hiddenChips = next; - localStorage.setItem('hiddenListChips', JSON.stringify([...next])); + safeStorage.setItem('hiddenListChips', JSON.stringify([...next])); } function resetAllFilters() { searchTerm = ''; @@ -335,7 +339,7 @@ lastTypes = new Set(); viewMode = ''; hiddenChips = new Set(); - localStorage.setItem('hiddenListChips', '[]'); + safeStorage.setItem('hiddenListChips', '[]'); chipPopup = false; } @@ -410,6 +414,147 @@ }; } + // --- 26-B: LIST VIRTUALISATION (roadmap 26 Stage 0, audit M6) ------------------- + // The tree rendered EVERY visible row, recursively, and re-reconciled all of them on + // every scene poke. At 3,000 objects that is 3,000 component instances each carrying + // nine handlers and a Tooltip — the object list alone was several hundred ms of the + // reported freeze, and it is why deleting one object in a big scene felt worse than + // the delete itself. + // + // Above the threshold the SAME row component renders in `flat` mode over the + // flattened `visibleObjectRows` — the one array the keyboard walker, Ctrl+A and the + // type-ahead already read their order from — with two spacer divs standing in for + // what is off screen. Sharing that array is what keeps the arrows and the window + // agreeing by construction; deriving a second order would be a copy guaranteed to + // drift (the Explorer's `gridEntries` ruling, one panel over). + // + // Below the threshold NOTHING changes: the recursive tree renders exactly as it did, + // indent borders and all, so the common case is byte-identical. + const VIRTUAL_MIN = 500; + /** rows drawn beyond each edge, so a fast flick does not show blank space */ + const OVERSCAN = 12; + let rowH = $state(24); + let scrollTop = $state(0); + let viewportH = $state(0); + let treeScroller: HTMLElement | null = null; + const treeRows = $derived(viewMode ? [] : visibleObjectRows($objectsGroup, $expandedObjects, $objectFilter as any)); + const virtualising = $derived(treeRows.length > VIRTUAL_MIN); + const windowStart = $derived(virtualising ? Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN) : 0); + const windowEnd = $derived( + virtualising + ? Math.min(treeRows.length, Math.ceil((scrollTop + (viewportH || 400)) / rowH) + OVERSCAN) + : 0 + ); + const windowRows = $derived(virtualising ? treeRows.slice(windowStart, windowEnd) : []); + + /** Find the real scrolling ancestor by SCROLLABILITY, never by class name — the + * scroller is flowbite's `Listgroup`, whose element we do not own (the deep-link + * ruling in Section.svelte, same reason). */ + function trackTreeScroll(node: HTMLElement) { + let ro: any = null; + const read = () => { + if (!treeScroller) return; + scrollTop = treeScroller.scrollTop; + viewportH = treeScroller.clientHeight; + // measure ONE real row rather than trusting a constant: the spacers are in + // pixels, so a wrong height makes the window drift away from the scrollbar + const first = node.querySelector('[role="treeitem"] > div') as HTMLElement | null; + const h = first?.offsetHeight ?? 0; + if (h > 8 && Math.abs(h - rowH) > 0.5) rowH = h; + }; + let el: HTMLElement | null = node.parentElement; + while (el) { + const style = getComputedStyle(el); + if (/(auto|scroll)/.test(style.overflowY)) break; + el = el.parentElement; + } + treeScroller = el; + if (treeScroller) { + treeScroller.addEventListener('scroll', read, { passive: true }); + ro = new ResizeObserver(read); + ro.observe(treeScroller); + ro.observe(node); + } + read(); + return { + destroy() { + treeScroller?.removeEventListener('scroll', read); + ro?.disconnect(); + treeScroller = null; + } + }; + } + + // keyboard follow: in the window a selected row that is off screen has no element to + // scroll itself into view, so the WINDOW moves instead (the arrows would otherwise + // walk silently into nothing). + let lastFollowed = ''; + $effect(() => { + const uuid = $selectedObjects.length ? $selectedObjects[$selectedObjects.length - 1] : ''; + if (!virtualising || !uuid || uuid === lastFollowed) { lastFollowed = uuid; return; } + lastFollowed = uuid; + const index = treeRows.findIndex((r) => r.uuid === uuid); + if (index < 0 || !treeScroller) return; + const top = index * rowH; + const view = treeScroller.clientHeight; + if (top < treeScroller.scrollTop) treeScroller.scrollTop = top; + else if (top + rowH > treeScroller.scrollTop + view) treeScroller.scrollTop = top + rowH - view; + }); + + // 26-A: the meter's dot and its tooltip. The reading is the sampler's; this only + // picks the worst tier and spells out what is over budget, so the tooltip answers + // "over budget on WHAT" without opening anything. + const budgetProfileNow = $derived($sceneMetrics.profile === 'vr' ? 'vr' : 'desktop'); + const budgetTier = $derived(worstTier($sceneMetrics, budgetProfileNow)); + /** A DIRECT listener, not `on:click`/`onclick`: this file is written in the `on:` + * style throughout, so an attribute handler here is a hard "mixing syntaxes" error, + * and the `on:` form is deprecated in runes mode — the action is the way out of both, + * and it is what the panel-chrome rule asks for anyway (a delegated handler inside a + * panel can be swallowed on its way up). */ + function openStats(node: HTMLElement) { + const open = () => statsOpen.set(true); + node.addEventListener('click', open); + return { destroy() { node.removeEventListener('click', open); } }; + } + const budgetTitle = $derived.by(() => { + const over = budgetRows($sceneMetrics, budgetProfileNow).filter((r) => r.tier === 'amber' || r.tier === 'red'); + if (!over.length) return 'Scene budget — within the ' + (budgetProfileNow === 'vr' ? 'VR / mobile' : 'desktop') + ' budget. Click for statistics.'; + return 'Scene budget: over on ' + over.map((r) => r.label.toLowerCase()).join(', ') + '. Click for statistics.'; + }); + + // 26-D: THE QUALITY CHIP. The governor acts on its own, so it has to be SEEN acting and + // be answerable in one click — an automatic change a person cannot see or undo is just a + // different kind of broken (26-G's rule). Two states, one button: reducing on its own + // (click keeps it), or held (click gives full quality back). A direct listener for the + // same reason as openStats. + const qualityTitle = $derived.by(() => { + const q = $qualityState; + const what = q.labels.join(', ').toLowerCase(); + return q.pinned + ? 'Held at reduced quality (' + what + '). Click to restore full quality.' + : 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It comes back on its own when frames recover. Click to keep it this way.'; + }); + function qualityChipClick(node: HTMLElement) { + const click = () => (get(qualityState).pinned ? releaseQuality() : pinQuality()); + node.addEventListener('click', click); + return { destroy() { node.removeEventListener('click', click); } }; + } + // …and once per session, a toast when it FIRST acts, because the chip lives in the object + // list's footer and that window can be closed + let qualityToasted = false; + $effect(() => { + const q = $qualityState; + if (q.level > 0 && !qualityToasted) { + qualityToasted = true; + untrack(() => + showQualityToast('Quality reduced — this scene is heavy for this device (' + q.labels.join(', ').toLowerCase() + '). It comes back on its own.', [ + { label: 'Restore full quality', action: () => releaseQuality() }, + { label: 'Keep it', action: () => pinQuality() } + ]) + ); + } + }); + // bottom status line: totals across the whole tree (N objects · M hidden) let objectCount = $state(0); let hiddenCount = $state(0); @@ -429,7 +574,7 @@ // --- advanced mode: System filter shows scene-root module/env objects --- let systemRows = $state([]); let systemNoticeDismissed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('systemNoticeDismissed') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('systemNoticeDismissed') === 'true' ); let expandedSystem = $state({}); function refreshSystemRows() { @@ -478,7 +623,7 @@ // --- environment filter (70.4): read-only rows for environment-root --- let envRows = $state([]); let envNoticeDismissed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('envNoticeDismissed') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('envNoticeDismissed') === 'true' ); function refreshEnvRows() { const scene = $globalScene; @@ -534,7 +679,7 @@ // 80.1: proper resize (start-size captured, clamped) + persisted rect let saved: any = null; try { - saved = JSON.parse(localStorage.getItem('objectListRect') ?? 'null'); + saved = JSON.parse(safeStorage.getItem('objectListRect') ?? 'null'); } catch {} let moving = false; let left = saved?.left ?? 350; @@ -589,7 +734,7 @@ } const persist = () => - localStorage.setItem( + safeStorage.setItem( 'objectListRect', JSON.stringify({ left, top, width: node.offsetWidth, height: node.offsetHeight }) ); @@ -735,7 +880,7 @@ // vrOverride is the STRING mirror Settings writes; Scene seeds the store // from localStorage on boot, so both halves have to move together. vrOverride.set(true); - localStorage.setItem('vrOverride', 'true'); + safeStorage.setItem('vrOverride', 'true'); requestPlay(); } }, @@ -746,9 +891,9 @@ tooltip: $vrSupported ? 'Immersive VR — the scene replaces your view' : 'No immersive-vr support detected', action: () => { vrOverride.set(false); - localStorage.removeItem('vrOverride'); + safeStorage.removeItem('vrOverride'); vrPassthrough.set(false); - localStorage.setItem('vrPassthrough', 'false'); + safeStorage.setItem('vrPassthrough', 'false'); requestPlay(); } }, @@ -761,9 +906,9 @@ : 'No immersive-ar (passthrough) support detected', action: () => { vrOverride.set(false); - localStorage.removeItem('vrOverride'); + safeStorage.removeItem('vrOverride'); vrPassthrough.set(true); - localStorage.setItem('vrPassthrough', 'true'); + safeStorage.setItem('vrPassthrough', 'true'); requestPlay(); } }, @@ -923,7 +1068,7 @@ function loadLayout(): ControlsLayout { if (typeof localStorage === 'undefined') return defaultLayout(); try { - const raw = localStorage.getItem('controlsLayout'); + const raw = safeStorage.getItem('controlsLayout'); if (!raw) return defaultLayout(); const saved = JSON.parse(raw) ?? {}; // W8b: kept ids are the ones the REGISTRY knows, not the ones the DEFAULT order @@ -962,7 +1107,7 @@ function saveLayout() { try { - localStorage.setItem('controlsLayout', JSON.stringify(controlsLayout)); + safeStorage.setItem('controlsLayout', JSON.stringify(controlsLayout)); } catch { // private mode / storage full — the bar still works for this session } @@ -978,7 +1123,7 @@ function resetLayout() { controlsLayout = defaultLayout(); try { - localStorage.removeItem('controlsLayout'); + safeStorage.removeItem('controlsLayout'); } catch { // nothing to clear } @@ -1115,7 +1260,7 @@ * than duplicated, so the two rows can say which one is on. `setDocked` keeps this * flag in step with the panel, so it is the honest answer either way. */ function explorerOpensDocked(): boolean { - return typeof localStorage === 'undefined' || localStorage.getItem('explorerDocked') !== 'false'; + return typeof localStorage === 'undefined' || safeStorage.getItem('explorerDocked') !== 'false'; } /** Move the Explorer between dock tab and floating window. @@ -2115,7 +2260,7 @@ class="rounded-sm bg-gray-600 px-1 text-white" on:click={() => { systemNoticeDismissed = true; - localStorage.setItem('systemNoticeDismissed', 'true'); + safeStorage.setItem('systemNoticeDismissed', 'true'); }}>✕
{/if} @@ -2168,7 +2313,7 @@ class="rounded-sm bg-gray-600 px-1 text-white" on:click={() => { envNoticeDismissed = true; - localStorage.setItem('envNoticeDismissed', 'true'); + safeStorage.setItem('envNoticeDismissed', 'true'); }}>✕ {/if} @@ -2198,8 +2343,14 @@ {#if $objectsGroup} -
- {#if $objectsGroup.children.length > 0} +
+ {#if virtualising} + + {#each windowRows as row (row.uuid)} + + {/each} + + {:else if $objectsGroup.children.length > 0} {#each $objectsGroup.children.filter((/** @type {any} */ c) => !c.userData?.__localOnly) as element} {/each} @@ -2209,9 +2360,29 @@ {/if}
-
- {objectCount} object{objectCount === 1 ? '' : 's'}{hiddenCount ? ' · ' + hiddenCount + ' hidden' : ''} -
+ + {#if $qualityState.level > 0 || $qualityState.pinned} + + {/if} +
{ if (inspectorH || typeof window === 'undefined') return; - const saved = parseInt(localStorage.getItem('inspectorSheetH') || ''); + const saved = parseInt(safeStorage.getItem('inspectorSheetH') || ''); inspectorH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.45) : saved; }); let insResizing = $state(false); @@ -303,7 +303,7 @@ insResizing = false; /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); try { - localStorage.setItem('inspectorSheetH', String(inspectorH)); + safeStorage.setItem('inspectorSheetH', String(inspectorH)); } catch {} } @@ -498,7 +498,7 @@ setShaderGraphFor(object.uuid, null); detachFrom(object); } - objectsGroup.update((v) => v); + pokeScene(); showToast(own.length === 1 ? 'Shader removed from this object' : 'Shader removed from ' + own.length + ' objects'); } @@ -1184,7 +1184,7 @@ } } selectedObject.update((s) => s); - objectsGroup.update((v) => v); + pokeScene(); } /** A picked image file → every selected material, decoded once. @param {File} file */ @@ -1198,7 +1198,7 @@ if (uuids.length > 1) endHistoryBatch(`Texture (${uuids.length})`); } selectedObject.update((s) => s); - objectsGroup.update((v) => v); + pokeScene(); } /** CL-A A4: which material preset matches the current values (else 'custom') @param {any} p */ @@ -1220,7 +1220,7 @@ } function sendName() { - objectsGroup.update((value) => value); // refresh the object list + pokeScene(); // refresh the object list $peers.send({ type: 'name', name: $selectedObject.name, uuid: $selectedObject.uuid }); } @@ -1846,8 +1846,8 @@ checked={!!$showGrid} onchange={() => { 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'); }}>Show grid v); + pokeScene(); rerenderSelectGroup = !rerenderSelectGroup; }} /> @@ -3352,7 +3352,7 @@ object.material.needsUpdate = true; $peers.send({ type: 'color', uuid: object.uuid, color: c.hex }); } - objectsGroup.update((v) => v); + pokeScene(); }} /> {/if} diff --git a/src/components/menu/NotesDrawer.svelte b/src/components/menu/NotesDrawer.svelte index 7d2a6b82..88dec3a5 100644 --- a/src/components/menu/NotesDrawer.svelte +++ b/src/components/menu/NotesDrawer.svelte @@ -21,6 +21,7 @@ DEFAULT_NOTE_COLOR } from '$lib/annotationsHandler'; import { objectsGroup } from '../../stores/sceneStore.js'; + import { safeStorage } from '$lib/safeStorage'; // One bottom sheet at a time on narrow: opening scene notes closes the object/scene // settings sheet (they'd otherwise stack at the bottom). @@ -37,7 +38,7 @@ // bottom dock) with a drag handle to adjust its height — the right-side drawer was // covered by the profile chrome there. On wide screens it stays the right drawer. let stored = - typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('notesSheetH') || '') : NaN; + typeof localStorage !== 'undefined' ? parseInt(safeStorage.getItem('notesSheetH') || '') : NaN; let sheetH = $state( !stored || Number.isNaN(stored) ? Math.round((typeof window !== 'undefined' ? window.innerHeight : 800) * 0.45) @@ -65,7 +66,7 @@ resizing = false; /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); try { - localStorage.setItem('notesSheetH', String(sheetH)); + safeStorage.setItem('notesSheetH', String(sheetH)); } catch {} } diff --git a/src/components/menu/Objects.svelte b/src/components/menu/Objects.svelte index d30407a3..a80328f7 100644 --- a/src/components/menu/Objects.svelte +++ b/src/components/menu/Objects.svelte @@ -1,7 +1,11 @@ + +{#if $statsOpen} +
+
+
+ +
+

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

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

Frame

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

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

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

Nothing sent or received yet.

+ {:else} + + + {#each wire.rows.slice(0, 10) as row (row.type)} + + + + + + + + {/each} + +
{row.type}{num(row.in)} in{num(row.out)} out{row.perSecond.toFixed(1)}/s{row.bytes == null ? '' : '≈' + num(row.bytes) + 'B'}
+ {/if} +
+
+{/if} + + diff --git a/src/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;"
Connection request {String(approval.peerId).slice(0, 6).toUpperCase()}
+ +
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} - + {#if approval.status !== 'retry'} - + {/if} {:else} - + {/if} - + + {#if roomFull && hearsNo(approval)} + + {/if} +
@@ -481,7 +605,7 @@ style="z-index: var(--z-toast-low); pointer-events: none;" {#if $fixLight}
- { localStorage.setItem('hasSeenDisclaimer', 'true'); } + { safeStorage.setItem('hasSeenDisclaimer', 'true'); } }>
@@ -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'))} {/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' + } +});