diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..abf64162 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +# 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 + + 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 6a393370..fead07f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,56 @@ window's cards gained a small "save to Library" button that files a starter as a new scene without loading it. +### 🛡️ 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. + ## 1.10.0 — Publish, play, remix ☁️ The engine learned the moves a community needs — publish the open scene, open a diff --git a/check-baseline.json b/check-baseline.json new file mode 100644 index 00000000..fbfda597 --- /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": 358, + "warnings": 47, + "measured": "2026-09-12" +} diff --git a/package-lock.json b/package-lock.json index 7a82dbfe..cc49b6a5 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 c58537b9..a4581e40 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "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" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", @@ -38,7 +39,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/src/App.svelte b/src/App.svelte index 118d45a5..98d98514 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -31,7 +31,14 @@ import SplineToolbar from './components/menu/SplineToolbar.svelte' import ModuleToolboxLayer from './components/ui/ModuleToolboxLayer.svelte' import { isLocked } from './stores/sceneStore' - import { startFlowRuntime } from '$lib/flowRuntime' + import { objectsGroup, globalRenderer } from './stores/sceneStore' + import { startFlowRuntime, resumeFlowRuntime } from '$lib/flowRuntime' + // 27-G: the one overlay that must sit above everything, because nothing else on + // screen is usable while the graphics context is gone. + import ContextLostOverlay from './components/ContextLostOverlay.svelte' + // 27-D: safe mode pauses the runtime BEFORE it is started, so a scene whose scripts + // hang on load can still be opened and edited. + import { flowPaused } from './stores/flowStore' import { startNodeSync } from '$lib/nodesHandler' import { startLockSweep } from '$lib/lockControl' import { loadUserModules } from '$lib/userModules' @@ -59,6 +66,10 @@ import { registerVRPatch } from './lib/vrPatch' import { startMusicToolbox } from './lib/musicToolbox' import { startWhatsNew } from '$lib/whatsNew' import { startUpdateCheck } from '$lib/updateCheck' + // 27-B: the diagnostics ring buffer + global error capture (hardening audit H4) + import { startDiagnostics, registerDiagnosticsSection } from '$lib/diagnostics' + // 27-A: the wire-failure counters contribute their own diagnostics section + import { startWireErrors } from '$lib/wireErrors' import { startTrackpadNav } from '$lib/trackpadNav' import { startInviteLinks } from '$lib/inviteLinks' import { startHelperLayer, helpersInPlay } from '$lib/helperLayer' @@ -80,7 +91,8 @@ import { startMusicToolbox } from './lib/musicToolbox' import HudLayer from './components/hud/HudLayer.svelte' import HudEditor from './components/editors/HudEditor.svelte' import { importFile, load } from '$lib/fileHandler.svelte' - import { showToast } from './stores/appStore' + import { showToast, showInfoToast } from './stores/appStore' + import { peers, userdata } from './stores/appStore' import { get } from 'svelte/store' import { initModules, disabledModules } from '$lib/moduleSDK' import { coreModules } from './modules/index.js' @@ -95,11 +107,59 @@ import { startMusicToolbox } from './lib/musicToolbox' // node graph animations keep running even when the flow drawer is closed onMount(() => { + // 27-B: FIRST, so a failure during the rest of this boot is already recorded. + startDiagnostics() + startWireErrors() + // The bundle reads its context through registered sections, which is what keeps + // diagnostics.js a leaf: it never imports a store, the root component does. + registerDiagnosticsSection('session', () => { + /** @type {any} */ const peer = get(peers) + /** @type {any} */ const group = get(objectsGroup) + /** @type {any} */ const renderer = get(globalRenderer) + let objects = 0 + let meshes = 0 + group?.traverse?.((/** @type {any} */ o) => { + if (o === group) return + objects++ + if (o.isMesh) meshes++ + }) + return { + peerId: peer?.peer?.id ?? null, + openConns: peer ? Object.keys(peer.connections ?? {}).length : 0, + roster: (get(userdata) ?? []).length, + objects, + meshes, + render: renderer?.info + ? { + calls: renderer.info.render.calls, + triangles: renderer.info.render.triangles, + geometries: renderer.info.memory.geometries, + textures: renderer.info.memory.textures + } + : null + } + }) // 15-N: register the PWA service worker (a no-cache passthrough — see // static/sw.js) so mobile browsers offer "Install app". Dev is skipped: a // SW in front of vite's HMR only causes confusion. if ('serviceWorker' in navigator && import.meta.env.PROD) navigator.serviceWorker.register('/sw.js').catch(() => {}) + // 27-D (audit C1): SAFE MODE. A scene whose scripts hang on load cannot be repaired, + // because the editor never gets a frame to repair it in. Opening the same URL with + // `#safe` starts with the flow runtime PAUSED: the graph loads, the node can be + // edited or deleted, and Resume (or a reload without the hash) starts it again. + // + // The hash is the whole mechanism, deliberately. A HELD key cannot be read at boot — + // there is no synchronous API for modifier state, only events — so a Shift check here + // would look like a second way in while being one the first frame could never honour. + if (typeof location !== 'undefined' && /(^|[#&])safe\b/i.test(location.hash)) { + flowPaused.set({ paused: true, reason: 'safe mode' }) + showInfoToast( + 'safe-mode', + 'Safe mode: the flow runtime is paused, so scripts are not running. Fix the node, then press Resume.', + [{ label: 'Resume', action: () => resumeFlowRuntime() }] + ) + } startFlowRuntime() startNodeSync() startLockSweep() @@ -367,9 +427,12 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/objectListNav'), import('./lib/inviteLinks'), import('./lib/helperLayer'), - import('./lib/explorerClipboard') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib } + import('./lib/explorerClipboard'), + import('./lib/diagnostics'), + import('./lib/wireValidate'), + import('./lib/wireErrors') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib } }) } }) @@ -428,6 +491,7 @@ import { startMusicToolbox } from './lib/musicToolbox'
DEBUG · helpers
{/if} + diff --git a/src/components/ContextLostOverlay.svelte b/src/components/ContextLostOverlay.svelte new file mode 100644 index 00000000..e1733ef6 --- /dev/null +++ b/src/components/ContextLostOverlay.svelte @@ -0,0 +1,94 @@ + + +{#if $contextLost} +
+
+

The 3D view has stopped

+

+ The browser took back this page's graphics context. That is usually temporary, and + it often comes back by itself. Your scene is still here — it lives + in the page, not on the graphics card — so you can save it right now. +

+
+ + +
+
+
+{/if} + + diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 44a07b66..5cd65074 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -8,7 +8,7 @@ import { peers, username, userdata, specatorMode, avatarConfig, viewportMenu, objectContextMenu, viewportMenuOpener, addMenu, addMenuOpener, showToast, multiSelectMode } from '../stores/appStore'; import { get } from 'svelte/store'; import { vrPostEnabled } from '$lib/viewportOverrides'; - import { isLocked, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, sessionCompositesOverRoom, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode } from '../stores/sceneStore'; + import { isLocked, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, sessionCompositesOverRoom, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode, contextLost } from '../stores/sceneStore'; import { selectObject, deselectObject, @@ -124,6 +124,7 @@ interactivity(); const scale = spring(0.5); let rotation = 0; + let lastCameraSendAt = 0 // 27-E: the camera stream's rate gate let lastCameraPosition = new THREE.Vector3(); // P2b THE OTHER HALF OF THE SEND GATE. The camera broadcast is CHANGE-GATED, so a // peer who travels into our scene while we are standing still would never receive a @@ -289,8 +290,18 @@ camContentPos.copy(camera.current.position); camContentQuat.copy(camera.current.quaternion); worldToContentPose($worldRig, camContentPos, camContentQuat); - if (camContentPos.distanceTo(lastCameraPosition) > ($isVRMode ? 0.0001 : 0.01) || - camContentQuat.angleTo(lastCameraQuaternion) > THREE.MathUtils.degToRad(1)) { + // 27-E (audit H7): the camera stream is RATE-GATED now. It used to send on every + // frame the camera moved past a threshold — in VR that threshold is 0.0001 m, so + // at 90 Hz it is a message per frame, and at N=10 each peer both sends and + // receives ~800 a second. The movement threshold is unchanged; this only bounds + // HOW OFTEN, which is the `vrhands` pattern one block below. Golden rule 11: a + // receiver eases between samples, we never raise a send rate to paper over it. + const camGapMs = $isVRMode ? 33 : 50; + const nowMs = performance.now(); + if ((camContentPos.distanceTo(lastCameraPosition) > ($isVRMode ? 0.0001 : 0.01) || + camContentQuat.angleTo(lastCameraQuaternion) > THREE.MathUtils.degToRad(1)) && + nowMs - lastCameraSendAt >= camGapMs) { + lastCameraSendAt = nowMs; camContentEuler.setFromQuaternion(camContentQuat); $peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z] }); lastCameraPosition.copy(camContentPos); @@ -556,6 +567,27 @@ renderer.xr.addEventListener('sessionstart', onSessionStart); const element = renderer.domElement; + + // 27-G (audit M13): a lost WebGL context is SILENT. The canvas stops updating while + // every other part of the app keeps answering, so it reads to a user as "the whole + // thing froze" with nothing to act on. preventDefault() is load-bearing rather than + // a formality: without it the browser never fires a restore event AT ALL, so there + // is no way back short of a reload. + const onContextLost = (event: any) => { + event.preventDefault(); + $contextLost = true; + }; + const onContextRestored = () => { + // three rebuilds its own GPU objects lazily, but a material compiled against the + // dead context keeps its stale program, so force a recompile across the scene. + $globalScene?.traverse((o: any) => { + const list = Array.isArray(o.material) ? o.material : o.material ? [o.material] : []; + for (const m of list) if (m) m.needsUpdate = true; + }); + $contextLost = false; + }; + element.addEventListener('webglcontextlost', onContextLost); + element.addEventListener('webglcontextrestored', onContextRestored); let downPosition = null; let downTime = 0; let strokeActive = false; @@ -1253,6 +1285,8 @@ stopPlayInteract(); // 21-B B3 (releases any carried body with zero velocity) element.removeEventListener('pointerdown', onPointerDown); element.removeEventListener('contextmenu', onContextMenu); + element.removeEventListener('webglcontextlost', onContextLost); + element.removeEventListener('webglcontextrestored', onContextRestored); window.removeEventListener('pointerup', onPointerUp); xrControllers.forEach((controller) => { controller.removeEventListener('select', onXRSelect); diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 8945c74f..fe53390a 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -5,6 +5,8 @@ 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 } from '$lib/connectionState'; import { cancelOutboundRequest, requestConnect } from '$lib/peerApproval'; import { sessionHost } from '$lib/connectionState'; import { connectSlot, drawerSlot } from '$lib/cloudHooks'; @@ -39,6 +41,26 @@ // 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')); + // 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' ); @@ -259,7 +281,15 @@ {:else if connState === 'pending'}
- + 0 ? ' · ' + Math.floor(pendingLeft / 60) + ':' + String(pendingLeft % 60).padStart(2, '0') : '')} + /> + + What the app has been doing: version, this session's peer and scene counts, and the last + 300 log lines. It goes to your clipboard and nowhere else — paste it into a bug report. + {#if $cloudPluginInfo} {$cloudPluginInfo.name} {$cloudPluginInfo.version} {/if} diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index c35d1511..92d4fcdd 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -20,6 +20,13 @@ 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' + // 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'; @@ -116,6 +123,24 @@ 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)); @@ -157,6 +182,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 {} @@ -198,12 +235,39 @@ 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'); +}); + $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()}` + + // 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() } @@ -418,14 +482,26 @@ 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}
@@ -593,7 +669,20 @@ 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; } diff --git a/src/lib/autosave.js b/src/lib/autosave.js index b699a2f3..660f5b0b 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -25,8 +25,12 @@ import { gameState, gameStateSnapshot, gameStateRestore } from './gameState'; import { peers, showToast, showInfoToast } 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 } 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'; // 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 @@ -124,7 +128,7 @@ function exportScene() { unpark(); unstamp(); restore(); - console.log('autosave export failed', error); + log('warn', 'autosave', 'export failed', String(error)); resolve(null); } ); @@ -216,7 +220,7 @@ async function saveSnapshot() { await idbPut('latest', snapshot); dirty = false; } catch (error) { - console.log('autosave failed', error); + log('warn', 'autosave', 'snapshot save failed', String(error)); } } @@ -275,16 +279,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' && !!localStorage.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 +338,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,6 +349,17 @@ 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); } @@ -347,6 +371,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') localStorage.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) { @@ -426,7 +459,7 @@ async function applyRestore(snapshot) { } return true; } catch (error) { - console.log('restore failed', error); + log('warn', 'autosave', 'restore failed', String(error)); return false; } } diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index b2ed200c..59a9e512 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -22,7 +22,11 @@ import { annotations } from '$lib/annotationsHandler' import { isViewer, warnViewerReadOnly } from '$lib/objectPermissions' import { get } 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'; //Access scene Store let scene = $state(); @@ -65,7 +69,12 @@ 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 +148,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}); } @@ -239,8 +252,22 @@ export function sceneCommand(command) { * 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(); + // 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([]); @@ -260,8 +287,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,6 +318,7 @@ 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); @@ -483,9 +513,11 @@ 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); } @@ -522,6 +554,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 +583,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){ diff --git a/src/lib/connectionState.js b/src/lib/connectionState.js index ffa55343..979930fc 100644 --- a/src/lib/connectionState.js +++ b/src/lib/connectionState.js @@ -33,10 +33,125 @@ 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(localStorage.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') localStorage.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))); +} + /** 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 } /** 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/flowRuntime.js b/src/lib/flowRuntime.js index 6048db04..dcfc31e2 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1,9 +1,9 @@ import * as THREE from 'three'; 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 +63,9 @@ 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'; // H3: inputRuntime is reached via a PRIMED dynamic import (the moduleSDK // pattern) — a static edge would close the TDZ cycle history -> flowRuntime -> @@ -2767,7 +2770,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; } @@ -2952,8 +2955,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 @@ -3235,7 +3253,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 +3269,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 { + localStorage.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} */ diff --git a/src/lib/geometries.svelte.js b/src/lib/geometries.svelte.js index dd3bef28..030cbc89 100644 --- a/src/lib/geometries.svelte.js +++ b/src/lib/geometries.svelte.js @@ -15,6 +15,9 @@ function initRectAreaUniforms() { } import { notifyExternalMove, noteObjectPose } from '$lib/flowRuntime'; import { globalScene, objectsGroup, TControls, lockedObjects, selectedObject, selectedObjects } 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(); @@ -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/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/moduleSDK.js b/src/lib/moduleSDK.js index 48b2cf74..11cda385 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -36,6 +36,8 @@ 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'; // 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 +80,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 +110,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)); } }); } @@ -1481,9 +1483,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 +1510,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) => { @@ -1573,7 +1575,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 +1627,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 +1646,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/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/peerApproval.js b/src/lib/peerApproval.js index 78cbe5da..8698ef1d 100644 --- a/src/lib/peerApproval.js +++ b/src/lib/peerApproval.js @@ -1,6 +1,11 @@ import { get } from 'svelte/store'; import { peers, userdata, pendingApprovals, waitingForApproval, showToast } from '../stores/appStore'; -import { sessionHost } from './connectionState'; +import { + sessionHost, + APPROVAL_WINDOW_MS, + noteApprovalStarted, + clearApprovalStarted +} from './connectionState'; // Pending-connection approval (211). Kept in its own store-only module so VR // (vrControls -> executeVRMenuAction) can call it WITHOUT statically importing @@ -172,6 +177,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 +189,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 +249,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..c7a38bb7 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -18,8 +18,15 @@ import { applyUvPaint, applyUvPaintEnd } from '$lib/uvEditor'; import { applySplineEdit } from '$lib/splineTool'; import { initVoiceChat, attachVoiceToPeer, voicePeerConnected } 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'; +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 { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; import { applyPing } from '$lib/ping'; import { applyAssetFile, answerAssetRequest, applyAssetThumb, answerAssetThumbRequest, applyAssetStart, applyAssetChunk, applyAssetMissing } from '$lib/assetShare'; @@ -97,6 +104,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 +129,27 @@ userdata.subscribe(value => { users = value }); * Pure presence, re-sent continuously, useless to somebody in a different world. */ const STREAM_TYPES = new Set(['camera', 'vrhands']); +/** + * 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; @@ -178,16 +210,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 +259,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 +294,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 +316,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 +365,30 @@ 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] ?? ''; + 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 +415,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 @@ -361,8 +440,12 @@ export class PeerConnection { 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 @@ -417,7 +500,11 @@ export class PeerConnection { var approvals = get(pendingApprovals); if (!approvals.some(toast => toast.peerId === conn.peer)) { approvals.push({ peerId: conn.peer }); - pendingApprovals.set(approvals); + // 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 +517,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 +538,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 +585,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 @@ -922,11 +1025,42 @@ 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; + } + // …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); + } + }); } } @@ -1115,7 +1249,7 @@ export class PeerConnection { // 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 +1320,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]; @@ -1196,14 +1330,14 @@ export class PeerConnection { } const conn = this.peer.connect(peerId); 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 +1348,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 +1380,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); } @@ -1292,7 +1426,7 @@ export class PeerConnection { 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); @@ -1431,7 +1565,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/physics.js b/src/lib/physics.js index 002e4cff..d0379f86 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -1243,8 +1243,40 @@ export function applyThrow(data) { 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 { + stepInner(now); + } 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.'); + } +} + +/** 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 @@ -1503,7 +1535,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 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/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/flowStore.js b/src/stores/flowStore.js index 86961931..75ca7082 100644 --- a/src/stores/flowStore.js +++ b/src/stores/flowStore.js @@ -194,6 +194,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 -- diff --git a/src/stores/sceneStore.js b/src/stores/sceneStore.js index 59f5167b..df49458a 100644 --- a/src/stores/sceneStore.js +++ b/src/stores/sceneStore.js @@ -51,6 +51,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); /** 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/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.test.cjs b/tests/e2e/net-stress.test.cjs new file mode 100644 index 00000000..8dfb67d1 --- /dev/null +++ b/tests/e2e/net-stress.test.cjs @@ -0,0 +1,176 @@ +// 27-I — THE SMALL MESH REGRESSION SUITE. +// +// `net-stress.cjs` beside this file is the MEASUREMENT RIG: a many-minute sweep across +// mesh sizes that spawns its own signaling server and refuses any non-localhost APP_URL. +// Its header has always pointed at this file for the quick check, and this file did not +// exist — so `npm run e2e -- net-stress` matched the rig's name and ran nothing. +// +// What this pins, on a THREE-peer mesh, is the handful of properties the rig measures +// that would be a real regression if they broke: +// 1. the mesh FILLS — a late joiner dials one peer and ends up connected to both +// 2. a broadcast reaches every peer with NO loss, by sequence number +// 3. one send's fan-out cost stays bounded (it is a per-conn loop, never batched) +// 4. the same, while a second sender is loading the mesh — nobody starves +// +// The probe rides a REAL `move` payload with additive `__ns` fields, which is the rig's +// own trick and matters twice over: it exercises the real applier path, and since 27-A +// validates every incoming message, a made-up uuid would be REJECTED by that guard — so +// the probe carries an actual object's uuid. +// +// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- net-stress.test +const h = require('./helpers.cjs'); + +/** A reduced installProbe: hook every conn, count probe messages per sender by seq. */ +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: {}, sendMs: [], seq: 0 }); + ns.pc = pc; + // the app's outgoing map AND peerjs's own, which also holds INBOUND conns — an + // ack can come back over a conn this peer never dialled + 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 || 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; + }; + // conns keep appearing through the join phase, so keep re-scanning + 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 — without this the + // two-way check below passes on numbers left over from the first blast. + ns.reset = () => { + ns.rx = {}; + }; + 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) }; + }; + 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 openConns = (peer) => + peer.page.evaluate(() => { + let pc; + window.__stores.peers.subscribe((p) => (pc = p))(); + return pc?.openedPeers?.size ?? 0; + }); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + const C = await h.setupPage(browser, 'C'); + + // ---- 1. the mesh fills ------------------------------------------------------------- + await h.connect(B, A); + // a CONNECTED peer's pill has no dial input, so the late joiner dials the HOST + await h.connect(C, A); + await h.eventually(() => openConns(C), (n) => n >= 2, 'the late joiner ends up connected to BOTH peers', 30000); + await h.eventually(() => openConns(A), (n) => n >= 2, 'the host holds both connections', 20000); + await h.eventually(() => openConns(B), (n) => n >= 2, 'and the first joiner was filled in by the mesh', 20000); + + // 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 A.page.waitForTimeout(800); + + for (const p of [A, B, C]) 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); + + const atB = await received(B, A.id); + const atC = await received(C, A.id); + h.check(atB.count === 60, `every message reached the first joiner (${atB.count}/60, maxSeq ${atB.maxSeq})`); + h.check(atC.count === 60, `every message reached the late joiner (${atC.count}/60, maxSeq ${atC.maxSeq})`); + h.check( + atB.maxSeq === 59 && atC.maxSeq === 59, + `and the LAST one arrived, so nothing was dropped off the tail (${atB.maxSeq}, ${atC.maxSeq})` + ); + + // ---- 3. fan-out cost stays bounded -------------------------------------------------- + // `send` is a per-conn loop with no batching, so this is the number that grows with N. + h.check( + blast.maxSendMs < 250, + `one broadcast's fan-out stays bounded (worst send ${blast.maxSendMs.toFixed(1)}ms across 2 conns)` + ); + + // ---- 4. two senders at once: nobody starves ----------------------------------------- + // clear the counters first, or section 2's seq 59 makes this check unfalsifiable + for (const p of [A, B, C]) await p.page.evaluate(() => window.__probe.reset()); + await Promise.all([ + A.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 40, 25]), + B.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 40, 25]) + ]); + await A.page.waitForTimeout(1500); + const cFromA = await received(C, A.id); + const cFromB = await received(C, B.id); + h.check( + cFromA.maxSeq === 39 && cFromB.maxSeq === 39 && cFromA.count === 40 && cFromB.count === 40, + `under two-way load the late joiner got both streams WHOLE (A ${cFromA.count}/40 seq ${cFromA.maxSeq}, B ${cFromB.count}/40 seq ${cFromB.maxSeq})` + ); + + 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/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/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/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/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/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' + } +});