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.
+
+
+ Save scene
+ Reload
+
+
+
+{/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') : '')}
+ />
{/if}
+ {#if $signalingRetry.retrying}
+
+ Reconnecting… {$signalingRetry.attempt}
+ {/if}
+
@@ -444,6 +488,19 @@
}
/* the chevron is a lucide component's svg — the class lands OUTSIDE this
component's scope hash, so these selectors must be :global to reach it */
+ /* 27-F: the signaling retry chip. Amber like the pending state, compact, and only
+ present while the link is down — so it costs the pill no width the rest of the time. */
+ .cx-retry {
+ align-self: center;
+ white-space: nowrap;
+ border-radius: 9999px;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ color: #78350f;
+ background: #fbbf24;
+ }
+
.cx-toggle :global(.cx-chevron) {
transition: transform 0.2s ease;
}
diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte
index 6c97f20a..6dc0eebd 100644
--- a/src/components/menu/Settings.svelte
+++ b/src/components/menu/Settings.svelte
@@ -12,6 +12,10 @@
import { gamepadPrefs, setGamepadPrefs, DEADZONE_RANGE, SENSITIVITY_RANGE } from '$lib/gamepadPrefs';
import { drawerSlot, cloudPluginInfo } from '$lib/cloudHooks';
import { versionString } from '$lib/version.js';
+ // 27-B: the diagnostics bundle — clipboard only, nothing leaves the browser
+ import { copyDiagnostics } from '$lib/diagnostics';
+ // 27-E: session size. LOCAL, like every other connection preference.
+ import { softPeerCap, HARD_PEER_CAP, SOFT_PEER_CAP_DEFAULT } from '$lib/connectionState';
const appVersionString = versionString();
import { vrFaceCap, VR_FACE_CAP } from '$lib/faceEdit';
import { doubleClickAction, DOUBLE_CLICK_ACTIONS } from '$lib/selectionPrefs';
@@ -2006,6 +2010,27 @@
{#snippet header()}Connection{/snippet}
+
+
+ {
+ const n = Number(e.target.value);
+ softPeerCap.set(Number.isFinite(n) ? Math.min(HARD_PEER_CAP, Math.max(2, Math.round(n))) : SOFT_PEER_CAP_DEFAULT);
+ }}
+ />
+
+ How many people you expect in a session. Everyone connects to everyone, so each
+ extra person costs every other person bandwidth — voice and live gestures are the
+ hungry parts. Past this number an approval still works but warns; the hard limit
+ is {HARD_PEER_CAP} , where approving would degrade
+ the session for everybody rather than just for whoever joined last.
+
{#snippet header()}About{/snippet}
{appVersionString}
+
+
+ {
+ const ok = await copyDiagnostics();
+ showToast(ok ? 'Diagnostics copied to the clipboard' : 'Could not copy the diagnostics');
+ }}>Copy diagnostics
+
+ 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}
- approvePeer(approval, null)} title="Approve as a view-only viewer">View only
+ approvePeer(approval, null)} title={roomFull ? 'This session is full (' + HARD_PEER_CAP + ')' : 'Approve as a view-only viewer'}>View only
{#if approval.status !== 'retry'}
- approvePeer(approval, 'editor')} title="Approve and grant edit access">Editor access
+ approvePeer(approval, 'editor')} title={roomFull ? 'This session is full (' + HARD_PEER_CAP + ')' : 'Approve and grant edit access'}>Editor access
{/if}
{:else}
- approvePeer(approval, null)}>Approve
+ approvePeer(approval, null)} title={roomFull ? 'This session is full (' + HARD_PEER_CAP + ')' : 'Approve this request'}>Approve
{/if}
rejectPeer(approval)} title="Decline">Reject
@@ -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'
+ }
+});