From 2faa46b7690bfa85453922f23114feb7b3f0fe3e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 05:46:13 +0300 Subject: [PATCH 01/27] [feat] 27-B: the diagnostics ring, global error capture and a copyable bundle - src/lib/diagnostics.js, a zero-store LEAF (svelte/store + version.js only): a 300-entry ring, log(level, scope, message, data), the lastUncaught store, the registerDiagnosticsSection seam, bundle/bundleText/copyDiagnostics, and startDiagnostics installing window error + unhandledrejection capture. - App.svelte installs it FIRST in onMount and registers the session section (peer id, open conns, roster, object and mesh counts, renderer.info). The seam is what keeps diagnostics.js free of store imports, so the modules sitting inside the documented import cycles can log without closing one. - Toasts.svelte mirrors lastUncaught into ONE sticky card with Copy diagnostics (the restoreAvailable idiom); Settings About gains a Diagnostics row. - Recovery paths report through log(warn, ...) now: autosave 5 sites, flowRuntime 3, moduleSDK 8. peerHandler's are deferred to 27-A, which rewrites that file. - Nothing leaves the browser: the bundle goes to the clipboard and nowhere else. Why: the audit's H4. src/lib held 135 console.log against 17 console.error/warn and there was no window.onerror or unhandledrejection handler anywhere, so an uncaught error in a store subscriber broke that subscriber chain silently and a user had no way to hand over what happened. Suite tests/e2e/diagnostics.test.cjs, 17 checks, ALL PASS. Counterfactuals, each broken then restored (file verified byte-identical after): - window error listener removed: the three error-capture checks go red. - section try/catch removed: the run ABORTS on section-boom, which is the point. A bundle that cannot be produced when something is broken is worthless. - CAP raised 300 to 400: the ring holds 320 and keeps line 0. Gates: npm run build exit 0; svelte-check 361 errors / 47 warnings, equal to the base measured in this worktree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- src/App.svelte | 42 ++++- src/components/menu/Settings.svelte | 17 ++ src/components/menu/Toasts.svelte | 25 +++ src/lib/autosave.js | 13 +- src/lib/diagnostics.js | 262 ++++++++++++++++++++++++++++ src/lib/flowRuntime.js | 9 +- src/lib/moduleSDK.js | 18 +- tests/e2e/diagnostics.test.cjs | 151 ++++++++++++++++ 8 files changed, 518 insertions(+), 19 deletions(-) create mode 100644 src/lib/diagnostics.js create mode 100644 tests/e2e/diagnostics.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 118d45a5..cbcc8948 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -31,6 +31,7 @@ import SplineToolbar from './components/menu/SplineToolbar.svelte' import ModuleToolboxLayer from './components/ui/ModuleToolboxLayer.svelte' import { isLocked } from './stores/sceneStore' + import { objectsGroup, globalRenderer } from './stores/sceneStore' import { startFlowRuntime } from '$lib/flowRuntime' import { startNodeSync } from '$lib/nodesHandler' import { startLockSweep } from '$lib/lockControl' @@ -59,6 +60,8 @@ 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' import { startTrackpadNav } from '$lib/trackpadNav' import { startInviteLinks } from '$lib/inviteLinks' import { startHelperLayer, helpersInPlay } from '$lib/helperLayer' @@ -81,6 +84,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import HudEditor from './components/editors/HudEditor.svelte' import { importFile, load } from '$lib/fileHandler.svelte' import { showToast } 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,6 +99,37 @@ 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() + // 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. @@ -367,9 +402,10 @@ 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') + ]).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]) => { + 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 } }) } }) diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 6c97f20a..608095c2 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -12,6 +12,8 @@ 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'; const appVersionString = versionString(); import { vrFaceCap, VR_FACE_CAP } from '$lib/faceEdit'; import { doubleClickAction, DOUBLE_CLICK_ACTIONS } from '$lib/selectionPrefs'; @@ -2188,6 +2190,21 @@ {#snippet header()}About{/snippet} {appVersionString} + + + + + 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..f96af3ef 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -20,6 +20,10 @@ 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' import { rolesInfo } from '$lib/cloudHooks' import { sceneCommand } from '$lib/commandsHandler.svelte'; import { objectsGroup, camSave, globalCamera, globalScene } from '../../stores/sceneStore.js'; @@ -198,6 +202,27 @@ 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) diff --git a/src/lib/autosave.js b/src/lib/autosave.js index b699a2f3..541fff9a 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -25,6 +25,9 @@ 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'; @@ -124,7 +127,7 @@ function exportScene() { unpark(); unstamp(); restore(); - console.log('autosave export failed', error); + log('warn', 'autosave', 'export failed', String(error)); resolve(null); } ); @@ -216,7 +219,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)); } } @@ -284,7 +287,7 @@ async function checkRestore() { else restoreAvailable.set(offer); }); } catch (error) { - console.log('autosave restore check failed', error); + log('warn', 'autosave', 'restore check failed', String(error)); } } @@ -325,7 +328,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); @@ -426,7 +429,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/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/flowRuntime.js b/src/lib/flowRuntime.js index 6048db04..a864954a 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -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); + log('warn', 'flow', 'module effect ' + anim.type + ' failed', String(error)); } return; } @@ -3235,7 +3238,7 @@ function runTick(now) { try { task(time); } catch (error) { - console.log('module frame task failed', error); + log('warn', 'flow', 'module frame task failed', String(error)); } }); @@ -3248,7 +3251,7 @@ function runTick(now) { try { postTick(now); } catch (error) { - console.log('post-tick hook failed', error); + log('warn', 'flow', 'post-tick hook failed', String(error)); } } } 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/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); +}); From 2a63b9f0a6f09866d924ea21a7f4fe52481f3793 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 06:05:25 +0300 Subject: [PATCH 02/27] [feat] 27-F: the signaling link never gives up, and a closed peer is rebuilt - netBackoff gains jitter with an injectable rng, and an unbounded max. Both are ADDITIVE and inert by default, so every existing caller is byte-identical. backoffSchedule takes a limit, because an unbounded max has no full schedule and the loop never terminated - measured as RangeError: Invalid array length beforehand. - peerHandler now has ONE recreate ritual. Three callers each kept 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. That is why a closed peer stayed dead: reconnect() cannot revive a spent Peer object, so the only way out was a reload. - The retry is unbounded: 800ms doubling to an 8s ceiling, plus or minus 25 percent so a room of tabs does not return in lockstep. It used to stop after five attempts and tell the user to reload, which drops every live DataConnection AND the invite id, while the thing that failed is usually a lid closing or a wifi hop. The CAPPED interval protects the server; the attempt count protected nobody. - online and visibilitychange retry immediately and reset the schedule: the wait exists for a server that is down, not for a link that has just come back. - unavailable-id met on a REBUILD now schedules another rebuild rather than falling through to "please reload" - the same dead end, by another door. - One toast on the way in, one on recovery, and a chip on the Connect pill for the live state. An unbounded retry that toasts per attempt is spam. Suites: signaling-reconnect NEW, 17 checks, ALL PASS. net-backoff 8 to 15 checks (jitter at three injected rng values, the clamp that stops a negative wait, unbounded saturation at the cap, schedule termination). connect-states green, which is the pill's data-state contract. Counterfactuals, each broken then restored, peerHandler verified byte-identical after: - bounded retry restored: the attempt stalls at 5 and the reload toast returns, 3 red. - close handler reverted to log-only: the rebuild and reopen checks, 2 red. - the two retry listeners removed: retry-now and schedule-reset, 2 red. Two defects this phase's own suite found in it, both fixed here: the rebuild is guarded on open, which a synthetic close leaves true, so two neighbouring checks had been passing vacuously against the object they were meant to replace; and the id-collision branch only ever covered the first open. net-reconnect's "B's new object reaches A after the heal" is PRE-EXISTING, not from this phase: the same single check fails with these five files reverted to 2faa46b. Gates: npm run build exit 0; svelte-check 361 errors / 47 warnings, equal to the base measured in this worktree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- src/components/menu/Connect.svelte | 29 ++++ src/lib/connectionState.js | 21 +++ src/lib/netBackoff.js | 30 +++- src/lib/peerHandler.svelte.js | 91 +++++++++--- tests/e2e/net-backoff.test.cjs | 30 ++++ tests/e2e/signaling-reconnect.test.cjs | 188 +++++++++++++++++++++++++ 6 files changed, 360 insertions(+), 29 deletions(-) create mode 100644 tests/e2e/signaling-reconnect.test.cjs diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 8945c74f..49e282c7 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 } from '$lib/connectionState'; import { cancelOutboundRequest, requestConnect } from '$lib/peerApproval'; import { sessionHost } from '$lib/connectionState'; import { connectSlot, drawerSlot } from '$lib/cloudHooks'; @@ -292,6 +294,20 @@ {/if} + {#if $signalingRetry.retrying} + + Reconnecting… {$signalingRetry.attempt} + {/if} + @@ -444,6 +460,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/lib/connectionState.js b/src/lib/connectionState.js index ffa55343..e6b954bd 100644 --- a/src/lib/connectionState.js +++ b/src/lib/connectionState.js @@ -33,6 +33,27 @@ 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 }); +} + /** Full reset — leaving the session / cancelling out. */ export function resetSession() { sessionHost.set(null); 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/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 743be46a..a53bf645 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -18,7 +18,7 @@ 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'; +import { sessionHost, markPeerJoined, resetSession, signalingRetry, noteSignalingRetry, clearSignalingRetry } from '$lib/connectionState'; import { canApply, getAuthProvider, dispatchCloudMessage, rolesInfo } from '$lib/cloudHooks'; import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; import { applyPing } from '$lib/ping'; @@ -97,6 +97,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; @@ -178,16 +182,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 +231,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 +266,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,21 +288,34 @@ 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', () => { + console.log('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'); 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); @@ -311,14 +337,23 @@ 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; + console.log('id still held by the old registration — rebuilding in ' + wait + 'ms'); + 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(); + recreatePeer(this.didFallback); return; } if (err.type === 'peer-unavailable') { @@ -348,6 +383,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 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/signaling-reconnect.test.cjs b/tests/e2e/signaling-reconnect.test.cjs new file mode 100644 index 00000000..05f44571 --- /dev/null +++ b/tests/e2e/signaling-reconnect.test.cjs @@ -0,0 +1,188 @@ +// 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; + }); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await page.waitForTimeout(150); + const onOnline = await page.evaluate(() => ({ + calls: window.__sig.calls, + attempts: window.__sig.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)' + ); + + await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))); + await page.waitForTimeout(250); + const onVisible = await page.evaluate(() => ({ calls: window.__sig.calls, hidden: document.hidden })); + h.check( + onVisible.calls === onOnline.calls + 1, + `a tab becoming visible retries too, a lid or a phone lock ends here (calls ${onOnline.calls} -> ${onVisible.calls}, 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); +}); From 35b00386f3b302e4f390c5d11063d1dd31b2fc4e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 06:30:12 +0300 Subject: [PATCH 03/27] [fix] 27-C: the runtime loops survive a throw instead of ending the session - flowRuntime: `tick` re-arms the frame in a `finally`. It used to call runTick and THEN requestAnimationFrame, so an exception escaped before the re-arm and no further frame was ever scheduled: every flow animation and every physics step stopped for the rest of the session, silently. One bad module task, post-tick hook or node evaluator was enough. - safeRunTick is shared by the desktop scheduler and the XR pump, so a headset gets the same guard rather than a second copy of the bug. - A tick that keeps throwing PAUSES after 120 consecutive failures, with a Resume card, instead of burning a core at the frame rate with nobody watching. flowPaused lives in flowStore because 27-D's safe-mode boot sets it before the runtime starts. - Per-frame failures are rate-limited PER KIND, first three then one per 300. A throwing frame task writes 60 lines a second otherwise, which evicts the context around the first failure - the only line that says what broke. - physics: the step is wrapped, so a throw (a NaN off the wire, a poisoned body, a rapier panic inside wasm) stops the simulation ONCE with a toast and leaves the scene intact. It used to escape into the post-tick slot and be logged forever with the sim dead. - Two TEST-ONLY hooks, failTicksForTest and throwOnNextStepForTest, because every real path into those bodies is individually caught - which IS this phase - so the threshold and the re-arm would otherwise be unprovable. Suite tests/e2e/runtime-resilience.test.cjs, 15 checks, ALL PASS. Counterfactuals, each broken then restored (both files verified byte-identical after): - runTick called unguarded: the loop dies. Spin 0 to 0, frame task 0 calls, and the pump throw escapes as SCRIPT FAILED. That is the historical bug, reproduced. - physics wrapper removed: both physics-stop checks go red. - rate limiter removed: 7 log lines for 5 calls instead of at most 4. Held: flow-runtime ALL PASS. UNRESOLVED, and deliberately NOT claimed as pre-existing: flow-physics-actions (1 red, the stale-stamp guard) and game-loop-v4 (3 red, scene travel). The A/B that would settle whether they are mine was killed twice by this machine's OOM killer, so there is no verdict yet. Both families are documented as timing-sensitive and the box is loaded, but that is a hypothesis. Owed: re-run the A/B when it is quieter. A trap that cost five errors and three hunts, worth carrying into the gotchas: anchoring an insertion on a DECLARATION silently orphans the JSDoc comment above it. It hit runTick's @param, stopSimulation's options, and mutedFlowObjects' @type - and that last one surfaced as THREE errors in objectMenu.js, a file this phase never edited, because a store that loses its annotation infers never[]. Gates: npm run build exit 0; svelte-check 361 errors / 47 warnings, equal to base, with zero new entries by a full list diff. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- src/lib/flowRuntime.js | 91 +++++++++++-- src/lib/physics.js | 35 ++++- src/stores/flowStore.js | 10 ++ tests/e2e/runtime-resilience.test.cjs | 177 ++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/runtime-resilience.test.cjs diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index a864954a..bb877e3d 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'; @@ -2770,7 +2770,7 @@ function applyAnimation(object, base, anim, time, ctx) { trigger: moduleTriggerInfo(anim, ctx) }); } catch (error) { - log('warn', 'flow', 'module effect ' + anim.type + ' failed', String(error)); + noteFrameFailure('module effect ' + anim.type, error); } return; } @@ -2955,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 @@ -3238,7 +3253,10 @@ function runTick(now) { try { task(time); } catch (error) { - log('warn', 'flow', 'module frame task failed', String(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); } }); @@ -3251,22 +3269,79 @@ function runTick(now) { try { postTick(now); } catch (error) { - log('warn', 'flow', 'post-tick hook failed', String(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; + +/** 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; + 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/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/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/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); +}); From dd1594738133fe18e801b26d297a085dbb62a03c Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 06:41:00 +0300 Subject: [PATCH 04/27] [fix] 27-A: one bad message can no longer kill a connection - NEW src/lib/wireValidate.js, a zero-import leaf: isUuid / isFiniteArray / isVec3 / isQuatOrEuler, sanitizeTransform, and a VALIDATORS table keyed by message type. THE RULE THAT KEEPS IT ADDITIVE: an absent entry ALLOWS, so a peer one release ahead is never rejected on shape - only counted as unknown by the dispatcher. - NEW src/lib/wireErrors.js: per-(peer, type) counters, noteWireError, a rate-limited toast after five failures from one pair, and a diagnostics section registered through 27-B's own seam. A separate leaf because diagnostics.js must stay dependency-free. - peerHandler: the dispatch chain is named, so a try/catch wraps 440 lines without re-indenting any of them. Shape is checked BEFORE anything reads data.type, then the type's own shape, then dispatch inside the guard; an unknown type is counted in a real else. canApply stays the first POLICY gate. - THE RAW-STRING BRANCH IS GONE (audit M11). It routed a peer's string into sceneCommand, where "/clear all" wipes the scene AND re-broadcasts it - a receiver re-broadcasting, which is golden rule 1 inverted. Nothing sends raw strings, so it stood unreachable and armed; what is there now is the unknown-type counter that makes version skew visible. - Every conn reports its own failures: error and iceStateChanged listeners live in handleData, the one function all five creation sites already call (four dials plus the adopted inbound conn). eventemitter3 swallows an error nobody listens for. - The appliers that trusted shape are guarded: userData and lockRestore accept only arrays, and moveGeometry routes through sanitizeTransform. Counters are dropped on disconnect, which is golden rule 3's obligation. Suite tests/e2e/wire-hardening.test.cjs, 15 checks, ALL PASS. It feeds the REAL dispatcher through a stubbed conn: null, a raw string, a number, an unknown type, malformed hosts / userdata / locked, a NaN move, then a VALID move that must still land. Counterfactuals, each isolating ONE guard (peerHandler restored byte-identical after): - shape guard removed: a null reaches data.type and the run dies, Cannot read properties of null. - validator removed, try/catch KEPT: the three refused-before-its-applier checks go red and NOTHING escapes - the try/catch contains the applier throw. - validator AND try/catch removed: it escapes, data.hosts.forEach is not a function. That triple was designed after noticing the obvious version proves nothing: with the validator in place, no hostile message reaches an applier, so removing the try/catch alone changes nothing. A duplicate defence found and documented rather than left ambiguous: the validator refuses a non-finite transform at the gate, so sanitizeTransform never runs for wire traffic. Rejection is right for a transform - a partially repaired pose is one nobody sent - so the sanitiser stays as the backstop for callers that do not pass the dispatcher, and counterfactual (b) is what proves it fires when the gate is absent. Gates: npm run build exit 0; svelte-check 359 errors / 47 warnings against a 361/47 base, with ZERO new entries by a full error-list diff (the two removed come from deleting the string branch and narrowing the guarded appliers). The baseline could ratchet 361 to 359 at integration; release.yml is not touched by a phase commit. OWED: the held suites net-handshake, net-locks and object-sync were not run - they need a dev server up, and this machine has killed five jobs for memory. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- src/App.svelte | 11 ++- src/lib/commandsHandler.svelte.js | 11 ++- src/lib/geometries.svelte.js | 19 ++++ src/lib/peerHandler.svelte.js | 55 ++++++++++- src/lib/wireErrors.js | 105 +++++++++++++++++++++ src/lib/wireValidate.js | 142 ++++++++++++++++++++++++++++ tests/e2e/wire-hardening.test.cjs | 151 ++++++++++++++++++++++++++++++ 7 files changed, 485 insertions(+), 9 deletions(-) create mode 100644 src/lib/wireErrors.js create mode 100644 src/lib/wireValidate.js create mode 100644 tests/e2e/wire-hardening.test.cjs diff --git a/src/App.svelte b/src/App.svelte index cbcc8948..95bdc742 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -62,6 +62,8 @@ import { startMusicToolbox } from './lib/musicToolbox' 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' @@ -101,6 +103,7 @@ import { startMusicToolbox } from './lib/musicToolbox' 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', () => { @@ -403,9 +406,11 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/inviteLinks'), import('./lib/helperLayer'), import('./lib/explorerClipboard'), - import('./lib/diagnostics') - ]).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]) => { - 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 } + 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 } }) } }) diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index b2ed200c..a11d7711 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -22,6 +22,7 @@ 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'; //Access scene Store @@ -65,7 +66,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) @@ -260,8 +266,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 +297,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); diff --git a/src/lib/geometries.svelte.js b/src/lib/geometries.svelte.js index dd3bef28..0de6cbfe 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]); diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index a53bf645..b4cc6ad9 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -20,6 +20,10 @@ import { initVoiceChat, attachVoiceToPeer, voicePeerConnected } from '$lib/voice import { resolvePeerOptions, describePeerServer, peerServerStatus, parseInviteHash, decodeInviteServer, applyInviteServerOverride, inviteServerOverride } from '$lib/peerServer'; import { sessionHost, markPeerJoined, resetSession, signalingRetry, noteSignalingRetry, clearSignalingRetry } 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'; @@ -498,7 +502,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). @@ -969,11 +983,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); + } + }); } } 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/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); +}); From 34907d4ae5a1950e05f1b6594cf25e14576e8a3b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 06:50:09 +0300 Subject: [PATCH 05/27] [feat] 27-I: CI on pull requests, and one place where the baseline lives - .github/workflows/ci.yml: build, check and unit are REQUIRED on every pull request and on pushes to main and release/next. Until now the only workflow ran on a v* TAG, so CONTRIBUTING's rule (build passes, check adds no new errors) was enforced by whoever remembered, and a broken main was found at release time. - scripts/check-ratchet.cjs + check-baseline.json: the svelte-check floor is DATA read by one script, and release.yml now calls that script instead of carrying its own copy. The hardcoded number in that shell block had gone stale - it said 362 while the tree measures 359 - and a gate whose number is wrong either blocks honest work or waves a regression through. Ratcheting down is the project's convention, so --update writes the new floor rather than making it an edit in two places. - vitest + vitest.config.ts + npm run test:unit, with four suites over the leaves that import NOTHING: netBackoff (the default schedule as a contract, 27-F's jitter with an INJECTED rng, the unbounded retry that must still terminate when asked for a schedule), wireValidate (the additive rule - an unknown type is ALLOWED - plus the hostile shapes and the nullable sanitiser), hudRichText (a run is text, icon or br; there is no html kind, so a hostile string has nothing to become) and meshBudget (the RELATIONSHIPS between the ceilings, which is what silently breaks, not the measured numbers). WHY THE UNIT LAYER IS NARROW: only zero-import modules qualify, so a run needs no browser, no jsdom, no svelte compiler and no three.js - 41 tests in 153 ms, fast enough to be a required job. throwVelocity (imports three) and transferLedger (imports svelte/store) are deliberately NOT in this first cut; padding the list with modules that need a runtime is how a unit suite becomes a slow second e2e suite. WHY e2e-smoke REPORTS RATHER THAN GATES: it renders WebGL through SwiftShader on a GPU-less runner, where this project measures ~4.5 fps, and several suites are documented as timing-sensitive. Blocking every PR on that before it has been seen green would train people to ignore a red tick, which is worse than no tick. The comment in the file says when to flip it. Verification, since GitHub Actions cannot be exercised without a push: every job's commands were run locally in order - build exit 0, node scripts/check-ratchet.cjs exit 0 at 359/47 equal to the floor, npm run test:unit 4 files / 41 tests. The ratchet's failure path was proven separately by tightening the floor (exit 1) and its improvement path by loosening it. The gate caught its own author twice: these unit tests first added NINE type errors (reading .color off a run union whose br variant has no such field, and probing meshBudget for three functions that do not exist) and then FOUR more (reading through a nullable return without saying which case the test expected). Both are fixed here; the errors are the evidence that the check job does something. OWED, not done here: tests/e2e/net-stress.test.cjs, the small N=4 mesh regression the stress harness header has promised since B5. It needs a local signalling server and four browsers, and this machine has killed five jobs for memory today; faking it would be worse than recording it. Gates: npm run build exit 0; svelte-check 359 errors / 47 warnings, equal to the committed baseline, with zero new entries by a full error-list diff. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- .github/workflows/ci.yml | 95 +++++++++++ .github/workflows/release.yml | 22 +-- check-baseline.json | 6 + package-lock.json | 279 +++++++++++++++++++++++++++++++- package.json | 6 +- scripts/check-ratchet.cjs | 107 ++++++++++++ tests/unit/hudRichText.test.js | 118 ++++++++++++++ tests/unit/meshBudget.test.js | 49 ++++++ tests/unit/netBackoff.test.js | 61 +++++++ tests/unit/wireValidate.test.js | 116 +++++++++++++ vitest.config.ts | 16 ++ 11 files changed, 849 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 check-baseline.json create mode 100644 scripts/check-ratchet.cjs create mode 100644 tests/unit/hudRichText.test.js create mode 100644 tests/unit/meshBudget.test.js create mode 100644 tests/unit/netBackoff.test.js create mode 100644 tests/unit/wireValidate.test.js create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..16fb3387 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +# 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: | + for s in net-backoff net-mesh wire-hardening runtime-resilience diagnostics mesh-budget; 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/check-baseline.json b/check-baseline.json new file mode 100644 index 00000000..3ffc9cec --- /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": 359, + "warnings": 47, + "measured": "2026-09-13" +} 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/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/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' + } +}); From 0dc8b1ea2b9c50cd4f5a8a8bf697f813d2004b0f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 07:38:51 +0300 Subject: [PATCH 06/27] [feat] 27-E approval timeouts and session size Roadmap 25 A-D, from the hardening audit (H3, H7, M10, L7). An approval could hang forever on BOTH sides: the joiner sat on Requesting AB12 with no countdown and no end, the host collected one card per dial with nothing ever dropping them, and nothing bounded how many peers a full mesh accepts. - connectionState: APPROVAL_WINDOW_MS (90s), MAX_PENDING_APPROVALS (12), SOFT_PEER_CAP_DEFAULT (8) / HARD_PEER_CAP (16), a persisted softPeerCap, and approvalStartedAt as the ONE clock the joiner countdown and the host card age both read, so the two sides cannot disagree about one request. - peerApproval: the dial stamps that clock and arms an expiry; expiry cancels the request, takes back the optimistic whitelist row and offers Try again. peer-unavailable now ENDS the request instead of toasting unreachable while the pill still says Requesting. - peerHandler: the pending queue is bounded, dropping EXPIRED cards before live ones; approval REMOVES the waitingForApproval row rather than mutating it (audit M10 - the array grew one dead row per join for the tab lifetime); the mesh-fill guard refuses past the hard cap. - Connect: the pill counts down. Toasts: the card shows its age, stays approvable past the window, and the approve buttons disable at the cap with the reason on the button. Settings: a Session size control. - Scene: the camera stream is rate-gated to ~20/s (33ms in VR), measured at 6 sends in 1149ms rather than one per frame (audit H7). TWO DEFECTS FOUND WHILE COVERING THIS, both fixed here: - armApprovalTimeout called clearApprovalTimeout defensively to avoid a duplicate timer, and that helper ALSO cleared the stamp - so dial wrote the clock and the very next line deleted it. Every outbound request lost its countdown, the pill fell back to a fabricated 1:30 that never decremented, and the host card read asked 1s ago forever. Cancelling a TIMER is not ending a REQUEST, so the clock stays there now; the paths that really end one clear it. - The cap was counted off userdata.length in four places. That roster is the WHITELIST, written at DIAL time, so a host who dialled sixteen names would refuse every approval while sitting alone. sessionSize/roomIsFull count the OPEN connections plus you, in one place, unit tested. The pill no longer fabricates a countdown when there is no stamp: a confident frozen 1:30 is worse than none, and it is also what made the new countdown check pass vacuously. Verification: approval-timeout 18/18 (new suite). Unit 50/50 with a new connectionState leaf suite. Counterfactuals proven by breaking each guard and watching it go red - restoring the clock wipe turns the stamp AND countdown checks red, restoring the whitelist count re-enables the approve button at the cap - each restored byte-identical. Neighbours green: connect-states 28 (its exact-match assertion updated for the countdown, still exact on the peer id), connect-decision 46, hud-content 160, spatial-voice, modal-layering, vr-peer-approve, roadmap-13-notifications-notes. invite-link-live fails one check, measured PRE-EXISTING by an A/B against HEAD (21 passes and the same single failure on both sides, 206s vs 207s); its failing leg dials across the public PeerJS cloud. Build green, svelte-check 359/47 unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- src/components/Scene.svelte | 15 +- src/components/menu/Connect.svelte | 32 +++- src/components/menu/Settings.svelte | 23 +++ src/components/menu/Toasts.svelte | 66 ++++++++- src/lib/connectionState.js | 94 ++++++++++++ src/lib/geometries.svelte.js | 26 +++- src/lib/peerApproval.js | 66 ++++++++- src/lib/peerHandler.svelte.js | 47 +++++- tests/e2e/approval-timeout.test.cjs | 222 ++++++++++++++++++++++++++++ tests/e2e/connect-states.test.cjs | 2 +- tests/unit/connectionState.test.js | 74 ++++++++++ 11 files changed, 651 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/approval-timeout.test.cjs create mode 100644 tests/unit/connectionState.test.js diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 44a07b66..7df4caf1 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -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); diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 49e282c7..fe53390a 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -6,7 +6,7 @@ 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 } from '$lib/connectionState'; + 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'; @@ -41,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' ); @@ -261,7 +281,15 @@ {:else if connState === 'pending'}
- + 0 ? ' · ' + Math.floor(pendingLeft / 60) + ':' + String(pendingLeft % 60).padStart(2, '0') : '')} + /> + {#if approval.status !== 'retry'} - + {/if} {:else} - + {/if}
@@ -618,7 +663,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/connectionState.js b/src/lib/connectionState.js index e6b954bd..979930fc 100644 --- a/src/lib/connectionState.js +++ b/src/lib/connectionState.js @@ -54,10 +54,104 @@ export function clearSignalingRetry() { 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/geometries.svelte.js b/src/lib/geometries.svelte.js index 0de6cbfe..030cbc89 100644 --- a/src/lib/geometries.svelte.js +++ b/src/lib/geometries.svelte.js @@ -340,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/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 b4cc6ad9..10f5c03e 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -18,7 +18,7 @@ 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, signalingRetry, noteSignalingRetry, clearSignalingRetry } from '$lib/connectionState'; +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. @@ -126,6 +126,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; @@ -361,6 +382,10 @@ export class PeerConnection { 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.'); @@ -412,8 +437,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 @@ -468,7 +497,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(); } @@ -549,6 +582,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 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/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); + }); +}); From 7a7f447d2ac92405b597b05624ef8e5a3eba2c73 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 08:07:09 +0300 Subject: [PATCH 07/27] [test] 27-I: the mesh regression suite the rig has always pointed at net-stress.cjs 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 pointed at net-stress.test.cjs for the quick check since the day it was written, and that file did not exist. The runner only lists files ending in .test.cjs, so npm run e2e -- net-stress matched nothing runnable and quietly ran no checks at all. The new suite pins, on a THREE-peer mesh, the properties the rig measures that would be a real regression if they broke: - the mesh FILLS: a late joiner dials one peer and ends up connected to both - a broadcast reaches every peer with NO loss, checked by sequence number - one send's fan-out stays bounded (it is a per-conn loop, never batched) - under simultaneous load from two senders, a joiner gets both streams whole The probe rides a REAL move payload with additive fields, which is the rig's own trick and matters twice over here: it exercises the real applier path, and since 27-A validates every incoming message, a made-up uuid would be rejected by that very guard - so the probe carries an actual object's uuid, read back off objectsGroup the way undo.test.cjs does. ONE CHECK WAS VACUOUS on its first green and is fixed in the same commit: maxSeq is a running MAXIMUM and the received counts accumulate, so section 2's 60-message blast left maxSeq at 59 and the later two-way check could not fail. The probe resets its counters between sections and the assertion is exact on both counts and both sequence numbers. CHANGELOG: a section under Unreleased for the hardening batch so far - requests that end, session size, the signaling link that stops giving up, the wire guard, the runtime surviving a bad frame, and the copyable diagnostics bundle. Verified: net-stress 10/10 on three peers (233s). svelte-check 359/47 unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- CHANGELOG.md | 27 ++++++ tests/e2e/net-stress.test.cjs | 176 ++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 tests/e2e/net-stress.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a393370..eb03d8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,33 @@ 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. + ## 1.10.0 — Publish, play, remix ☁️ The engine learned the moves a community needs — publish the open scene, open a 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); +}); From 8f0b1f6b9baad95d14240f1011369295bf3a909c Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 08:36:59 +0300 Subject: [PATCH 08/27] [fix] 27-D: a runaway script node no longer freezes every peer Audit finding C1, the only CRITICAL one. A Script node runs on EVERY peer, every frame, on the main thread inside the shared flow tick - so a while(true) in one node did not hang its author, it hung the tab of everyone in the session, with no way out but closing it. MEASURED, by bypassing the new guard and re-running the suite: one check passes, then the run dies with Target page, context or browser has been closed, and the runner axes it at its 480s cap. With the guard in, the same suite is 11/11 in 31s. - lib/loopGuard.js (NEW leaf, imports nothing): instrument(code) declares a counter per run and injects a limit check at the top of every loop BODY. The budget is per FRAME 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. It is a SCANNER, not a parser: it knows just enough to tell code from a string, a template, a comment and a regex, so a commented-out while is not instrumented and a division is not read as the start of one. What it cannot bracket-match it REFUSES, and a refusal shows as the node's error badge rather than silently running unguarded. - scriptRuntime: instrument inside compile(), which caches by CODE STRING - so each distinct script is transformed exactly once, and an edit re-instruments it and clears the old badge for free. Plus a per-node TIME budget, because a merely SLOW node returns between frames and no loop counter can ever see it: over 8ms for 30 consecutive frames pauses the node with a paused: too slow badge, and editing the code re-arms it. Binding entry.fn before the call also removed a PRE-EXISTING possibly-undefined invocation, which is why the baseline ratchets 359 -> 358. - Safe mode: opening the app with #safe pauses the flow runtime BEFORE it starts, so a scene whose scripts misbehave on load can still be opened and repaired; Resume is 27-C's existing exit. The hash is the whole mechanism on purpose - a HELD key cannot be read at boot (there is no synchronous API for modifier state, only events), so a Shift check would look like a second way in while being one the first frame could never honour. - restoreArmed: autosave arms it before applying a snapshot - inside applyRestore, so the explicit Restore button is covered too - and flowRuntime clears it on the first CLEAN tick. A flag still set at the next boot means that restore never reached a working frame, so auto-restore is skipped and the prompt says why, which is what stops one bad scene becoming a boot loop nobody can escape. Written straight to localStorage in flowRuntime because the import edge runs autosave -> flowRuntime, and reversing it would close a cycle into the history family. - tests: script-guard drives a REAL while(true) node through the runtime (the node needs an objectselector and an edge, or nothing resolves a target and the whole suite would pass vacuously). helpers.setupPage gains an additive hash option: safe mode is read once during onMount, so the page must LOAD with the hash rather than have one assigned afterwards. Absent means an unchanged URL. THREE DEFECTS IN MY OWN SCANNER, each found by RUNNING its output rather than matching its text, and each now pinned by a unit test: edits were applied in push order rather than by POSITION, so nested unbraced loops emitted Unexpected token }; a do/while's trailing while was read as a loop header with no body and refused the whole script; and a slash after return was read as division, because return ends in an identifier character. One fixture bug worth recording, because it is a trap the guard itself creates: the slow-node check first failed because 900k plain additions measure about a millisecond. The guard caps every script at a million iterations, so a slow-but-terminating fixture cannot buy time by looping MORE - it has to do more work per iteration. The suite now measures the fixture in the page first (22.1ms here) so a fast machine fails the PREMISE rather than the feature. Verified: script-guard 11/11 (new), 18 new loopGuard unit tests, unit suite 68/68, build green, svelte-check 358/47 with the baseline ratcheted down to match. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- CHANGELOG.md | 10 + check-baseline.json | 4 +- src/App.svelte | 23 ++- src/components/menu/Toasts.svelte | 8 +- src/lib/autosave.js | 22 ++- src/lib/flowRuntime.js | 22 +++ src/lib/loopGuard.js | 301 ++++++++++++++++++++++++++++++ src/lib/scriptRuntime.js | 62 +++++- tests/e2e/helpers.cjs | 6 +- tests/e2e/script-guard.test.cjs | 175 +++++++++++++++++ tests/unit/loopGuard.test.js | 121 ++++++++++++ 11 files changed, 744 insertions(+), 10 deletions(-) create mode 100644 src/lib/loopGuard.js create mode 100644 tests/e2e/script-guard.test.cjs create mode 100644 tests/unit/loopGuard.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index eb03d8d3..b409b909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,16 @@ - 🩺 **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. ## 1.10.0 — Publish, play, remix ☁️ diff --git a/check-baseline.json b/check-baseline.json index 3ffc9cec..fbfda597 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 359, + "errors": 358, "warnings": 47, - "measured": "2026-09-13" + "measured": "2026-09-12" } diff --git a/src/App.svelte b/src/App.svelte index 95bdc742..06977f73 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -32,7 +32,10 @@ import ModuleToolboxLayer from './components/ui/ModuleToolboxLayer.svelte' import { isLocked } from './stores/sceneStore' import { objectsGroup, globalRenderer } from './stores/sceneStore' - import { startFlowRuntime } from '$lib/flowRuntime' + import { startFlowRuntime, resumeFlowRuntime } from '$lib/flowRuntime' + // 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' @@ -85,7 +88,7 @@ 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' @@ -138,6 +141,22 @@ import { startMusicToolbox } from './lib/musicToolbox' // 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() diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index 3e3a02e9..92d4fcdd 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -261,7 +261,13 @@ $effect(() => { 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() } diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 541fff9a..332854a5 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -278,12 +278,21 @@ 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) { @@ -350,6 +359,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) { diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index bb877e3d..dcfc31e2 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -3287,12 +3287,34 @@ let tickFails = 0; * 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++; 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/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/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/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/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); + }); +}); From 032704aebe7e271056d8a5be0b037ad3320f3d10 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 08:57:20 +0300 Subject: [PATCH 09/27] [fix] 27-G: GPU memory comes back, and a lost context says so Audit H6 and M13. H6: removing an object dropped the reference and NOTHING else. Its geometry, its materials and every texture they held stayed resident until the context died, so a session that imported and deleted the same model ten times paid for ten copies. deleteObject has been parent.remove(object) and no more since it was written. M13: nothing anywhere handled webglcontextlost. When the browser takes the context away the canvas simply stops updating while every other part of the app keeps answering, so it reads as the whole thing having crashed, with nothing to act on. - lib/disposeTree.js (NEW leaf, THREE only): keepSet(scene, doomed) works out what the REST of the scene still holds in one traversal, and disposeTree(root, {keep}) frees only what nothing else refers to. THE DIFFICULTY IS SHARING, not freeing: clone() shares geometry and material (which is why editOverlays detaches without disposing), onionSkin borrows a real mesh's geometry, and a material fanned across a selection is one object. Disposing something still drawn does not throw - it renders BLACK, later, somewhere else, with nothing to connect it to the delete that caused it. Textures are found by scanning a material's OWN properties for isTexture rather than a hardcoded map list, because three grows new map slots release to release and a list silently stops covering the newest one. - Call sites: the /delete command, deleteObject, clearSceneLocal (where clear() freed nothing at all), both override-replace paths, and autosave's twin swap. The keep set spans the SCENE ROOT rather than the replicated group, because scene-root helpers share resources with real meshes on purpose; autosave is the one exception and says why in place. - Undo needed nothing, and that was CHECKED rather than assumed: history's captureObjectSnapshot calls object.toJSON() and applyPresence restores through an ObjectLoader, so no entry holds a live GPU resource. Had it held references, disposing on delete would have handed undo objects with freed buffers - the one failure mode in this phase that reports nothing at all. - faceEdit needed nothing either: applyMeshGeo already disposes the previous geometry before swapping. Recorded rather than changed. - Context loss: Scene.svelte listens on the canvas, with teardown beside its siblings. preventDefault() is load-bearing rather than a formality - without it the browser never fires a restore event AT ALL. Restoring forces a material recompile across the scene. ContextLostOverlay says what happened, offers to save (.tpscene, which needs no selection where a GLTF export would ask) and to reload, and says the scene is intact because it lives in the page, not on the card. TWO VACUOUS CHECKS OF MY OWN, both caught by the counterfactual rather than by reading them: - an orphan invariant comparing renderer.info.memory against a scene walk PASSED with disposal entirely bypassed. The two are not a superset relationship - gizmo parts and VR helpers are referenced without all being uploaded. Replaced with an exact floor-return check, which does fail. - the floor itself was measured wrong. The gizmo's geometries upload the first time they are DRAWN, not when an object is selected, so a warm-up that created and deleted inside one evaluate left them for the next section to allocate and read a floor of 4 where the truth was 18. Verified: dispose 14/14 (new), 11 new disposeTree unit tests including the keep-set counterfactual (a shared texture IS destroyed without it), unit suite 79/79, build green, svelte-check 358/47 unchanged. Counterfactual with disposal bypassed: three checks red (floor 19 against 29 held, peak 29 -> 29, clear 37 -> 37), restored byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- CHANGELOG.md | 13 ++ src/App.svelte | 4 + src/components/ContextLostOverlay.svelte | 94 +++++++++++ src/components/Scene.svelte | 25 ++- src/lib/autosave.js | 12 ++ src/lib/commandsHandler.svelte.js | 28 ++++ src/lib/disposeTree.js | 118 ++++++++++++++ src/stores/sceneStore.js | 9 ++ tests/e2e/dispose.test.cjs | 193 +++++++++++++++++++++++ tests/unit/disposeTree.test.js | 164 +++++++++++++++++++ 10 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 src/components/ContextLostOverlay.svelte create mode 100644 src/lib/disposeTree.js create mode 100644 tests/e2e/dispose.test.cjs create mode 100644 tests/unit/disposeTree.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b409b909..fead07f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,19 @@ 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 ☁️ diff --git a/src/App.svelte b/src/App.svelte index 06977f73..98d98514 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -33,6 +33,9 @@ import { isLocked } from './stores/sceneStore' 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' @@ -488,6 +491,7 @@ import { startMusicToolbox } from './lib/musicToolbox'
DEBUG · helpers
{/if} + diff --git a/src/components/ContextLostOverlay.svelte b/src/components/ContextLostOverlay.svelte new file mode 100644 index 00000000..e1733ef6 --- /dev/null +++ b/src/components/ContextLostOverlay.svelte @@ -0,0 +1,94 @@ + + +{#if $contextLost} +
+
+

The 3D view has stopped

+

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

+
+ + +
+
+
+{/if} + + diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 7df4caf1..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, @@ -567,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; @@ -1264,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/lib/autosave.js b/src/lib/autosave.js index 332854a5..660f5b0b 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -30,6 +30,7 @@ import { idbGet, idbPut, idbDelete } from './idb'; 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 @@ -348,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); } diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index a11d7711..59a9e512 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -24,6 +24,9 @@ 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(); @@ -145,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}); } @@ -245,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([]); @@ -492,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); } @@ -531,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. @@ -557,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/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/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/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/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 }); + }); +}); From c3cb8cd2caa29623693343a3b7a157a6a61bb4d6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 09:08:09 +0300 Subject: [PATCH 10/27] [chore] integration A: the recovery story reaches the bundle, and CI smokes the new suites The glue commit the execution plan asks for after wave 1, minus one item that turned out to need no work. - peerHandler had NO diagnostics logger and 24 console calls. The RECOVERY narrative is what somebody needs when reporting a session that fell apart - the id collision and rebuild, adopting an inbound conn as the send channel, a signaling link that is down, each restore attempt and its give-up, a drop with no goodbye, the reconnect, and a failed send - and all of it existed only in a console nobody copies. Those 14 move to log(), so they ride the copyable bundle 27-B added. The chatty ones (ids, hosts, per-send noise) stay on console deliberately. diagnostics.js imports only svelte/store and version.js, so this closes no cycle - checked by building, since a TDZ cycle in this neighbourhood takes the app down at boot and every suite dies in setupPage. - CI smoke gains the three new SINGLE-PEER suites: script-guard, dispose and approval-timeout. net-stress and signaling-reconnect are deliberately NOT added: they are multi-peer and meet on the self-hosted signaling box, which a public runner cannot reach and should not be pointed at. The workflow header already says two-peer suites stay a manual gate; this keeps that promise. - The plan's third item, registering wire statistics in the bundle, was ALREADY DONE by 27-A: wireErrors.js registers a diagnostics section named wire, publishing the failure total and the first twenty entries. Nothing called wireStats exists, and nothing needs to. Recorded rather than invented to match the wording. MEASURED, not assumed: signaling-reconnect's visibility check is flaky, and it is not this change. Three runs of the unmodified branch read 16/17/16 passes, and an A/B against HEAD's peerHandler failed the same check. The counts disagree in both directions - 1 -> 1 on base, 1 -> 2 and 1 -> 3 on later runs of identical code - because the check compares an exact delta against a number read in an earlier await, so a retry already scheduled by the sections above can land inside its window. Fixed separately in the suite. An instrumented run capturing the reconnect call stacks is what settled it: one call, from retryNow, via the online event. Verified: diagnostics 16/16, net-peer-id 7/7, unit suite 79/79, build green, svelte-check 358/47 unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- .github/workflows/ci.yml | 6 +++++- src/lib/peerHandler.svelte.js | 31 +++++++++++++++++-------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16fb3387..abf64162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,11 @@ jobs: env: APP_URL: https://localhost:5173/ run: | - for s in net-backoff net-mesh wire-hardening runtime-resilience diagnostics mesh-budget; do + # 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::" diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 10f5c03e..c7a38bb7 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -18,6 +18,9 @@ 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'; +// 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 @@ -318,7 +321,7 @@ export class PeerConnection { // 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', () => { - console.log('server closed'); + 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...'); @@ -335,7 +338,7 @@ export class PeerConnection { // 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, RETRY_BACKOFF) ?? 8000; @@ -346,7 +349,7 @@ export class PeerConnection { }, 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 && @@ -369,7 +372,7 @@ export class PeerConnection { if (err.type === 'unavailable-id' && this.hasOpened && this.idRetries < 3) { this.idRetries++; const wait = backoffDelay(this.idRetries, RETRY_BACKOFF) ?? 8000; - console.log('id still held by the old registration — rebuilding in ' + wait + 'ms'); + 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; @@ -377,7 +380,7 @@ export class PeerConnection { if (err.type === 'unavailable-id' && !this.hasOpened && this.idRetries < 3) { this.idRetries++; this.myId = createPeer(); - console.log('session id collided — retrying as ' + this.myId); + log('warn', 'net', 'session id collided — retrying', { id: this.myId }); recreatePeer(this.didFallback); return; } @@ -514,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)); @@ -1246,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; } @@ -1317,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]; @@ -1327,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); @@ -1345,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 {} @@ -1377,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); } @@ -1423,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); @@ -1562,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) }); } }); } From 87c9d720eec43bef7b1ed97364c02257a166a9d5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 09:15:03 +0300 Subject: [PATCH 11/27] [test] signaling-reconnect: the retry checks stop racing a peer rebuild NOT flaky - deterministically wrong, which is a more useful thing to know. The glue commit before this one called it flaky; this corrects the record. Both checks stage a dead signaling link by shadowing open/disconnected/destroyed on the peer object and counting reconnect() calls. The app's correct response to a dead link is to REBUILD the peer, and a rebuild replaces this.peer outright - which discards the shadows and the counter with it. Every earlier shape of these checks was measuring an orphan, which is why they read 0, 1, 2 and 3 retries across runs of identical code, on base as well as on the branch. An instrumented run named the guard. Before the dispatch the live peer read open:false, disconnected:false, destroyed:false - a freshly rebuilt peer in the CONNECTING state, which retryNow deliberately has no branch for - while the event itself was delivered (a fresh listener counted it) and the object being measured was not the one that had been stubbed. dispatchEvent runs its listeners SYNCHRONOUSLY, so stubbing, dispatching and reading the counter inside ONE page evaluation leaves no window for a rebuild to intervene. Both checks are rewritten that way, against the peer the app holds at that instant rather than one captured at setup. NOT a product defect: retryNow reads this.peer live, so a genuinely disconnected peer is still retried. Only the fixture was stale. Verified: three consecutive runs 17/17, where the previous shape gave 16/17/16 and then 16/15/15. Counterfactual with retryNow neutered: 14 passes and exactly the three checks red (both retry counts and the schedule reset), restored byte-identical. svelte-check 358/47 unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HYKS3CfQLm1FyDzbxG7qRX --- tests/e2e/signaling-reconnect.test.cjs | 36 ++++++++++++++++++-------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/tests/e2e/signaling-reconnect.test.cjs b/tests/e2e/signaling-reconnect.test.cjs index 05f44571..49d6f559 100644 --- a/tests/e2e/signaling-reconnect.test.cjs +++ b/tests/e2e/signaling-reconnect.test.cjs @@ -157,24 +157,38 @@ h.run(async () => { p.reconnect = () => window.__sig.calls++; pc.reconnectAttempts = 5; }); - await page.evaluate(() => window.dispatchEvent(new Event('online'))); - await page.waitForTimeout(150); - const onOnline = await page.evaluate(() => ({ - calls: window.__sig.calls, - attempts: window.__sig.pc.reconnectAttempts - })); + 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)' ); - await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))); - await page.waitForTimeout(250); - const onVisible = await page.evaluate(() => ({ calls: window.__sig.calls, hidden: document.hidden })); + 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 === onOnline.calls + 1, - `a tab becoming visible retries too, a lid or a phone lock ends here (calls ${onOnline.calls} -> ${onVisible.calls}, document.hidden=${onVisible.hidden})` + 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(() => { From b7f5ec107ea586bf4b7a33d59370a462f6dac448 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 10:26:58 +0300 Subject: [PATCH 12/27] [fix] 27-H: an IndexedDB operation always settles The hardening audit's M3, and the half of it this project had already MEASURED from the outside: storageUsage.js's `safeGet` exists because "idb.js settles only on the request's own onsuccess/onerror, so an aborted transaction leaves a promise pending FOREVER". This is the fix that finding was owed. - `tx.onabort` rejects, in all four wrappers. THE TIMING IS THE WHOLE POINT and is why the first version of the test passed for the wrong reason: abort a transaction with a request still in flight and that request errors FIRST, which bubbles to `tx.onerror`, so the old code happened to settle. Abort once every request has succeeded and `onabort` is the only event that fires - that is the case that hung, and it is what the seam now reproduces. - `withTimeout` bounds every operation at 10s. Rule 1 covers the aborts the browser reports; the bound covers the class it does not, where the request object simply never fires again. The error carries `timedOut` so a caller can branch without matching a string, and it logs through 27-B's diagnostics ring. - `open()` is cached, with the cache dropped on `onclose`, on `onversionchange`, on a failed open, and on the `InvalidStateError` a stale handle throws (which `withDb` retries once - that retry is what pays for the cache). Every op used to open its own connection and a storage scan makes a few hundred in a burst. - 10s IS MEASURED, not assumed: a 25MB put - larger than the Explorer's own import cap - takes ~480ms here, so the bound has ~20x headroom over the largest write the app can make. The suite asserts a 5x margin, so a change that makes writes genuinely slow turns red instead of silently failing a user's import. - storageUsage.js's comment said the fix was "still owed"; it now says it landed and why the 5s bounded read stays anyway (a panel must not wait 10s per key). Counterfactuals, each proven by breaking the code and watching the suite: - `tx.onabort` removed -> "an aborted transaction REJECTS rather than hanging" reads `still waiting in 5369ms`, which is the bug verbatim (3 checks red). - the `Promise.race` bound removed -> the stalled transaction reads `still waiting, timedOut=false` after 5263ms (2 checks red). - the `open()` cache removed -> "20 reads reuse one connection" reads `20 new opens`. - unit: the same three properties with no browser, including a `still waiting` race that says what an unbounded await does. Suites: storage-hardening NEW 10/10. Held green: autosave-object-flows, explorer-storage (239s for the pair). Unit 86 tests / 8 files (base 79 / 7). svelte-check 358/47, exactly the committed baseline. Build green with the dev server stopped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um --- src/lib/idb.js | 254 ++++++++++++++++++++++++--- src/lib/storageUsage.js | 27 +-- tests/e2e/storage-hardening.test.cjs | 165 +++++++++++++++++ tests/unit/idbTimeout.test.js | 74 ++++++++ 4 files changed, 481 insertions(+), 39 deletions(-) create mode 100644 tests/e2e/storage-hardening.test.cjs create mode 100644 tests/unit/idbTimeout.test.js diff --git a/src/lib/idb.js b/src/lib/idb.js index d5cbfb38..cbaec46c 100644 --- a/src/lib/idb.js +++ b/src/lib/idb.js @@ -1,57 +1,257 @@ // Minimal promise wrapper around IndexedDB — used for autosave snapshots, // which regularly exceed the localStorage size limit. +// +// 27-H (hardening audit M3) — A PROMISE FROM HERE ALWAYS SETTLES. +// +// It used to settle on the request's own `onsuccess` / `onerror` and nothing else, so a +// transaction that ABORTED without firing either left the promise pending FOREVER and an +// `await` on it stalled its caller with no error anywhere: no rejection, no +// `unhandledrejection`, nothing in the console. `storageUsage.js` measured the symptom +// from the outside ("a scan opened from the header chip stopped after three keys") and +// wrote a bounded read around it; this is the fix that finding is owed. +// +// Three rules now, and they compose: +// 1. `tx.onabort` REJECTS. An abort is a real outcome — quota, a closing connection, a +// `tx.abort()` from anywhere — and it has to reach the caller as one. +// 2. Every op is bounded by `withTimeout`. Rule 1 covers the aborts the browser tells +// us about; a timeout covers the ones it does not, which is the whole class of "the +// request object simply never fires again". A bounded failure a caller can report +// beats an unbounded wait it cannot. +// 3. `open()` is CACHED. Every call used to open its own connection — one per read, +// one per write — and a storage scan makes a few hundred of them in a burst. The +// cache is dropped whenever the connection dies (`onclose`, `onversionchange`, or a +// `transaction()` that throws because the handle is closing), so the next call +// reopens rather than inheriting a dead handle. +import { log } from './diagnostics'; const DB_NAME = 'theprototype'; const STORE = 'snapshots'; +/** + * How long any one operation may take before it is reported as failed. + * + * MEASURED before choosing it (storage-hardening §1): a 25 MB put — larger than the + * Explorer's own 25 MB import cap and half the autosave snapshot ceiling — completes in + * well under a second on this hardware, so 10s is roughly two orders of magnitude of + * headroom over the largest write the app can make. The number exists to bound a HANG, + * not to police slowness, and the suite asserts the margin so a future change that makes + * writes genuinely slow turns it red rather than silently failing a user's import. + */ +export const OP_TIMEOUT_MS = 10_000; + +/** @type {number | null} test override for the timeout (null = OP_TIMEOUT_MS) */ +let timeoutOverride = null; +/** @type {'abort' | 'stall' | null} test override for the next transaction */ +let forcedFailure = null; + +/** + * TEST SEAM: make the next transaction fail the way the two unbounded cases do. + * `'abort'` calls `tx.abort()` once the request is queued (what a quota failure or a + * closing connection does); `'stall'` swallows every completion callback, which is the + * state that used to hang forever and now hits the timeout. One-shot — it clears itself + * as soon as it is used, so a suite cannot poison the rest of its own run. + * @param {'abort' | 'stall' | null} mode + */ +export function debugForceNextTx(mode) { + forcedFailure = mode; +} + +/** + * TEST SEAM: shorten the timeout so the bounded-failure path can be exercised in a suite + * without a ten-second wait. `null` restores the default. + * @param {number | null} ms + */ +export function debugTimeoutMs(ms) { + timeoutOverride = ms; +} + +/** @returns {number} */ +function limit() { + return timeoutOverride ?? OP_TIMEOUT_MS; +} + +/** + * Bound a promise. Exported because it is the pure half of this module and is unit + * tested with no IndexedDB at all (tests/unit/idbTimeout). + * + * The timer is cleared on BOTH settlements, not only on the win: a 10s handle left + * running for every read would keep a storage scan's few hundred timers alive and, in a + * test environment, hold the process open. + * @template T + * @param {Promise} promise @param {number} ms @param {string} label + * @returns {Promise} + */ +export function withTimeout(promise, ms, label) { + /** @type {any} */ + let timer = null; + const settled = promise.then( + (value) => { + clearTimeout(timer); + return value; + }, + (error) => { + clearTimeout(timer); + throw error; + } + ); + return Promise.race([ + settled, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new Error(`idb ${label} timed out after ${ms}ms`); + // @ts-ignore - a marker the callers can branch on without string matching + error.timedOut = true; + log('warn', 'idb', 'operation timed out', { op: label, ms }); + reject(error); + }, ms); + }) + ]); +} + +/** @type {Promise | null} */ +let dbPromise = null; + +/** Drop the cached connection so the next call reopens. @param {Promise} [only] */ +function invalidate(only) { + if (!only || dbPromise === only) dbPromise = null; +} + /** @returns {Promise} */ function open() { - return new Promise((resolve, reject) => { + if (dbPromise) return dbPromise; + /** @type {Promise} */ + const pending = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, 1); request.onupgradeneeded = () => request.result.createObjectStore(STORE); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + // A cached handle that the browser closes underneath us (a tab in another + // window upgrading the schema, storage being cleared, the OS reclaiming it) + // would otherwise be handed out forever, and every transaction on it throws. + db.onclose = () => invalidate(pending); + db.onversionchange = () => { + db.close(); + invalidate(pending); + }; + resolve(db); + }; + request.onerror = () => reject(request.error ?? new Error('idb open failed')); + request.onblocked = () => reject(new Error('idb open blocked')); }); + dbPromise = pending; + // a FAILED open must not be cached, or one transient error disables storage for the + // life of the tab + pending.catch(() => invalidate(pending)); + return withTimeout(pending, limit(), 'open'); } -/** @param {string} key */ -export async function idbGet(key) { - const db = await open(); +/** + * Run one transaction against the cached connection, reopening once if the handle turned + * out to be dead. `db.transaction()` throws synchronously on a closing connection, which + * is exactly the case the cache introduces — so the retry is what pays for the cache. + * @template T + * @param {string} label @param {(db: IDBDatabase) => Promise} body @returns {Promise} + */ +async function withDb(label, body) { + try { + return await withTimeout(body(await open()), limit(), label); + } catch (error) { + const name = /** @type {any} */ (error)?.name; + if (name !== 'InvalidStateError' && name !== 'TransactionInactiveError') throw error; + invalidate(); + log('warn', 'idb', 'connection was stale, reopening', { op: label }); + return withTimeout(body(await open()), limit(), label); + } +} + +/** + * Settle on every outcome a transaction has: complete, error AND abort. The abort arm is + * the one that was missing, and it is not hypothetical — `tx.abort()` fires it with + * `tx.error === null`, which is why the fallback message exists. + * @param {IDBTransaction} tx @param {() => any} value @returns {Promise} + */ +function settle(tx, value) { return new Promise((resolve, reject) => { - const request = db.transaction(STORE).objectStore(STORE).get(key); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + tx.oncomplete = () => resolve(value()); + tx.onerror = () => reject(tx.error ?? new Error('idb transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('idb transaction aborted')); + }); +} + +/** + * Apply a one-shot test override to a live transaction. + * + * `'abort'` aborts AFTER the request has succeeded, and the timing is the whole point: + * abort a transaction with a request still in flight and that request errors first, which + * BUBBLES to `tx.onerror` — so the old wrapper happened to settle. Abort once every + * request has already succeeded and `onabort` is the ONLY event that fires, which is the + * case that hung forever and the one the counterfactual has to reproduce. + * + * `'stall'` removes every handler the transaction could settle through: the shape of an + * operation the browser never reports on at all, which only the timeout can catch. + * @param {IDBTransaction} tx @param {IDBRequest} [request] + */ +function applyForcedFailure(tx, request) { + const mode = forcedFailure; + forcedFailure = null; + if (mode === 'abort') { + const fire = () => { + try { + tx.abort(); + } catch {} + }; + // `onsuccess` is free to overwrite: every read below takes its value at + // `oncomplete`, not from this handler + if (request) request.onsuccess = fire; + else queueMicrotask(fire); + } else if (mode === 'stall') + queueMicrotask(() => { + tx.oncomplete = null; + tx.onerror = null; + tx.onabort = null; + }); +} + +/** @param {string} key */ +export function idbGet(key) { + return withDb('get', (db) => { + const tx = db.transaction(STORE); + const request = tx.objectStore(STORE).get(key); + const promise = settle(tx, () => request.result); + applyForcedFailure(tx, request); + return promise; }); } /** @param {string} key @param {any} value */ -export async function idbPut(key, value) { - const db = await open(); - return new Promise((resolve, reject) => { +export function idbPut(key, value) { + return withDb('put', (db) => { const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).put(value, key); - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); + const request = tx.objectStore(STORE).put(value, key); + const promise = settle(tx, () => undefined); + applyForcedFailure(tx, request); + return promise; }); } /** All keys in the store (used to list saved environment presets) */ -export async function idbKeys() { - const db = await open(); - return new Promise((resolve, reject) => { - const request = db.transaction(STORE).objectStore(STORE).getAllKeys(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); +export function idbKeys() { + return withDb('keys', (db) => { + const tx = db.transaction(STORE); + const request = tx.objectStore(STORE).getAllKeys(); + const promise = settle(tx, () => request.result); + applyForcedFailure(tx, request); + return promise; }); } /** @param {string} key */ -export async function idbDelete(key) { - const db = await open(); - return new Promise((resolve, reject) => { +export function idbDelete(key) { + return withDb('delete', (db) => { const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).delete(key); - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); + const request = tx.objectStore(STORE).delete(key); + const promise = settle(tx, () => undefined); + applyForcedFailure(tx, request); + return promise; }); } diff --git a/src/lib/storageUsage.js b/src/lib/storageUsage.js index c62751df..99fb03ac 100644 --- a/src/lib/storageUsage.js +++ b/src/lib/storageUsage.js @@ -205,19 +205,22 @@ const READ_TIMEOUT_MS = 5000; /** a sentinel the timeout resolves with — `undefined` is a legitimate stored value */ const UNMEASURED = Symbol('unmeasured'); /** - * A BOUNDED read. `idb.js` settles its promise on the request's own `onsuccess` / - * `onerror` and nothing else — so a transaction that ABORTS without firing either leaves - * the promise pending FOREVER, and an `await` on it stalls whatever is holding it with no - * error anywhere. Measured here, and it is worth stating precisely because the symptom is - * so unhelpful: a scan opened from the header chip stopped after three keys, the panel - * kept showing the PREVIOUS reading, `unhandledrejection` never fired, and a scan started - * a few seconds later over the same store completed normally. + * A BOUNDED read. This was written around a bug in `idb.js`: it settled its promise on + * the request's own `onsuccess` / `onerror` and nothing else, so a transaction that + * ABORTED without firing either left the promise pending FOREVER and an `await` on it + * stalled its holder with no error anywhere. Worth stating precisely, because the symptom + * was so unhelpful: a scan opened from the header chip stopped after three keys, the + * panel kept showing the PREVIOUS reading, `unhandledrejection` never fired, and a scan + * started a few seconds later over the same store completed normally. * - * A panel whose whole job is to report a number must not be able to hang silently, so a - * read that does not come back inside the window is reported as an UNMEASURED row rather - * than being waited on. It is the honest degradation: the row still appears, still says - * what it is, and still offers to remove itself — only its size is missing, and it says - * so. (The scan needs six of these now rather than one per file: see the blob branch.) + * 27-H FIXED THAT AT THE SOURCE — `idb.js` rejects on `onabort` and bounds every + * operation at 10s — and this stays anyway, for a reason that has not changed: a panel + * whose whole job is to report a number must not wait ten seconds per key for a store + * that is misbehaving. A read that does not come back inside THIS window is reported as + * an UNMEASURED row rather than being waited on. It is the honest degradation: the row + * still appears, still says what it is, and still offers to remove itself — only its size + * is missing, and it says so. (The scan needs six of these now rather than one per file: + * see the blob branch.) * @param {string} key @returns {Promise<{value: any, measured: boolean}>} */ async function safeGet(key) { diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs new file mode 100644 index 00000000..ea6db781 --- /dev/null +++ b/tests/e2e/storage-hardening.test.cjs @@ -0,0 +1,165 @@ +// 27-H (hardening audit M3, M4, M5, M9) — STORAGE THAT FAILS OUT LOUD. +// +// Four things this covers, each of which used to fail silently: +// 1. an IndexedDB transaction that ABORTS or STALLS now rejects, instead of leaving +// its caller awaiting a promise that never settles +// 2. autosave cannot re-enter itself, measures its own export, backs off when the +// scene gets expensive, and raises a STICKY toast when the disk is full +// 3. `safeStorage` keeps working when `localStorage` throws (Safari private mode, a +// full quota), so a setting still applies for the session +// 4. the microphone is released when voice goes off +// +// Run: APP_URL=https://theprototype.app:5176/ npm run e2e -- storage-hardening +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // ---- 1. a transaction always settles ----------------------------------------------- + const seams = await A.page.evaluate( + () => typeof window.__stores.idb?.debugForceNextTx === 'function' && typeof window.__stores.idb?.debugTimeoutMs === 'function' + ); + h.check(seams, 'premise: the idb test seams are reachable'); + + const wrote = await A.page.evaluate(async () => { + try { + await window.__stores.idb.idbPut('27h-probe', { hello: 'world' }); + const back = await window.__stores.idb.idbGet('27h-probe'); + return back?.hello ?? null; + } catch (e) { + return 'threw: ' + e; + } + }); + h.check(wrote === 'world', `premise: an ordinary put/get round trip still works (${wrote})`); + + // THE FINDING. `tx.abort()` fires `onabort` and NOTHING else — no `oncomplete`, no + // `onerror` — so the old wrapper's promise stayed pending forever. The assertion is + // that the put REJECTS, not that it resolves: an abort is a failure and has to reach + // the caller as one. + // The probe RACES a 5s timer so the counterfactual reads as a clean failure rather + // than a harness crash: with `tx.onabort` removed this promise never settles, and + // "still waiting" is exactly the bug's name. + const aborted = await A.page.evaluate(async () => { + const t0 = performance.now(); + window.__stores.idb.debugForceNextTx('abort'); + const put = window.__stores.idb + .idbPut('27h-abort', { n: 1 }) + .then(() => ({ outcome: 'resolved', message: '' })) + .catch((error) => ({ outcome: 'rejected', message: String(error && error.message) })); + const result = await Promise.race([ + put, + new Promise((resolve) => setTimeout(() => resolve({ outcome: 'still waiting', message: '' }), 5000)) + ]); + return { ...result, ms: performance.now() - t0 }; + }); + h.check( + aborted.outcome === 'rejected', + `an aborted transaction REJECTS rather than hanging (${aborted.outcome} in ${Math.round(aborted.ms)}ms)` + ); + h.check( + /abort/i.test(aborted.message || ''), + `and it says an abort is what happened ("${aborted.message}")` + ); + h.check( + aborted.ms < 1000, + `and it says so immediately, not after the 10s bound (${Math.round(aborted.ms)}ms)` + ); + + // The other half: an operation the browser never reports on at all. `'stall'` removes + // every handler the transaction could settle through, which IS the original bug — the + // timeout is what turns it into a failure a caller can report. + const stalled = await A.page.evaluate(async () => { + window.__stores.idb.debugTimeoutMs(400); + const t0 = performance.now(); + window.__stores.idb.debugForceNextTx('stall'); + const put = window.__stores.idb + .idbPut('27h-stall', { n: 2 }) + .then(() => ({ outcome: 'resolved', timedOut: false, message: '' })) + .catch((error) => ({ + outcome: 'rejected', + timedOut: !!(error && error.timedOut), + message: String(error && error.message) + })); + const result = await Promise.race([ + put, + new Promise((resolve) => + setTimeout(() => resolve({ outcome: 'still waiting', timedOut: false, message: '' }), 5000) + ) + ]); + window.__stores.idb.debugTimeoutMs(null); + return { ...result, ms: performance.now() - t0 }; + }); + h.check( + stalled.outcome === 'rejected' && stalled.timedOut === true, + `a transaction that never reports back is bounded and rejects (${stalled.outcome}, timedOut=${stalled.timedOut})` + ); + h.check( + stalled.ms >= 350 && stalled.ms < 3000, + `and it waits the bound it was given, no more (${Math.round(stalled.ms)}ms for a 400ms bound)` + ); + + // A failure must not disable storage for the rest of the session — the abort and the + // stall above both went through the CACHED connection, so this is also the check that + // the cache is not poisoned by them. + const recovered = await A.page.evaluate(async () => { + try { + await window.__stores.idb.idbPut('27h-after', { n: 3 }); + const back = await window.__stores.idb.idbGet('27h-after'); + return back?.n ?? null; + } catch (e) { + return 'threw: ' + e; + } + }); + h.check(recovered === 3, `storage still works after both failures (${recovered})`); + + // The cache. Every op used to open its own connection, and a storage scan makes a few + // hundred in a burst. Counted at the source rather than inferred from timing. + const opens = await A.page.evaluate(async () => { + const real = indexedDB.open.bind(indexedDB); + let count = 0; + // @ts-ignore - deliberate instrumentation + indexedDB.open = (...args) => { + count++; + return real(...args); + }; + try { + await window.__stores.idb.idbGet('27h-probe'); // warm, in case nothing had opened yet + const warm = count; + for (let i = 0; i < 20; i++) await window.__stores.idb.idbGet('27h-probe'); + return { warm, after: count }; + } finally { + // @ts-ignore + indexedDB.open = real; + } + }); + h.check( + opens.after === opens.warm, + `20 reads reuse one connection instead of opening 20 (${opens.after - opens.warm} new opens)` + ); + + // WHY 10s IS THE RIGHT BOUND, measured rather than assumed: the largest write this app + // can make is an Explorer import at its own 25MB cap (the autosave ceiling is 50MB of + // JSON, which structured-clones comparably). If this ever approaches the bound, the + // timeout would start failing legitimate saves — so the margin is asserted, not hoped + // for. + const big = await A.page.evaluate(async () => { + const bytes = new Uint8Array(25 * 1024 * 1024); + for (let i = 0; i < bytes.length; i += 4096) bytes[i] = i & 255; // not all-zero + const t0 = performance.now(); + await window.__stores.idb.idbPut('27h-big', bytes); + const ms = performance.now() - t0; + await window.__stores.idb.idbDelete('27h-big'); + return { ms, bound: window.__stores.idb.OP_TIMEOUT_MS }; + }); + h.check( + big.ms * 5 < big.bound, + `a 25MB put has at least 5x headroom under the bound (${Math.round(big.ms)}ms of ${big.bound}ms)` + ); + + await A.page.evaluate(async () => { + for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k); + }); + + await h.finish(browser); +}); diff --git a/tests/unit/idbTimeout.test.js b/tests/unit/idbTimeout.test.js new file mode 100644 index 00000000..faa9c1ea --- /dev/null +++ b/tests/unit/idbTimeout.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { withTimeout, OP_TIMEOUT_MS } from '../../src/lib/idb.js'; + +// 27-H (audit M3). `withTimeout` is the pure half of the IndexedDB wrapper — the half +// that decides whether a caller ever hears back — so it is tested here, with no browser +// and no IndexedDB at all. The e2e suite covers the parts that need a real transaction +// (an abort rejecting, a stalled one hitting this bound). +// +// THE THING THAT MATTERS is the last describe: a promise that never settles must still +// reject, because that is the exact shape of the bug this phase exists to fix. + +describe('a settled promise passes straight through', () => { + it('resolves with its own value', async () => { + await expect(withTimeout(Promise.resolve(7), 1000, 'get')).resolves.toBe(7); + }); + + it('rejects with its own error, not a timeout', async () => { + const boom = new Error('aborted'); + await expect(withTimeout(Promise.reject(boom), 1000, 'put')).rejects.toBe(boom); + }); +}); + +describe('a promise that never settles is rejected anyway', () => { + it('rejects with a labelled, marked timeout', async () => { + const never = new Promise(() => {}); + const error = await withTimeout(never, 5, 'put').catch((e) => e); + expect(error).toBeInstanceOf(Error); + expect(error.timedOut).toBe(true); + expect(String(error.message)).toContain('put'); + expect(String(error.message)).toContain('5ms'); + }); + + // The counterfactual for the fix itself: without the bound, awaiting the same promise + // produces nothing at all. `Promise.race` against a short timer is how the test says + // "this never came back" without hanging the run. + it('would hang forever without it', async () => { + const never = new Promise(() => {}); + const outcome = await Promise.race([ + never.then(() => 'settled'), + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 30)) + ]); + expect(outcome).toBe('still waiting'); + }); +}); + +describe('the timer never outlives the operation', () => { + it('is cleared when the promise resolves first', async () => { + vi.useFakeTimers(); + try { + await withTimeout(Promise.resolve('ok'), 10_000, 'get'); + // a leaked 10s handle per read would keep a few hundred timers alive across + // one storage scan, and hold a node process open + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('is cleared when the promise rejects first', async () => { + vi.useFakeTimers(); + try { + await withTimeout(Promise.reject(new Error('nope')), 10_000, 'put').catch(() => {}); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('the default bound is a contract', () => { + it('is ten seconds — enough for any write this app can make', () => { + expect(OP_TIMEOUT_MS).toBe(10_000); + }); +}); From b5898ec1668e7d643ca418b393cb582c8a679015 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 10:47:23 +0300 Subject: [PATCH 13/27] [fix] 27-H: autosave stops stuttering, stops re-entering itself, and says when it fails The audit's M3 (re-entrancy, no quota feedback) and M5 (a full GLTF export of the whole scene on the main thread every 30s, worst exactly when the scene is biggest). - ONE SAVE AT A TIME. `markDirty` rescheduled `saveSnapshot` unconditionally and a snapshot is several awaits long, so on a scene whose export outlasts the debounce every tick started a FRESH full export while the previous one ran, each parking and unparking the same objects. A save asked for mid-write is now folded into the one in flight and scheduled once when it finishes. `saveNow` deliberately does NOT fold - it is the path whose promise is "it is on disk when I resolve", so it waits its turn. - THE CADENCE ADAPTS. `exportScene` measures itself and `cadenceFor(ms)` - pure, and exported so it can be asserted directly - turns that into the wait: 150ms or less keeps 30s, then it doubles per doubling of the cost to a 5min cap. Derived from ONE measurement rather than a stateful "double it, halve it", which oscillates. The 3-minute safety-net interval respects it too, or the backoff buys nothing. - THE PROBE STRINGIFY IS GONE. `JSON.stringify(snapshot).length` serialised everything and threw it away to learn a number, and then `idbPut` walked the same graph again. `estimateSnapshotBytes` reads the `.length` of the handful of base64 strings that ARE the bytes (GLTF buffers/images, animated-import file bytes) and estimates the rest from counts. MEASURED at 0.010ms against the stringify's 9.0ms on an 8MB snapshot. - A FAILED AUTOSAVE IS SAID OUT LOUD. A full disk reached `console.log` and stopped there, so crash recovery had silently switched itself off with nothing to tell the user - the worst shape a safety feature can fail in. Now a STICKY toast naming what it means for recovery, carrying "Manage storage", cleared by the next successful save; the reason also lands in 27-B's diagnostics bundle through a new `autosave` section (cadence, last cost, last error - the single most useful line in a lost-work report). `isQuotaError` tests all three spellings; Firefox's is a legacy numeric code. - Clearing `dirty` is now conditional on `dirtyPulse` not having moved during the export, the held-body `lastWritten` rule: a change made DURING a save is not in the bytes that save wrote. - The Storage panel renders the cadence in words, the last export's cost, and - only when it has backed off - why. An adaptive interval nobody can see is indistinguishable from autosave being broken. Counterfactuals, each proven by breaking the code: - re-entrancy guard removed -> three ticks during one save write 3 snapshots, 0 coalesced. - the failure report removed -> all five quota checks red, `lastError` null. - the cadence frozen at 30s -> "the live cadence is the one that measurement implies" reads `509ms -> 30000ms`. - the probe stringify restored -> "at least 20x cheaper" reads 2.270ms vs 2.1ms. One suite trap worth the line: the panel was opened with a page-side `import('/src/lib/storageUsage.js')`, which binds a SECOND module instance once vite has timestamped the app's copy - it passed once and then failed in two counterfactual runs for a reason that had nothing to do with the counterfactual. It goes through `window.__stores` now. Suites: storage-hardening 28/28 (10 -> 28). Held green: autosave-object-flows, explorer-storage, diagnostics (4 suites, 296s). svelte-check 358/47. Unit 86/86. Build green with the dev server stopped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um --- src/components/menu/StorageModal.svelte | 33 ++++ src/lib/autosave.js | 243 ++++++++++++++++++++++-- src/lib/idb.js | 35 +++- tests/e2e/storage-hardening.test.cjs | 171 +++++++++++++++++ 4 files changed, 459 insertions(+), 23 deletions(-) diff --git a/src/components/menu/StorageModal.svelte b/src/components/menu/StorageModal.svelte index ee496091..5f0ee57e 100644 --- a/src/components/menu/StorageModal.svelte +++ b/src/components/menu/StorageModal.svelte @@ -22,6 +22,11 @@ import { HardDrive, RefreshCw, Trash2, Info, ChevronRight } from '@lucide/svelte'; import { showConfirm } from '$lib/confirmDialog'; import { showToast } from '../../stores/appStore'; + // 27-H (audit M5): autosave backs its own cadence off when an export gets expensive, + // and a save cadence that quietly moved from 30s to 5 minutes should be visible + // somewhere rather than guessed at. This panel is already where "what is this app + // doing to my disk" is answered. + import { autosaveStatus, autosaveEnabled } from '$lib/autosave'; import { storageModalOpen, storageScan, @@ -162,6 +167,14 @@ } } + /** "every 30 seconds" / "every 2 minutes" — the cadence in words. @param {number} ms */ + function fmtCadence(ms) { + const seconds = Math.round(ms / 1000); + if (seconds < 90) return seconds + ' seconds'; + const minutes = Math.round(seconds / 60); + return minutes + (minutes === 1 ? ' minute' : ' minutes'); + } + /** the fill of the used/quota bar, as a percentage @param {any} s */ function usedPct(s) { if (!s?.estimate?.quota) return 0; @@ -234,6 +247,26 @@ {:else}

Reading the store…

{/if} +

+ {#if !$autosaveEnabled} + Autosave is off, so nothing here is crash recovery. + {:else if $autosaveStatus.lastError} + Autosave is failing — the last snapshot + could not be written, so there is nothing to recover from a crash. + {:else} + Autosave writes a snapshot + {fmtCadence($autosaveStatus.debounceMs)} + after a change{#if $autosaveStatus.lastExportMs}, and the last one took + {Math.round($autosaveStatus.lastExportMs)}ms + to prepare{/if}. + {#if $autosaveStatus.debounceMs > 30_000} + It has slowed itself down because this scene is expensive to export; a shorter + interval would stutter while you work. + {/if} + {/if} +

{#if groups.length} diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 660f5b0b..530fbcba 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -22,12 +22,12 @@ import { transport, transportSnapshot, transportRestore } from './musicClock'; import { patch, patchSnapshot, patchRestore } from './audioPatch'; import { hudDocs, hudDocsSnapshot, hudDocsRestore } from './hudDocs'; import { gameState, gameStateSnapshot, gameStateRestore } from './gameState'; -import { peers, showToast, showInfoToast } from '../stores/appStore'; +import { peers, showToast, showInfoToast, dismissToastById } from '../stores/appStore'; import { isMultiMaterial, serializeMeshWithGroups } from './materialsHandler'; import { idbGet, idbPut, idbDelete } from './idb'; // 27-B: recovery paths report through the diagnostics ring instead of console.log, // so a user can hand over what happened (hardening audit H4). A zero-import leaf. -import { log } from './diagnostics'; +import { log, registerDiagnosticsSection } from './diagnostics'; // #20 P5: selection + edit session + panel layout, restored only on an EXPLICIT restore import { captureEditResume, applyEditResume } from './editResume'; import { disposeTree, keepSet } from './disposeTree'; @@ -39,6 +39,82 @@ import { disposeTree, keepSet } from './disposeTree'; const DEBOUNCE_MS = 30_000; const INTERVAL_MS = 180_000; const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024; +/** + * 27-H (hardening audit M5) — THE CADENCE ADAPTS TO WHAT A SAVE COSTS. + * + * A snapshot is one GLTF export of the whole scene on the main thread, so its cost + * grows with the scene while the interval stayed flat at 30s: on a big scene that is a + * hitch every half minute for as long as you keep editing, which is the "the app + * stutters periodically" report waiting to be filed. Above this threshold the interval + * doubles per doubling of the cost, so an export stays a roughly constant FRACTION of + * the time between saves instead of growing without bound. + */ +const SLOW_EXPORT_MS = 150; +const MAX_DEBOUNCE_MS = 300_000; + +/** + * What autosave is doing and what it last cost. Rendered by the Storage panel, because + * a save cadence that quietly moved from 30s to 5 minutes is exactly the kind of + * adaptive behaviour a user should be able to SEE rather than guess at. + * @type {import('svelte/store').Writable<{lastExportMs: number, lastBytes: number, + * debounceMs: number, lastSaveAt: number, writes: number, coalesced: number, + * lastError: string | null}>} + */ +export const autosaveStatus = writable({ + lastExportMs: 0, + lastBytes: 0, + debounceMs: DEBOUNCE_MS, + lastSaveAt: 0, + /** snapshots actually written */ + writes: 0, + /** saves asked for while one was already running, and therefore folded into it */ + coalesced: 0, + lastError: /** @type {string | null} */ (null) +}); + +/** + * How long to wait after a change, given what the last export cost. PURE and exported + * so it can be asserted directly: ONE measurement decides the whole answer, which is + * what keeps this from oscillating the way a stateful "double it, halve it" rule does. + * + * 150ms or less -> 30s (unchanged) · 150-300 -> 1min · 300-600 -> 2min · 600-1200 -> + * 4min · beyond that the 5min cap. + * @param {number} exportMs @returns {number} + */ +export function cadenceFor(exportMs) { + if (!(exportMs > SLOW_EXPORT_MS)) return DEBOUNCE_MS; + const doublings = Math.ceil(Math.log2(exportMs / SLOW_EXPORT_MS)); + return Math.min(MAX_DEBOUNCE_MS, DEBOUNCE_MS * 2 ** doublings); +} + +/** + * A CHEAP size estimate. This used to be `JSON.stringify(snapshot).length` — a full + * serialisation of everything, thrown away immediately, purely to learn a number, + * after which the structured clone inside `idbPut` walked the same graph again. Near + * the 50MB ceiling the probe alone is hundreds of milliseconds, on the main thread, + * every single save. + * + * Almost every byte of a snapshot lives in a handful of base64 strings whose `.length` + * is free to read: the GLTF buffer and image data URIs, and the original file bytes of + * each animated import. The rest is structure, estimated from COUNTS. The number is + * approximate and says so — it exists to refuse a pathological write early, and + * `idbPut` remains the thing that actually fails on size. + * @param {any} snapshot @returns {number} + */ +export function estimateSnapshotBytes(snapshot) { + let bytes = 0; + const scene = snapshot?.scene; + for (const buffer of scene?.buffers ?? []) bytes += buffer?.uri?.length ?? buffer?.byteLength ?? 0; + for (const image of scene?.images ?? []) bytes += image?.uri?.length ?? 0; + for (const entry of snapshot?.animated ?? []) bytes += entry?.bytes?.length ?? 0; + // a multi-material twin carries its own toJSON, embedded textures included + for (const entry of snapshot?.multiMaterial ?? []) + for (const image of entry?.element?.images ?? []) bytes += image?.url?.length ?? 0; + // structure: node/mesh/accessor metadata, and the graph documents beside it + bytes += (scene?.nodes?.length ?? 0) * 400; + bytes += (snapshot?.nodes?.length ?? 0) * 300; + return bytes; +} export const autosaveEnabled = writable( typeof localStorage === 'undefined' || localStorage.getItem('autosave') !== 'false' @@ -95,6 +171,15 @@ function multiMaterialSnapshot() { function exportScene() { return new Promise((resolve) => { + const started = performance.now(); + /** M5: the measurement the cadence is derived from. Taken around the WHOLE export, + * park and stamp rituals included, because that is what the main thread spends. + * @param {any} result */ + const done = (result) => { + const ms = performance.now() - started; + autosaveStatus.update((state) => ({ ...state, lastExportMs: ms, debounceMs: cadenceFor(ms) })); + resolve(result); + }; const group = get(objectsGroup); if (!group || group.children.length === 0) return resolve(null); // snapshots must store animation BASE poses, not the current swing (88) @@ -122,21 +207,71 @@ function exportScene() { unpark(); // before unstamp, so the parked objects lose their __uuid too unstamp(); restore(); - resolve(result); + done(result); }, (error) => { unpark(); unstamp(); restore(); log('warn', 'autosave', 'export failed', String(error)); - resolve(null); + done(null); } ); }); } -async function saveSnapshot() { - if (!get(autosaveEnabled)) return; +/** + * 27-H (audit M3): ONE SAVE AT A TIME. `markDirty` rescheduled `saveSnapshot` + * unconditionally, and a snapshot is several awaits long (a GLTF export, then a put + * that may be bounded at 10s) — so on a scene where the export is slower than the + * debounce, every tick started a FRESH full export while the previous one was still + * running, each one parking and unparking the same objects. The one that is running + * will pick up whatever changed; a save asked for while it runs is remembered and + * scheduled once, when it finishes. + */ +let saving = false; +let queuedWhileSaving = false; +/** @type {Promise | null} the write in flight, so an explicit save can await it */ +let savingPromise = null; + +function saveSnapshot() { + if (!get(autosaveEnabled)) return Promise.resolve(); + if (saving) { + queuedWhileSaving = true; + autosaveStatus.update((state) => ({ ...state, coalesced: state.coalesced + 1 })); + return savingPromise ?? Promise.resolve(); + } + saving = true; + savingPromise = (async () => { + try { + await writeSnapshot(); + } finally { + saving = false; + savingPromise = null; + if (queuedWhileSaving) { + queuedWhileSaving = false; + schedule(); + } + } + })(); + return savingPromise; +} + +/** Is a snapshot being written right now? (Storage panel / tests) */ +export function isSaving() { + return saving; +} + +/** + * TEST SEAM: exactly what the debounce timer calls — including the re-entrancy refusal, + * which `saveNow` deliberately does NOT do (it waits its turn instead). The suite needs + * the timer's path to prove that three ticks during one slow export produce ONE export. + */ +export function debugRequestSave() { + return saveSnapshot(); +} + +async function writeSnapshot() { // H1: persist EVERY graph document; orphan object graphs (owner object gone) // are pruned from the OUTPUT only. Legacy nodes/edges fields keep carrying the // scene graph so an old build can still restore this snapshot. @@ -212,18 +347,73 @@ async function saveSnapshot() { ? { position: camera.position.toArray(), target: controls?.target?.toArray() ?? [0, 0, 0] } : null }; + // what changed BEFORE the write; anything dirtied during it must survive the clear + const markAtStart = get(dirtyPulse); + const bytes = estimateSnapshotBytes(snapshot); + autosaveStatus.update((state) => ({ ...state, lastBytes: bytes })); try { - if (JSON.stringify(snapshot).length > MAX_SNAPSHOT_BYTES) { - console.warn('autosave skipped: snapshot too large'); + if (bytes > MAX_SNAPSHOT_BYTES) { + log('warn', 'autosave', 'snapshot too large, skipped', { bytes }); + reportSaveFailure( + 'too-large', + 'This scene is too large to autosave, so crash recovery is off for it. Save it yourself.' + ); return; } await idbPut('latest', snapshot); - dirty = false; + // a change made DURING the export is not in the bytes just written (the held-body + // `lastWritten` rule): clearing unconditionally would mark it saved when it isn't + if (get(dirtyPulse) === markAtStart) dirty = false; + autosaveStatus.update((state) => ({ + ...state, + lastSaveAt: Date.now(), + writes: state.writes + 1, + lastError: null + })); + dismissToastById('autosave-failed'); } catch (error) { log('warn', 'autosave', 'snapshot save failed', String(error)); + const full = isQuotaError(error); + reportSaveFailure( + full ? 'quota' : 'failed', + full + ? 'There is no room left to autosave this session. Crash recovery is off until some space is freed.' + : 'Autosave could not write a snapshot, so crash recovery is off for now.', + String(error) + ); } } +/** + * Is this the disk being full? Every engine spells it differently and two of the three + * spellings are legacy numeric codes, so the name test alone would miss Firefox. + * @param {any} error + */ +function isQuotaError(error) { + const name = String(error?.name ?? ''); + return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED' || error?.code === 22; +} + +/** + * 27-H (audit M3): A FAILED AUTOSAVE IS SAID OUT LOUD. It used to reach `console.log` + * and stop there — so a full disk meant autosave had silently stopped and the + * crash-recovery promise was void with nothing to tell the user, which is the worst + * shape a safety feature can fail in. STICKY, because a 5s toast about losing work is + * a toast nobody reads, and it carries the way to act on it. + * @param {string} kind @param {string} text @param {string} [detail] + */ +function reportSaveFailure(kind, text, detail) { + autosaveStatus.update((state) => ({ ...state, lastError: detail ?? kind })); + showInfoToast('autosave-failed', text, [ + { + label: 'Manage storage', + // storageUsage imports THIS module (clearSavedSession), so the edge has to be + // dynamic or it is a cycle + action: () => import('./storageUsage').then((m) => m.openStorageModal()) + } + ]); +} + /** 21-G8: one-shot listeners for "the scene just got dirtied" — the seam behind the * "Save into your project" prompt after opening a loose .tpscene. Each fires ONCE and * is removed BEFORE it runs (a listener that saves would re-enter markDirty). @@ -248,8 +438,16 @@ function markDirty() { fn(); } catch {} } + schedule(); +} + +/** + * Arm the debounce at the CURRENT cadence — 30s normally, longer while the export is + * expensive. Split out of `markDirty` because the re-entrancy guard re-arms it too. + */ +function schedule() { clearTimeout(debounceTimer); - debounceTimer = setTimeout(saveSnapshot, DEBOUNCE_MS); + debounceTimer = setTimeout(saveSnapshot, get(autosaveStatus).debounceMs); } /** Phase 22 registers its annotations getter/setter here (avoids a hard dependency) */ @@ -481,9 +679,15 @@ export function dismissRestore() { restoreAvailable.set(null); } -/** Immediate save (Settings action / tests) */ +/** + * Immediate save (Settings action / tests). With the re-entrancy guard in place a bare + * `saveSnapshot()` during an in-flight save would return having only QUEUED one, and + * this is the path whose whole promise is "it is on disk when I resolve" — so it waits + * for the running write and then takes its own turn. + */ export function saveNow() { - return saveSnapshot(); + const inflight = savingPromise; + return inflight ? inflight.then(() => saveSnapshot()) : saveSnapshot(); } /** @@ -537,7 +741,11 @@ export function startAutosave() { // and once more: a game's state changes touch no object either gameState.subscribe(() => markDirty()); setInterval(() => { - if (dirty) saveSnapshot(); + // M5: the safety-net interval has to respect the adaptive cadence as well, or a + // scene that backed off to 5 minutes still pays for a full export every 3 + // and the backoff buys nothing + const state = get(autosaveStatus); + if (dirty && Date.now() - state.lastSaveAt >= Math.min(state.debounceMs, INTERVAL_MS)) saveSnapshot(); }, INTERVAL_MS); window.addEventListener('beforeunload', () => { // best effort — the async export may not finish, the debounce usually already ran @@ -545,5 +753,14 @@ export function startAutosave() { }); autosaveEnabled.subscribe((value) => localStorage.setItem('autosave', String(value))); autoRestoreEnabled.subscribe((value) => localStorage.setItem('autoRestore', String(value))); + // 27-H: the storage story belongs in the bundle a user hands over. "Autosave last + // failed with QuotaExceededError and has been backing off to 5 minutes" is the + // single most useful line for a lost-work report, and nowhere else records it. + registerDiagnosticsSection('autosave', () => ({ + ...get(autosaveStatus), + enabled: get(autosaveEnabled), + dirty, + saving + })); checkRestore(); } diff --git a/src/lib/idb.js b/src/lib/idb.js index cbaec46c..3c63c1df 100644 --- a/src/lib/idb.js +++ b/src/lib/idb.js @@ -41,16 +41,19 @@ export const OP_TIMEOUT_MS = 10_000; /** @type {number | null} test override for the timeout (null = OP_TIMEOUT_MS) */ let timeoutOverride = null; -/** @type {'abort' | 'stall' | null} test override for the next transaction */ +/** @type {'abort' | 'stall' | 'quota' | null} test override for the next transaction */ let forcedFailure = null; +/** @type {any} the error a forced failure should report instead of the transaction's own */ +let forcedError = null; /** - * TEST SEAM: make the next transaction fail the way the two unbounded cases do. - * `'abort'` calls `tx.abort()` once the request is queued (what a quota failure or a - * closing connection does); `'stall'` swallows every completion callback, which is the - * state that used to hang forever and now hits the timeout. One-shot — it clears itself - * as soon as it is used, so a suite cannot poison the rest of its own run. - * @param {'abort' | 'stall' | null} mode + * TEST SEAM: make the next transaction fail the way the real ones do. + * `'abort'` aborts it, `'stall'` swallows every completion callback (the state that + * used to hang forever and now hits the timeout), and `'quota'` reports the exact + * `QuotaExceededError` a full disk reports — which cannot be provoked honestly in a + * headless run, where the origin is granted tens of gigabytes. One-shot: each clears + * itself as soon as it is used, so a suite cannot poison the rest of its own run. + * @param {'abort' | 'stall' | 'quota' | null} mode */ export function debugForceNextTx(mode) { forcedFailure = mode; @@ -172,9 +175,15 @@ async function withDb(label, body) { */ function settle(tx, value) { return new Promise((resolve, reject) => { + /** @param {string} fallback */ + const fail = (fallback) => { + const forced = forcedError; + forcedError = null; + reject(forced ?? tx.error ?? new Error(fallback)); + }; tx.oncomplete = () => resolve(value()); - tx.onerror = () => reject(tx.error ?? new Error('idb transaction failed')); - tx.onabort = () => reject(tx.error ?? new Error('idb transaction aborted')); + tx.onerror = () => fail('idb transaction failed'); + tx.onabort = () => fail('idb transaction aborted'); }); } @@ -189,12 +198,18 @@ function settle(tx, value) { * * `'stall'` removes every handler the transaction could settle through: the shape of an * operation the browser never reports on at all, which only the timeout can catch. + * + * `'quota'` aborts the same way and hands `settle` the error a full disk raises, so the + * whole failure path downstream — the name test in autosave, the sticky toast, the + * diagnostics line — runs against the real exception rather than a stand-in for it. * @param {IDBTransaction} tx @param {IDBRequest} [request] */ function applyForcedFailure(tx, request) { const mode = forcedFailure; forcedFailure = null; - if (mode === 'abort') { + if (mode === 'abort' || mode === 'quota') { + if (mode === 'quota') + forcedError = new DOMException('The quota has been exceeded.', 'QuotaExceededError'); const fire = () => { try { tx.abort(); diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs index ea6db781..db391b63 100644 --- a/tests/e2e/storage-hardening.test.cjs +++ b/tests/e2e/storage-hardening.test.cjs @@ -157,6 +157,177 @@ h.run(async () => { `a 25MB put has at least 5x headroom under the bound (${Math.round(big.ms)}ms of ${big.bound}ms)` ); + // ---- 2. autosave: one at a time, adaptive, and loud when it fails ------------------ + // A snapshot needs something to snapshot: `saveSnapshot` refuses to overwrite a good + // snapshot with emptiness, so an empty scene never writes at all. + await A.page.evaluate(() => { + for (let i = 0; i < 6; i++) + window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [i * 2 - 5, 0.5, -4]); + }); + await A.page.waitForTimeout(1200); + + const cadence = await A.page.evaluate(() => { + const f = window.__stores.autosave.cadenceFor; + return { at0: f(0), at150: f(150), at151: f(151), at300: f(300), at700: f(700), huge: f(1e9) }; + }); + h.check( + cadence.at0 === 30_000 && cadence.at150 === 30_000, + `a cheap export leaves the 30s cadence alone (${cadence.at0} / ${cadence.at150})` + ); + h.check( + cadence.at151 === 60_000 && cadence.at300 === 60_000 && cadence.at700 === 240_000, + `past 150ms it doubles per doubling of the cost (151ms -> ${cadence.at151}, 300 -> ${cadence.at300}, 700 -> ${cadence.at700})` + ); + h.check(cadence.huge === 300_000, `and it caps at 5 minutes (${cadence.huge})`); + + // The estimate. WHY IT EXISTS: the probe it replaces was a full `JSON.stringify` of + // everything, thrown away immediately, purely to learn a number — so the property that + // matters is not accuracy, it is COST. + const sizing = await A.page.evaluate(() => { + const big = 'A'.repeat(4 * 1024 * 1024); + const snapshot = { + scene: { buffers: [{ uri: big }], images: [], nodes: new Array(500).fill({ name: 'n' }) }, + animated: [{ bytes: big }], + multiMaterial: [], + nodes: new Array(50).fill({ id: 'n' }) + }; + const t0 = performance.now(); + let bytes = 0; + for (let i = 0; i < 20; i++) bytes = window.__stores.autosave.estimateSnapshotBytes(snapshot); + const estimateMs = (performance.now() - t0) / 20; + const t1 = performance.now(); + const probe = JSON.stringify(snapshot).length; + const probeMs = performance.now() - t1; + return { bytes, probe, estimateMs, probeMs }; + }); + h.check( + sizing.bytes > 8 * 1024 * 1024 && sizing.bytes < sizing.probe * 1.5, + `the estimate is in the right neighbourhood (${sizing.bytes} vs a real ${sizing.probe})` + ); + h.check( + sizing.estimateMs * 20 < sizing.probeMs, + `and it is at least 20x cheaper than the stringify it replaced (${sizing.estimateMs.toFixed(3)}ms vs ${sizing.probeMs.toFixed(1)}ms)` + ); + + // ONE EXPORT AT A TIME. `debugRequestSave` is what the debounce timer calls — including + // the re-entrancy refusal, which `saveNow` deliberately skips (it waits its turn). + const reentry = await A.page.evaluate(async () => { + const a = window.__stores.autosave; + let before = null; + a.autosaveStatus.subscribe((v) => (before = v))(); + const all = [a.debugRequestSave(), a.debugRequestSave(), a.debugRequestSave()]; + const duringFirst = a.isSaving(); + await Promise.all(all); + let after = null; + a.autosaveStatus.subscribe((v) => (after = v))(); + return { + duringFirst, + writes: after.writes - before.writes, + coalesced: after.coalesced - before.coalesced, + exportMs: after.lastExportMs, + debounceMs: after.debounceMs + }; + }); + h.check(reentry.duringFirst === true, 'premise: a save really was in flight'); + h.check( + reentry.writes === 1, + `three ticks during one save write ONE snapshot, not three (${reentry.writes})` + ); + h.check( + reentry.coalesced === 2, + `and the other two are folded into it rather than starting their own export (${reentry.coalesced})` + ); + + // The cadence is DERIVED from that measurement, so the relation holds whatever the + // host's speed — which is the only honest way to assert it on a machine whose export + // cost is not ours to fix. + const derived = await A.page.evaluate(() => { + const a = window.__stores.autosave; + let state = null; + a.autosaveStatus.subscribe((v) => (state = v))(); + return { ms: state.lastExportMs, debounce: state.debounceMs, expected: a.cadenceFor(state.lastExportMs) }; + }); + h.check( + derived.ms > 0 && derived.debounce === derived.expected, + `the live cadence is the one that measurement implies (${Math.round(derived.ms)}ms -> ${derived.debounce}ms)` + ); + + // A FAILED AUTOSAVE IS SAID OUT LOUD. This used to reach `console.log` and stop there, + // so a full disk meant crash recovery had silently switched itself off. The quota error + // is raised through the idb seam because a headless origin is granted tens of gigabytes + // and cannot honestly be filled. + const quota = await A.page.evaluate(async () => { + window.__stores.toastStore.set([]); + window.__stores.idb.debugForceNextTx('quota'); + await window.__stores.autosave.saveNow(); + let toasts = []; + window.__stores.toastStore.subscribe((v) => (toasts = v))(); + let state = null; + window.__stores.autosave.autosaveStatus.subscribe((v) => (state = v))(); + const card = toasts.find((t) => t && t.id === 'autosave-failed'); + return { + found: !!card, + sticky: !!card?.sticky, + text: card?.text ?? '', + actions: (card?.actions ?? []).map((entry) => entry.label), + lastError: state.lastError + }; + }); + h.check(quota.found, 'a full disk raises a toast instead of a console line'); + h.check(quota.sticky, '...and it is STICKY — a 5s toast about losing work is one nobody reads'); + h.check( + /room left/i.test(quota.text) && /recovery/i.test(quota.text), + `...saying what it means for crash recovery ("${quota.text}")` + ); + h.check( + quota.actions.includes('Manage storage'), + `...and carrying the way to act on it (${JSON.stringify(quota.actions)})` + ); + h.check( + /Quota/i.test(String(quota.lastError)), + `...and the diagnostics bundle records why (${quota.lastError})` + ); + + // and it clears itself once a save works again, or it is a permanent scar + const cleared = await A.page.evaluate(async () => { + await window.__stores.autosave.saveNow(); + let toasts = []; + window.__stores.toastStore.subscribe((v) => (toasts = v))(); + let state = null; + window.__stores.autosave.autosaveStatus.subscribe((v) => (state = v))(); + return { still: toasts.some((t) => t && t.id === 'autosave-failed'), lastError: state.lastError }; + }); + h.check( + !cleared.still && cleared.lastError === null, + 'a later successful save takes the warning back down' + ); + + // The Storage panel says what the cadence currently is — an adaptive interval nobody + // can see is indistinguishable from autosave being broken. + // NOT a page-side `import()` of the module path: once vite has timestamped the app's + // own copy that binds a SECOND instance, whose stores nothing is rendering — the + // documented HMR module-identity trap, which cost two runs here before it was spotted. + const panel = await A.page.evaluate(() => { + window.__stores.storageUsage.openStorageModal(); + return true; + }); + h.check(panel, 'premise: the Storage panel opens'); + await A.page.waitForSelector('#storage-autosave', { timeout: 15000 }); + const line = await A.page.evaluate(() => { + const el = document.querySelector('#storage-autosave'); + return { + text: el ? el.textContent.replace(/\s+/g, ' ').trim() : '', + cadence: document.querySelector('#storage-autosave-cadence')?.textContent ?? '', + cost: document.querySelector('#storage-autosave-cost')?.textContent ?? '' + }; + }); + h.check( + /seconds|minute/.test(line.cadence), + `the panel names the current cadence in words ("${line.cadence}")` + ); + h.check(/ms$/.test(line.cost), `...and what the last snapshot cost to prepare ("${line.cost}")`); + await A.page.evaluate(() => window.__stores.storageUsage.storageModalOpen.set(false)); + await A.page.evaluate(async () => { for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k); }); From f3d2dd0cf1f9a2f08baccba21143ae85e0d1cffa Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 11:16:05 +0300 Subject: [PATCH 14/27] [feat] 27-H: one place that writes a preference, and a gate that keeps it that way The audit's M4. It counted 136 bare `localStorage.setItem` calls in 25 files; the tree has grown since, and the real number measured here is 507 call sites across 94 files. WHY IT MATTERS, in one sentence: `setItem` throws synchronously in Safari private mode and on a full quota, and most of these sit inside `$effect`s and store subscribers - so the throw does not merely fail to persist a setting, it KILLS THAT SUBSCRIBER for the session, and the UI it drives stops updating. The suite reproduces exactly that with the wrapper removed: toggling a setting in a broken world leaves it stuck at its old value and raises QuotaExceededError out of the subscriber. Reading is not safe either, which is less well known - in a sandboxed iframe merely TOUCHING `window.localStorage` throws SecurityError, which every `typeof localStorage === 'undefined'` guard in this codebase misses, and there are about a hundred of them. - `src/lib/safeStorage.js`, a leaf that imports NOTHING (it is reached from stores, from components and from both sides of the history-cycle family, so any import here is a future cycle - and it is what lets the unit layer test it with no browser). get/set/remove per the spec, plus getItem/setItem/removeItem/clear/keys so the codemod is ONE IDENTIFIER per line - a rename a reviewer can check by eye rather than 507 chances to move a semicolon. - THE FALLBACK IS PER-KEY, which is what makes the promise honest: a setting whose write failed is kept in memory, so it still APPLIES this session and reads back as what you set; it just does not survive a reload. A SUCCESSFUL write drops the shadow again, or a stale one outvotes the real value forever. - `keys()` enumerates through `length`/`key(i)` rather than `Object.keys`, the form dragWindow used: that happens to work on the real Storage exotic object and returns METHOD NAMES on anything else implementing the interface. - The codemod, plus two hand cases the regex could not see: units.js's `const ls = typeof localStorage !== 'undefined' ? localStorage : null` alias, and dragWindow's `Object.keys(localStorage)` sweep. - `scripts/check-storage.cjs` + `npm run check:storage`, wired into ci.yml's `check` job. Without it the codemod decays on the next feature, because the file you are editing still shows you ninety-three examples of the old way. `src/app.html` is ALLOWED with its reason spelled out: an inline diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 8f305979..cf052ac0 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -40,6 +40,7 @@ // 16-Q4: the camera preview window renders as an inset viewport of THIS renderer import { pipRect, pipTarget, glRect } from '$lib/cameraPip'; import { buildCamera } from '$lib/cameraObjects'; + import { safeStorage } from '$lib/safeStorage'; let outlineEffectSelected: OutlineEffect | null = null; let outlineEffectLocked: OutlineEffect | null = null; @@ -428,7 +429,7 @@ }); // e2e hook (debugStores opt-in): the effects live in this component only onMount(() => { - if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores')) + if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores')) (window as any).__outlineDebug = () => ({ selected: outlineEffectSelected?.selection.size ?? -1, locked: outlineEffectLocked?.selection.size ?? -1, @@ -439,7 +440,7 @@ // L1: the compiled chain lives in this component only, and its ORDER is the // thing worth asserting — so the hook names each pass by identity rather than // by constructor (minified in a build) and reports the merge plan. - if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores')) + if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores')) (window as any).__postDebug = () => ({ chain: ((composer as any).passes ?? []).map((pass: any) => { if (pass === renderPass) return 'render'; diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 5cd65074..1e8a8915 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -87,6 +87,7 @@ import PathWaypoints from './PathWaypoints.svelte'; import LockHighlights from './LockHighlights.svelte'; import Grid from '../extensions/Grid.svelte'; + import { safeStorage } from '$lib/safeStorage'; import Outline from './Outline.svelte' import Player from './play/Player.svelte' import { Mesh, Vector3 } from 'three' @@ -99,23 +100,23 @@ $globalScene.background = new THREE.Color(0x101010); - $username = localStorage.getItem('username'); - $userdata.push([$peers.peer.id, localStorage.getItem('username'), localStorage.getItem('avatar'), null, null, get(avatarConfig)]); + $username = safeStorage.getItem('username'); + $userdata.push([$peers.peer.id, safeStorage.getItem('username'), safeStorage.getItem('avatar'), null, null, get(avatarConfig)]); $userdata = $userdata; - $showGrid = localStorage.getItem('showGrid') === 'false' ? false : true; - $vrOverride = localStorage.getItem('vrOverride'); + $showGrid = safeStorage.getItem('showGrid') === 'false' ? false : true; + $vrOverride = safeStorage.getItem('vrOverride'); camera.current.position.set(10.5, 7.57, 11.4); let fov = camera.current.fov let resetSettings = false; setTimeout(() => { // $peers.send({ type: 'userdata', userdata: $userdata }); - if(localStorage.getItem("camx")) - camera.current.position.x = localStorage.getItem("camx"); - if(localStorage.getItem("camy")) - camera.current.position.y = localStorage.getItem("camy"); - if(localStorage.getItem("camz")) - camera.current.position.z = localStorage.getItem("camz"); + if(safeStorage.getItem("camx")) + camera.current.position.x = safeStorage.getItem("camx"); + if(safeStorage.getItem("camy")) + camera.current.position.y = safeStorage.getItem("camy"); + if(safeStorage.getItem("camz")) + camera.current.position.z = safeStorage.getItem("camz"); // console.log(camera.current.position) resetSettings = true; @@ -276,9 +277,9 @@ // console.log(camera.current.rotation) } if (resetSettings == true) { - // localStorage.setItem("camx",camera.current.position.x); - // localStorage.setItem("camy",camera.current.position.y); - // localStorage.setItem("camz",camera.current.position.z); + // safeStorage.setItem("camx",camera.current.position.x); + // safeStorage.setItem("camy",camera.current.position.y); + // safeStorage.setItem("camz",camera.current.position.z); } if (!$specatorMode) { diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index 98f13ae1..f7829e7c 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -51,6 +51,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; // live-follow the primary selection (keeps a truthy [] before the first select) const target = $derived($selectedObject && $selectedObject.uuid ? $selectedObject : null); @@ -93,12 +94,12 @@ let view = $state(/** @type {'sheet'|'graph'} */ ('sheet')); /** 'off' | 'frame' | a step in seconds as a string */ let snapMode = $state( - typeof localStorage !== 'undefined' ? (localStorage.getItem('animationSnap') ?? 'frame') : 'frame' + typeof localStorage !== 'undefined' ? (safeStorage.getItem('animationSnap') ?? 'frame') : 'frame' ); let renaming = $state(/** @type {string|null} */ (null)); // how tall the clip list is allowed to be, dragged by the divider under it let clipsH = $state( - typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('animationClipsH') ?? '96') || 96 : 96 + typeof localStorage !== 'undefined' ? parseInt(safeStorage.getItem('animationClipsH') ?? '96') || 96 : 96 ); let clipsResizing = $state(false); /** the sidebar's own height, measured — the resize ceiling comes from it */ @@ -130,7 +131,7 @@ if (!clipsResizing) return; clipsResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); - localStorage.setItem('animationClipsH', String(clipsH)); + safeStorage.setItem('animationClipsH', String(clipsH)); } // imported clips for the selected object (empty for anything not imported @@ -215,12 +216,12 @@ let winW = $state(660); let winH = $state(460); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('animationDocked') !== 'false'; + docked = safeStorage.getItem('animationDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. // Fitted before the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('animationWinW') ?? '660') || 660, - parseInt(localStorage.getItem('animationWinH') ?? '460') || 460, + parseInt(safeStorage.getItem('animationWinW') ?? '660') || 660, + parseInt(safeStorage.getItem('animationWinH') ?? '460') || 460, WIN_MIN ); winW = savedWin.w; @@ -228,7 +229,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('animationDocked', String(v)); + safeStorage.setItem('animationDocked', String(v)); if (v) activateDock('animation'); else forgetDockTab('animation'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -443,7 +444,7 @@ // select exactly what the eye picks out, including under zoom and pan. /** @type {'box'|'lasso'} */ let marqMode = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('animationMarquee') === 'lasso' + typeof localStorage !== 'undefined' && safeStorage.getItem('animationMarquee') === 'lasso' ? 'lasso' : 'box' ); @@ -459,7 +460,7 @@ function setMarqMode(/** @type {'box'|'lasso'} */ mode) { marqMode = mode; try { - localStorage.setItem('animationMarquee', mode); + safeStorage.setItem('animationMarquee', mode); } catch {} } @@ -710,7 +711,7 @@ // MEAN, and one object can hold a 24fps swing beside a 60fps flourish — with a // LOCAL default for clips that never set one (`animationFps` in localStorage). const DEFAULT_FPS = (() => { - const raw = typeof localStorage !== 'undefined' ? Number(localStorage.getItem('animationFps')) : NaN; + const raw = typeof localStorage !== 'undefined' ? Number(safeStorage.getItem('animationFps')) : NaN; return Number.isFinite(raw) && raw >= 1 && raw <= 240 ? raw : 30; })(); const FPS = $derived(anim?.fps ?? DEFAULT_FPS); @@ -1427,7 +1428,7 @@ tooltip: FPS + ' fps', action: () => { snapMode = snapMode === 'frame' ? 'off' : 'frame'; - localStorage.setItem('animationSnap', snapMode); + safeStorage.setItem('animationSnap', snapMode); } }); menu = { x: e.clientX, y: e.clientY, items }; @@ -1652,8 +1653,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('animationWinW', String(winW)); - localStorage.setItem('animationWinH', String(winH)); + safeStorage.setItem('animationWinW', String(winW)); + safeStorage.setItem('animationWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { @@ -2086,7 +2087,7 @@ value={snapMode} onchange={(e) => { snapMode = e.currentTarget.value; - localStorage.setItem('animationSnap', snapMode); + safeStorage.setItem('animationSnap', snapMode); }} > diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 5141d61e..eea23e3d 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -288,6 +288,7 @@ import WindowShell from '../shared/WindowShell.svelte'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { fly } from 'svelte/transition'; + import { safeStorage } from '$lib/safeStorage'; const clampH = (h: number) => Math.min(Math.max(h || 300, 200), Math.round(window.innerHeight * 0.8)); @@ -313,25 +314,25 @@ // ('explorerHeight'). It is a dock TAB now, so the dock's shared height owns // it — adopt the old value once, then drop the key. try { - const legacyH = localStorage.getItem('explorerHeight'); + const legacyH = safeStorage.getItem('explorerHeight'); if (legacyH) { dockHeight.set(clampH(parseInt(legacyH) || 300)); - localStorage.removeItem('explorerHeight'); + safeStorage.removeItem('explorerHeight'); } } catch {} - docked = localStorage.getItem('explorerDocked') !== 'false'; + docked = safeStorage.getItem('explorerDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized — // that is the state whose resize grip sits off-screen. Fitted BEFORE the // assignment so nothing reads $state during init (state_referenced_locally). const savedWin = clampWinSize( - parseInt(localStorage.getItem('explorerWinW') ?? '720') || 720, - parseInt(localStorage.getItem('explorerWinH') ?? '440') || 440, + parseInt(safeStorage.getItem('explorerWinW') ?? '720') || 720, + parseInt(safeStorage.getItem('explorerWinH') ?? '440') || 440, WIN_MIN ); winW = savedWin.w; winH = savedWin.h; - singleClickOpen = localStorage.getItem('explorerSingleClickOpen') === 'true'; - showBreadcrumb = localStorage.getItem('explorerBreadcrumb') !== 'false'; + singleClickOpen = safeStorage.getItem('explorerSingleClickOpen') === 'true'; + showBreadcrumb = safeStorage.getItem('explorerBreadcrumb') !== 'false'; } // touch / limited-width: keep the Explorer docked (no room to float; undock hidden), // unless the user opted into undocking on touch (Settings > Allow undocking) @@ -347,7 +348,7 @@ function setDocked(v: boolean) { docked = v; - localStorage.setItem('explorerDocked', String(v)); + safeStorage.setItem('explorerDocked', String(v)); if (v) bottomDockActive.set('explorer'); // re-docking makes it the visible panel else forgetDockTab('explorer'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -431,8 +432,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('explorerWinW', String(winW)); - localStorage.setItem('explorerWinH', String(winH)); + safeStorage.setItem('explorerWinW', String(winW)); + safeStorage.setItem('explorerWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { @@ -722,34 +723,34 @@ let expanded = $state(new Set()); if (typeof localStorage !== 'undefined') { try { - expanded = new Set(JSON.parse(localStorage.getItem('explorerExpanded') ?? '[]')); + expanded = new Set(JSON.parse(safeStorage.getItem('explorerExpanded') ?? '[]')); } catch {} } function toggleExpand(id: string) { const next = new Set(expanded); next.has(id) ? next.delete(id) : next.add(id); expanded = next; - localStorage.setItem('explorerExpanded', JSON.stringify([...next])); + safeStorage.setItem('explorerExpanded', JSON.stringify([...next])); } // 197: Library is always open (no caret). Scene is pinned at the bottom and // collapsed by default; double-click it to reveal audio/config/textures. let sceneExpanded = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerSceneExpanded') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerSceneExpanded') === 'true' ); function toggleScene() { sceneExpanded = !sceneExpanded; - localStorage.setItem('explorerSceneExpanded', String(sceneExpanded)); + safeStorage.setItem('explorerSceneExpanded', String(sceneExpanded)); } // N6: Packs section (mirror Scene) — expandable, lists packs; opening a pack // shows its items with lazily-resolved thumbnails. let packsExpanded = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerPacksExpanded') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerPacksExpanded') === 'true' ); function togglePacks() { packsExpanded = !packsExpanded; - localStorage.setItem('explorerPacksExpanded', String(packsExpanded)); + safeStorage.setItem('explorerPacksExpanded', String(packsExpanded)); if (packsExpanded && $packs.length === 0) loadPacks(); } let thumbIdx: Record = $state({}); // per pack-item webp->png->screenshot cursor @@ -757,14 +758,14 @@ // 21-G8: the "Import project as folder (.tp)…" menu entry's hidden picker let tpImportInput: HTMLInputElement | undefined = $state(); let hideBuiltinPacks = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerHideBuiltinPacks') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerHideBuiltinPacks') === 'true' ); // P5: per-pack hide (built-ins can't be truly deleted — they're bundled/CDN — so // hiding is the reversible alternative; imported packs delete outright) let hiddenPacks = $state(new Set(loadHiddenPacks())); function loadHiddenPacks(): string[] { try { - return JSON.parse(localStorage.getItem('explorerHiddenPacks') || '[]'); + return JSON.parse(safeStorage.getItem('explorerHiddenPacks') || '[]'); } catch { return []; } @@ -773,12 +774,12 @@ const s = new Set(hiddenPacks); s.add(name); hiddenPacks = s; - localStorage.setItem('explorerHiddenPacks', JSON.stringify([...s])); + safeStorage.setItem('explorerHiddenPacks', JSON.stringify([...s])); if ($activeFolder === 'pack:' + name) openFolder('packs'); } function showAllHiddenPacks() { hiddenPacks = new Set(); - localStorage.setItem('explorerHiddenPacks', '[]'); + safeStorage.setItem('explorerHiddenPacks', '[]'); } let shownPacks = $derived( $packs.filter( @@ -2426,7 +2427,7 @@ let treeColH = $state(0); let rootsResizing = $state(false); let rootsH = $state( - (typeof localStorage !== 'undefined' && parseInt(localStorage.getItem('explorerRootsH') ?? '')) || + (typeof localStorage !== 'undefined' && parseInt(safeStorage.getItem('explorerRootsH') ?? '')) || 160 ); @@ -2509,13 +2510,13 @@ if (!rootsResizing) return; rootsResizing = false; (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); - localStorage.setItem('explorerRootsH', String(rootsH)); + safeStorage.setItem('explorerRootsH', String(rootsH)); } // 18-B's rule for every grip in the app: a double-click restores a size you might // otherwise have no way to get back function resetRootsH() { rootsH = Math.min(160, rootsMax); - localStorage.setItem('explorerRootsH', String(rootsH)); + safeStorage.setItem('explorerRootsH', String(rootsH)); } // ---- R22 round 13 P3: THE MOUNTS SECTION ----------------------------------------- @@ -2656,7 +2657,7 @@ const next = new Set(expanded); next.add(volumeKey(vol.id)); expanded = next; - localStorage.setItem('explorerExpanded', JSON.stringify([...next])); + safeStorage.setItem('explorerExpanded', JSON.stringify([...next])); openFolder(volumeKey(vol.id)); } /** @@ -7601,7 +7602,7 @@ checked={singleClickOpen} onchange={(e) => { singleClickOpen = e.currentTarget.checked; - localStorage.setItem('explorerSingleClickOpen', String(singleClickOpen)); + safeStorage.setItem('explorerSingleClickOpen', String(singleClickOpen)); }} /> Single-click opens folders @@ -7613,7 +7614,7 @@ checked={showBreadcrumb} onchange={(e) => { showBreadcrumb = e.currentTarget.checked; - localStorage.setItem('explorerBreadcrumb', String(showBreadcrumb)); + safeStorage.setItem('explorerBreadcrumb', String(showBreadcrumb)); }} /> Show path bar @@ -7664,7 +7665,7 @@ checked={hideBuiltinPacks} onchange={(e) => { hideBuiltinPacks = e.currentTarget.checked; - localStorage.setItem('explorerHideBuiltinPacks', String(hideBuiltinPacks)); + safeStorage.setItem('explorerHideBuiltinPacks', String(hideBuiltinPacks)); }} /> Hide built-in packs diff --git a/src/components/editors/FlowCode.svelte b/src/components/editors/FlowCode.svelte index 42f6ec0b..f106f495 100644 --- a/src/components/editors/FlowCode.svelte +++ b/src/components/editors/FlowCode.svelte @@ -15,6 +15,7 @@ import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; let text = $state(''); let error = $state(''); @@ -22,13 +23,13 @@ let winW = $state(460); let winH = $state(440); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('flowCodeDocked') !== 'false'; // start docked - winW = parseInt(localStorage.getItem('flowCodeWinW') ?? '460') || 460; - winH = parseInt(localStorage.getItem('flowCodeWinH') ?? '440') || 440; + docked = safeStorage.getItem('flowCodeDocked') !== 'false'; // start docked + winW = parseInt(safeStorage.getItem('flowCodeWinW') ?? '460') || 460; + winH = parseInt(safeStorage.getItem('flowCodeWinH') ?? '440') || 440; } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('flowCodeDocked', String(v)); + safeStorage.setItem('flowCodeDocked', String(v)); if (v) activateDock('flowcode'); else forgetDockTab('flowcode'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -129,8 +130,8 @@ if (!winResizing) return; winResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); - localStorage.setItem('flowCodeWinW', String(winW)); - localStorage.setItem('flowCodeWinH', String(winH)); + safeStorage.setItem('flowCodeWinW', String(winW)); + safeStorage.setItem('flowCodeWinH', String(winH)); } diff --git a/src/components/editors/HudEditor.svelte b/src/components/editors/HudEditor.svelte index 9978e794..8840d17f 100644 --- a/src/components/editors/HudEditor.svelte +++ b/src/components/editors/HudEditor.svelte @@ -68,6 +68,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; // 21-D5: WHICH document is being authored. `hudDocs` was already keyed // `'scene' | objectUuid`, so "attach this HUD to a camera" is simply authoring the @@ -116,10 +117,10 @@ let winW = $state(680); let winH = $state(480); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('hudDocked') !== 'false'; + docked = safeStorage.getItem('hudDocked') !== 'false'; const saved = clampWinSize( - parseInt(localStorage.getItem('hudWinW') ?? '680') || 680, - parseInt(localStorage.getItem('hudWinH') ?? '480') || 480, + parseInt(safeStorage.getItem('hudWinW') ?? '680') || 680, + parseInt(safeStorage.getItem('hudWinH') ?? '480') || 480, WIN_MIN ); winW = saved.w; @@ -127,7 +128,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('hudDocked', String(v)); + safeStorage.setItem('hudDocked', String(v)); if (v) activateDock('hud'); else forgetDockTab('hud'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -181,7 +182,7 @@ const SCREENS_RESERVE = 148; let paneH = $state(0); let screensH = $state( - parseInt((typeof localStorage !== 'undefined' && localStorage.getItem('hudScreens:h')) || '132') || 132 + parseInt((typeof localStorage !== 'undefined' && safeStorage.getItem('hudScreens:h')) || '132') || 132 ); let screensResizing = $state(false); const screensMax = $derived(Math.max(56, (paneH || 320) - SCREENS_RESERVE)); @@ -203,7 +204,7 @@ screensResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); try { - localStorage.setItem('hudScreens:h', String(screensH)); + safeStorage.setItem('hudScreens:h', String(screensH)); } catch {} } @@ -275,20 +276,20 @@ /** @param {string} key @param {number} fallback */ function snapPref(key, fallback) { if (typeof localStorage === 'undefined') return fallback; - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); const n = raw === null ? NaN : parseFloat(raw); return Number.isFinite(n) ? n : fallback; } let snapOn = $state( - typeof localStorage === 'undefined' ? SNAP_DEFAULTS.on : localStorage.getItem('hud:snapOn') !== 'false' + typeof localStorage === 'undefined' ? SNAP_DEFAULTS.on : safeStorage.getItem('hud:snapOn') !== 'false' ); let snapGrid = $state(Math.max(1, snapPref('hud:snapGrid', SNAP_DEFAULTS.grid))); let snapThreshold = $state(Math.max(0, snapPref('hud:snapThreshold', SNAP_DEFAULTS.threshold))); $effect(() => { try { - localStorage.setItem('hud:snapOn', String(snapOn)); - localStorage.setItem('hud:snapGrid', String(snapGrid)); - localStorage.setItem('hud:snapThreshold', String(snapThreshold)); + safeStorage.setItem('hud:snapOn', String(snapOn)); + safeStorage.setItem('hud:snapGrid', String(snapGrid)); + safeStorage.setItem('hud:snapThreshold', String(snapThreshold)); } catch {} }); // the lines the LIVE gesture is actually sitting on, drawn as 1px overlays. Cleared @@ -948,8 +949,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('hudWinW', String(winW)); - localStorage.setItem('hudWinH', String(winH)); + safeStorage.setItem('hudWinW', String(winW)); + safeStorage.setItem('hudWinH', String(winH)); } function resetWinSize() { const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN); diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 875be0b4..c7ec646a 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -71,6 +71,7 @@ import { isValidFlowConnection, typeColor, replaceableInputEdges } from '$lib/flowSockets'; import { moduleNodeGroups, moduleNodeComponents } from '$lib/moduleSDK'; import { peers, username, modulesOpen, flowFocus } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; // 21-D7: DEEP LINK — 'show me the node that drives this HUD element'. A write-once // request that we act on and CLEAR, the inspectorScrollTo shape, so it cannot re-fire @@ -277,7 +278,7 @@ // 3775px for a 200px gesture. A test that needs to press a field needs this. $effect(() => { if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; - if (localStorage.getItem('debugStores') !== 'true') return; + if (safeStorage.getItem('debugStores') !== 'true') return; // TS syntax, not a JSDoc cast: this file is lang="ts", where JSDoc @type is IGNORED (window as any).__flowViewport = { setViewport, fitView }; // A6.4: which types this MOUNTED pane can actually render, plus the snapshot it @@ -300,10 +301,10 @@ // inset its content above the Controls HUD only when the palette is actually shown. let { paletteOpen = $bindable( - typeof localStorage === 'undefined' || localStorage.getItem('flowPaletteOpen') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('flowPaletteOpen') !== 'false' ) }: { paletteOpen?: boolean } = $props(); - let paletteSide = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('flowPaletteSide') ?? 'left' : 'left'); + let paletteSide = $state(typeof localStorage !== 'undefined' ? safeStorage.getItem('flowPaletteSide') ?? 'left' : 'left'); // #20 P7: the left column's own height, measured — the graph tree's resize ceiling let paletteColH = $state(0); @@ -687,7 +688,7 @@ title={paletteOpen ? 'Hide the node palette' : 'Show the node palette'} onclick={() => { paletteOpen = !paletteOpen; - localStorage.setItem('flowPaletteOpen', String(paletteOpen)); + safeStorage.setItem('flowPaletteOpen', String(paletteOpen)); }} > {paletteOpen ? (paletteSide === 'right' ? '▸' : '◂') : paletteSide === 'right' ? '◂' : '▸'} @@ -699,7 +700,7 @@ title="Move the palette to the other side" onclick={() => { paletteSide = paletteSide === 'right' ? 'left' : 'right'; - localStorage.setItem('flowPaletteSide', paletteSide); + safeStorage.setItem('flowPaletteSide', paletteSide); }} > ⇄ diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte index 2cffb660..abee349a 100644 --- a/src/components/editors/ShaderEditor.svelte +++ b/src/components/editors/ShaderEditor.svelte @@ -61,6 +61,7 @@ import ShaderTexturePicker from './nodes/ShaderTexturePicker.svelte'; import ShaderVectorInput from './nodes/ShaderVectorInput.svelte'; import DragRow from '../ui/DragRow.svelte'; + import { safeStorage } from '$lib/safeStorage'; const nodeTypes = Object.fromEntries(shaderNodeDefs().map((def) => [def.key, ShaderNode])); const catalog = shaderNodeDefs().filter((def) => def.key !== SURFACE_NODE); @@ -375,12 +376,12 @@ let winW = $state(720); let winH = $state(480); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('shaderDocked') !== 'false'; + docked = safeStorage.getItem('shaderDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. Fitted // BEFORE the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('shaderWinW') ?? '720') || 720, - parseInt(localStorage.getItem('shaderWinH') ?? '480') || 480, + parseInt(safeStorage.getItem('shaderWinW') ?? '720') || 720, + parseInt(safeStorage.getItem('shaderWinH') ?? '480') || 480, WIN_MIN ); winW = savedWin.w; @@ -397,7 +398,7 @@ function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('shaderDocked', String(v)); + safeStorage.setItem('shaderDocked', String(v)); if (v) activateDock('shader'); // re-docking makes it the visible tab else forgetDockTab('shader'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -479,8 +480,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('shaderWinW', String(winW)); - localStorage.setItem('shaderWinH', String(winH)); + safeStorage.setItem('shaderWinW', String(winW)); + safeStorage.setItem('shaderWinH', String(winH)); } function resetWinSize() { const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN); diff --git a/src/components/editors/UvEditor.svelte b/src/components/editors/UvEditor.svelte index b6799246..912cdb56 100644 --- a/src/components/editors/UvEditor.svelte +++ b/src/components/editors/UvEditor.svelte @@ -47,6 +47,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; /** the armed transform modes, in 1/2/3 order */ const MODES = /** @type {['move'|'rotate'|'scale', string, string][]} */ ([ @@ -128,12 +129,12 @@ let winW = $state(640); let winH = $state(460); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('uvDocked') !== 'false'; + docked = safeStorage.getItem('uvDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. // Fitted before the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('uvWinW') ?? '640') || 640, - parseInt(localStorage.getItem('uvWinH') ?? '460') || 460, + parseInt(safeStorage.getItem('uvWinW') ?? '640') || 640, + parseInt(safeStorage.getItem('uvWinH') ?? '460') || 460, WIN_MIN ); winW = savedWin.w; @@ -141,7 +142,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('uvDocked', String(v)); + safeStorage.setItem('uvDocked', String(v)); if (v) activateDock('uv'); else forgetDockTab('uv'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -1543,8 +1544,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('uvWinW', String(winW)); - localStorage.setItem('uvWinH', String(winH)); + safeStorage.setItem('uvWinW', String(winW)); + safeStorage.setItem('uvWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { diff --git a/src/components/menu/CharacterModal.svelte b/src/components/menu/CharacterModal.svelte index db2712d2..479eda41 100644 --- a/src/components/menu/CharacterModal.svelte +++ b/src/components/menu/CharacterModal.svelte @@ -3,6 +3,7 @@ import ThemedSelect from '../ui/ThemedSelect.svelte'; import { characterModalOpen, avatarConfig, userdata, peers } from '../../stores/appStore.js'; import { FACE_SHAPES, resolveAvatar } from '$lib/avatarModel'; + import { safeStorage } from '$lib/safeStorage'; // resolve so shape/showLabel have defaults even for older stored configs $: cfg = resolveAvatar($avatarConfig); @@ -29,7 +30,7 @@ function update(partial: any) { const next = { ...$avatarConfig, ...partial }; $avatarConfig = next; - localStorage.setItem('avatarConfig', JSON.stringify(next)); + safeStorage.setItem('avatarConfig', JSON.stringify(next)); // update our own userdata row and broadcast $userdata.forEach((element) => { if (element[0] === $peers.peer.id) element[5] = next; diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index fe53390a..49db56a6 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -12,6 +12,7 @@ import { connectSlot, drawerSlot } from '$lib/cloudHooks'; import CloudSlot from '../CloudSlot.svelte'; import ConnectInfoDrawer from './ConnectInfoDrawer.svelte'; + import { safeStorage } from '$lib/safeStorage'; let peerIdToConnect = $state(''); let displayid = $state('Generating...'); @@ -168,10 +169,10 @@ // is fine for a quick try but not recommended for real use. Shown once. try { const isLocalVersion = !/(\.io|\.app)$/i.test(location.hostname); - const firstRun = !localStorage.getItem('peerServerConfig'); - const seen = localStorage.getItem('localPeerNoticeSeen'); + const firstRun = !safeStorage.getItem('peerServerConfig'); + const seen = safeStorage.getItem('localPeerNoticeSeen'); if (isLocalVersion && firstRun && !seen) { - localStorage.setItem('localPeerNoticeSeen', '1'); + safeStorage.setItem('localPeerNoticeSeen', '1'); showToast( 'It looks like you are running a local build of theprototype. Configure a peer signaling server in Settings for reliable connections — the public PeerJS cloud is not recommended for real use.', [ diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index bc500bda..4e48ad99 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -37,6 +37,7 @@ import { togglePanel } from '$lib/panelToggles'; import { requestPlay, willEnterXR, willEnterAR, vrSupported, arSupported, xrSessionFailed } from '$lib/playMode'; import { DOCK_VIEWS } from '$lib/dockMenu'; + import { safeStorage } from '$lib/safeStorage'; import { VRButton, XRButton } from '@threlte/xr' // A panel is "shown" when it is open AND either the visible dock tab OR floating @@ -313,7 +314,7 @@ let hiddenChips: Set = $state( new Set( typeof localStorage !== 'undefined' - ? JSON.parse(localStorage.getItem('hiddenListChips') ?? '[]') + ? JSON.parse(safeStorage.getItem('hiddenListChips') ?? '[]') : [] ) ); @@ -327,7 +328,7 @@ if (viewMode === value) viewMode = ''; } hiddenChips = next; - localStorage.setItem('hiddenListChips', JSON.stringify([...next])); + safeStorage.setItem('hiddenListChips', JSON.stringify([...next])); } function resetAllFilters() { searchTerm = ''; @@ -335,7 +336,7 @@ lastTypes = new Set(); viewMode = ''; hiddenChips = new Set(); - localStorage.setItem('hiddenListChips', '[]'); + safeStorage.setItem('hiddenListChips', '[]'); chipPopup = false; } @@ -429,7 +430,7 @@ // --- advanced mode: System filter shows scene-root module/env objects --- let systemRows = $state([]); let systemNoticeDismissed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('systemNoticeDismissed') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('systemNoticeDismissed') === 'true' ); let expandedSystem = $state({}); function refreshSystemRows() { @@ -478,7 +479,7 @@ // --- environment filter (70.4): read-only rows for environment-root --- let envRows = $state([]); let envNoticeDismissed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('envNoticeDismissed') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('envNoticeDismissed') === 'true' ); function refreshEnvRows() { const scene = $globalScene; @@ -534,7 +535,7 @@ // 80.1: proper resize (start-size captured, clamped) + persisted rect let saved: any = null; try { - saved = JSON.parse(localStorage.getItem('objectListRect') ?? 'null'); + saved = JSON.parse(safeStorage.getItem('objectListRect') ?? 'null'); } catch {} let moving = false; let left = saved?.left ?? 350; @@ -589,7 +590,7 @@ } const persist = () => - localStorage.setItem( + safeStorage.setItem( 'objectListRect', JSON.stringify({ left, top, width: node.offsetWidth, height: node.offsetHeight }) ); @@ -735,7 +736,7 @@ // vrOverride is the STRING mirror Settings writes; Scene seeds the store // from localStorage on boot, so both halves have to move together. vrOverride.set(true); - localStorage.setItem('vrOverride', 'true'); + safeStorage.setItem('vrOverride', 'true'); requestPlay(); } }, @@ -746,9 +747,9 @@ tooltip: $vrSupported ? 'Immersive VR — the scene replaces your view' : 'No immersive-vr support detected', action: () => { vrOverride.set(false); - localStorage.removeItem('vrOverride'); + safeStorage.removeItem('vrOverride'); vrPassthrough.set(false); - localStorage.setItem('vrPassthrough', 'false'); + safeStorage.setItem('vrPassthrough', 'false'); requestPlay(); } }, @@ -761,9 +762,9 @@ : 'No immersive-ar (passthrough) support detected', action: () => { vrOverride.set(false); - localStorage.removeItem('vrOverride'); + safeStorage.removeItem('vrOverride'); vrPassthrough.set(true); - localStorage.setItem('vrPassthrough', 'true'); + safeStorage.setItem('vrPassthrough', 'true'); requestPlay(); } }, @@ -923,7 +924,7 @@ function loadLayout(): ControlsLayout { if (typeof localStorage === 'undefined') return defaultLayout(); try { - const raw = localStorage.getItem('controlsLayout'); + const raw = safeStorage.getItem('controlsLayout'); if (!raw) return defaultLayout(); const saved = JSON.parse(raw) ?? {}; // W8b: kept ids are the ones the REGISTRY knows, not the ones the DEFAULT order @@ -962,7 +963,7 @@ function saveLayout() { try { - localStorage.setItem('controlsLayout', JSON.stringify(controlsLayout)); + safeStorage.setItem('controlsLayout', JSON.stringify(controlsLayout)); } catch { // private mode / storage full — the bar still works for this session } @@ -978,7 +979,7 @@ function resetLayout() { controlsLayout = defaultLayout(); try { - localStorage.removeItem('controlsLayout'); + safeStorage.removeItem('controlsLayout'); } catch { // nothing to clear } @@ -1115,7 +1116,7 @@ * than duplicated, so the two rows can say which one is on. `setDocked` keeps this * flag in step with the panel, so it is the honest answer either way. */ function explorerOpensDocked(): boolean { - return typeof localStorage === 'undefined' || localStorage.getItem('explorerDocked') !== 'false'; + return typeof localStorage === 'undefined' || safeStorage.getItem('explorerDocked') !== 'false'; } /** Move the Explorer between dock tab and floating window. @@ -2115,7 +2116,7 @@ class="rounded-sm bg-gray-600 px-1 text-white" on:click={() => { systemNoticeDismissed = true; - localStorage.setItem('systemNoticeDismissed', 'true'); + safeStorage.setItem('systemNoticeDismissed', 'true'); }}>✕ {/if} @@ -2168,7 +2169,7 @@ class="rounded-sm bg-gray-600 px-1 text-white" on:click={() => { envNoticeDismissed = true; - localStorage.setItem('envNoticeDismissed', 'true'); + safeStorage.setItem('envNoticeDismissed', 'true'); }}>✕ {/if} diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index c4f2393c..238d2b3e 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -200,6 +200,7 @@ saveSnapAnchorAsOrigin } from '$lib/snapEngine'; import { peers, inspectorClose, inspectorKind, inspectorPinned, showToast, inspectorFilter, notesDrawerOpen } from '../../stores/appStore.js'; + import { safeStorage } from '$lib/safeStorage'; import { isShaderDriven, openShaderEditor, @@ -278,7 +279,7 @@ let inspectorH = $state(0); $effect(() => { if (inspectorH || typeof window === 'undefined') return; - const saved = parseInt(localStorage.getItem('inspectorSheetH') || ''); + const saved = parseInt(safeStorage.getItem('inspectorSheetH') || ''); inspectorH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.45) : saved; }); let insResizing = $state(false); @@ -303,7 +304,7 @@ insResizing = false; /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); try { - localStorage.setItem('inspectorSheetH', String(inspectorH)); + safeStorage.setItem('inspectorSheetH', String(inspectorH)); } catch {} } @@ -1846,8 +1847,8 @@ checked={!!$showGrid} onchange={() => { showGrid.update((v) => !v); - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); }}>Show grid { - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); }} /> Display grid on floor @@ -1436,8 +1437,8 @@ { - if (localStorage.getItem('vrOverride')) localStorage.removeItem('vrOverride'); - else localStorage.setItem('vrOverride', 'true'); + if (safeStorage.getItem('vrOverride')) safeStorage.removeItem('vrOverride'); + else safeStorage.setItem('vrOverride', 'true'); }} /> Forces normal play even if immersive-vr is enabled @@ -1448,7 +1449,7 @@ checked={$vrFlying} onchange={(e) => { $vrFlying = e.target.checked; - localStorage.setItem('vrFlying', String($vrFlying)); + safeStorage.setItem('vrFlying', String($vrFlying)); }} /> Left-stick movement follows where the controller points (fly); off = stay level @@ -1463,7 +1464,7 @@ checked={$vrPassthrough} onchange={(e: any) => { $vrPassthrough = e.target.checked; - localStorage.setItem('vrPassthrough', String($vrPassthrough)); + safeStorage.setItem('vrPassthrough', String($vrPassthrough)); showToast('Passthrough ' + ($vrPassthrough ? 'on' : 'off') + ' — takes effect on the next VR entry'); }} /> @@ -1476,7 +1477,7 @@ onclick={() => { const next = $vrMenuHand === 'left' ? 'right' : 'left'; $vrMenuHand = next; - localStorage.setItem('vrMenuHand', next); + safeStorage.setItem('vrMenuHand', next); }} /> Which controller opens the VR quick-menu (the other hand points) @@ -1488,7 +1489,7 @@ checked={$vrMenuHold} onchange={(e: any) => { $vrMenuHold = e.target.checked; - localStorage.setItem('vrMenuHold', String($vrMenuHold)); + safeStorage.setItem('vrMenuHold', String($vrMenuHold)); }} /> Hold B/Y to show the radial menu, release over a sector to pick it (off = press toggles) @@ -1505,7 +1506,7 @@ value={$vrSnapAngle} onchange={(v) => { $vrSnapAngle = parseInt(v); - localStorage.setItem('vrSnapAngle', String($vrSnapAngle)); + safeStorage.setItem('vrSnapAngle', String($vrSnapAngle)); }} /> @@ -1518,7 +1519,7 @@ checked={$vrMirrorSnapTurn} onchange={(e: any) => { $vrMirrorSnapTurn = e.target.checked; - localStorage.setItem('vrMirrorSnapTurn', String($vrMirrorSnapTurn)); + safeStorage.setItem('vrMirrorSnapTurn', String($vrMirrorSnapTurn)); }} /> Flip the flick direction — left turns right and vice-versa @@ -1530,7 +1531,7 @@ checked={$vrTeleportEnabled} onchange={(e: any) => { $vrTeleportEnabled = e.target.checked; - localStorage.setItem('vrTeleportEnabled', String($vrTeleportEnabled)); + safeStorage.setItem('vrTeleportEnabled', String($vrTeleportEnabled)); }} /> Right-stick-up teleport arc — off if you navigate only by stick/fly @@ -1542,7 +1543,7 @@ checked={$vrSleeveEnabled} onchange={(e: any) => { $vrSleeveEnabled = e.target.checked; - localStorage.setItem('vrSleeveEnabled', String($vrSleeveEnabled)); + safeStorage.setItem('vrSleeveEnabled', String($vrSleeveEnabled)); }} /> Experimental — a strip of ghost primitives on your forearm: trigger-drag one out to place it (stick scales, wrist rotates). Grip-drop an object onto the strip to keep it as a personal slot @@ -1554,7 +1555,7 @@ checked={$vrVertexHold} onchange={(e: any) => { $vrVertexHold = e.target.checked; - localStorage.setItem('vrVertexHold', String($vrVertexHold)); + safeStorage.setItem('vrVertexHold', String($vrVertexHold)); }} /> Hold the trigger to carry a vertex (release drops it); off = press to grab, press again to drop @@ -2248,7 +2249,7 @@ {#snippet footer()} - + {/snippet} diff --git a/src/components/menu/Sidebar.svelte b/src/components/menu/Sidebar.svelte index ce8156d5..db2154d0 100644 --- a/src/components/menu/Sidebar.svelte +++ b/src/components/menu/Sidebar.svelte @@ -25,6 +25,7 @@ import { sidebarSlot } from '$lib/cloudHooks'; import CloudSlot from '../CloudSlot.svelte'; import { whatsNewUnseen, openWhatsNew } from '$lib/whatsNew'; + import { safeStorage } from '$lib/safeStorage'; // 203: redesigned as a compact floating panel — flat list (order preserved, // no boxed group / section headers / vertical bar), a fast fade-in (was a @@ -44,8 +45,8 @@ // your work, and it was taking a permanent third of a row from the two that are. // An enabled optional format renders on a SECOND ROW rather than widening the first, // so the primary pair never moves as the cog is toggled. - const initShowJson = typeof localStorage !== 'undefined' && localStorage.getItem('showJsonFormat') === 'true'; - const initShowGltf = typeof localStorage !== 'undefined' && localStorage.getItem('showGltfFormat') === 'true'; + const initShowJson = typeof localStorage !== 'undefined' && safeStorage.getItem('showJsonFormat') === 'true'; + const initShowGltf = typeof localStorage !== 'undefined' && safeStorage.getItem('showGltfFormat') === 'true'; /** * A STORED format can name one that is no longer on screen — a Save button pointing * at a control the user cannot see, which is the bug the JSON rule already existed @@ -57,7 +58,7 @@ if (f === 'gltf' && !gltf) return 'tp'; return f; } - const initFormat = typeof localStorage !== 'undefined' ? localStorage.getItem('saveFormat') || 'tp' : 'tp'; + const initFormat = typeof localStorage !== 'undefined' ? safeStorage.getItem('saveFormat') || 'tp' : 'tp'; let saveFormat = $state(visibleFormat(initFormat, initShowJson, initShowGltf)); let showJson = $state(initShowJson); let showGltf = $state(initShowGltf); @@ -78,9 +79,9 @@ exportPos = { top, left }; exportSettingsOpen = true; } - let tpAssets = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpsceneAssets') !== 'false'); - let tpPacks = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpscenePacks') === 'true'); - let tpFlow = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpsceneFlow') !== 'false'); + let tpAssets = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpsceneAssets') !== 'false'); + let tpPacks = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpscenePacks') === 'true'); + let tpFlow = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpsceneFlow') !== 'false'); // 21-I5 (locked answer 2): the PROJECT box is ON by default, because a .tp has carried // its scene history since 21-G3 and flipping that off silently would make an existing // behaviour vanish — and it gates machinery with its own proper import. @@ -90,10 +91,10 @@ // an unnamed or never-travelled scene has no manifest entry, so the box that used to // sit here silently bundled nothing. The Explorer's scene card knows the name and the // history unambiguously, so downloading versions lives on ITS menu instead. - let tpProjectVersions = $state(typeof localStorage === 'undefined' || localStorage.getItem('tpProjectVersions') !== 'false'); + let tpProjectVersions = $state(typeof localStorage === 'undefined' || safeStorage.getItem('tpProjectVersions') !== 'false'); function pickFormat(f: string) { saveFormat = f; - localStorage.setItem('saveFormat', f); + safeStorage.setItem('saveFormat', f); } /** Called after either cog checkbox moves: if what is selected just went off screen, * fall back (and PERSIST the fallback — the stored value is what the next boot reads). */ @@ -291,31 +292,31 @@

Export settings

Scene (.tpscene) includes:

Project (.tp) includes:

diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index 92d4fcdd..67258067 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -38,6 +38,7 @@ import { peerScenes, elsewhereThan, PRIVATE_SCENE } from '$lib/peerScenes'; import { currentLevel } from '$lib/levels'; import { showToast } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; /** * Stop watching and give the camera back. EXTRACTED from the banner button so the @@ -389,9 +390,9 @@ $effect(() => { $effect(() => { const notice = $appNotice; - const seen = typeof localStorage !== 'undefined' && !!localStorage.getItem('hasSeenDisclaimer'); + const seen = typeof localStorage !== 'undefined' && !!safeStorage.getItem('hasSeenDisclaimer'); const markSeen = () => { - try { localStorage.setItem('hasSeenDisclaimer', 'true'); } catch {} + try { safeStorage.setItem('hasSeenDisclaimer', 'true'); } catch {} }; if (notice && !seen) showInfoToast( @@ -557,7 +558,7 @@ style="z-index: var(--z-toast-low); pointer-events: none;" {#if $fixLight}
- { localStorage.setItem('hasSeenDisclaimer', 'true'); } + { safeStorage.setItem('hasSeenDisclaimer', 'true'); } }>
diff --git a/src/components/menu/Users.svelte b/src/components/menu/Users.svelte index 1ff4e389..50a29047 100644 --- a/src/components/menu/Users.svelte +++ b/src/components/menu/Users.svelte @@ -106,6 +106,7 @@ import NotificationCenter from './NotificationCenter.svelte'; import CloudSlot from '../CloudSlot.svelte'; import { usersSlot, profileSlot, rolesInfo, scenePresence } from '$lib/cloudHooks'; + import { safeStorage } from '$lib/safeStorage'; // N3: latency-band dot color for a peer's network-quality indicator const qColor = (level: string) => @@ -145,7 +146,7 @@ const reader = new FileReader(); reader.onload = function(fileLoadedEvent) { avatarImage = fileLoadedEvent.target.result; - localStorage.setItem('avatar', avatarImage); + safeStorage.setItem('avatar', avatarImage); //find and update, same for image $userdata.forEach(element => { @@ -159,7 +160,7 @@ }; reader.readAsDataURL(avatarFile); // an uploaded image is a CUSTOM avatar - try { localStorage.removeItem('avatarReset'); } catch {} + try { safeStorage.removeItem('avatarReset'); } catch {} } } @@ -168,7 +169,7 @@ // (pushed by the plugin via cloudApi.setAccountIdentity -> $cloudIdentity) UNLESS // the user set a custom one. "Custom username" = the usernameCustom flag; "custom // avatar" = an uploaded image in localStorage.avatar. - const ls = (k: string) => (typeof localStorage !== 'undefined' ? localStorage.getItem(k) : null); + const ls = (k: string) => (typeof localStorage !== 'undefined' ? safeStorage.getItem(k) : null); const usernameIsCustom = () => ls('usernameCustom') === '1'; const cid = $derived($cloudIdentity); /** what the header/button/peers show */ @@ -196,7 +197,7 @@ /** @param {string} v */ function setPeersView(v: string) { peersView = v; - try { localStorage.setItem('peers:view', v); } catch {} + try { safeStorage.setItem('peers:view', v); } catch {} } /** WHO AM I in the roster. The flat list has always taken index 0 as self (userdata * is built that way), so the fallback is not a guess — it is the same rule, reached @@ -443,15 +444,15 @@ function onUsernameEdited() { try { - localStorage.setItem('username', $username || ''); - localStorage.setItem('usernameCustom', ($username || '').trim() ? '1' : '0'); + safeStorage.setItem('username', $username || ''); + safeStorage.setItem('usernameCustom', ($username || '').trim() ? '1' : '0'); } catch {} broadcastUserdata(); } function resetAvatarToDefault() { avatarImage = ''; - try { localStorage.removeItem('avatar'); } catch {} + try { safeStorage.removeItem('avatar'); } catch {} broadcastUserdata(); // falls back to the cloud-account avatar (or default) } @@ -985,7 +986,7 @@ > {/if} - {#if avatarImage || (typeof localStorage !== 'undefined' && localStorage.getItem('avatar'))} + {#if avatarImage || (typeof localStorage !== 'undefined' && safeStorage.getItem('avatar'))} {/if} diff --git a/src/components/menu/ViewportMenu.svelte b/src/components/menu/ViewportMenu.svelte index a0f84745..67a611c9 100644 --- a/src/components/menu/ViewportMenu.svelte +++ b/src/components/menu/ViewportMenu.svelte @@ -18,6 +18,7 @@ import { togglePanel } from '$lib/panelToggles'; import { trackpadMode } from '$lib/trackpadNav'; import { helpersInPlay } from '$lib/helperLayer'; + import { safeStorage } from '$lib/safeStorage'; // Scene.svelte routes right-TAPS here (77): empty viewport → this menu with // the clicked ground point; an object under the cursor → its own context @@ -264,8 +265,8 @@ checked: !!$showGrid, action: () => { showGrid.update((v) => !v); - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); } }, { diff --git a/src/components/play/PlayReticle.svelte b/src/components/play/PlayReticle.svelte index 2edf1f7f..993276a6 100644 --- a/src/components/play/PlayReticle.svelte +++ b/src/components/play/PlayReticle.svelte @@ -5,11 +5,12 @@ // playInteract.js. import { isLocked, isVRMode } from '../../stores/sceneStore'; import { playInteractState } from '$lib/playInteract'; + import { safeStorage } from '$lib/safeStorage'; // the scroll hint is worth exactly one showing, so it is a LOCAL pref and // never touches the wire let hintSeen = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('playCarryHintSeen') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('playCarryHintSeen') === 'true' ); const reticle = $derived($playInteractState); @@ -20,7 +21,7 @@ if (!carrying || hintSeen) return; hintSeen = true; try { - localStorage.setItem('playCarryHintSeen', 'true'); + safeStorage.setItem('playCarryHintSeen', 'true'); } catch {} }); diff --git a/src/components/ui/ToolboxSection.svelte b/src/components/ui/ToolboxSection.svelte index 7e70c8f9..7270b89e 100644 --- a/src/components/ui/ToolboxSection.svelte +++ b/src/components/ui/ToolboxSection.svelte @@ -11,6 +11,7 @@ // The open/closed state is a LOCAL preference (localStorage, per section // key): which sections a user keeps open is workflow, not scene data. import { ChevronRight } from '@lucide/svelte'; + import { safeStorage } from '$lib/safeStorage'; /** @type {{ key: string, label: string, open?: boolean, forceOpen?: boolean, * id?: string, children: any }} */ @@ -23,14 +24,14 @@ const isOpen = $derived.by(() => { if (forceOpen) return true; const saved = - override ?? (typeof localStorage !== 'undefined' ? localStorage.getItem(storeKey) : null); + override ?? (typeof localStorage !== 'undefined' ? safeStorage.getItem(storeKey) : null); return saved === null ? open : saved === 'open'; }); function toggle() { override = isOpen ? 'closed' : 'open'; try { - localStorage.setItem(storeKey, override); + safeStorage.setItem(storeKey, override); } catch {} } diff --git a/src/components/ui/ToolboxWindow.svelte b/src/components/ui/ToolboxWindow.svelte index 9019d906..8fb2c5c4 100644 --- a/src/components/ui/ToolboxWindow.svelte +++ b/src/components/ui/ToolboxWindow.svelte @@ -47,6 +47,7 @@ import { dragWindow } from '$lib/dragWindow'; import { focusStack } from '$lib/windowFocus'; import { notesDrawerOpen, inspectorClose } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; /** @type {{ id: string, title: string, key: string, * defaultRect?: { left?: number, top?: number, right?: number, bottom?: number }, @@ -89,7 +90,7 @@ const sheetKey = $derived('tbxSheetH:' + key); $effect(() => { if (sheetH || typeof window === 'undefined') return; - const saved = parseInt(localStorage.getItem(sheetKey) || ''); + const saved = parseInt(safeStorage.getItem(sheetKey) || ''); sheetH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.4) : saved; }); let sheetResizing = $state(false); @@ -121,7 +122,7 @@ /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); } catch {} try { - localStorage.setItem(sheetKey, String(sheetH)); + safeStorage.setItem(sheetKey, String(sheetH)); } catch {} } diff --git a/src/lib/ai/meshProviders.js b/src/lib/ai/meshProviders.js index 79a1e6b9..30cdb179 100644 --- a/src/lib/ai/meshProviders.js +++ b/src/lib/ai/meshProviders.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from '../safeStorage'; // Text/image -> 3D mesh generation providers (roadmap #11, G1). Mirrors // ai/providers.js (the LLM providers) but for mesh backends: a self-hosted ComfyUI @@ -53,7 +54,7 @@ const ENABLED_KEY = 'meshGenEnabled'; /** @returns {MeshProviderConfig[]} */ function loadProviders() { try { - const raw = localStorage.getItem(PROVIDERS_KEY); + const raw = safeStorage.getItem(PROVIDERS_KEY); const parsed = raw ? JSON.parse(raw) : null; return Array.isArray(parsed) ? parsed : []; } catch { @@ -64,7 +65,7 @@ function loadProviders() { /** @param {MeshProviderConfig[]} list */ function persist(list) { try { - localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); + safeStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); } catch {} } @@ -75,7 +76,7 @@ export const meshProviders = writable(loadProviders()); export const meshActiveProvider = writable( (() => { try { - return localStorage.getItem(ACTIVE_KEY) || null; + return safeStorage.getItem(ACTIVE_KEY) || null; } catch { return null; } @@ -86,7 +87,7 @@ export const meshActiveProvider = writable( export const meshGenEnabled = writable( (() => { try { - return localStorage.getItem(ENABLED_KEY) === 'true'; + return safeStorage.getItem(ENABLED_KEY) === 'true'; } catch { return false; } @@ -160,8 +161,8 @@ export function removeMeshProvider(id) { export function setMeshActiveProvider(id) { meshActiveProvider.set(id); try { - if (id) localStorage.setItem(ACTIVE_KEY, id); - else localStorage.removeItem(ACTIVE_KEY); + if (id) safeStorage.setItem(ACTIVE_KEY, id); + else safeStorage.removeItem(ACTIVE_KEY); } catch {} } @@ -169,7 +170,7 @@ export function setMeshActiveProvider(id) { export function setMeshGenEnabled(on) { meshGenEnabled.set(!!on); try { - localStorage.setItem(ENABLED_KEY, String(!!on)); + safeStorage.setItem(ENABLED_KEY, String(!!on)); } catch {} } diff --git a/src/lib/ai/providers.js b/src/lib/ai/providers.js index de2adda2..754dbfc7 100644 --- a/src/lib/ai/providers.js +++ b/src/lib/ai/providers.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from '../safeStorage'; // AI provider settings (roadmap #10, A1). A LOCAL per-device preference — the // only credentials the app stores. Keys live in PLAINTEXT localStorage (there is @@ -89,7 +90,7 @@ const ENABLED_KEY = 'aiEnabled'; /** @returns {AiProviderConfig[]} */ function loadProviders() { try { - const raw = localStorage.getItem(PROVIDERS_KEY); + const raw = safeStorage.getItem(PROVIDERS_KEY); const parsed = raw ? JSON.parse(raw) : null; return Array.isArray(parsed) ? parsed : []; } catch { @@ -100,7 +101,7 @@ function loadProviders() { /** @param {AiProviderConfig[]} list */ function persistProviders(list) { try { - localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); + safeStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); } catch {} } @@ -113,7 +114,7 @@ export const aiProviders = writable(loadProviders()); export const aiActiveProvider = writable( (() => { try { - return localStorage.getItem(ACTIVE_KEY) || null; + return safeStorage.getItem(ACTIVE_KEY) || null; } catch { return null; } @@ -124,7 +125,7 @@ export const aiActiveProvider = writable( export const aiEnabled = writable( (() => { try { - return localStorage.getItem(ENABLED_KEY) === 'true'; + return safeStorage.getItem(ENABLED_KEY) === 'true'; } catch { return false; } @@ -203,8 +204,8 @@ export function removeAiProvider(id) { export function setAiActiveProvider(id) { aiActiveProvider.set(id); try { - if (id) localStorage.setItem(ACTIVE_KEY, id); - else localStorage.removeItem(ACTIVE_KEY); + if (id) safeStorage.setItem(ACTIVE_KEY, id); + else safeStorage.removeItem(ACTIVE_KEY); } catch {} } @@ -212,7 +213,7 @@ export function setAiActiveProvider(id) { export function setAiEnabled(on) { aiEnabled.set(!!on); try { - localStorage.setItem(ENABLED_KEY, String(!!on)); + safeStorage.setItem(ENABLED_KEY, String(!!on)); } catch {} } diff --git a/src/lib/annotationsHandler.js b/src/lib/annotationsHandler.js index a8b30b8b..49749832 100644 --- a/src/lib/annotationsHandler.js +++ b/src/lib/annotationsHandler.js @@ -21,6 +21,7 @@ import { } from '../stores/appStore'; import { registerAnnotationsPersistence, markAnnotationsDirty } from './autosave'; import { flyTo } from './objectActions'; +import { safeStorage } from './safeStorage'; // Synced note pins on objects. Offsets are object-local so pins follow their // object; one note per pin. Replication mirrors the flow-graph pattern: @@ -49,10 +50,10 @@ export const noteMarkers = writable([]); /** H3: LOCAL pref — pins visible in the viewport (not replicated) */ export const showNotePins = writable( - typeof localStorage === 'undefined' || localStorage.getItem('showNotePins') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('showNotePins') !== 'false' ); if (typeof localStorage !== 'undefined') - showNotePins.subscribe((value) => localStorage.setItem('showNotePins', String(value))); + showNotePins.subscribe((value) => safeStorage.setItem('showNotePins', String(value))); /** H9: pin shapes (replicated per note; 'round' = the historical pin) */ export const NOTE_SHAPES = ['round', 'star', 'square']; @@ -172,9 +173,9 @@ let authorKeyCache = ''; export function myAuthorKey() { if (authorKeyCache) return authorKeyCache; try { - const stored = localStorage.getItem(AUTHOR_KEY); + const stored = safeStorage.getItem(AUTHOR_KEY); authorKeyCache = stored || crypto.randomUUID(); - if (!stored) localStorage.setItem(AUTHOR_KEY, authorKeyCache); + if (!stored) safeStorage.setItem(AUTHOR_KEY, authorKeyCache); } catch { authorKeyCache = 'local'; } diff --git a/src/lib/arProbe.js b/src/lib/arProbe.js index 39ceb61b..ef741fae 100644 --- a/src/lib/arProbe.js +++ b/src/lib/arProbe.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // CO0 — the on-device WebXR capability probe. // @@ -47,7 +48,7 @@ const RESTORE_DEADLINE = 45; function loadFindings() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(FINDINGS_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(FINDINGS_KEY) : null; const stored = raw ? JSON.parse(raw) : null; return Array.isArray(stored) ? stored : []; } catch { @@ -67,7 +68,7 @@ export const probeRunning = writable(false); /** @param {any} list */ function persistFindings(list) { try { - if (typeof localStorage !== 'undefined') localStorage.setItem(FINDINGS_KEY, JSON.stringify(list)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(FINDINGS_KEY, JSON.stringify(list)); } catch { // private mode / quota: the on-screen report still works for this run } @@ -143,7 +144,7 @@ function ago(ms) { function readStoredAnchor() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(ANCHOR_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(ANCHOR_KEY) : null; const stored = raw ? JSON.parse(raw) : null; return stored && typeof stored.handle === 'string' && stored.handle ? stored : null; } catch { @@ -154,7 +155,7 @@ function readStoredAnchor() { /** @param {any} record */ function writeStoredAnchor(record) { try { - if (typeof localStorage !== 'undefined') localStorage.setItem(ANCHOR_KEY, JSON.stringify(record)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(ANCHOR_KEY, JSON.stringify(record)); return true; } catch { return false; @@ -599,8 +600,8 @@ export async function clearProbeState() { resetFindings(); try { if (typeof localStorage !== 'undefined') { - localStorage.removeItem(ANCHOR_KEY); - localStorage.removeItem(FINDINGS_KEY); + safeStorage.removeItem(ANCHOR_KEY); + safeStorage.removeItem(FINDINGS_KEY); } } catch { // nothing to do — the store is already reset diff --git a/src/lib/audioPatch.js b/src/lib/audioPatch.js index 4b062974..01a2e0fc 100644 --- a/src/lib/audioPatch.js +++ b/src/lib/audioPatch.js @@ -9,6 +9,7 @@ import { registerHistoryKind, recordEntry } from './history'; import { ensureAudioContext } from './audioEngine'; import { deviceHandle, deviceSpec, isDeviceObject } from './audioDevices'; import { wireframeActive } from './viewMode'; +import { safeStorage } from './safeStorage'; // THE PATCH (roadmap #23 A4, cloud plans-core/pending/23-a-audio-engine.md). // @@ -381,7 +382,7 @@ export function reconcileRouting() { /** LOCAL pref: draw the cables. On by default — a patch you cannot see is not much of * a patch. */ -export const showCables = writable(typeof localStorage === 'undefined' || localStorage.getItem('showCables') !== 'false'); +export const showCables = writable(typeof localStorage === 'undefined' || safeStorage.getItem('showCables') !== 'false'); /** The flowSockets palette, by PORT kind, so a wire means the same thing in the 3D * world and in the node editor: audio = orange (an effect), cv = number blue, midi = @@ -550,7 +551,7 @@ export function startCables() { }); showCables.subscribe((value) => { try { - localStorage.setItem('showCables', String(value)); + safeStorage.setItem('showCables', String(value)); } catch {} }); } diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 530fbcba..8ab0f6d1 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -31,6 +31,7 @@ import { log, registerDiagnosticsSection } from './diagnostics'; // #20 P5: selection + edit session + panel layout, restored only on an EXPLICIT restore import { captureEditResume, applyEditResume } from './editResume'; import { disposeTree, keepSet } from './disposeTree'; +import { safeStorage } from './safeStorage'; // 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 @@ -117,7 +118,7 @@ export function estimateSnapshotBytes(snapshot) { } export const autosaveEnabled = writable( - typeof localStorage === 'undefined' || localStorage.getItem('autosave') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('autosave') !== 'false' ); /** * 18-A: restore the snapshot on boot instead of asking. OFF by default — an @@ -125,7 +126,7 @@ export const autosaveEnabled = writable( * construction because checkRestore only ever fires on an EMPTY scene. */ export const autoRestoreEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('autoRestore') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('autoRestore') === 'true' ); /** restore offer for the toast: { ts, objects, snapshot } | null */ /** @type {import('svelte/store').Writable} */ @@ -479,7 +480,7 @@ async function checkRestore() { if (group.children.length !== 0) return; let armed = false; try { - armed = typeof localStorage !== 'undefined' && !!localStorage.getItem('restoreArmed'); + armed = typeof localStorage !== 'undefined' && !!safeStorage.getItem('restoreArmed'); } catch { /* unreadable storage reads as "not armed" — the old behaviour */ } @@ -574,7 +575,7 @@ async function applyRestore(snapshot) { // 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'); + if (typeof localStorage !== 'undefined') safeStorage.setItem('restoreArmed', '1'); } catch { /* private mode or a full quota: the guard degrades to the old behaviour */ } @@ -751,8 +752,8 @@ export function startAutosave() { // best effort — the async export may not finish, the debounce usually already ran if (dirty) saveSnapshot(); }); - autosaveEnabled.subscribe((value) => localStorage.setItem('autosave', String(value))); - autoRestoreEnabled.subscribe((value) => localStorage.setItem('autoRestore', String(value))); + autosaveEnabled.subscribe((value) => safeStorage.setItem('autosave', String(value))); + autoRestoreEnabled.subscribe((value) => safeStorage.setItem('autoRestore', String(value))); // 27-H: the storage story belongs in the bundle a user hands over. "Autosave last // failed with QuotaExceededError and has been backing off to 5 minutes" is the // single most useful line for a lost-work report, and nowhere else records it. diff --git a/src/lib/cameraBookmarks.js b/src/lib/cameraBookmarks.js index e070a188..ae649d6e 100644 --- a/src/lib/cameraBookmarks.js +++ b/src/lib/cameraBookmarks.js @@ -3,6 +3,7 @@ import { globalCamera, orbitControls } from '../stores/sceneStore'; import { showToast } from '../stores/appStore'; import { flyTo } from './objectActions'; import { cameraNear, cameraFar, setCameraNear, setCameraFar } from './cameraClip'; +import { safeStorage } from './safeStorage'; // Saved camera views, persisted LOCALLY (never replicated), recalled from the // viewport menu, Configure Scene ▸ Camera, or Shift+1..5 for the first five. @@ -38,7 +39,7 @@ export function normalizeBookmark(entry, index) { function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; const list = raw ? JSON.parse(raw) : []; return Array.isArray(list) ? list.map(normalizeBookmark) : []; } catch { @@ -50,7 +51,7 @@ function load() { export const bookmarks = writable(load()); bookmarks.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** the current view as a bookmark payload, or null when the camera isn't ready */ diff --git a/src/lib/cameraClip.js b/src/lib/cameraClip.js index cdd0920a..89867559 100644 --- a/src/lib/cameraClip.js +++ b/src/lib/cameraClip.js @@ -1,6 +1,7 @@ import { writable, get } from 'svelte/store'; import { editorCam, playerCam, orbitControls } from '../stores/sceneStore'; import { sceneRadius } from './sceneBounds'; +import { safeStorage } from './safeStorage'; // Camera clip planes (123): a LOCAL per-device view preference (never // replicated) exposed in Configure Scene. The far plane still grows to fit the @@ -14,7 +15,7 @@ const FAR_CAP = 200000; /** @param {string} key @param {number} fallback */ function stored(key, fallback) { try { - const v = parseFloat(localStorage.getItem(key) ?? ''); + const v = parseFloat(safeStorage.getItem(key) ?? ''); return isFinite(v) ? v : fallback; } catch { return fallback; @@ -59,7 +60,7 @@ export function setCameraNear(v) { const n = Math.min(Math.max(v, 0.001), 10); cameraNear.set(n); try { - localStorage.setItem('cameraNear', String(n)); + safeStorage.setItem('cameraNear', String(n)); } catch {} applyCameraClip(); } @@ -69,7 +70,7 @@ export function setCameraFar(v) { const f = Math.min(Math.max(v, 10), FAR_CAP); cameraFar.set(f); try { - localStorage.setItem('cameraFar', String(f)); + safeStorage.setItem('cameraFar', String(f)); } catch {} applyCameraClip(); } @@ -83,7 +84,7 @@ export const DEFAULT_ORBIT = { rotateSpeed: 1, zoomSpeed: 1, panSpeed: 1, dampin function storedOrbit() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('orbitPrefs') : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem('orbitPrefs') : null; return raw ? { ...DEFAULT_ORBIT, ...JSON.parse(raw) } : { ...DEFAULT_ORBIT }; } catch { return { ...DEFAULT_ORBIT }; @@ -110,7 +111,7 @@ export function applyOrbitPrefs() { export function setOrbitPrefs(patch) { orbitPrefs.update((value) => ({ ...value, ...patch })); try { - localStorage.setItem('orbitPrefs', JSON.stringify(get(orbitPrefs))); + safeStorage.setItem('orbitPrefs', JSON.stringify(get(orbitPrefs))); } catch {} applyOrbitPrefs(); } @@ -118,7 +119,7 @@ export function setOrbitPrefs(patch) { export function resetOrbitPrefs() { orbitPrefs.set({ ...DEFAULT_ORBIT }); try { - localStorage.setItem('orbitPrefs', JSON.stringify(DEFAULT_ORBIT)); + safeStorage.setItem('orbitPrefs', JSON.stringify(DEFAULT_ORBIT)); } catch {} applyOrbitPrefs(); } diff --git a/src/lib/cameraHelpers.js b/src/lib/cameraHelpers.js index e8b8c19c..ec90b20a 100644 --- a/src/lib/cameraHelpers.js +++ b/src/lib/cameraHelpers.js @@ -8,6 +8,7 @@ import { wireframeActive } from './viewMode'; // without the debug toggle, or a camera preview) — see helperLayer.js for the rule import { markHelper, setMarkersHidden, helpersHidden, helpersInPlay } from './helperLayer'; import { isLocked } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; // 16-P5: frustum visualization for camera OBJECTS — the colliderHelpers pattern. // One wireframe frustum per camera object, built from `userData.camera` and @@ -18,7 +19,7 @@ import { isLocked } from '../stores/sceneStore'; // much of a camera. `showCameraFrustums` is a LOCAL pref for turning it off. export const showCameraFrustums = writable( - typeof localStorage === 'undefined' || localStorage.getItem('showCameraFrustums') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('showCameraFrustums') !== 'false' ); /** the camera currently PREVIEWED — its own frustum is pointless (you're inside it) @@ -185,7 +186,7 @@ export function startCameraHelpers() { }); showCameraFrustums.subscribe((value) => { try { - localStorage.setItem('showCameraFrustums', String(value)); + safeStorage.setItem('showCameraFrustums', String(value)); } catch {} sync(); }); diff --git a/src/lib/cloudPlugin.js b/src/lib/cloudPlugin.js index 807c6545..7ce6d6f7 100644 --- a/src/lib/cloudPlugin.js +++ b/src/lib/cloudPlugin.js @@ -24,6 +24,7 @@ import { // cloudPlugin path is in history's import subtree — App alone imports this module). import { currentLevel } from './levels'; import { myPlayMode, peerPlayModes } from './gamePresence'; +import { safeStorage } from './safeStorage'; // 28-A (roadmap #28, publish · play · remix): the seams below reach cycle-sensitive // modules — sessions is history-family, cameraBookmarks imports objectActions, playMode is @@ -58,7 +59,7 @@ export async function startCloudPlugin() { try { url = (import.meta && import.meta.env && import.meta.env.VITE_CLOUD_PLUGIN) || - (typeof localStorage !== 'undefined' && localStorage.getItem('cloudPluginUrl')) || + (typeof localStorage !== 'undefined' && safeStorage.getItem('cloudPluginUrl')) || ''; } catch { url = ''; diff --git a/src/lib/colliderHelpers.js b/src/lib/colliderHelpers.js index 2defaf7c..5c7aaeb4 100644 --- a/src/lib/colliderHelpers.js +++ b/src/lib/colliderHelpers.js @@ -6,6 +6,7 @@ import { globalScene, objectsGroup } from '../stores/sceneStore'; import { colliderSpecOf } from './colliderSpec'; import { wireframeActive } from './viewMode'; import { scenePhysicsGround } from './scenePhysics'; +import { safeStorage } from './safeStorage'; // CL-A A7: collider visualization (the lightHelpers pattern). Per tracked // object a wireframe built FROM colliderSpecOf — the SAME spec physics @@ -15,7 +16,7 @@ import { scenePhysicsGround } from './scenePhysics'; /** global toggle (scene ▸ View), LOCAL pref, default OFF */ export const showColliders = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('showColliders') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('showColliders') === 'true' ); /** per-object opt-in (Inspector ▸ Physics "Show collider") — session-local, * NOT persisted or replicated. @type {import('svelte/store').Writable>} */ @@ -264,7 +265,7 @@ export function startColliderHelpers() { }); showColliders.subscribe((value) => { try { - localStorage.setItem('showColliders', String(value)); + safeStorage.setItem('showColliders', String(value)); } catch {} sync(); }); diff --git a/src/lib/colocationAnchors.js b/src/lib/colocationAnchors.js index a34d342f..8f58ab73 100644 --- a/src/lib/colocationAnchors.js +++ b/src/lib/colocationAnchors.js @@ -53,6 +53,7 @@ import { import { calibrating, worldGrabActive } from './colocationCalibrate'; import { forgetNudge } from './colocationNudge'; import { registerVRFrameHook } from './vrControls'; +import { safeStorage } from './safeStorage'; import { sessionContext, createAnchorAt, @@ -88,7 +89,7 @@ const GRAB_ACTIVE_MS = 400; /** @returns {Record} */ function loadRecords() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORE_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(STORE_KEY) : null; const stored = raw ? JSON.parse(raw) : null; return stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {}; } catch { @@ -105,7 +106,7 @@ export const anchorRecords = writable(loadRecords()); function saveRecords(map) { anchorRecords.set(map); try { - if (typeof localStorage !== 'undefined') localStorage.setItem(STORE_KEY, JSON.stringify(map)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(STORE_KEY, JSON.stringify(map)); } catch { // private mode / quota: the in-memory mirror still works for this run } diff --git a/src/lib/colocationNudge.js b/src/lib/colocationNudge.js index b47a0452..3e93def0 100644 --- a/src/lib/colocationNudge.js +++ b/src/lib/colocationNudge.js @@ -39,6 +39,7 @@ import { registerVRFrameHook } from './vrControls'; import { registerVRMenuEntry } from './vrRadialMenu'; import { getInput } from './inputRuntime'; import { calibrating } from './colocationCalibrate'; +import { safeStorage } from './safeStorage'; const STORE_KEY = 'colocation-nudge-v1'; @@ -59,7 +60,7 @@ export const nudgeMode = writable(false); function readAll() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORE_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(STORE_KEY) : null; const parsed = raw ? JSON.parse(raw) : null; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { @@ -70,7 +71,7 @@ function readAll() { /** @param {any} all */ function writeAll(all) { try { - if (typeof localStorage !== 'undefined') localStorage.setItem(STORE_KEY, JSON.stringify(all)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(STORE_KEY, JSON.stringify(all)); } catch { // private mode / quota: the live correction still works for this session } @@ -313,7 +314,7 @@ export function resetColocationNudge() { loadedKey = null; lastTick = 0; try { - if (typeof localStorage !== 'undefined') localStorage.removeItem(STORE_KEY); + if (typeof localStorage !== 'undefined') safeStorage.removeItem(STORE_KEY); } catch { // nothing to do } diff --git a/src/lib/colocationPresence.js b/src/lib/colocationPresence.js index 7578d291..8f3b7b95 100644 --- a/src/lib/colocationPresence.js +++ b/src/lib/colocationPresence.js @@ -52,6 +52,7 @@ import { writable, derived, get } from 'svelte/store'; import { peers } from '../stores/appStore'; import { roomAlignment, roomKey } from './colocation'; +import { safeStorage } from './safeStorage'; /** REMOTE peers only, `peerId -> roomKey`. A peer NOT in this map is not colocated — * absence is the single representation of that, so nothing ever writes a null row. @@ -66,7 +67,7 @@ export const peerColocation = writable({}); * hands are visible but the thing they hold is not. * @type {import('svelte/store').Writable} */ export const colocatedGhostHands = writable( - typeof localStorage === 'undefined' || localStorage.getItem('colocatedGhostHands') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('colocatedGhostHands') !== 'false' ); /** How faint. Low enough to read as a hint rather than as an avatar, high enough to @@ -246,5 +247,5 @@ export function resetColocationPresence() { // Declared last so nothing above it can be read by this subscriber before its `let`s // exist — the same TDZ rule the wiring comment states. colocatedGhostHands.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('colocatedGhostHands', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('colocatedGhostHands', String(value)); }); diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index 59a9e512..10e64de1 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -27,6 +27,7 @@ import { peers, userdata } from '../stores/appStore'; // 27-G (audit H6): removing an object frees NOTHING on the GPU. These free what only // the departing object was using, and never what the rest of the scene still holds. import { disposeTree, keepSet } from '$lib/disposeTree'; +import { safeStorage } from './safeStorage'; //Access scene Store let scene = $state(); @@ -163,12 +164,12 @@ export function sceneCommand(command) { if (command.split(' ')[1] == 'on') { showGrid.set(true); - localStorage.removeItem('showGrid') + safeStorage.removeItem('showGrid') } else if (command.split(' ')[1] == 'off') { showGrid.set(false); - localStorage.setItem('showGrid', false); + safeStorage.setItem('showGrid', false); } } else if (command.startsWith('/create')) { diff --git a/src/lib/connectionState.js b/src/lib/connectionState.js index 979930fc..3709f3ed 100644 --- a/src/lib/connectionState.js +++ b/src/lib/connectionState.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** * Session-connection state (roadmap #14 CN). STORE-ONLY module (svelte/store only) @@ -106,14 +107,14 @@ export function roomIsFull(peers) { function readSoftCap() { if (typeof localStorage === 'undefined') return SOFT_PEER_CAP_DEFAULT; - const raw = Number(localStorage.getItem('connect:softPeerCap')); + const raw = Number(safeStorage.getItem('connect:softPeerCap')); return Number.isFinite(raw) && raw >= 2 && raw <= HARD_PEER_CAP ? raw : SOFT_PEER_CAP_DEFAULT; } /** LOCAL, like every other connection preference. @type {import('svelte/store').Writable} */ export const softPeerCap = writable(readSoftCap()); softPeerCap.subscribe((v) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('connect:softPeerCap', String(v)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('connect:softPeerCap', String(v)); }); /** @@ -192,7 +193,7 @@ export const mergeOnConnect = writable(readMergeOnConnect()); * default, never a crash. The `readFlag` idiom from sharedLibrary. */ function readMergeOnConnect() { try { - return localStorage.getItem('connect:mergeOnConnect') === 'true'; + return safeStorage.getItem('connect:mergeOnConnect') === 'true'; } catch { return false; } @@ -202,6 +203,6 @@ function readMergeOnConnect() { // callback only ever reads its own argument, so it is safe wherever it sits. mergeOnConnect.subscribe((v) => { try { - localStorage.setItem('connect:mergeOnConnect', String(v)); + safeStorage.setItem('connect:mergeOnConnect', String(v)); } catch {} }); diff --git a/src/lib/docking.js b/src/lib/docking.js index ec88fb1e..5450ea80 100644 --- a/src/lib/docking.js +++ b/src/lib/docking.js @@ -1,6 +1,7 @@ import { get } from 'svelte/store'; import { inspectorClose, closeMenu } from '../stores/appStore'; import { bottomDockWouldTake } from './bottomDockDrop'; +import { safeStorage } from './safeStorage'; // Docking lite (phase 81L). Drag a window near the left/right screen edge to // dock it as a full-height panel (--z-drawer tier); drag its header away to @@ -19,17 +20,17 @@ let docked = { left: null, right: null }; const registry = new Map(); // key -> {node, prevRect, handle} try { - const saved = JSON.parse(localStorage.getItem('dockedWindows') ?? 'null'); + const saved = JSON.parse(safeStorage.getItem('dockedWindows') ?? 'null'); if (saved) docked = { left: saved.left ?? null, right: saved.right ?? null }; } catch {} function persist() { - localStorage.setItem('dockedWindows', JSON.stringify(docked)); + safeStorage.setItem('dockedWindows', JSON.stringify(docked)); } /** @param {string} key */ function widthOf(key) { - const value = parseInt(localStorage.getItem('dockWidth:' + key) ?? '300'); + const value = parseInt(safeStorage.getItem('dockWidth:' + key) ?? '300'); return Math.min(Math.max(Number.isNaN(value) ? 300 : value, 250), Math.round(window.innerWidth * 0.4)); } @@ -102,7 +103,7 @@ function apply(key) { const move = (/** @type {any} */ ev) => { const delta = currentSide === 'left' ? ev.clientX - startX : startX - ev.clientX; const next = Math.min(Math.max(250, startWidth + delta), Math.round(window.innerWidth * 0.4)); - localStorage.setItem('dockWidth:' + key, String(next)); + safeStorage.setItem('dockWidth:' + key, String(next)); apply(key); }; const up = () => { diff --git a/src/lib/dragWindow.js b/src/lib/dragWindow.js index 382d5138..33a1b346 100644 --- a/src/lib/dragWindow.js +++ b/src/lib/dragWindow.js @@ -3,6 +3,7 @@ // Windows sit on the --z-window tier; the caller sets size and z-index. import { clampWinSize, clampResize, bottomReserve } from './windowSize'; +import { safeStorage } from './safeStorage'; // 169: live reset registry — every draggable window (this action + the object // list's own dragMe) registers a reset fn so Settings can rescue windows stuck @@ -42,10 +43,10 @@ export function revealWindow(key) { * button, so it is the honest hatch rather than a second one. */ export function resetWindowLayout() { if (typeof localStorage !== 'undefined') { - for (const key of Object.keys(localStorage)) - if (key.startsWith('win:')) localStorage.removeItem(key); + for (const key of safeStorage.keys()) + if (key.startsWith('win:')) safeStorage.removeItem(key); ['objectListRect', 'explorerWinW', 'explorerWinH', 'explorerHeight', 'explorerTreeW', 'uvWinW', 'uvWinH', 'controlsLayout'].forEach((k) => - localStorage.removeItem(k) + safeStorage.removeItem(k) ); } resetters.forEach((fn) => { @@ -76,7 +77,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi /** @type {any} */ let rect = null; try { - rect = JSON.parse(localStorage.getItem('win:' + key) ?? 'null'); + rect = JSON.parse(safeStorage.getItem('win:' + key) ?? 'null'); } catch { rect = null; } @@ -189,7 +190,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi payload.w = rect.w; if (axis !== 'x') payload.h = rect.h; } - localStorage.setItem('win:' + key, JSON.stringify(payload)); + safeStorage.setItem('win:' + key, JSON.stringify(payload)); } // right/bottom-anchored defaults need the rendered size — resolve on the @@ -268,7 +269,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi // 169: reset this window to its default spot (Settings rescue) function resetToDefault() { try { - localStorage.removeItem('win:' + key); + safeStorage.removeItem('win:' + key); } catch {} rect = { ...defaultRect }; if (resizable) { diff --git a/src/lib/environment.js b/src/lib/environment.js index 23df42f7..6f1ecf1b 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -8,6 +8,7 @@ import { createLight } from './geometries.svelte'; import { cappedShadowSize, shadowQuality } from './lightParams'; import { wireframeActive } from './viewMode'; import { idbGet, idbPut, idbDelete, idbKeys } from './idb'; +import { safeStorage } from './safeStorage'; // Environment v2 (phase 70). Everything environmental lives under ONE group at // the scene root: `environment-root` — the preset rig (hemi+sun) plus any @@ -66,7 +67,7 @@ const DEFAULT_STATE = { preset: 'studio', exposure: 1, customPreset: null, light function persisted() { try { - const raw = localStorage.getItem('environment'); + const raw = safeStorage.getItem('environment'); if (raw) return { ...DEFAULT_STATE, ...JSON.parse(raw) }; } catch {} return { ...DEFAULT_STATE }; @@ -608,7 +609,7 @@ export function startEnvironment() { loadEnvPresets(); environment.subscribe((state) => { try { - localStorage.setItem('environment', JSON.stringify(state)); + safeStorage.setItem('environment', JSON.stringify(state)); } catch {} }); // scene/renderer arrive async at boot diff --git a/src/lib/explorerView.js b/src/lib/explorerView.js index 61dddd88..bd3e89b9 100644 --- a/src/lib/explorerView.js +++ b/src/lib/explorerView.js @@ -19,6 +19,7 @@ // columns that distinguish the bin or leave dead columns in the library. import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** * @typedef {{key: string, label: string, always?: boolean, numeric?: boolean, width?: string}} ExplorerColumn @@ -74,7 +75,7 @@ const GROUP_KEY = 'explorer:deletedGroup'; function load(key, fallback) { if (typeof localStorage === 'undefined') return fallback; try { - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); if (!raw) return fallback; const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? { ...fallback, ...parsed } : fallback; @@ -87,7 +88,7 @@ function load(key, fallback) { function save(key, value) { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(key, JSON.stringify(value)); + safeStorage.setItem(key, JSON.stringify(value)); } catch {} } @@ -97,7 +98,7 @@ function save(key, value) { */ export const explorerViewMode = writable( /** @type {'thumbnails'|'list'} */ ( - typeof localStorage !== 'undefined' && localStorage.getItem(MODE_KEY) === 'list' + typeof localStorage !== 'undefined' && safeStorage.getItem(MODE_KEY) === 'list' ? 'list' : 'thumbnails' ) @@ -105,7 +106,7 @@ export const explorerViewMode = writable( explorerViewMode.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(MODE_KEY, v); + safeStorage.setItem(MODE_KEY, v); } catch {} }); @@ -200,7 +201,7 @@ explorerSort.subscribe((v) => save(SORT_KEY, v)); */ export const explorerDeletedGroup = writable( /** @type {'none'|'deleter'} */ ( - typeof localStorage !== 'undefined' && localStorage.getItem(GROUP_KEY) === 'deleter' + typeof localStorage !== 'undefined' && safeStorage.getItem(GROUP_KEY) === 'deleter' ? 'deleter' : 'none' ) @@ -208,7 +209,7 @@ export const explorerDeletedGroup = writable( explorerDeletedGroup.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(GROUP_KEY, v); + safeStorage.setItem(GROUP_KEY, v); } catch {} }); @@ -228,7 +229,7 @@ const BIN_SPENT_KEY = 'explorer:binShowSpent'; */ export const explorerBinLayout = writable( /** @type {'tree'|'plain'} */ ( - typeof localStorage !== 'undefined' && localStorage.getItem(BIN_LAYOUT_KEY) === 'plain' + typeof localStorage !== 'undefined' && safeStorage.getItem(BIN_LAYOUT_KEY) === 'plain' ? 'plain' : 'tree' ) @@ -236,7 +237,7 @@ export const explorerBinLayout = writable( explorerBinLayout.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(BIN_LAYOUT_KEY, v); + safeStorage.setItem(BIN_LAYOUT_KEY, v); } catch {} }); @@ -250,12 +251,12 @@ explorerBinLayout.subscribe((v) => { * row of grid height. @type {import('svelte/store').Writable} */ export const explorerBinShowSpent = writable( - typeof localStorage !== 'undefined' && localStorage.getItem(BIN_SPENT_KEY) === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem(BIN_SPENT_KEY) === 'true' ); explorerBinShowSpent.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(BIN_SPENT_KEY, String(v)); + safeStorage.setItem(BIN_SPENT_KEY, String(v)); } catch {} }); diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index 9ed2fab3..6fc17685 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -45,6 +45,7 @@ import { endProportionalWheel } from './proportional'; import { showProportionalRingAt, hideProportionalRing } from './proportionalRing'; +import { safeStorage } from './safeStorage'; // the custom transform PIVOT (a LOCAL per-object pref). Another leaf — meshPivot // imports THREE, the two stores and `proportional`, and nothing from here. import { @@ -100,11 +101,11 @@ export const VR_FACE_CAP = 2500; * @type {import('svelte/store').Writable} */ export const vrFaceCap = writable( typeof localStorage !== 'undefined' - ? parseInt(localStorage.getItem('vrFaceCap') ?? '') || VR_FACE_CAP + ? parseInt(safeStorage.getItem('vrFaceCap') ?? '') || VR_FACE_CAP : VR_FACE_CAP ); if (typeof localStorage !== 'undefined') - vrFaceCap.subscribe((value) => localStorage.setItem('vrFaceCap', String(value))); + vrFaceCap.subscribe((value) => safeStorage.setItem('vrFaceCap', String(value))); /** D7: over-limit / blocked-edit warning with a deep link into the Settings * VR section (works in noVR immediately; VR users see it on exit — on-device @@ -1539,10 +1540,10 @@ let wireSource = null; /** wireframe overlay display toggle — honored by BOTH edit modes, local pref */ export const meshEditWireframe = writable( - typeof localStorage === 'undefined' || localStorage.getItem('meshEditWireframe') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('meshEditWireframe') !== 'false' ); meshEditWireframe.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditWireframe', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditWireframe', String(value)); if (wire) wire.visible = value; // live toggle mid-session (face mode) }); @@ -1552,10 +1553,10 @@ meshEditWireframe.subscribe((value) => { * editorNavigation (W/A/S/D/Q/E fly is suppressed while it's on; toggling the * pref OFF is the escape hatch that returns the camera keys, quiz 15-D3). */ export const meshEditHotkeys = writable( - typeof localStorage === 'undefined' || localStorage.getItem('meshEditHotkeys') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('meshEditHotkeys') !== 'false' ); meshEditHotkeys.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditHotkeys', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditHotkeys', String(value)); }); /** Show the object SELECTION OUTLINE while mesh-editing — local pref, default @@ -1564,10 +1565,10 @@ meshEditHotkeys.subscribe((value) => { * what they do with depthTest/renderOrder: while you are editing elements, the * object-level outline is pure glare. Read by Outline.svelte. */ export const meshEditOutline = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('meshEditOutline') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('meshEditOutline') === 'true' ); meshEditOutline.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditOutline', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditOutline', String(value)); }); /** Show the raw TRIANGULATION in the edit wireframe — local pref, default OFF. @@ -1576,7 +1577,7 @@ meshEditOutline.subscribe((value) => { * not dissolvable, so drawing it advertised an edge the tools refuse to touch. * Every modeller shows quads in edit mode for the same reason. */ export const meshEditTriWire = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('meshEditTriWire') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('meshEditTriWire') === 'true' ); /** meshEdit owns the vertex-mode overlay; it imports THIS module, so it hands @@ -1591,7 +1592,7 @@ export function registerVertexWireRebuild(fn) { } meshEditTriWire.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditTriWire', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditTriWire', String(value)); // the edge set differs, so this rebuilds rather than toggling visibility. // `wire` is the only session state read here: faceEdited lives further down // the file and would TDZ-crash the SSR eval, so refreshFaceWireframe (which @@ -6919,12 +6920,12 @@ export function registerGizmoPrefListener(fn) { * subscriber runs at module eval (the store-subscriber TDZ gotcha). * @type {import('svelte/store').Writable<'local'|'world'>} */ export const faceGizmoSpace = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('faceGizmoSpace') === 'world' + typeof localStorage !== 'undefined' && safeStorage.getItem('faceGizmoSpace') === 'world' ? 'world' : 'local' ); faceGizmoSpace.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('faceGizmoSpace', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('faceGizmoSpace', String(value)); /** @type {any} */ const controls = get(TControls); // live flip while the face gizmo is seated @@ -6943,10 +6944,10 @@ faceGizmoSpace.subscribe((value) => { * of the way" — modelling with click-select and the ops toolbar only. * @type {import('svelte/store').Writable} */ export const meshGizmoEnabled = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('meshGizmoEnabled') !== '0' : true + typeof localStorage !== 'undefined' ? safeStorage.getItem('meshGizmoEnabled') !== '0' : true ); meshGizmoEnabled.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshGizmoEnabled', value ? '1' : '0'); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshGizmoEnabled', value ? '1' : '0'); if (typeof window === 'undefined') return; // live: seat or drop the gizmo the moment the switch flips, in whichever mode is open. // 24-B1: switching it back ON also restores a pick the mode key hid, so the toolbox diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index b93a4c32..1b243849 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -25,6 +25,7 @@ import { parkAnimatedAtBase } from '$lib/flowRuntime'; import { stripEditOverlays } from '$lib/editOverlays'; import { saveFileBase } from '$lib/saveName'; import { peers, fixLight, loadingFile, showToast } from '../stores/appStore'; +import { safeStorage } from './safeStorage'; //Access objects Store let sceneObjects = $state(); @@ -61,7 +62,7 @@ export function currentSceneName() { // B3: .tpscene export prefs (set from the Sidebar export-settings cog) export function tpsceneOptions() { const read = (/** @type {string} */ k, /** @type {boolean} */ dflt) => { - const v = typeof localStorage !== 'undefined' ? localStorage.getItem(k) : null; + const v = typeof localStorage !== 'undefined' ? safeStorage.getItem(k) : null; return v === null ? dflt : v === 'true'; }; // 21-I5 REVISED: there is deliberately no `versions` option here. This path exports diff --git a/src/lib/filePreview.js b/src/lib/filePreview.js index ba589b58..4669ca90 100644 --- a/src/lib/filePreview.js +++ b/src/lib/filePreview.js @@ -22,6 +22,7 @@ // Deriving it a second time here would be a copy of that logic guaranteed to drift. import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** * What the preview window can actually SHOW. A `.txt` opens in the code editor and a @@ -199,7 +200,7 @@ previewAutoPlay.subscribe((v) => saveFlag('preview:autoPlay', v)); */ export function previewFps() { if (typeof localStorage === 'undefined') return 30; - const raw = Number(localStorage.getItem('animationFps')); + const raw = Number(safeStorage.getItem('animationFps')); return Number.isFinite(raw) && raw >= 1 && raw <= 240 ? Math.round(raw) : 30; } @@ -242,14 +243,14 @@ export function frameAt(t, duration, fps = previewFps()) { /** @param {string} key @param {boolean} fallback */ function readFlag(key, fallback) { if (typeof localStorage === 'undefined') return fallback; - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); return raw === null ? fallback : raw === 'true'; } /** @param {string} key @param {boolean} value */ function saveFlag(key, value) { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(key, String(value)); + safeStorage.setItem(key, String(value)); } catch {} } diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index dcfc31e2..0ac824f1 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -66,6 +66,7 @@ import { // 27-B: recovery paths report through the diagnostics ring instead of console.log, // so a user can hand over what happened (hardening audit H4). A zero-import leaf. import { log } from './diagnostics'; +import { safeStorage } from './safeStorage'; // H3: inputRuntime is reached via a PRIMED dynamic import (the moduleSDK // pattern) — a static edge would close the TDZ cycle history -> flowRuntime -> @@ -3302,7 +3303,7 @@ function clearRestoreArmed() { if (armedCleared || typeof localStorage === 'undefined') return; armedCleared = true; try { - localStorage.removeItem('restoreArmed'); + safeStorage.removeItem('restoreArmed'); } catch { /* private mode, quota, a browser refusing site data — nothing to do */ } @@ -3457,7 +3458,7 @@ export function startFlowRuntime() { }); syncedAnimations.subscribe((value) => { synced = value; - if (typeof localStorage !== 'undefined') localStorage.setItem('syncedAnimations', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('syncedAnimations', String(value)); }); requestAnimationFrame(tick); diff --git a/src/lib/gamepadPrefs.js b/src/lib/gamepadPrefs.js index 6c7dbd9e..11e4be78 100644 --- a/src/lib/gamepadPrefs.js +++ b/src/lib/gamepadPrefs.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // 21-E5: THE GAMEPAD LEAF — the standard-mapping table plus this device's preferences. // @@ -105,7 +106,7 @@ export function normalizeGamepadPrefs(raw) { function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; return normalizeGamepadPrefs(raw ? JSON.parse(raw) : {}); } catch { return { ...DEFAULT_GAMEPAD_PREFS }; @@ -116,7 +117,7 @@ function load() { export const gamepadPrefs = writable(load()); gamepadPrefs.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** @param {Partial} patch */ diff --git a/src/lib/githubStars.js b/src/lib/githubStars.js index 985270d6..7311beaa 100644 --- a/src/lib/githubStars.js +++ b/src/lib/githubStars.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // 15-M: the repo's GitHub star count, for the Welcome overlay's GitHub link. // Deliberately tiny and FAIL-QUIET: unauthenticated api.github.com allows 60 @@ -19,7 +20,7 @@ let started = false; /** Read the cached count (fresh or stale) @returns {{n: number, ts: number}|null} */ function cached() { try { - const raw = localStorage.getItem(CACHE_KEY); + const raw = safeStorage.getItem(CACHE_KEY); if (!raw) return null; const entry = JSON.parse(raw); return typeof entry?.n === 'number' ? entry : null; @@ -45,7 +46,7 @@ export function loadGithubStars() { if (typeof n !== 'number') return; // rate limited / offline — keep the cache githubStars.set(n); try { - localStorage.setItem(CACHE_KEY, JSON.stringify({ n, ts: Date.now() })); + safeStorage.setItem(CACHE_KEY, JSON.stringify({ n, ts: Date.now() })); } catch {} }) .catch(() => {}); // offline / blocked: the link renders without a count diff --git a/src/lib/gridSettings.js b/src/lib/gridSettings.js index 6e50354b..09b66afb 100644 --- a/src/lib/gridSettings.js +++ b/src/lib/gridSettings.js @@ -1,5 +1,6 @@ import { writable, get } from 'svelte/store'; import { snapSettings } from './snapping'; +import { safeStorage } from './safeStorage'; // Grid appearance (16-P3): a LOCAL per-device view preference, never replicated — // same family as `showGrid`, `viewMode` and the cameraClip planes. Peers each get @@ -43,7 +44,7 @@ export const DEFAULT_GRID = { function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; // unknown/missing keys fall back to defaults, so old payloads keep working const stored = raw ? JSON.parse(raw) : {}; const value = { ...DEFAULT_GRID, ...stored }; @@ -61,7 +62,7 @@ function load() { export const gridSettings = writable(load()); gridSettings.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** @param {Partial} patch */ diff --git a/src/lib/handModels.js b/src/lib/handModels.js index fe2dda2e..ebdffe31 100644 --- a/src/lib/handModels.js +++ b/src/lib/handModels.js @@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store'; import { peers } from '../stores/appStore'; import { itemByHash, itemBlob } from './explorer'; import { requestAsset, sendAsset } from './assetShare'; +import { safeStorage } from './safeStorage'; // Custom hand models (R-3): a user's chosen hand GLB is part of their IDENTITY // (the avatar-photo precedent) — the content HASH rides a tiny `handmodel` @@ -16,7 +17,7 @@ import { requestAsset, sendAsset } from './assetShare'; /** my chosen hand model hash ('' = none), LOCAL pref that broadcasts */ export const myHandModel = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('myHandModel') ?? '' : '' + typeof localStorage !== 'undefined' ? safeStorage.getItem('myHandModel') ?? '' : '' ); /** @type {import('svelte/store').Writable>} peerId -> hash */ @@ -96,7 +97,7 @@ export function startHandModels() { started = true; myHandModel.subscribe((hash) => { try { - localStorage.setItem('myHandModel', hash ?? ''); + safeStorage.setItem('myHandModel', hash ?? ''); } catch {} }); // missing bytes may arrive later (assetShare pull) — retry pending parses diff --git a/src/lib/helperLayer.js b/src/lib/helperLayer.js index 6d417e35..1a5c55d2 100644 --- a/src/lib/helperLayer.js +++ b/src/lib/helperLayer.js @@ -23,6 +23,7 @@ // Imports sceneStore only (the lightHelpers/cameraHelpers family), no THREE. import { get, writable } from 'svelte/store'; import { isLocked, editorCam, globalCamera, objectsGroup } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; export const HELPER_LAYER = 1; @@ -30,10 +31,10 @@ export const HELPER_LAYER = 1; * in Play and a DEBUG chip sits in the play HUD so a screenshot cannot be mistaken for * the game. @type {import('svelte/store').Writable} */ export const helpersInPlay = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('helpersInPlay') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('helpersInPlay') === 'true' ); helpersInPlay.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('helpersInPlay', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('helpersInPlay', String(value)); }); /** Put a scene-root helper (and its whole subtree) on the helper layer, only. diff --git a/src/lib/hudDocs.js b/src/lib/hudDocs.js index af9c371a..ad828591 100644 --- a/src/lib/hudDocs.js +++ b/src/lib/hudDocs.js @@ -27,6 +27,7 @@ import { HUD_KINDS as REGISTERED_KINDS, defaultsForKind, styleDefaultsForKind, k // 21-D6: a screen can follow the GAME STATE. gameState is a leaf too, so this closes no // cycle — and it is what lets a menu hide itself when the game starts, with no wiring. import { gameState } from './gameState'; +import { safeStorage } from './safeStorage'; /** The scene-wide HUD, and the only key the v1 UI creates. */ export const HUD_SCENE_KEY = 'scene'; @@ -85,12 +86,12 @@ export const hudSelection = writable({}); * `viewportOverrides.hud` is the separate, persistent local kill switch. * @type {import('svelte/store').Writable} */ export const hudPreviewInViewport = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('hudPreviewInViewport') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('hudPreviewInViewport') === 'true' ); if (typeof localStorage !== 'undefined') hudPreviewInViewport.subscribe((on) => { try { - localStorage.setItem('hudPreviewInViewport', String(!!on)); + safeStorage.setItem('hudPreviewInViewport', String(!!on)); } catch {} }); diff --git a/src/lib/importDuplicates.js b/src/lib/importDuplicates.js index 7805feb0..e48c3bb1 100644 --- a/src/lib/importDuplicates.js +++ b/src/lib/importDuplicates.js @@ -28,13 +28,14 @@ import { writable, get } from 'svelte/store'; import { explorerItems, hiddenItems, registerDuplicateResolver } from './explorer'; import { showToast } from '../stores/appStore'; +import { safeStorage } from './safeStorage'; export const DUPLICATE_MODES = ['ask', 'skip', 'copy']; const STORAGE_KEY = 'importDuplicateMode'; function readMode() { try { - const stored = localStorage.getItem(STORAGE_KEY); + const stored = safeStorage.getItem(STORAGE_KEY); if (stored && DUPLICATE_MODES.includes(stored)) return stored; } catch {} return 'ask'; @@ -45,7 +46,7 @@ function readMode() { export const duplicateImportMode = writable(readMode()); duplicateImportMode.subscribe((mode) => { try { - localStorage.setItem(STORAGE_KEY, String(mode)); + safeStorage.setItem(STORAGE_KEY, String(mode)); } catch {} }); diff --git a/src/lib/lightHelpers.js b/src/lib/lightHelpers.js index 37e66933..3eb4599e 100644 --- a/src/lib/lightHelpers.js +++ b/src/lib/lightHelpers.js @@ -4,6 +4,7 @@ import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js import { globalScene, objectsGroup } from '../stores/sceneStore'; // 24-E2: helpers + proxies live on the helper layer (the editor camera enables it) import { markHelper } from './helperLayer'; +import { safeStorage } from './safeStorage'; // Makes lights visible and draggable: a type-specific helper plus a small // wireframe "bulb" pick proxy per light. Helpers and proxies live at the @@ -12,16 +13,16 @@ import { markHelper } from './helperLayer'; // uuid; Scene.svelte routes clicks on them to selectObject(lightUuid). export const showLightHelpers = writable( - typeof localStorage === 'undefined' || localStorage.getItem('showLightHelpers') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('showLightHelpers') !== 'false' ); /** 24-E1: how far along its forward a directional/spot light's target sits (the * helper's line length; display only — the direction is what shadows read, and the * distance changes nothing for either light type). LOCAL pref, Settings ▸ Scene. */ export const lightHelperLength = writable( - typeof localStorage === 'undefined' ? 2 : Math.max(0.2, Number(localStorage.getItem('lightHelperLength')) || 2) + typeof localStorage === 'undefined' ? 2 : Math.max(0.2, Number(safeStorage.getItem('lightHelperLength')) || 2) ); lightHelperLength.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('lightHelperLength', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('lightHelperLength', String(value)); }); const forward = new THREE.Vector3(); const worldQuat = new THREE.Quaternion(); @@ -159,7 +160,7 @@ export function startLightHelpers() { }); showLightHelpers.subscribe((value) => { visible = value; - localStorage.setItem('showLightHelpers', String(value)); + safeStorage.setItem('showLightHelpers', String(value)); applyVisibility(); }); } diff --git a/src/lib/lightParams.js b/src/lib/lightParams.js index d954eaa9..75659e4c 100644 --- a/src/lib/lightParams.js +++ b/src/lib/lightParams.js @@ -1,6 +1,7 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { objectsGroup, globalScene, globalRenderer } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; // Light parameter registry (phase 79): type-specific settings the Inspector // renders (color/intensity/visible are common rows it already has). Values @@ -36,7 +37,7 @@ export const SHADOW_SIZES = [512, 1024, 2048]; const QUALITY_CAPS = { off: 512, low: 512, medium: 1024, high: 2048 }; export const shadowQuality = writable( typeof localStorage !== 'undefined' - ? localStorage.getItem('shadowQuality') ?? 'high' + ? safeStorage.getItem('shadowQuality') ?? 'high' : 'high' ); @@ -127,7 +128,7 @@ export function startLightParams() { if (started || typeof window === 'undefined') return; started = true; shadowQuality.subscribe((value) => { - localStorage.setItem('shadowQuality', String(value)); + safeStorage.setItem('shadowQuality', String(value)); applyShadowQualityCap(); }); } diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js index 753dbc5d..2808f0e6 100644 --- a/src/lib/meshEdit.js +++ b/src/lib/meshEdit.js @@ -56,6 +56,7 @@ import { slideClamp } from './meshToolParams'; // W9: where the viewport is. A leaf (svelte/store + sceneStore) — no new edge out of // the history-cycle family this module belongs to. import { canvasRect } from './canvasRect'; +import { safeStorage } from './safeStorage'; // the custom transform PIVOT (local pref). Another leaf: meshPivot imports THREE // + the two stores + proportional, and nothing from here or faceEdit. import { @@ -138,13 +139,13 @@ const HANDLE_MULTI = 0x22c55e; // 177: ctrl/shift multi-select for Create face * @type {import('svelte/store').Writable} */ export const vertexHandleScale = writable( typeof localStorage !== 'undefined' - ? Math.min(Math.max(parseFloat(localStorage.getItem('vertexHandleScale') ?? '') || 1, 0.1), 4) + ? Math.min(Math.max(parseFloat(safeStorage.getItem('vertexHandleScale') ?? '') || 1, 0.1), 4) : 1 ); /** Screen-constant handle size (default ON — see refreshHandleMatrix). A local pref. * @type {import('svelte/store').Writable} */ export const vertexHandleAdaptive = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vertexHandleAdaptive') !== '0' : true + typeof localStorage !== 'undefined' ? safeStorage.getItem('vertexHandleAdaptive') !== '0' : true ); /** reused so the per-frame path allocates nothing */ const scaleVector = new THREE.Vector3(); @@ -187,14 +188,14 @@ const APPARENT_PX = 9; vertexHandleAdaptive.subscribe((value) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('vertexHandleAdaptive', value ? '1' : '0'); + safeStorage.setItem('vertexHandleAdaptive', value ? '1' : '0'); if (!handleMesh || !edited) return; // re-pose every handle: the matrices carry the scale, so switching modes is a rewrite for (let i = 0; i < handles.length; i++) refreshHandleMatrix(i); }); vertexHandleScale.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('vertexHandleScale', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('vertexHandleScale', String(value)); // live, and cheap: the size lives in the instance MATRICES, so nothing is rebuilt and // no handle index moves — the selection survives a size change if (!handleMesh || !edited) return; @@ -1649,11 +1650,11 @@ export const VR_VERTEX_CAP = 800; * @type {import('svelte/store').Writable} */ export const vrVertexCap = writable( typeof localStorage !== 'undefined' - ? parseInt(localStorage.getItem('vrVertexCap') ?? '') || VR_VERTEX_CAP + ? parseInt(safeStorage.getItem('vrVertexCap') ?? '') || VR_VERTEX_CAP : VR_VERTEX_CAP ); if (typeof localStorage !== 'undefined') - vrVertexCap.subscribe((value) => localStorage.setItem('vrVertexCap', String(value))); + vrVertexCap.subscribe((value) => safeStorage.setItem('vrVertexCap', String(value))); /** Vertex (position entry) count of an object's geometry @param {any} object */ export function vertexCount(object) { diff --git a/src/lib/meshPivot.js b/src/lib/meshPivot.js index abfb83a6..1fc2376b 100644 --- a/src/lib/meshPivot.js +++ b/src/lib/meshPivot.js @@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store'; import { globalScene, globalCamera, globalRenderer, TControls, transformMode } from '../stores/sceneStore'; import { showToast, showInfoToast, dismissToastById } from '../stores/appStore'; import { proportionalAnchor } from './proportional'; +import { safeStorage } from './safeStorage'; // The mesh editor's CUSTOM TRANSFORM PIVOT — where the gizmo sits, and what // rotate/scale turn around, in all three element modes. @@ -40,7 +41,7 @@ const MAX_STORED = 200; /** @returns {Record} */ function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; const stored = raw ? JSON.parse(raw) : {}; if (!stored || typeof stored !== 'object') return {}; /** @type {Record} */ @@ -72,7 +73,7 @@ export const meshPivotPicking = writable(false); export const meshPivotMoving = writable(false); meshPivots.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** meshEdit/faceEdit register here so the gizmo re-seats the moment the pivot diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 11cda385..055a861b 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -38,6 +38,7 @@ import { APP_VERSION } from './version.js'; import { ndcFromClient } from './canvasRect'; // 27-B: recovery paths report through the diagnostics ring (hardening audit H4) import { log } from './diagnostics'; +import { safeStorage } from './safeStorage'; // Module SDK v1 — in-repo modules under src/modules// register through // the api object passed to their register(api). See MODULES.md for the guide. @@ -1536,7 +1537,7 @@ export function isModuleLoaded(id) { function readDisabled() { try { - return JSON.parse(localStorage.getItem('disabledModules') ?? '[]'); + return JSON.parse(safeStorage.getItem('disabledModules') ?? '[]'); } catch { return []; } @@ -1548,7 +1549,7 @@ export const disabledModules = writable( ); disabledModules.subscribe((list) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('disabledModules', JSON.stringify(list)); + safeStorage.setItem('disabledModules', JSON.stringify(list)); }); /** diff --git a/src/lib/multiTransform.js b/src/lib/multiTransform.js index be7dd722..568ed753 100644 --- a/src/lib/multiTransform.js +++ b/src/lib/multiTransform.js @@ -5,6 +5,7 @@ import { peers } from '../stores/appStore'; import { recordTransformSet } from './history'; import { hasOrigin, originWorld, setOriginFromWorld } from './objectOrigin'; import { suspendAnimation, resumeAnimation } from './flowRuntime'; +import { safeStorage } from './safeStorage'; // physics is reached DYNAMICALLY: a static import would close the cycle // multiTransform -> physics -> lockControl -> objectActions -> multiTransform // (the vite-dev TDZ trap; Rollup tolerates it, the dev server 500s) @@ -56,13 +57,13 @@ let lastLiveSend = 0; /** @type {import('svelte/store').Writable<'median'|'active'|'parent'|'individual'>} */ export const pivotMode = writable( /** @type {any} */ ( - typeof localStorage !== 'undefined' && ['median', 'active', 'parent', 'individual'].includes(localStorage.getItem('pivotMode') || '') - ? localStorage.getItem('pivotMode') + typeof localStorage !== 'undefined' && ['median', 'active', 'parent', 'individual'].includes(safeStorage.getItem('pivotMode') || '') + ? safeStorage.getItem('pivotMode') : 'median' ) ); pivotMode.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('pivotMode', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('pivotMode', String(value)); }); /** The parent every member shares, when it is a real object (not objectsGroup). diff --git a/src/lib/musicToolbox.js b/src/lib/musicToolbox.js index 50aae68a..319cbfc3 100644 --- a/src/lib/musicToolbox.js +++ b/src/lib/musicToolbox.js @@ -3,6 +3,7 @@ import { writable, get } from 'svelte/store'; import MusicToolbox from '../components/menu/MusicToolbox.svelte'; import { registerModuleToolbox, unregisterModuleToolbox } from './moduleToolboxes'; import { setDeviceFor, deviceCatalog, deviceCatalogVersion } from './audioDevices'; +import { safeStorage } from './safeStorage'; // THE MUSIC TOOLBOX (roadmap #23 B2, cloud plans-core/pending/23-b-interfaces.md). // @@ -77,7 +78,7 @@ const PRESETS_KEY = 'musicPresets'; /** @returns {Record}[]>} kind -> presets */ function loadPresets() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(PRESETS_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(PRESETS_KEY) : null; const parsed = raw ? JSON.parse(raw) : {}; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { @@ -91,7 +92,7 @@ export const musicPresets = writable(loadPresets()); function persist() { try { - localStorage.setItem(PRESETS_KEY, JSON.stringify(get(musicPresets))); + safeStorage.setItem(PRESETS_KEY, JSON.stringify(get(musicPresets))); } catch {} } diff --git a/src/lib/onionSkin.js b/src/lib/onionSkin.js index 396ad30b..157fdf2d 100644 --- a/src/lib/onionSkin.js +++ b/src/lib/onionSkin.js @@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store'; import { globalScene, objectsGroup, selectedObject } from '../stores/sceneStore'; import { activeClip, keyTimes, poseAt, ghostBase, playheadOf } from './animationPreview'; import { wireframeActive } from './viewMode'; +import { safeStorage } from './safeStorage'; // 17-E F6: ONION SKIN — faint copies of the object at the neighbouring keys, so you // can see where a movement came from and where it is going while you work on the @@ -19,14 +20,14 @@ import { wireframeActive } from './viewMode'; // is not what someone opening a file wants to see. export const showOnionSkin = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('showOnionSkin') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('showOnionSkin') === 'true' ); /** @param {boolean} on */ export function setOnionSkin(on) { showOnionSkin.set(on); try { - localStorage.setItem('showOnionSkin', on ? 'true' : 'false'); + safeStorage.setItem('showOnionSkin', on ? 'true' : 'false'); } catch {} } diff --git a/src/lib/packs.js b/src/lib/packs.js index 325cff61..2c0fda65 100644 --- a/src/lib/packs.js +++ b/src/lib/packs.js @@ -1,6 +1,7 @@ import { writable, get } from 'svelte/store'; import { contentBase } from './contentBase'; import { addItemFromBytes, createFolder, explorerFolders } from './explorer'; +import { safeStorage } from './safeStorage'; // N6 (roadmap 7 / ship-qa D1): object packs. Two sources, one normalized model: // - DEFAULT packs from static/libraryList.json (bundled today; the model bytes @@ -39,7 +40,7 @@ let loadSeq = 0; /** @returns {any[]} imported packs persisted locally */ function getInstalled() { try { - return JSON.parse(localStorage.getItem(INSTALLED_KEY) || '[]'); + return JSON.parse(safeStorage.getItem(INSTALLED_KEY) || '[]'); } catch { return []; } @@ -47,7 +48,7 @@ function getInstalled() { /** @param {any[]} list */ function setInstalled(list) { try { - localStorage.setItem(INSTALLED_KEY, JSON.stringify(list)); + safeStorage.setItem(INSTALLED_KEY, JSON.stringify(list)); } catch {} } @@ -59,7 +60,7 @@ const THUMB_KEY = 'packThumbCache'; /** @returns {Record} */ function getThumbCache() { try { - return JSON.parse(localStorage.getItem(THUMB_KEY) || '{}'); + return JSON.parse(safeStorage.getItem(THUMB_KEY) || '{}'); } catch { return {}; } @@ -74,7 +75,7 @@ export function rememberThumb(packName, itemName, url) { if (c[`${packName}/${itemName}`] === url) return; c[`${packName}/${itemName}`] = url; try { - localStorage.setItem(THUMB_KEY, JSON.stringify(c)); + safeStorage.setItem(THUMB_KEY, JSON.stringify(c)); } catch {} } // 21-G1: PACK RENAME. The report was "the Audio Essentials folder can't be renamed", and @@ -94,7 +95,7 @@ const TITLE_KEY = 'packTitles'; /** @returns {Record} */ function getTitleOverrides() { try { - return JSON.parse(localStorage.getItem(TITLE_KEY) || '{}'); + return JSON.parse(safeStorage.getItem(TITLE_KEY) || '{}'); } catch { return {}; } @@ -114,7 +115,7 @@ export function renamePack(name, title) { const map = getTitleOverrides(); map[name] = clean; try { - localStorage.setItem(TITLE_KEY, JSON.stringify(map)); + safeStorage.setItem(TITLE_KEY, JSON.stringify(map)); } catch {} packs.update((list) => list.map((/** @type {any} */ p) => (p.name === name ? { ...p, title: clean } : p))); return true; @@ -125,7 +126,7 @@ function dropTitleOverride(packName) { if (!(packName in map)) return; delete map[packName]; try { - localStorage.setItem(TITLE_KEY, JSON.stringify(map)); + safeStorage.setItem(TITLE_KEY, JSON.stringify(map)); } catch {} } @@ -137,7 +138,7 @@ function dropPackThumbs(packName) { for (const k of Object.keys(c)) if (k.startsWith(prefix)) (delete c[k], (changed = true)); if (changed) try { - localStorage.setItem(THUMB_KEY, JSON.stringify(c)); + safeStorage.setItem(THUMB_KEY, JSON.stringify(c)); } catch {} } diff --git a/src/lib/panelToggles.js b/src/lib/panelToggles.js index 87082b9c..ce54ce77 100644 --- a/src/lib/panelToggles.js +++ b/src/lib/panelToggles.js @@ -21,6 +21,7 @@ import { import { raiseWindow, isTopVisibleWindow } from './windowFocus'; import { groupOfKey, activateTab } from './windowTabs'; import { revealWindow } from './dragWindow'; +import { safeStorage } from './safeStorage'; // ONE decision tree for the Controls panel buttons AND their keyboard shortcuts // (O / N). Before this module the Object list button had taskbar semantics @@ -105,7 +106,7 @@ function isDockedPresent(key) { /** Would opening this panel put it in the dock? @param {PanelConfig} cfg */ function opensDocked(cfg) { if (!cfg.dockedLs) return false; // floating-only panel - return typeof localStorage === 'undefined' || localStorage.getItem(cfg.dockedLs) !== 'false'; + return typeof localStorage === 'undefined' || safeStorage.getItem(cfg.dockedLs) !== 'false'; } /** Is this panel the one the dock is actually SHOWING? @param {PanelConfig} cfg */ diff --git a/src/lib/peerServer.js b/src/lib/peerServer.js index 252a60f9..0ab0b2ba 100644 --- a/src/lib/peerServer.js +++ b/src/lib/peerServer.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** * Peer signaling-server selection + ICE (STUN/TURN) config. @@ -154,7 +155,7 @@ function defaults() { function load() { if (typeof localStorage === 'undefined') return defaults(); try { - const raw = localStorage.getItem(LS_KEY); + const raw = safeStorage.getItem(LS_KEY); if (raw) { const parsed = JSON.parse(raw); return { ...defaults(), ...parsed, custom: { ...defaults().custom, ...(parsed.custom || {}) } }; @@ -170,7 +171,7 @@ export const peerServerConfig = writable(load()); peerServerConfig.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(LS_KEY, JSON.stringify(v)); + safeStorage.setItem(LS_KEY, JSON.stringify(v)); } catch { /* storage full / disabled */ } diff --git a/src/lib/ping.js b/src/lib/ping.js index e196a9fe..b7b94a53 100644 --- a/src/lib/ping.js +++ b/src/lib/ping.js @@ -4,6 +4,7 @@ import { peers, username } from '../stores/appStore'; import { objectsGroup } from '../stores/sceneStore'; import { peerColor } from './lockControl'; import { playPing } from './pingAudio'; +import { safeStorage } from './safeStorage'; // Ping a world point (or object) so every peer sees a pulse there for ~4s. // V2 (87): pings carry the sender's chosen color + chime — everyone renders @@ -16,14 +17,14 @@ export const pings = writable([]); // per-user ping preferences (Settings; '' color = automatic peer color) export const pingColor = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('pingColor') ?? '' : '' + typeof localStorage !== 'undefined' ? safeStorage.getItem('pingColor') ?? '' : '' ); export const pingSound = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('pingSound') ?? 'ding' : 'ding' + typeof localStorage !== 'undefined' ? safeStorage.getItem('pingSound') ?? 'ding' : 'ding' ); if (typeof localStorage !== 'undefined') { - pingColor.subscribe((value) => localStorage.setItem('pingColor', value)); - pingSound.subscribe((value) => localStorage.setItem('pingSound', value)); + pingColor.subscribe((value) => safeStorage.setItem('pingColor', value)); + pingSound.subscribe((value) => safeStorage.setItem('pingSound', value)); } /** @param {any} ping */ diff --git a/src/lib/projectFile.js b/src/lib/projectFile.js index a882ff38..7bf8dbbc 100644 --- a/src/lib/projectFile.js +++ b/src/lib/projectFile.js @@ -58,6 +58,7 @@ import { projectName } from './projectManifest'; import { ensureScenesFolder, currentLevel } from './levels'; +import { safeStorage } from './safeStorage'; /** V4's gating pattern with its own int: a NEWER format ASKS before importing, an * older or absent one loads silently. `appVersion` beside it is display-only @@ -511,7 +512,7 @@ export async function exportProjectFromSession(payload) { * export preference. */ export function projectVersionsEnabled() { try { - return localStorage.getItem('tpProjectVersions') !== 'false'; + return safeStorage.getItem('tpProjectVersions') !== 'false'; } catch { return true; } diff --git a/src/lib/projectManifest.js b/src/lib/projectManifest.js index 82ae392f..f2594d4d 100644 --- a/src/lib/projectManifest.js +++ b/src/lib/projectManifest.js @@ -31,6 +31,7 @@ import { showChoice } from './confirmDialog'; import { sessionHost } from './connectionState'; import { isViewer } from './objectPermissions'; import { idbGet, idbPut } from './idb'; +import { safeStorage } from './safeStorage'; const IDB_KEY = 'project:manifest'; /** versions of ONE scene kept locally beyond the pinned set (fork 4) — the DEFAULT of @@ -50,7 +51,7 @@ export const keepVersionsSetting = writable(readKeepVersions()); function readKeepVersions() { try { - const raw = localStorage.getItem('project:keepVersions'); + const raw = safeStorage.getItem('project:keepVersions'); if (raw === null) return KEEP_VERSIONS; const n = Number(raw); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : KEEP_VERSIONS; @@ -61,7 +62,7 @@ function readKeepVersions() { keepVersionsSetting.subscribe((n) => { try { - localStorage.setItem('project:keepVersions', String(n)); + safeStorage.setItem('project:keepVersions', String(n)); } catch {} }); diff --git a/src/lib/proportional.js b/src/lib/proportional.js index f7da0758..e427c17f 100644 --- a/src/lib/proportional.js +++ b/src/lib/proportional.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // 19-A P4: PROPORTIONAL EDITING's shared state, split out of meshEdit as a LEAF // (svelte/store only) so faceEdit can read it too. faceEdit cannot import @@ -17,11 +18,11 @@ export const proportionalEdit = writable(false); * @type {import('svelte/store').Writable} */ export const proportionalRadius = writable( typeof localStorage !== 'undefined' - ? Math.min(Math.max(parseFloat(localStorage.getItem('proportionalRadius') ?? '') || 1, 0.01), 100) + ? Math.min(Math.max(parseFloat(safeStorage.getItem('proportionalRadius') ?? '') || 1, 0.01), 100) : 1 ); proportionalRadius.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('proportionalRadius', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('proportionalRadius', String(value)); }); /** diff --git a/src/lib/safeStorage.js b/src/lib/safeStorage.js new file mode 100644 index 00000000..2d071317 --- /dev/null +++ b/src/lib/safeStorage.js @@ -0,0 +1,166 @@ +// 27-H (hardening audit M4) — LOCAL STORAGE THAT CANNOT TAKE A SUBSCRIBER DOWN WITH IT. +// +// THE FINDING: ~500 bare `localStorage` calls across ~90 files, and `setItem` THROWS +// synchronously in Safari private mode and whenever the origin's quota is full. Most of +// these sit inside `$effect`s and store subscribers, so the throw does not merely fail to +// persist a setting — it kills that subscriber for the rest of the session, and the UI it +// drives stops updating. "The theme picker stopped working" is what that looks like from +// the outside, and nothing in it points at storage. +// +// Reading is not safe either, which is less well known: in a sandboxed iframe, and under +// some enterprise policies, merely TOUCHING `window.localStorage` throws SecurityError — +// so even `typeof localStorage === 'undefined'` guards, which this codebase has a hundred +// of, do not cover it. Every access here goes through one try/catch. +// +// THE FALLBACK IS PER-KEY, and that is what makes the promise honest. A setting whose +// write failed is remembered in memory, so it still APPLIES for this session and reads +// back as what you set; it simply does not survive a reload. That is the degradation a +// user can live with. A successful write drops the key from memory again, because +// localStorage is then the truth and a stale shadow would outvote it. +// +// A DELIBERATE LEAF: this module imports NOTHING. It is reached from stores, from +// components, from the diagnostics layer's own neighbours and from modules on every side +// of the history-cycle family, so any import at all here is a future cycle. It is also +// what lets the unit layer test it with no browser. + +/** keys whose real write failed, or everything when storage is unreachable @type {Map} */ +const memory = new Map(); +/** how many writes have fallen back — read by the diagnostics section and the suite */ +let failures = 0; +/** @type {string | null} the last failure's name, so a report can say WHICH kind it was */ +let lastError = null; + +/** + * The backing store, or null when it is unreachable. The property access itself is inside + * the try: that is the SecurityError case above, and it is the one every `typeof` guard + * in this codebase misses. + * @returns {Storage | null} + */ +function backing() { + try { + return typeof localStorage === 'undefined' ? null : localStorage; + } catch { + return null; + } +} + +/** @param {any} error */ +function noteFailure(error) { + failures++; + lastError = String(error?.name || error || 'unknown'); +} + +/** + * Read a key. Memory first, because a key is only in memory when its real write FAILED, + * and the value you just set is the one you expect to read back. + * @param {string} key @returns {string | null} + */ +export function getItem(key) { + if (memory.has(key)) return /** @type {string} */ (memory.get(key)); + try { + return backing()?.getItem(key) ?? null; + } catch (error) { + noteFailure(error); + return null; + } +} + +/** + * Write a key. NEVER throws — that is the entire point — and returns whether it reached + * real storage, for the rare caller that wants to say so. + * @param {string} key @param {any} value @returns {boolean} + */ +export function setItem(key, value) { + const text = String(value); + const store = backing(); + if (store) { + try { + store.setItem(key, text); + // the real store is the truth again; a leftover shadow would outvote it + memory.delete(key); + return true; + } catch (error) { + noteFailure(error); + } + } + memory.set(key, text); + return false; +} + +/** @param {string} key */ +export function removeItem(key) { + memory.delete(key); + try { + backing()?.removeItem(key); + } catch (error) { + noteFailure(error); + } +} + +/** + * Every stored key, real and fallen-back (the "reset my window layout" sweep needs it). + * + * Enumerated through `length` + `key(i)` rather than `Object.keys`, which is what the + * call site this replaces used: `Object.keys` happens to work on the real `Storage` + * exotic object and returns METHOD NAMES on anything that merely implements the + * interface, so the standards-defined enumeration is both more correct and the one a + * stand-in can satisfy. + */ +export function keys() { + /** @type {Set} */ + const out = new Set(memory.keys()); + try { + const store = backing(); + if (store) for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (key != null) out.add(key); + } + } catch (error) { + noteFailure(error); + } + return [...out]; +} + +/** Wipe everything (Settings ▸ Reset settings) */ +export function clear() { + memory.clear(); + try { + backing()?.clear(); + } catch (error) { + noteFailure(error); + } +} + +/** The spec's short names, for new code. Identical behaviour. */ +export const get = getItem; +export const set = setItem; +export const remove = removeItem; + +/** + * A DROP-IN for the `localStorage` object itself, so the codemod that replaced ~500 call + * sites is one identifier per line and nothing else — a rename a reviewer can check by + * eye, rather than 500 opportunities to change a semicolon. + */ +export const safeStorage = { getItem, setItem, removeItem, clear, keys }; + +/** + * Is persistence working, and what has it cost? The diagnostics bundle asks; so does the + * suite. `degraded` is the thing worth reading: it means settings are applying but not + * surviving a reload, which is otherwise completely invisible. + */ +export function storageDebug() { + return { + available: !!backing(), + degraded: memory.size > 0 || failures > 0, + fallbackKeys: memory.size, + failures, + lastError + }; +} + +/** TEST SEAM: forget the fallback, so one suite section cannot colour the next. */ +export function debugResetStorage() { + memory.clear(); + failures = 0; + lastError = null; +} diff --git a/src/lib/saveName.js b/src/lib/saveName.js index c73bfd73..5bc427e8 100644 --- a/src/lib/saveName.js +++ b/src/lib/saveName.js @@ -20,6 +20,7 @@ // delegates to `fileNameBase`. import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** What a save is called when nothing else is said: the thing's own name. */ export const DEFAULT_TEMPLATE = '[name]'; @@ -135,7 +136,7 @@ const KEY = 'saveNameTemplate'; function readTemplate() { try { - const raw = localStorage.getItem(KEY); + const raw = safeStorage.getItem(KEY); return raw === null ? DEFAULT_TEMPLATE : String(raw); } catch { return DEFAULT_TEMPLATE; @@ -149,7 +150,7 @@ export const saveNameTemplate = writable(readTemplate()); saveNameTemplate.subscribe((value) => { try { - localStorage.setItem(KEY, String(value ?? '')); + safeStorage.setItem(KEY, String(value ?? '')); } catch {} }); diff --git a/src/lib/sceneMusic.js b/src/lib/sceneMusic.js index f975c799..a3af2b68 100644 --- a/src/lib/sceneMusic.js +++ b/src/lib/sceneMusic.js @@ -3,6 +3,7 @@ import { peers } from '../stores/appStore'; import { ensureAudioContext, bus } from './audioEngine'; import { itemByHash, itemBlob } from './explorer'; import { requestAsset, sendAsset } from './assetShare'; +import { safeStorage } from './safeStorage'; // Scene music (M-1): ONE shared background track per scene — a singleton synced // latest-wins like the environment, so everyone hears the same track at the same @@ -19,10 +20,10 @@ export const music = writable({ ...DEFAULT }); // per-device overlay (LOCAL, persisted) — your own volume trim + mute export const musicLocalVolume = writable( - typeof localStorage !== 'undefined' ? +(localStorage.getItem('musicLocalVolume') ?? '1') : 1 + typeof localStorage !== 'undefined' ? +(safeStorage.getItem('musicLocalVolume') ?? '1') : 1 ); export const musicMuted = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('musicMuted') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('musicMuted') === 'true' : false ); /** whether the audio context is currently blocked by the browser autoplay policy */ @@ -235,13 +236,13 @@ export function startSceneMusic() { started = true; musicLocalVolume.subscribe((v) => { try { - localStorage.setItem('musicLocalVolume', String(v)); + safeStorage.setItem('musicLocalVolume', String(v)); } catch {} reconcile(); }); musicMuted.subscribe((v) => { try { - localStorage.setItem('musicMuted', String(v)); + safeStorage.setItem('musicMuted', String(v)); } catch {} reconcile(); }); diff --git a/src/lib/selectionPrefs.js b/src/lib/selectionPrefs.js index 143b33a8..3e71b379 100644 --- a/src/lib/selectionPrefs.js +++ b/src/lib/selectionPrefs.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // Phase 85: what a DOUBLE-CLICK on an object does, as a LOCAL preference. // @@ -26,12 +27,12 @@ const DEFAULT = 'properties'; /** @type {DoubleClickAction} */ const stored = typeof localStorage !== 'undefined' && - DOUBLE_CLICK_ACTIONS.some((a) => a.value === localStorage.getItem(KEY)) - ? /** @type {any} */ (localStorage.getItem(KEY)) + DOUBLE_CLICK_ACTIONS.some((a) => a.value === safeStorage.getItem(KEY)) + ? /** @type {any} */ (safeStorage.getItem(KEY)) : DEFAULT; /** @type {import('svelte/store').Writable} */ export const doubleClickAction = writable(stored); if (typeof localStorage !== 'undefined') - doubleClickAction.subscribe((value) => localStorage.setItem(KEY, value)); + doubleClickAction.subscribe((value) => safeStorage.setItem(KEY, value)); diff --git a/src/lib/sharedLibrary.js b/src/lib/sharedLibrary.js index 788a844e..28b46da3 100644 --- a/src/lib/sharedLibrary.js +++ b/src/lib/sharedLibrary.js @@ -130,6 +130,7 @@ import { transfers, removeTransfer } from './transferLedger'; // R22 round 33: automatic downloads WAIT while the joiner is being asked what to do with // its own scene. A store-only leaf, so this edge closes nothing. import { pendingConnectDecision } from './connectionState'; +import { safeStorage } from './safeStorage'; /** * Hashes we have ASKED the mesh for and not yet received. A remote card with nothing to @@ -460,7 +461,7 @@ export const unshareAuthority = writable(readAuthority()); function readAuthority() { try { - return localStorage.getItem('shared:unshareAuthority') === 'owner' ? 'owner' : 'anyone'; + return safeStorage.getItem('shared:unshareAuthority') === 'owner' ? 'owner' : 'anyone'; } catch { return 'anyone'; } @@ -468,7 +469,7 @@ function readAuthority() { unshareAuthority.subscribe((v) => { try { - localStorage.setItem('shared:unshareAuthority', v); + safeStorage.setItem('shared:unshareAuthority', v); } catch {} }); @@ -507,9 +508,9 @@ export const shareNewFiles = writable(readShareNewFiles()); * was "do not publish everything", never "do not ask me". */ function readShareNewFiles() { try { - const raw = localStorage.getItem('shared:shareNewFiles'); + const raw = safeStorage.getItem('shared:shareNewFiles'); if (raw === 'ask' || raw === 'always' || raw === 'never') return raw; - return localStorage.getItem('shared:autoShareAll') === 'true' ? 'always' : 'ask'; + return safeStorage.getItem('shared:autoShareAll') === 'true' ? 'always' : 'ask'; } catch { return 'ask'; } @@ -527,7 +528,7 @@ export const autoDownload = writable(readFlag('shared:autoDownload', true)); /** @param {string} key @param {boolean} fallback */ function readFlag(key, fallback) { try { - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); return raw === null ? fallback : raw === 'true'; } catch { return fallback; @@ -536,12 +537,12 @@ function readFlag(key, fallback) { shareNewFiles.subscribe((v) => { try { - localStorage.setItem('shared:shareNewFiles', v); + safeStorage.setItem('shared:shareNewFiles', v); } catch {} }); autoDownload.subscribe((v) => { try { - localStorage.setItem('shared:autoDownload', String(v)); + safeStorage.setItem('shared:autoDownload', String(v)); } catch {} }); @@ -555,7 +556,7 @@ export const deleteWithoutConfirm = writable(readFlag('shared:deleteNoConfirm', deleteWithoutConfirm.subscribe((v) => { try { - localStorage.setItem('shared:deleteNoConfirm', String(v)); + safeStorage.setItem('shared:deleteNoConfirm', String(v)); } catch {} }); @@ -576,12 +577,12 @@ export const keepRecycleBin = writable(readFlag('shared:keepRecycleBin', false)) recycleBinEnabled.subscribe((v) => { try { - localStorage.setItem('shared:recycleBin', String(v)); + safeStorage.setItem('shared:recycleBin', String(v)); } catch {} }); keepRecycleBin.subscribe((v) => { try { - localStorage.setItem('shared:keepRecycleBin', String(v)); + safeStorage.setItem('shared:keepRecycleBin', String(v)); } catch {} }); @@ -617,7 +618,7 @@ export const deletedLogEnabled = writable(readFlag('shared:deletedLog', true)); deletedLogEnabled.subscribe((v) => { try { - localStorage.setItem('shared:deletedLog', String(v)); + safeStorage.setItem('shared:deletedLog', String(v)); } catch {} }); @@ -2370,7 +2371,7 @@ const appliedDeletes = new Set(readApplied()); function readApplied() { try { - return JSON.parse(localStorage.getItem('shared:appliedDeletes') ?? '[]'); + return JSON.parse(safeStorage.getItem('shared:appliedDeletes') ?? '[]'); } catch { return []; } @@ -2381,7 +2382,7 @@ function noteApplied(hash) { appliedDeletes.add(hash); try { // bounded: the log itself is capped at 200, so this cannot outgrow it by much - localStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes].slice(-400))); + safeStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes].slice(-400))); } catch {} } @@ -2389,7 +2390,7 @@ function noteApplied(hash) { function forgetApplied(hash) { if (!appliedDeletes.delete(hash)) return; try { - localStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes])); + safeStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes])); } catch {} } diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index 27b77f2d..d871b61b 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -38,6 +38,7 @@ import { togglePanel, toggleDock } from './panelToggles'; // SSR prerender. import { requestPlay } from './playMode'; import { selectedObject } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; // Single source of truth for keyboard shortcuts: the same registry binds the keys // and renders the list in Settings -> Shortcuts. Other modules push entries via @@ -472,8 +473,8 @@ export const shortcuts = [ // A3: the SimControls HUD is off by default; P still works, but the first // time it's used while the HUD is hidden, point users at the setting so the // transport (pause/stop/reset) is discoverable. - if (!get(showSimControls) && typeof localStorage !== 'undefined' && !localStorage.getItem('simHudHintSeen')) { - localStorage.setItem('simHudHintSeen', '1'); + if (!get(showSimControls) && typeof localStorage !== 'undefined' && !safeStorage.getItem('simHudHintSeen')) { + safeStorage.setItem('simHudHintSeen', '1'); showToast('Simulation controls are hidden — enable them in Settings → Scene to show the pause/stop/reset buttons.', [ { label: 'Open Settings', @@ -576,7 +577,7 @@ let overrides = {}; function loadOverrides() { try { if (typeof localStorage === 'undefined') return {}; - const raw = localStorage.getItem(OVERRIDES_KEY); + const raw = safeStorage.getItem(OVERRIDES_KEY); const parsed = raw ? JSON.parse(raw) : null; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; /** @type {Record} */ @@ -591,9 +592,9 @@ function loadOverrides() { function saveOverrides() { try { if (typeof localStorage === 'undefined') return; - if (Object.keys(overrides).length) localStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides)); + if (Object.keys(overrides).length) safeStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides)); // an empty map is the DEFAULT state, so remove the key rather than store `{}` - else localStorage.removeItem(OVERRIDES_KEY); + else safeStorage.removeItem(OVERRIDES_KEY); } catch { /* private mode: the rebind still applies for this session */ } diff --git a/src/lib/snapping.js b/src/lib/snapping.js index 357f5f9d..0ff114a2 100644 --- a/src/lib/snapping.js +++ b/src/lib/snapping.js @@ -1,18 +1,19 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { TControls } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; // Grid snapping for the transform gizmo: translate, rotate AND scale. // Persisted in localStorage. "Snap to surface" is a future improvement. -const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('snapSettings') : null; +const stored = typeof localStorage !== 'undefined' ? safeStorage.getItem('snapSettings') : null; export const snapEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('snapEnabled') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('snapEnabled') === 'true' ); // translate drags keep the object resting on whatever is underneath it export const surfaceSnap = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('surfaceSnap') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('surfaceSnap') === 'true' ); /** @type {import('svelte/store').Writable<{translate: number, rotateDeg: number, scale: number}>} */ export const snapSettings = writable(stored ? JSON.parse(stored) : { translate: 0.5, rotateDeg: 15, scale: 0.1 }); @@ -40,14 +41,14 @@ export function startSnapping() { started = true; TControls.subscribe(apply); snapEnabled.subscribe((value) => { - localStorage.setItem('snapEnabled', String(value)); + safeStorage.setItem('snapEnabled', String(value)); apply(); }); surfaceSnap.subscribe((value) => { - localStorage.setItem('surfaceSnap', String(value)); + safeStorage.setItem('surfaceSnap', String(value)); }); snapSettings.subscribe((value) => { - localStorage.setItem('snapSettings', JSON.stringify(value)); + safeStorage.setItem('snapSettings', JSON.stringify(value)); apply(); }); } @@ -74,7 +75,7 @@ export const DEFAULT_SNAP_TARGETS = { function loadSnapTargets() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('snapTargets') : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem('snapTargets') : null; // unknown/missing keys fall back to defaults, so old payloads keep working return { ...DEFAULT_SNAP_TARGETS, ...(raw ? JSON.parse(raw) : {}) }; } catch { @@ -86,7 +87,7 @@ function loadSnapTargets() { export const snapTargets = writable(loadSnapTargets()); snapTargets.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('snapTargets', JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('snapTargets', JSON.stringify(value)); }); const DOWN = new THREE.Vector3(0, -1, 0); diff --git a/src/lib/themes.js b/src/lib/themes.js index dab42a29..69db7d59 100644 --- a/src/lib/themes.js +++ b/src/lib/themes.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // UI themes (phase 89): a theme is a token block on :root[data-theme] (see // styles/theme.css) — strictly LOCAL chrome, never replicated. 'light' also @@ -62,7 +63,7 @@ export const THEME_TOKENS = [ function loadCustomThemes() { if (typeof localStorage === 'undefined') return []; try { - const raw = localStorage.getItem('customThemes'); + const raw = safeStorage.getItem('customThemes'); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { @@ -75,13 +76,13 @@ export const customThemes = writable(loadCustomThemes()); // must be initialized BEFORE the theme subscriber so a persisted custom id resolves on load export const theme = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('theme') ?? 'dark' : 'dark' + typeof localStorage !== 'undefined' ? safeStorage.getItem('theme') ?? 'dark' : 'dark' ); customThemes.subscribe((value) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem('customThemes', JSON.stringify(value)); + safeStorage.setItem('customThemes', JSON.stringify(value)); } catch {} }); @@ -103,7 +104,7 @@ function applyTheme(id) { root.classList.toggle('dark', id !== 'light'); } try { - localStorage.setItem('theme', id); + safeStorage.setItem('theme', id); } catch {} } diff --git a/src/lib/touchControls.js b/src/lib/touchControls.js index d188f3b9..77557da6 100644 --- a/src/lib/touchControls.js +++ b/src/lib/touchControls.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // W4: THE TOUCH PLAY CONTROLS LEAF — a virtual move stick and a look drag, plus the // one local preference that tunes them. @@ -61,7 +62,7 @@ function clamp(value, min, max) { function storedSpeed() { if (typeof localStorage === 'undefined') return 1; - const raw = Number(localStorage.getItem(SPEED_KEY)); + const raw = Number(safeStorage.getItem(SPEED_KEY)); if (!Number.isFinite(raw) || raw <= 0) return 1; return clamp(raw, TOUCH_LOOK_SPEED_RANGE.min, TOUCH_LOOK_SPEED_RANGE.max); } @@ -78,7 +79,7 @@ export function setTouchLookSpeed(value) { const next = clamp(Number(value) || 1, TOUCH_LOOK_SPEED_RANGE.min, TOUCH_LOOK_SPEED_RANGE.max); touchLookSpeed.set(next); try { - if (typeof localStorage !== 'undefined') localStorage.setItem(SPEED_KEY, String(next)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(SPEED_KEY, String(next)); } catch { /* private mode — the pref is a convenience, never a requirement */ } diff --git a/src/lib/trackpadNav.js b/src/lib/trackpadNav.js index 57faeae1..2aeeaa21 100644 --- a/src/lib/trackpadNav.js +++ b/src/lib/trackpadNav.js @@ -17,54 +17,55 @@ import { globalCamera, globalRenderer, orbitControls } from '../stores/sceneStor // this one has to ask and stand down itself. proportional is a svelte/store-only // leaf: no cycle. import { proportionalWheelActive } from './proportional'; +import { safeStorage } from './safeStorage'; /** How two-finger swipes are treated: 'auto' (heuristic) | 'on' | 'off'. * @type {import('svelte/store').Writable} */ export const trackpadMode = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('trackpadMode') || 'auto' : 'auto' + typeof localStorage !== 'undefined' ? safeStorage.getItem('trackpadMode') || 'auto' : 'auto' ); trackpadMode.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadMode', value); + if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadMode', value); }); /** Accessibility escape hatch: let the BROWSER zoom the page again (pinch / * ctrl+wheel over UI, mobile pinch). Off by default — pinch is an app gesture. * @type {import('svelte/store').Writable} */ export const allowBrowserZoom = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('allowBrowserZoom') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('allowBrowserZoom') === 'true' ); allowBrowserZoom.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('allowBrowserZoom', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('allowBrowserZoom', String(value)); }); /** Flip the two-finger pan direction. The DEFAULT (off) is content-follows- * fingers, the user-picked direction; on = the opposite convention. * @type {import('svelte/store').Writable} */ export const reversePan = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('trackpadReversePan') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('trackpadReversePan') === 'true' ); reversePan.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadReversePan', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadReversePan', String(value)); }); /** Two-finger pan on/off (default ON). Off = trackpad swipes fall through to the * wheel zoom and panning stays available via right-click drag (OrbitControls). * @type {import('svelte/store').Writable} */ export const panEnabled = writable( - typeof localStorage === 'undefined' || localStorage.getItem('trackpadPanEnabled') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('trackpadPanEnabled') !== 'false' ); panEnabled.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadPanEnabled', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadPanEnabled', String(value)); }); /** Pinch-to-zoom on/off (default ON). Off = pinch does nothing to the camera * (the page-zoom guard still applies); zoom stays on the mouse wheel. * @type {import('svelte/store').Writable} */ export const pinchZoomEnabled = writable( - typeof localStorage === 'undefined' || localStorage.getItem('trackpadPinchZoom') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('trackpadPinchZoom') !== 'false' ); pinchZoomEnabled.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadPinchZoom', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadPinchZoom', String(value)); }); // ---- 24-A2: the wheel classifier ------------------------------------------------ @@ -216,8 +217,8 @@ function panBy(e) { /** A2.3: once ever, the first time the classifier turns a wheel into a pan in auto * mode, point at the one-click override. `wheelHintSeen` in localStorage. */ function maybeWheelHint() { - if (typeof localStorage === 'undefined' || localStorage.getItem('wheelHintSeen')) return; - localStorage.setItem('wheelHintSeen', '1'); + if (typeof localStorage === 'undefined' || safeStorage.getItem('wheelHintSeen')) return; + safeStorage.setItem('wheelHintSeen', '1'); import('../stores/appStore').then((m) => m.showToast('Wheel panned instead of zooming? Viewport menu ▸ View ▸ Mouse wheel switches it') ); diff --git a/src/lib/units.js b/src/lib/units.js index b5177ee5..3a089957 100644 --- a/src/lib/units.js +++ b/src/lib/units.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // #20 P3: display UNITS for numeric fields. // @@ -65,7 +66,9 @@ const ALIASES = { }; ALIASES.angleDeg = ALIASES.angle; -const ls = typeof localStorage !== 'undefined' ? localStorage : null; +// 27-H: `safeStorage` is the alias now — it already answers when there is no storage at +// all, so the `typeof` dance and the `?.` on every use below are what it replaces. +const ls = safeStorage; /** @param {string} key @param {string} fallback @param {string[]} allowed */ function storedUnit(key, fallback, allowed) { diff --git a/src/lib/uvEditor.js b/src/lib/uvEditor.js index ebdb1149..2866411e 100644 --- a/src/lib/uvEditor.js +++ b/src/lib/uvEditor.js @@ -10,6 +10,7 @@ import { applyMap, materialAt, recordMaterialChange, copyTextureParams } from '. // the unwrap REGISTRY: built-in projections, plus whatever a module registers import { unwrap } from './uvUnwrap'; import { MAX_SNAPSHOT } from './meshBudget'; +import { safeStorage } from './safeStorage'; // UV1: read-only reuse of the mesh snapshot pipeline. faceEdit owns the triangle // <-> geometry conversion AND the 'meshgeo' history kind (which already accepts a // {positions, groups, uvs} triple and re-broadcasts uvs on undo), so a UV commit @@ -60,13 +61,13 @@ export const uvBrushSize = writable(24); * @type {import('svelte/store').Writable<'size'|'opacity'|'off'>} */ export const uvPenPressure = writable( /** @type {any} */ ( - typeof localStorage !== 'undefined' && ['size', 'opacity', 'off'].includes(localStorage.getItem('uvPenPressure') || '') - ? localStorage.getItem('uvPenPressure') + typeof localStorage !== 'undefined' && ['size', 'opacity', 'off'].includes(safeStorage.getItem('uvPenPressure') || '') + ? safeStorage.getItem('uvPenPressure') : 'size' ) ); uvPenPressure.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('uvPenPressure', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('uvPenPressure', String(value)); }); /** a light touch still marks: the width/alpha factor at pressure 0 */ export const MIN_PRESSURE_FACTOR = 0.15; @@ -85,12 +86,12 @@ const pressureFactor = (w) => MIN_PRESSURE_FACTOR + (1 - MIN_PRESSURE_FACTOR) * * @type {import('svelte/store').Writable} */ export const uvFaceFilter = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('uvFaceFilter') ?? 'all' : 'all' + typeof localStorage !== 'undefined' ? safeStorage.getItem('uvFaceFilter') ?? 'all' : 'all' ); if (typeof localStorage !== 'undefined') uvFaceFilter.subscribe((value) => { try { - localStorage.setItem('uvFaceFilter', value); + safeStorage.setItem('uvFaceFilter', value); } catch {} }); diff --git a/src/lib/viewPrefs.js b/src/lib/viewPrefs.js index 3de5103a..6774f611 100644 --- a/src/lib/viewPrefs.js +++ b/src/lib/viewPrefs.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // 18-A: viewport LINE colours — the wireframe view mode, the selection outline and // the mesh-edit overlay. A LOCAL per-device view preference, never replicated and @@ -35,7 +36,7 @@ export const DEFAULT_VIEW_PREFS = { function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; // unknown/missing keys fall back to defaults, so old payloads keep working const stored = raw ? JSON.parse(raw) : {}; return { ...DEFAULT_VIEW_PREFS, ...stored }; @@ -48,7 +49,7 @@ function load() { export const viewPrefs = writable(load()); viewPrefs.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** @param {Partial} patch */ diff --git a/src/lib/viewportOverrides.js b/src/lib/viewportOverrides.js index 106cfd68..19e517cd 100644 --- a/src/lib/viewportOverrides.js +++ b/src/lib/viewportOverrides.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // B — VIEWPORT OVERRIDES (this device). // @@ -56,9 +57,9 @@ function load() { for (const def of OVERRIDES) state[def.key] = true; if (typeof localStorage === 'undefined') return state; try { - const raw = localStorage.getItem(KEY); + const raw = safeStorage.getItem(KEY); if (raw) Object.assign(state, JSON.parse(raw)); - else if (localStorage.getItem(LEGACY_POST_KEY) === 'false') state.post = false; + else if (safeStorage.getItem(LEGACY_POST_KEY) === 'false') state.post = false; } catch {} return state; } @@ -68,7 +69,7 @@ export const viewportOverrides = writable(load()); viewportOverrides.subscribe((state) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(KEY, JSON.stringify(state)); + safeStorage.setItem(KEY, JSON.stringify(state)); } catch {} }); @@ -96,10 +97,10 @@ export function viewportOverridesDebug() { * not a promise that it does. LOCAL, like every other override here. */ export const vrPostEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrPostEnabled') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrPostEnabled') === 'true' ); vrPostEnabled.subscribe((value) => { try { - localStorage.setItem('vrPostEnabled', String(value)); + safeStorage.setItem('vrPostEnabled', String(value)); } catch {} }); diff --git a/src/lib/voiceChat.js b/src/lib/voiceChat.js index 33a11315..c771b174 100644 --- a/src/lib/voiceChat.js +++ b/src/lib/voiceChat.js @@ -7,6 +7,7 @@ import { ensureAudioContext as engineContext, bus, updateListener, resumeAudio } // LOCALLY with a gain (see the colo stage below); nothing about what we transmit changes. import { colocatedPeers, isColocatedWith } from './colocationPresence'; import { letterOf } from './keyOf'; +import { safeStorage } from './safeStorage'; // Voice chat over the existing peerjs mesh (MediaConnection). // - mic toggle transmits continuously; while OFF, holding V is push-to-talk @@ -20,7 +21,7 @@ export const micGranted = writable(false); export const pttActive = writable(false); // positional audio: voices come from the peer's avatar (PannerNode per peer) export const spatialVoice = writable( - typeof localStorage === 'undefined' || localStorage.getItem('spatialVoice') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('spatialVoice') !== 'false' ); /** @type {import('svelte/store').Writable<'ptt' | 'open' | 'off'>} VR mic mode (quick-menu tile) */ export const vrMicMode = writable('ptt'); @@ -387,7 +388,7 @@ mutedPeers.subscribe((list) => { // writes a store from inside a subscriber. colocatedPeers.subscribe(() => applyColocationGains()); spatialVoice.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('spatialVoice', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('spatialVoice', String(on)); if (on) Object.entries(get(remoteStreams)).forEach(([peerId, stream]) => buildSpatialChain(peerId, stream)); else Object.keys(spatialChains).forEach(dropSpatialChain); }); diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 15173109..6bbbabd3 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -128,6 +128,7 @@ import { setVRAxes, setVRButtons } from './inputRuntime'; import { suspendAnimation, resumeAnimation } from './flowRuntime'; import { drawMode, toggleDrawMode, addStrokePoint, endStroke } from './drawMode'; import { setPttHeld, cycleMicMode, vrMicMode, micActive, pttActive } from './voiceChat'; +import { safeStorage } from './safeStorage'; import { HOLD_MS, vrWindowAdjust, @@ -1267,7 +1268,7 @@ export function raycastSettings(index) { export function applySnapMode(mode) { vrSnapMode.set(mode); try { - localStorage.setItem('vrSnapMode', mode); + safeStorage.setItem('vrSnapMode', mode); } catch {} snapEnabled.set(mode === 'grid' || mode === 'rotation'); surfaceSnap.set(mode === 'surface'); @@ -2691,19 +2692,19 @@ export function executeVRMenuAction(name) { if (key === 'close') vrSettingsPanelOpen.set(false); else if (key === 'teleport') { vrTeleportEnabled.update((v) => !v); - try { localStorage.setItem('vrTeleportEnabled', String(get(vrTeleportEnabled))); } catch {} + try { safeStorage.setItem('vrTeleportEnabled', String(get(vrTeleportEnabled))); } catch {} } else if (key === 'mirror') { vrMirrorSnapTurn.update((v) => !v); - try { localStorage.setItem('vrMirrorSnapTurn', String(get(vrMirrorSnapTurn))); } catch {} + try { safeStorage.setItem('vrMirrorSnapTurn', String(get(vrMirrorSnapTurn))); } catch {} } else if (key === 'vertexhold') { vrVertexHold.update((v) => !v); - try { localStorage.setItem('vrVertexHold', String(get(vrVertexHold))); } catch {} + try { safeStorage.setItem('vrVertexHold', String(get(vrVertexHold))); } catch {} } else if (key === 'angle') { // cycle Off -> 15 -> 30 -> 45 -> Off const steps = [0, 15, 30, 45]; const next = steps[(steps.indexOf(get(vrSnapAngle)) + 1) % steps.length]; vrSnapAngle.set(next); - try { localStorage.setItem('vrSnapAngle', String(next)); } catch {} + try { safeStorage.setItem('vrSnapAngle', String(next)); } catch {} } else if (key === 'hz') { // B2.1: cycle Auto(max) -> 90 -> 120 and apply live if presenting const steps = ['auto', '90', '120']; @@ -2718,12 +2719,12 @@ export function executeVRMenuAction(name) { // WebXR can't hot-swap session modes — applies on the next VR entry const next = !get(vrPassthrough); vrPassthrough.set(next); - try { localStorage.setItem('vrPassthrough', String(next)); } catch {} + try { safeStorage.setItem('vrPassthrough', String(next)); } catch {} showToast('Passthrough ' + (next ? 'on' : 'off') + ' — takes effect on the next VR entry'); } else if (key === 'sleeve') { // K1: experimental forearm sleeve palette (default off) vrSleeveEnabled.update((v) => !v); - try { localStorage.setItem('vrSleeveEnabled', String(get(vrSleeveEnabled))); } catch {} + try { safeStorage.setItem('vrSleeveEnabled', String(get(vrSleeveEnabled))); } catch {} } else if (key === 'resetpanels') { resetWindowPoses(); showToast('VR panel positions reset'); @@ -2845,7 +2846,7 @@ export function executeVRMenuAction(name) { vrWireframeSelection.update((v) => { const next = !v; try { - localStorage.setItem('vrWireframe', String(next)); + safeStorage.setItem('vrWireframe', String(next)); } catch {} return next; }); @@ -2943,7 +2944,7 @@ export function executeVRMenuAction(name) { vrStatsOpen.update((v) => { const next = !v; try { - localStorage.setItem('vrStats', String(next)); + safeStorage.setItem('vrStats', String(next)); } catch {} return next; }); @@ -2953,7 +2954,7 @@ export function executeVRMenuAction(name) { const next = order[(order.indexOf(get(vrGrabStyle)) + 1) % order.length]; vrGrabStyle.set(next); try { - localStorage.setItem('vrGrabStyle', next); + safeStorage.setItem('vrGrabStyle', next); } catch {} showToast( next === 'rigid' @@ -2975,8 +2976,8 @@ export function executeVRMenuAction(name) { } } else if (name === 'grid') { showGrid.update((v) => !v); - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); } else if (name === 'undo') undo(); else if (name === 'redo') redo(); else if (name === 'box') spawnPrimitive('/create Box 1 1 1'); @@ -2988,7 +2989,7 @@ export function executeVRMenuAction(name) { else if (name === 'hand') { vrMenuHand.update((hand) => { const next = hand === 'right' ? 'left' : 'right'; - localStorage.setItem('vrMenuHand', next); + safeStorage.setItem('vrMenuHand', next); return next; }); } else if (name === 'mic') { diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index 414e3cea..f1aa98a6 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -14,6 +14,7 @@ import { simulating, remoteSimulating, toggleSimulation } from './physics'; import { setMicMode, vrMicMode } from './voiceChat'; import { duplicateSelection, deleteSelection, groupSelection, selectionUuids } from './objectActions'; import { savePrefab, savePrefabSelection } from './prefabs'; +import { safeStorage } from './safeStorage'; // D4 (roadmap 13): selection-set helpers for the Edit ring — counted labels // act on the whole SET (parity with the desktop object menu, U-2) @@ -286,7 +287,7 @@ function registerBuiltins() { SNAP_ANGLES[(SNAP_ANGLES.indexOf(get(vrSnapAngle)) + 1) % SNAP_ANGLES.length]; vrSnapAngle.set(next); try { - localStorage.setItem('vrSnapAngle', String(next)); + safeStorage.setItem('vrSnapAngle', String(next)); } catch {} } }); @@ -321,7 +322,7 @@ function registerBuiltins() { const next = get(vrMenuHand) === 'left' ? 'right' : 'left'; vrMenuHand.set(/** @type {any} */ (next)); try { - localStorage.setItem('vrMenuHand', next); + safeStorage.setItem('vrMenuHand', next); } catch {} } }); diff --git a/src/lib/vrWindowPoses.js b/src/lib/vrWindowPoses.js index a3747bd9..90ad56ce 100644 --- a/src/lib/vrWindowPoses.js +++ b/src/lib/vrWindowPoses.js @@ -1,6 +1,7 @@ // @ts-ignore - no bundled three type declarations (project-wide) import * as THREE from 'three'; import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // VR window grab (111): every follower window (radial ring, objects panel, // color palette, stats card) can be detached by holding the other hand's grip @@ -23,7 +24,7 @@ export const vrWindowAdjust = writable(null); function loadPoses() { try { - return JSON.parse(localStorage.getItem('vrWindowPoses') ?? '{}') ?? {}; + return JSON.parse(safeStorage.getItem('vrWindowPoses') ?? '{}') ?? {}; } catch { return {}; } @@ -42,7 +43,7 @@ export function saveWindowPose(id, offset) { windowPoses.update((poses) => { const next = { ...poses, [id]: offset }; try { - localStorage.setItem('vrWindowPoses', JSON.stringify(next)); + safeStorage.setItem('vrWindowPoses', JSON.stringify(next)); } catch {} return next; }); @@ -52,7 +53,7 @@ export function saveWindowPose(id, offset) { export function resetWindowPoses() { windowPoses.set({}); try { - localStorage.removeItem('vrWindowPoses'); + safeStorage.removeItem('vrWindowPoses'); } catch {} } diff --git a/src/lib/whatsNew.js b/src/lib/whatsNew.js index f119d685..460effb4 100644 --- a/src/lib/whatsNew.js +++ b/src/lib/whatsNew.js @@ -10,6 +10,7 @@ import { APP_VERSION, IS_DEV } from './version.js'; import { showToast } from '../stores/appStore.js'; // The changelog ships as the repo-root CHANGELOG.md (GitHub renders the same file). import changelogRaw from '../../CHANGELOG.md?raw'; +import { safeStorage } from './safeStorage'; /** Raw markdown of the changelog, rendered by WhatsNew.svelte. */ export const CHANGELOG = String(changelogRaw || ''); @@ -22,12 +23,12 @@ const LAST_SEEN_VERSION = 'lastSeenVersion'; * @param {string} key @param {boolean} dflt */ function boolPref(key, dflt) { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(key) : null; const store = writable(raw === null ? dflt : raw === 'true'); if (typeof localStorage !== 'undefined') { store.subscribe((v) => { try { - localStorage.setItem(key, v ? 'true' : 'false'); + safeStorage.setItem(key, v ? 'true' : 'false'); } catch { /* storage disabled */ } @@ -54,7 +55,7 @@ export const whatsNewUnseen = writable(false); function markSeen() { try { - localStorage.setItem(LAST_SEEN_VERSION, APP_VERSION); + safeStorage.setItem(LAST_SEEN_VERSION, APP_VERSION); } catch { /* storage disabled */ } @@ -80,7 +81,7 @@ export function openWelcome() { export function closeWelcome() { welcomeOpen.set(false); try { - localStorage.setItem(SEEN_WELCOME, 'true'); + safeStorage.setItem(SEEN_WELCOME, 'true'); } catch { /* storage disabled */ } @@ -132,7 +133,7 @@ export function hasDeepLink() { */ export function startWhatsNew() { if (typeof localStorage === 'undefined') return; - const firstVisit = !localStorage.getItem(SEEN_WELCOME); + const firstVisit = !safeStorage.getItem(SEEN_WELCOME); // R22 round 7 — DO NOT GREET AN INVITE. A URL with a peer id in its hash is somebody // answering "join me", and the first thing they should see is the session, not an // introduction to the app. The overlay is for a bare open; the version badge and its @@ -150,7 +151,7 @@ export function startWhatsNew() { // COMMITTED assertion — measured: whats-new went red on my machine and would have // stayed green in CI, which is the worst shape a local override can take. The debug // hook is the one reliable signal that this page is a test. - const underTest = !!localStorage.getItem('debugStores'); + const underTest = !!safeStorage.getItem('debugStores'); const skipEnv = !underTest && String(import.meta.env.VITE_SKIP_WELCOME ?? '') === 'true'; const welcomeThisBoot = !invited && !skipEnv && (firstVisit || get(showWelcomeOnStart)); if (welcomeThisBoot) welcomeOpen.set(true); @@ -161,7 +162,7 @@ export function startWhatsNew() { return; } if (!get(showWhatsNewNotice)) return; - const lastSeen = localStorage.getItem(LAST_SEEN_VERSION); + const lastSeen = safeStorage.getItem(LAST_SEEN_VERSION); // IS_DEV: the version string is constant across dev reloads, so this stays quiet // after the first acknowledgement instead of nagging every HMR restart. if (!lastSeen) { diff --git a/src/lib/windowTabs.js b/src/lib/windowTabs.js index 022ed670..b1e8100b 100644 --- a/src/lib/windowTabs.js +++ b/src/lib/windowTabs.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // Window tab groups (phase 83, floating windows only — docked splits stay in // pending/81). Grouped windows share ONE rect; the active member is visible, @@ -19,7 +20,7 @@ let nextId = 1; function persist() { try { - localStorage.setItem( + safeStorage.setItem( 'windowTabGroups', JSON.stringify(get(tabGroups).map(({ id, members, active, rect }) => ({ id, members, active, rect }))) ); @@ -43,7 +44,7 @@ const migrateKey = (key) => KEY_ALIASES[key] ?? key; /** @type {any[]} groups waiting for their members to register+open again */ let pendingRestore = []; try { - pendingRestore = JSON.parse(localStorage.getItem('windowTabGroups') ?? '[]').map( + pendingRestore = JSON.parse(safeStorage.getItem('windowTabGroups') ?? '[]').map( (/** @type {any} */ saved) => ({ ...saved, members: (saved.members ?? []).map(migrateKey), diff --git a/src/stores/appStore.js b/src/stores/appStore.js index ef0de1d7..32709890 100644 --- a/src/stores/appStore.js +++ b/src/stores/appStore.js @@ -1,4 +1,5 @@ import { writable, derived, get } from 'svelte/store'; +import { safeStorage } from '../lib/safeStorage'; /** @type {import('svelte/store').Writable} */ export const settingsOpen = writable(null); @@ -19,12 +20,12 @@ export const inspectorKind = writable('selection'); * it). LOCAL preference. */ export const inspectorPinned = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('inspectorPinned') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('inspectorPinned') === 'true' ); if (typeof localStorage !== 'undefined') inspectorPinned.subscribe((v) => { try { - localStorage.setItem('inspectorPinned', String(v)); + safeStorage.setItem('inspectorPinned', String(v)); } catch {} }); export const flowGraphClose = writable(true); @@ -124,7 +125,7 @@ export const username = writable(null); // local player's avatar configuration (userdata slot 5, replicated to peers) const storedAvatarConfig = - typeof localStorage !== 'undefined' ? localStorage.getItem('avatarConfig') : null; + typeof localStorage !== 'undefined' ? safeStorage.getItem('avatarConfig') : null; /** @type {import('svelte/store').Writable<{body: string, hat: string, face: string}>} */ export const avatarConfig = writable( storedAvatarConfig ? JSON.parse(storedAvatarConfig) : { body: '#4f83cc', hat: 'none', face: 'label' } @@ -340,46 +341,46 @@ export const viewportMenuOpener = writable(null); /** @type {import('svelte/store').Writable} */ export const objectSearch = writable(null); export const objectSearchEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('objectSearchEnabled') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('objectSearchEnabled') === 'true' ); objectSearchEnabled.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('objectSearchEnabled', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('objectSearchEnabled', String(on)); }); // advanced mode: reveals system objects (module content, environment rig) // in the object list behind a System filter chip export const advancedMode = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('advancedMode') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('advancedMode') === 'true' ); advancedMode.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('advancedMode', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('advancedMode', String(on)); }); // object list: reveal the environment group behind an Environment chip (70.4) export const showEnvInList = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('showEnvInList') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('showEnvInList') === 'true' ); showEnvInList.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('showEnvInList', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('showEnvInList', String(on)); }); // A3 (roadmap #13): show the physics simulation transport (SimControls HUD). // Default OFF — the standalone ▶/⏸/⏹ HUD confuses with the main play button in // Controls; the P shortcut still starts/stops the sim when this is hidden. export const showSimControls = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('showSimControls') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('showSimControls') === 'true' ); showSimControls.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('showSimControls', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('showSimControls', String(on)); }); // N4: Explorer 3D model preview — a rotatable inline preview in Properties + a // popup on open. Global (all of Explorer), persisted; off by default. export const enable3dPreview = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('enable3dPreview') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('enable3dPreview') === 'true' ); enable3dPreview.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('enable3dPreview', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('enable3dPreview', String(on)); }); // 21-H3: dropping a MULTI-selection into the viewport. OFF = the N objects SPREAD in @@ -388,10 +389,10 @@ enable3dPreview.subscribe((on) => { // stack. A LOCAL pref like every other Explorer setting — `explorerDrop` reads it and // nothing about it goes on the wire (each placement replicates through its own path). export const stackOnDrop = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerStackOnDrop') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerStackOnDrop') === 'true' ); stackOnDrop.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('explorerStackOnDrop', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('explorerStackOnDrop', String(on)); }); // 21-I3 (locked answer 6): "Update from selection" REPLACES a prefab's bytes instantly @@ -400,20 +401,20 @@ stackOnDrop.subscribe((on) => { // can undo does not need a dialog in front of it, and the Undo is the safety net. A // LOCAL pref like every other Explorer setting; nothing about it goes on the wire. export const confirmPrefabUpdate = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('confirmPrefabUpdate') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('confirmPrefabUpdate') === 'true' ); confirmPrefabUpdate.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('confirmPrefabUpdate', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('confirmPrefabUpdate', String(on)); }); // Shift+A quick-add (the cursor-anchored Add popover). Opt-in, persisted; OFF by // default — Shift is a camera-strafe modifier in fly mode, so the shortcut only // exists for users who ask for it in Settings. export const enableShiftAdd = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('enableShiftAdd') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('enableShiftAdd') === 'true' ); enableShiftAdd.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('enableShiftAdd', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('enableShiftAdd', String(on)); }); /** @@ -433,7 +434,7 @@ enableShiftAdd.subscribe((on) => { export const touchTools = writable( (() => { if (typeof localStorage === 'undefined') return false; - const stored = localStorage.getItem('touchTools'); + const stored = safeStorage.getItem('touchTools'); if (stored !== null) return stored === 'true'; const coarse = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; @@ -442,7 +443,7 @@ export const touchTools = writable( })() ); touchTools.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('touchTools', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('touchTools', String(on)); }); // The sticky additive-selection MODE the cluster toggles. Touch cannot hold a modifier, @@ -459,32 +460,32 @@ export const multiSelectMode = writable(false); // what MY copy command does is not scene data. export const duplicateCarriesAnimation = writable( typeof localStorage === 'undefined' || - localStorage.getItem('duplicateCarriesAnimation') !== 'false' + safeStorage.getItem('duplicateCarriesAnimation') !== 'false' ); duplicateCarriesAnimation.subscribe((on) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('duplicateCarriesAnimation', String(on)); + safeStorage.setItem('duplicateCarriesAnimation', String(on)); }); export const duplicateCarriesFlow = writable( - typeof localStorage === 'undefined' || localStorage.getItem('duplicateCarriesFlow') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('duplicateCarriesFlow') !== 'false' ); duplicateCarriesFlow.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('duplicateCarriesFlow', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('duplicateCarriesFlow', String(on)); }); export const duplicateCarriesShader = writable( - typeof localStorage === 'undefined' || localStorage.getItem('duplicateCarriesShader') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('duplicateCarriesShader') !== 'false' ); duplicateCarriesShader.subscribe((on) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('duplicateCarriesShader', String(on)); + safeStorage.setItem('duplicateCarriesShader', String(on)); }); export const noteDoubleClickToOpen = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('noteDoubleClickToOpen') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('noteDoubleClickToOpen') === 'true' ); noteDoubleClickToOpen.subscribe((on) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('noteDoubleClickToOpen', String(on)); + safeStorage.setItem('noteDoubleClickToOpen', String(on)); }); // E1 (roadmap #13): notification center — a persisted history of everything that @@ -495,7 +496,7 @@ export const notifications = writable( (() => { if (typeof localStorage === 'undefined') return []; try { - return JSON.parse(localStorage.getItem('notifications') || '[]'); + return JSON.parse(safeStorage.getItem('notifications') || '[]'); } catch { return []; } @@ -504,7 +505,7 @@ export const notifications = writable( notifications.subscribe((list) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem('notifications', JSON.stringify(list.slice(-50))); + safeStorage.setItem('notifications', JSON.stringify(list.slice(-50))); } catch { /* storage full / disabled */ } @@ -546,11 +547,11 @@ export const connectBarHeight = writable(0); * hidden. Toggle in Settings; a `.allow-undock` root class drives the CSS, and the * panels read this to decide whether to force-dock on load. Persisted. */ export const mobileUndockAllowed = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('mobileUndockAllowed') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('mobileUndockAllowed') === 'true' : false ); if (typeof localStorage !== 'undefined') { mobileUndockAllowed.subscribe((v) => { - try { localStorage.setItem('mobileUndockAllowed', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('mobileUndockAllowed', v ? 'true' : 'false'); } catch { /* */ } if (typeof document !== 'undefined') document.documentElement.classList.toggle('allow-undock', !!v); }); } @@ -567,11 +568,11 @@ if (typeof localStorage !== 'undefined') { * shipped default-off, because the subscriber writes on the first flush — would be * pinned OFF forever with no way to tell that from never having chosen. Absent = ON. */ export const floatingToolbar = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('floatingToolbar') !== 'false' : true + typeof localStorage !== 'undefined' ? safeStorage.getItem('floatingToolbar') !== 'false' : true ); if (typeof localStorage !== 'undefined') { floatingToolbar.subscribe((v) => { - try { localStorage.setItem('floatingToolbar', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('floatingToolbar', v ? 'true' : 'false'); } catch { /* */ } }); } @@ -596,43 +597,43 @@ if (typeof localStorage !== 'undefined') { * fresh key makes absent mean "never chose" again. The pref never shipped in a tagged * release, so there is nothing real to migrate. */ export const toolbarAlwaysOnTop = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('toolbarOnTop') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('toolbarOnTop') === 'true' : false ); if (typeof localStorage !== 'undefined') { toolbarAlwaysOnTop.subscribe((v) => { - try { localStorage.setItem('toolbarOnTop', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('toolbarOnTop', v ? 'true' : 'false'); } catch { /* */ } }); } /** PINNED: keep the drawer's tab bar (+ status) visible even when the body is * collapsed, so it acts as a persistent mini-bar under the pill. Persisted. */ export const connectDrawerPinned = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('connectDrawerPinned') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('connectDrawerPinned') === 'true' : false ); /** Route toasts into the drawer's Toasts tab only — hide the viewport pop-ups even * when the drawer is closed (they still live in the Toasts tab + notification bell). * Persisted. */ export const toastsInDrawerOnly = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('toastsInDrawerOnly') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('toastsInDrawerOnly') === 'true' : false ); if (typeof localStorage !== 'undefined') { connectDrawerPinned.subscribe((v) => { - try { localStorage.setItem('connectDrawerPinned', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('connectDrawerPinned', v ? 'true' : 'false'); } catch { /* */ } }); toastsInDrawerOnly.subscribe((v) => { - try { localStorage.setItem('toastsInDrawerOnly', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('toastsInDrawerOnly', v ? 'true' : 'false'); } catch { /* */ } }); } /** Show the "Local objects" section in the object list (viewer WIP / editor-shareable * objects). OFF by default — auto-enabled when the first local object is made; also * togglable under the object-list filter cog. Persisted. */ export const showLocalObjects = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('showLocalObjects') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('showLocalObjects') === 'true' : false ); if (typeof localStorage !== 'undefined') { showLocalObjects.subscribe((v) => { try { - localStorage.setItem('showLocalObjects', v ? 'true' : 'false'); + safeStorage.setItem('showLocalObjects', v ? 'true' : 'false'); } catch { /* storage disabled */ } @@ -643,12 +644,12 @@ if (typeof localStorage !== 'undefined') { * cloud plugin is present). Default ON for discoverability; users can hide it and * still reach rooms via the chevron drawer's Rooms tab. Persisted. */ export const showRoomsButton = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('showRoomsButton') !== 'false' : true + typeof localStorage !== 'undefined' ? safeStorage.getItem('showRoomsButton') !== 'false' : true ); if (typeof localStorage !== 'undefined') { showRoomsButton.subscribe((v) => { try { - localStorage.setItem('showRoomsButton', v ? 'true' : 'false'); + safeStorage.setItem('showRoomsButton', v ? 'true' : 'false'); } catch { /* storage disabled */ } diff --git a/src/stores/flowStore.js b/src/stores/flowStore.js index 75ca7082..9c0a1928 100644 --- a/src/stores/flowStore.js +++ b/src/stores/flowStore.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from '../lib/safeStorage'; // Shared node graph state, replicated between peers. // @@ -224,7 +225,7 @@ export const flowCursors = writable({}); // animations use wall-clock time so phases match across peers (NTP keeps // machines within tens of ms); off = local page time like before export const syncedAnimations = writable( - typeof localStorage === 'undefined' || localStorage.getItem('syncedAnimations') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('syncedAnimations') !== 'false' ); // user-designed node definitions ({id, name, params, code}), replicated diff --git a/src/stores/sceneStore.js b/src/stores/sceneStore.js index df49458a..3f571e1f 100644 --- a/src/stores/sceneStore.js +++ b/src/stores/sceneStore.js @@ -1,6 +1,7 @@ import { writable } from 'svelte/store'; // dependency-free helper, so importing it keeps this store a leaf import { coarsePointer } from '../lib/inputDevice'; +import { safeStorage } from '../lib/safeStorage'; /** @type {import('svelte/store').Writable} */ export const globalScene = writable(null); @@ -79,45 +80,45 @@ export const peerHands = writable({}); // --- VR control suite --- // which hand carries the quick-menu (the other hand is the pointer) export const vrMenuHand = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vrMenuHand') || 'right' : 'right' + typeof localStorage !== 'undefined' ? safeStorage.getItem('vrMenuHand') || 'right' : 'right' ); export const vrMenuOpen = writable(false); // snap-turn angle in degrees (15 / 30 / 45, or 0 = off — 155) export const vrSnapAngle = writable( - typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('vrSnapAngle') || '45') : 45 + typeof localStorage !== 'undefined' ? parseInt(safeStorage.getItem('vrSnapAngle') || '45') : 45 ); // mirror snap-turn direction (155): left flick turns right and vice-versa export const vrMirrorSnapTurn = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrMirrorSnapTurn') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrMirrorSnapTurn') === 'true' ); // teleport locomotion (157): default ON; off disables the right-stick-up arc export const vrTeleportEnabled = writable( - typeof localStorage === 'undefined' || localStorage.getItem('vrTeleportEnabled') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('vrTeleportEnabled') !== 'false' ); // VR sleeve palette (K1, experimental): a forearm strip of ghost primitives on // the LEFT controller (mirrors right when the menu owns the left hand) — // trigger-drag a ghost out to place it. DEFAULT OFF. export const vrSleeveEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrSleeveEnabled') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrSleeveEnabled') === 'true' ); // vertex grab style (182): default HOLD (trigger held = carry, release = drop); // OFF = the toggle style (press to grab, press again to drop) export const vrVertexHold = writable( - typeof localStorage === 'undefined' || localStorage.getItem('vrVertexHold') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('vrVertexHold') !== 'false' ); // VR flying: left-stick movement follows the controller aim (pitch included) export const vrFlying = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrFlying') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrFlying') === 'true' ); // passthrough preference (90): the VR button requests immersive-ar instead of // immersive-vr on the NEXT session start (WebXR can't hot-swap modes) export const vrPassthrough = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrPassthrough') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrPassthrough') === 'true' ); // radial menu open style (74): false = B/Y toggles (default), true = hold B/Y // and release over a sector to activate it export const vrMenuHold = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrMenuHold') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrMenuHold') === 'true' ); // native VR objects panel (101), opened from the radial Objects sector export const vrObjectsPanelOpen = writable(false); @@ -150,18 +151,18 @@ export const vrApprovePanelOpen = writable(false); export const vrToolMode = writable('select'); // B2.1 (roadmap 9): target VR refresh rate — 'auto' picks the highest supported export const vrTargetHz = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vrTargetHz') || 'auto' : 'auto' + typeof localStorage !== 'undefined' ? safeStorage.getItem('vrTargetHz') || 'auto' : 'auto' ); vrTargetHz.subscribe((v) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('vrTargetHz', String(v)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('vrTargetHz', String(v)); }); // B2.3: how everyone's hand-tracked peers render LOCALLY — 'hands' (cuboid bones) // or 'spheres' (joint dots). A per-viewer preference, never replicated. export const peerHandStyle = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('peerHandStyle') || 'hands' : 'hands' + typeof localStorage !== 'undefined' ? safeStorage.getItem('peerHandStyle') || 'hands' : 'hands' ); peerHandStyle.subscribe((v) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('peerHandStyle', String(v)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('peerHandStyle', String(v)); }); // Viewport render mode (V-2): LOCAL per-viewer, never replicated — // 'shaded' | 'shaded-ao' (default on desktop) | 'wireframe' | 'custom' @@ -172,7 +173,7 @@ peerHandStyle.subscribe((v) => { // scenePost.adoptCustomView(), which only ever promotes a viewer who has not // explicitly picked a mode (see chooseViewMode). function defaultViewMode() { - const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('viewMode') : null; + const stored = typeof localStorage !== 'undefined' ? safeStorage.getItem('viewMode') : null; if (stored) return stored; // AO is a FULLSCREEN pass: a poor default on a phone GPU even when it works, // and several mobile drivers mis-compile it (the viewport then keeps showing a @@ -183,21 +184,21 @@ function defaultViewMode() { } export const viewMode = writable(defaultViewMode()); viewMode.subscribe((v) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('viewMode', String(v)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('viewMode', String(v)); }); // VR snap MODE (156): 'off' | 'grid' | 'surface' | 'rotation' export const vrSnapMode = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vrSnapMode') || 'off' : 'off' + typeof localStorage !== 'undefined' ? safeStorage.getItem('vrSnapMode') || 'off' : 'off' ); // 115: true = the prefabs window is world-fixed (📌), false = lazy-follows the view export const vrPrefabsPinned = writable(false); // VR selection indicator style (110): wireframe (default) or the shell export const vrWireframeSelection = writable( - typeof localStorage === 'undefined' || localStorage.getItem('vrWireframe') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('vrWireframe') !== 'false' ); // stats card on the pointer controller (102) — persisted so it re-attaches export const vrStatsOpen = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrStats') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrStats') === 'true' ); // true while an AR (passthrough) session presents — a LOCAL view mode: the // scene background/fog go transparent so the room shows through; the @@ -220,7 +221,7 @@ export const gizmoSuppressed = writable(false); export const vrTransformMode = writable('move'); /** grab style (100): 'rigid' = controller-as-handle (default); 'move'/'rotate' = legacy gizmo grabs */ export const vrGrabStyle = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vrGrabStyle') ?? 'rigid' : 'rigid' + typeof localStorage !== 'undefined' ? safeStorage.getItem('vrGrabStyle') ?? 'rigid' : 'rigid' ); /** handedness currently holding a grab ('left'|'right'|null) — gates that hand's stick */ export const vrGrabbedHand = writable(null); diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs index db391b63..60c84ceb 100644 --- a/tests/e2e/storage-hardening.test.cjs +++ b/tests/e2e/storage-hardening.test.cjs @@ -328,6 +328,91 @@ h.run(async () => { h.check(/ms$/.test(line.cost), `...and what the last snapshot cost to prepare ("${line.cost}")`); await A.page.evaluate(() => window.__stores.storageUsage.storageModalOpen.set(false)); + // ---- 3. safeStorage: a broken localStorage no longer kills its caller --------------- + // SAFARI PRIVATE MODE, simulated where the browser really fails: `Storage.prototype + // .setItem` throws. Stubbing the PROTOTYPE rather than our own module is the point — + // everything downstream, including the ~500 codemodded call sites, meets the real + // failure. Restored immediately afterwards, or every later section runs degraded. + const priv = await A.page.evaluate(() => { + const store = window.__stores.safeStorage; + store.debugResetStorage(); + const real = Storage.prototype.setItem; + let threw = 0; + Storage.prototype.setItem = function () { + threw++; + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + }; + let raised = null; + let wrote = null; + try { + wrote = store.setItem('27h-pref', 'chosen'); + } catch (error) { + raised = String(error); + } + const readBack = store.getItem('27h-pref'); + const state = store.storageDebug(); + // and the counterfactual, in the same broken world: the bare call this replaced + let bareThrew = false; + try { + localStorage.setItem('27h-pref-bare', 'chosen'); + } catch { + bareThrew = true; + } + Storage.prototype.setItem = real; + return { raised, wrote, readBack, state, threw, bareThrew }; + }); + h.check(priv.threw > 0, `premise: the stub really is in the write path (${priv.threw} throws)`); + h.check(priv.bareThrew, 'premise: a bare localStorage.setItem throws in that world — the bug'); + h.check(priv.raised === null, 'safeStorage.setItem does not throw, so the caller survives'); + h.check(priv.wrote === false, '...and it says the write did not reach the disk'); + h.check( + priv.readBack === 'chosen', + `...while the setting still APPLIES for this session (read back "${priv.readBack}")` + ); + h.check( + priv.state.degraded === true && priv.state.failures > 0, + `...and the app knows it is degraded (${JSON.stringify(priv.state)})` + ); + + // A real setting, driven the way the app drives it, in the same broken world: the + // subscriber that persists it must still run its OTHER work. This is the actual bug — + // a throw inside a store subscriber kills the subscriber for the session. + const setting = await A.page.evaluate(async () => { + const real = Storage.prototype.setItem; + Storage.prototype.setItem = function () { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + }; + let raised = null; + try { + const { autosaveEnabled } = window.__stores.autosave; + autosaveEnabled.set(false); + autosaveEnabled.set(true); + } catch (error) { + raised = String(error); + } + Storage.prototype.setItem = real; + let value = null; + window.__stores.autosave.autosaveEnabled.subscribe((v) => (value = v))(); + return { raised, value }; + }); + h.check( + setting.raised === null && setting.value === true, + `a setting toggled while storage is broken still applies (${setting.value}, raised ${setting.raised})` + ); + + // The whole codemod, asserted as a property rather than a diff: nothing in src/ calls + // localStorage directly any more, and CI fails on the next one that does. + const guard = await A.page.evaluate(() => ({ + exposed: typeof window.__stores.safeStorage?.setItem === 'function', + diagnostics: window.__stores.diagnostics.bundle().sections?.storage ?? null + })); + h.check(guard.exposed, 'premise: safeStorage is the module the app is using'); + h.check( + guard.diagnostics && typeof guard.diagnostics.degraded === 'boolean', + `the diagnostics bundle carries whether persistence is working (${JSON.stringify(guard.diagnostics)})` + ); + await A.page.evaluate(() => window.__stores.safeStorage.debugResetStorage()); + await A.page.evaluate(async () => { for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k); }); diff --git a/tests/unit/safeStorage.test.js b/tests/unit/safeStorage.test.js new file mode 100644 index 00000000..25d61dee --- /dev/null +++ b/tests/unit/safeStorage.test.js @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + getItem, + setItem, + removeItem, + keys, + clear, + storageDebug, + debugResetStorage, + safeStorage, + get, + set, + remove +} from '../../src/lib/safeStorage.js'; + +// 27-H (audit M4). The whole value of this module is what it does when storage is +// BROKEN, and every one of those states is reachable here with no browser: node has no +// `localStorage` at all, and the two failure modes are a `setItem` that throws (Safari +// private mode, a full quota) and a `localStorage` property that throws on ACCESS (a +// sandboxed iframe, some enterprise policies) — the second of which every +// `typeof localStorage === 'undefined'` guard in this codebase misses. + +/** a working stand-in, so the happy path is testable too @param {any} overrides */ +function fakeStorage(overrides = {}) { + /** @type {Map} */ + const map = new Map(); + return Object.assign( + { + /** @param {string} k */ + getItem: (k) => (map.has(k) ? map.get(k) : null), + /** @param {string} k @param {any} v */ + setItem: (k, v) => map.set(k, String(v)), + /** @param {string} k */ + removeItem: (k) => map.delete(k), + clear: () => map.clear(), + get length() { + return map.size; + }, + /** @param {number} i */ + key: (i) => [...map.keys()][i] ?? null, + __map: map + }, + overrides + ); +} + +/** @param {any} value */ +function install(value) { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + get() { + if (typeof value === 'function') return value(); + return value; + } + }); +} + +afterEach(() => { + // @ts-ignore - installed by `install()` above; node has no localStorage to begin with + delete globalThis.localStorage; + debugResetStorage(); +}); + +beforeEach(() => debugResetStorage()); + +describe('with no storage at all (SSR, or a browser that has none)', () => { + it('still remembers what you set, for this session', () => { + expect(setItem('theme', 'light')).toBe(false); + expect(getItem('theme')).toBe('light'); + }); + + it('says so, rather than pretending', () => { + setItem('theme', 'light'); + const state = storageDebug(); + expect(state.available).toBe(false); + expect(state.degraded).toBe(true); + expect(state.fallbackKeys).toBe(1); + }); + + it('reads a key nobody set as null, not undefined', () => { + expect(getItem('never-set')).toBe(null); + }); +}); + +describe('with working storage', () => { + it('writes through and keeps nothing in memory', () => { + const store = fakeStorage(); + install(store); + expect(setItem('theme', 'dark')).toBe(true); + expect(store.__map.get('theme')).toBe('dark'); + expect(storageDebug().fallbackKeys).toBe(0); + expect(storageDebug().degraded).toBe(false); + expect(getItem('theme')).toBe('dark'); + }); + + it('coerces like localStorage does', () => { + install(fakeStorage()); + setItem('count', 3); + expect(getItem('count')).toBe('3'); + }); + + it('removes from both sides', () => { + const store = fakeStorage(); + install(store); + setItem('theme', 'dark'); + removeItem('theme'); + expect(getItem('theme')).toBe(null); + expect(store.__map.has('theme')).toBe(false); + }); +}); + +describe("Safari private mode: setItem throws, and that used to kill the caller's subscriber", () => { + it('does not throw, and the setting still applies', () => { + install( + fakeStorage({ + setItem() { + throw new DOMException('QuotaExceededError', 'QuotaExceededError'); + } + }) + ); + expect(() => setItem('theme', 'light')).not.toThrow(); + expect(getItem('theme')).toBe('light'); + const state = storageDebug(); + expect(state.degraded).toBe(true); + expect(state.failures).toBe(1); + expect(state.lastError).toBe('QuotaExceededError'); + }); + + it('the counterfactual: a bare call in the same place does throw', () => { + install( + fakeStorage({ + setItem() { + throw new Error('nope'); + } + }) + ); + expect(() => globalThis.localStorage.setItem('theme', 'light')).toThrow(); + }); + + it('a later successful write makes real storage the truth again', () => { + let broken = true; + const store = fakeStorage({ + /** @param {string} k @param {any} v */ + setItem(k, v) { + if (broken) throw new Error('nope'); + store.__map.set(k, String(v)); + } + }); + install(store); + setItem('theme', 'light'); + expect(storageDebug().fallbackKeys).toBe(1); + broken = false; + setItem('theme', 'dark'); + // the shadow is dropped, or it would outvote the real value forever + expect(storageDebug().fallbackKeys).toBe(0); + expect(getItem('theme')).toBe('dark'); + }); +}); + +describe('a sandboxed iframe: touching localStorage throws on ACCESS', () => { + it('is survived, which no `typeof localStorage` guard manages', () => { + install(() => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }); + expect(() => setItem('theme', 'light')).not.toThrow(); + expect(() => getItem('theme')).not.toThrow(); + expect(() => keys()).not.toThrow(); + // the value is still readable — that is the promise — so read it BEFORE the two + // calls that legitimately drop the fallback + expect(getItem('theme')).toBe('light'); + expect(storageDebug().available).toBe(false); + expect(() => removeItem('theme')).not.toThrow(); + expect(() => clear()).not.toThrow(); + }); +}); + +describe('keys() is the union of both sides', () => { + it('lists real keys and fallen-back ones together', () => { + const store = fakeStorage({ + /** @param {string} k @param {any} v */ + setItem(k, v) { + if (k === 'bad') throw new Error('nope'); + store.__map.set(k, String(v)); + } + }); + install(store); + setItem('win:a', '1'); + setItem('bad', '2'); + expect(keys().sort()).toEqual(['bad', 'win:a']); + }); +}); + +describe('the shapes callers use', () => { + it('the drop-in object and the short names are the same functions', () => { + expect(safeStorage.getItem).toBe(getItem); + expect(safeStorage.setItem).toBe(setItem); + expect(safeStorage.removeItem).toBe(removeItem); + expect(get).toBe(getItem); + expect(set).toBe(setItem); + expect(remove).toBe(removeItem); + }); +}); From f731555dd6abe6627559c3f254180fb61eb106a8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 11:55:33 +0300 Subject: [PATCH 15/27] [fix] 27-H: the microphone is given back The audit's M9. Mute only ever set `track.enabled = false`, and nothing in this module has ever called `stop()`. A disabled track is still a LIVE track: the tab keeps its recording indicator, the OS keeps the device claimed so nothing else can open it, and both stay that way for the life of the page after one press. That is a trust problem before it is a resource one - the indicator says "this page is listening" and it is not true. `leaveSession` never touched voice at all, so it survived leaving the session too. - `releaseMic()` stops every track, drops the `self` analyser, and CLOSES THE OUTGOING CALLS. The last part is not tidiness: a MediaConnection carries this stream, and `callPeer` skips a peer that already has one - so leaving a dead channel up would make the next re-acquire reach nobody. Closing means `ensureStream` re-calls everybody, which costs a renegotiation and is the only version that works. INCOMING calls are deliberately left alone: listening never needed a microphone, and turning your own mic off is not a request to stop hearing other people. - Called from: the mic toggle going OFF (immediately - you said so, and the indicator is what you are watching), the VR mic mode reaching 'off', and `leaveSession`. - PUSH-TO-TALK releases after a 3s IDLE GRACE rather than on the keyup. That is the one piece of policy here, and it is there because re-acquiring costs a `getUserMedia` AND a renegotiation with every peer: releasing instantly would make the second sentence of a conversation arrive late. A few seconds of indicator after you stop talking is active use; forever is the bug. - `releaseMic` also clears `micActive`, and THE TWO-PEER SECTION IS WHAT FOUND THAT: with the flag left true and no stream behind it, the toolbar claimed an open mic and the next press was read as "off", so the peer was never called at all. Measured as B seeing `incoming: 0` through a 20s wait. The state has to agree with the device. - THE SPEAKING POLL used to be armed once at init and run at ~7Hz for the life of the tab, with no microphone, no peers and nothing to measure. `syncPoll` arms it only while something is measurable (our stream, or any call) and stands it down otherwise, clearing `speakingPeers` when it does - nobody can be speaking when nothing is measured. - The AudioContext is deliberately NOT closed: `audioEngine` owns it for the whole app since #22 A1, so closing it here would silence music, sounds and pings. Counterfactuals, each proven by breaking the code: - `stop()` swapped back for `enabled = false` -> 5 checks red, reading `{"stream":true,"live":1,"enabled":0}` - a live-but-disabled track, which IS the bug. - the unconditional `setInterval` restored -> "nothing is claimed and nothing is polling" reads `polling:true` with no mic and no peers. - `releaseMic()` removed from `leaveSession` -> the mic survives leaving the session (`live:1, enabled:1`). Suites: storage-hardening 51/51 (37 -> 51, now two peers for section 5). Held green: voice-ptt, spatial-voice, autosave-object-flows, explorer-storage (5 suites, 495s on a freshly restarted server). PRE-EXISTING RED, A/B'd against BOTH this branch's previous commit and the lane base 87c9d72, failing identically on all three: net-reconnect ("B's new object reaches A after the heal"). svelte-check 357/47. Unit 98/98. Build green with the dev server stopped. One method note: after the A/B checkouts above, every suite died in setupPage's `waitForFunction` with `$peers` null inside Scene - the documented mid-session HMR churn, not a regression. A dev-server restart and a curl-grep for a new symbol cleared it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um --- src/lib/peerHandler.svelte.js | 6 +- src/lib/voiceChat.js | 121 +++++++++++++++++++++++++- tests/e2e/storage-hardening.test.cjs | 124 ++++++++++++++++++++++++++- 3 files changed, 247 insertions(+), 4 deletions(-) diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index c7a38bb7..f54b4783 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -16,7 +16,7 @@ import { applyMeshGeo } from '$lib/faceEdit'; // materialsHandler, history) are already in this file's subtree. import { applyUvPaint, applyUvPaintEnd } from '$lib/uvEditor'; import { applySplineEdit } from '$lib/splineTool'; -import { initVoiceChat, attachVoiceToPeer, voicePeerConnected } from '$lib/voiceChat'; +import { initVoiceChat, attachVoiceToPeer, voicePeerConnected, releaseMic } from '$lib/voiceChat'; import { resolvePeerOptions, describePeerServer, peerServerStatus, parseInviteHash, decodeInviteServer, applyInviteServerOverride, inviteServerOverride } from '$lib/peerServer'; // 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. @@ -1501,6 +1501,10 @@ export class PeerConnection { userdata.set(get(userdata).filter(u => u[0] === this.peer.id)); waitingForApproval.set([]); pendingApprovals.set([]); + // 27-H (audit M9): leaving a session must hand the microphone back. Nothing here + // touched voice, so the tab's recording indicator stayed on and the device stayed + // claimed after you left — for the life of the page. + releaseMic(); resetSession(); checkLocks(); peers.update((value) => value); diff --git a/src/lib/voiceChat.js b/src/lib/voiceChat.js index c771b174..4c3c3de7 100644 --- a/src/lib/voiceChat.js +++ b/src/lib/voiceChat.js @@ -38,6 +38,8 @@ let pttHeld = false; /** @type {Record} */ const outgoingCalls = {}; /** @type {Record} */ const incomingCalls = {}; /** @type {Record} */ const analysers = {}; +/** @type {any} the speaking-detection interval, armed only while there is audio */ +let pollTimer = null; /** * The shared AudioContext. #22 A1 moved OWNERSHIP into `audioEngine` — the whole @@ -52,6 +54,7 @@ export function ensureAudioContext() { /** @param {any} call @param {'in'|'out'} direction */ function trackCall(call, direction) { (direction === 'in' ? incomingCalls : outgoingCalls)[call.peer] = call; + syncPoll(); call.on('stream', (/** @type {MediaStream} */ stream) => { remoteStreams.update((map) => ({ ...map, [call.peer]: stream })); watchStream(call.peer, stream); @@ -225,9 +228,11 @@ function cleanupCall(peerId, direction) { delete analysers[peerId]; dropSpatialChain(peerId); } + syncPoll(); } async function ensureStream() { + clearTimeout(idleRelease); if (localStream) return true; try { localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -235,6 +240,7 @@ async function ensureStream() { applyTrackState(); callEveryone(); watchStream('self', localStream); + syncPoll(); return true; } catch (error) { console.log('mic denied', error); @@ -243,6 +249,105 @@ async function ensureStream() { } } +/** + * 27-H (hardening audit M9) — GIVE THE MICROPHONE BACK. + * + * Mute only ever set `track.enabled = false`, and nothing in this module has ever + * called `stop()`. A disabled track is still a LIVE track: the tab keeps its recording + * indicator, the OS keeps the device claimed so nothing else can open it, and both + * stay that way for the life of the page after one press. That is a trust problem + * before it is a resource one — the indicator says "this page is listening" and it is + * not true. + * + * THE OUTGOING CALLS GO WITH IT, and they have to: a MediaConnection carries this + * stream, so leaving them up after stopping its tracks leaves peers holding a channel + * that can never carry audio again — `callPeer` skips a peer that already has one, so + * re-acquiring would reach nobody. Closing them means `ensureStream` re-calls + * everybody, which costs a renegotiation but is the only version that works. + * + * INCOMING calls are deliberately left alone: listening never needed a microphone, + * and turning your own mic off is not a request to stop hearing other people. + */ +export function releaseMic() { + clearTimeout(idleRelease); + if (!localStream) return false; + try { + localStream.getTracks().forEach((track) => track.stop()); + } catch {} + localStream = null; + delete analysers['self']; + for (const peerId of Object.keys(outgoingCalls)) { + try { + outgoingCalls[peerId].close(); + } catch {} + cleanupCall(peerId, 'out'); + } + pttActive.set(false); + // THE STATE HAS TO AGREE WITH THE DEVICE. Leaving `micActive` true with no stream + // behind it leaves the toolbar claiming the mic is open while nothing is being + // transmitted, and the NEXT toggle then turns it "off" — measured as B never being + // called at all, because the press the suite meant as "on" was read as "off". + micActive.set(false); + syncPoll(); + return true; +} + +/** + * How long the mic stays claimed after a push-to-talk release. + * + * NOT zero, and this is the one piece of policy in the change. Re-acquiring costs a + * `getUserMedia` AND a renegotiation with every peer, so releasing the instant a key + * comes up would make the second sentence of a conversation arrive seconds late. A few + * seconds of indicator after you stop talking is active use; forever is the bug. + * An explicit voice-OFF releases immediately — you said so. + */ +const PTT_IDLE_MS = 3000; +/** @type {any} */ let idleRelease = null; + +/** Arm the idle release, unless something is still transmitting. */ +function releaseWhenIdle() { + clearTimeout(idleRelease); + if (get(micActive) || pttHeld) return; + idleRelease = setTimeout(() => { + if (!get(micActive) && !pttHeld) releaseMic(); + }, PTT_IDLE_MS); +} + +/** + * 27-H (audit M9): the speaking poll used to be armed once at init and run at ~7Hz for + * the life of the tab — with no microphone, no peers and nothing to measure. It runs + * only while there is something to measure now: our own stream, or somebody on a call. + */ +function pollWanted() { + return !!localStream || Object.keys(incomingCalls).length > 0 || Object.keys(outgoingCalls).length > 0; +} + +function syncPoll() { + const wanted = pollWanted(); + if (wanted && !pollTimer) pollTimer = setInterval(pollSpeaking, 150); + else if (!wanted && pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + // nobody can be speaking when nothing is being measured + if (get(speakingPeers).length) speakingPeers.set([]); + } +} + +/** Is the microphone claimed, and is the analyser loop running? (tests / diagnostics) */ +export function voiceDebug() { + const tracks = localStream ? localStream.getTracks() : []; + return { + stream: !!localStream, + live: tracks.filter((t) => t.readyState === 'live').length, + ended: tracks.filter((t) => t.readyState === 'ended').length, + enabled: tracks.filter((t) => t.enabled).length, + polling: !!pollTimer, + outgoing: Object.keys(outgoingCalls).length, + incoming: Object.keys(incomingCalls).length, + analysers: Object.keys(analysers).length + }; +} + function applyTrackState() { const enabled = get(micActive) || pttHeld; localStream?.getAudioTracks().forEach((track) => (track.enabled = enabled)); @@ -266,6 +371,9 @@ export async function toggleMic() { if (next && !(await ensureStream())) return; micActive.set(next); applyTrackState(); + // M9: turning the mic off is an explicit "I am done" — the device goes back now, + // not after a grace, because the indicator is what the user is watching + if (!next) releaseMic(); } /** VR A-button push-to-talk (same track path as hold-V) @param {boolean} held */ @@ -275,7 +383,10 @@ export async function setPttHeld(held) { if (held) { if (await ensureStream()) applyTrackState(); else pttHeld = false; - } else applyTrackState(); + } else { + applyTrackState(); + releaseWhenIdle(); + } } /** Radial menu (74): jump straight to a mode, reusing the cycle transitions @@ -295,6 +406,8 @@ export async function cycleMicMode() { if (get(micActive)) await toggleMic(); pttHeld = false; applyTrackState(); + // M9: OFF means off — no stream, no device claim, no indicator + releaseMic(); } else { vrMicMode.set('ptt'); } @@ -337,6 +450,8 @@ function onKeyup(event) { if (letterOf(event) !== 'v' || !pttHeld) return; pttHeld = false; applyTrackState(); + // M9: hand the device back shortly after the hold ends + releaseWhenIdle(); } // --- speaking detection --- @@ -417,7 +532,9 @@ export function initVoiceChat(/** @type {any} */ pc) { window.addEventListener('keyup', onKeyup); // AudioContext starts suspended until a user gesture window.addEventListener('pointerdown', () => resumeAudio(), { once: false }); - setInterval(pollSpeaking, 150); + // M9: NOT an unconditional interval any more — `syncPoll` arms it when there is + // audio to measure and stands it down when there is not + syncPoll(); } /** A data connection to this peer just opened — call them if we transmit @param {string} peerId */ diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs index 60c84ceb..79db26f2 100644 --- a/tests/e2e/storage-hardening.test.cjs +++ b/tests/e2e/storage-hardening.test.cjs @@ -13,7 +13,12 @@ const h = require('./helpers.cjs'); h.run(async () => { - const browser = await h.launch(); + // A FAKE CAPTURE DEVICE, for section 4: the microphone checks read `track.readyState` + // on a real MediaStream, which headless Chromium will not produce without it — and a + // stubbed stream would be asserting a mock rather than the release. + const browser = await h.launch({ + args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] + }); const A = await h.setupPage(browser, 'A'); // ---- 1. a transaction always settles ----------------------------------------------- @@ -413,6 +418,123 @@ h.run(async () => { ); await A.page.evaluate(() => window.__stores.safeStorage.debugResetStorage()); + // ---- 4. the microphone is given back ----------------------------------------------- + // A fake device, so a real MediaStream with real tracks exists to be stopped — the + // whole check is about `track.readyState`, and a stub would be asserting a mock. + const seam = await A.page.evaluate(() => typeof window.__stores.voiceChat?.voiceDebug === 'function'); + h.check(seam, 'premise: the voice seam is reachable'); + + const idle = await A.page.evaluate(() => window.__stores.voiceChat.voiceDebug()); + h.check( + idle.stream === false && idle.polling === false, + `with no mic and no peers nothing is claimed and nothing is polling (${JSON.stringify(idle)})` + ); + + const on = await A.page.evaluate(async () => { + await window.__stores.voiceChat.toggleMic(); + return window.__stores.voiceChat.voiceDebug(); + }); + h.check(on.stream === true && on.live === 1, `premise: the mic really opened (${on.live} live track)`); + h.check(on.polling === true, 'the speaking poll runs while there is audio to measure'); + + const off = await A.page.evaluate(async () => { + const before = window.__stores.voiceChat.voiceDebug(); + await window.__stores.voiceChat.toggleMic(); + const after = window.__stores.voiceChat.voiceDebug(); + return { before, after }; + }); + h.check( + off.after.stream === false, + `turning the mic off releases the stream rather than muting a live track (${JSON.stringify(off.after)})` + ); + h.check( + off.after.live === 0, + "...so the tab's recording indicator goes out and the device is free for another app" + ); + h.check(off.after.polling === false, '...and the analyser loop stands down with it'); + h.check( + off.after.analysers === 0, + `...and the analyser it was feeding is dropped too (${off.after.analysers})` + ); + + // PTT re-acquires, and does NOT release the instant the key comes up: re-acquiring + // costs a getUserMedia and a renegotiation with every peer, so an immediate release + // would make the next sentence arrive late. A few seconds is active use; forever is + // the bug this section is about. + const ptt = await A.page.evaluate(async () => { + await window.__stores.voiceChat.setPttHeld(true); + const held = window.__stores.voiceChat.voiceDebug(); + await window.__stores.voiceChat.setPttHeld(false); + await new Promise((r) => setTimeout(r, 400)); + const justAfter = window.__stores.voiceChat.voiceDebug(); + await new Promise((r) => setTimeout(r, 4200)); + const settled = window.__stores.voiceChat.voiceDebug(); + return { held, justAfter, settled }; + }); + h.check(ptt.held.stream === true && ptt.held.live === 1, 'push-to-talk re-acquires the device'); + h.check(ptt.justAfter.stream === true, '...and does not drop it the instant the key comes up'); + h.check( + ptt.settled.stream === false && ptt.settled.live === 0, + `...but hands it back once the hold is over (${JSON.stringify(ptt.settled)})` + ); + + // leaving a session is the other half of the report: nothing in the peer layer used to + // touch voice at all + const left = await A.page.evaluate(async () => { + await window.__stores.voiceChat.toggleMic(); + const before = window.__stores.voiceChat.voiceDebug(); + let peer = null; + window.__stores.peers.subscribe((/** @type {any} */ v) => (peer = v))(); + peer.leaveSession(); + return { before, after: window.__stores.voiceChat.voiceDebug() }; + }); + h.check(left.before.stream === true, 'premise: the mic was open when the session ended'); + h.check( + left.after.stream === false && left.after.live === 0, + `leaving the session hands the microphone back (${JSON.stringify(left.after)})` + ); + + // ---- 5. releasing the device must not cost the session its voice -------------------- + // THE RISK THIS CHANGE INTRODUCES, asserted rather than reasoned about: releasing the + // stream closes our OUTGOING MediaConnections (they carry it, and `callPeer` skips a + // peer that already has one, so leaving a dead channel up would make the next + // re-acquire reach nobody). So the thing to prove is that turning the mic back on + // really does call everybody again. + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const voiceOf = (peer) => peer.page.evaluate(() => window.__stores.voiceChat.voiceDebug()); + + await A.page.evaluate(() => window.__stores.voiceChat.toggleMic()); + await h.eventually(() => voiceOf(B), (v) => v.incoming > 0, "premise: A's first mic-on reaches B", 20000); + + await A.page.evaluate(() => window.__stores.voiceChat.toggleMic()); + const released = await voiceOf(A); + h.check( + released.stream === false && released.outgoing === 0, + `mic-off releases the device AND the channel that carried it (${JSON.stringify(released)})` + ); + + await A.page.evaluate(() => window.__stores.voiceChat.toggleMic()); + await h.eventually( + () => voiceOf(A), + (v) => v.stream === true && v.outgoing > 0, + 'turning the mic back on re-establishes the call, so voice survives a release', + 20000 + ); + await h.eventually( + () => voiceOf(B), + (v) => v.incoming > 0, + '...and the peer has a live incoming call again', + 20000 + ); + const restored = await voiceOf(A); + h.check( + restored.stream === true && restored.live === 1 && restored.outgoing > 0, + `...with a live device behind it (${JSON.stringify(restored)})` + ); + await A.page.evaluate(() => window.__stores.voiceChat.releaseMic()); + await A.page.evaluate(async () => { for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k); }); From 7646fc2cde4d1192f1910db946caff5c12e651e6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 12:29:32 +0300 Subject: [PATCH 16/27] [fix] 27-H: the dirty flag is read before the export, not beside the write Found reviewing my own phase 2 diff (b5898ec). That commit added the right guard - "a change made DURING the export is not in the bytes just written" - and then read `markAtStart = get(dirtyPulse)` immediately before `idbPut`, which is AFTER the GLTF export has already finished. The export is the slow part and therefore the entire window the guard exists for, so as written it compared a stamp taken after the risky period against itself and cleared `dirty` unconditionally in every real case. It is read on the first line of `writeSnapshot` now. Not a lost-work bug in practice - the `markDirty` that raced the save also armed a fresh debounce, so the edit still reached disk 30s later - but `isDirty()` read false in between, and that store is what Settings and the window title's dirty asterisk consult. The honest version of the guard is the one that measures the right window. Suite: two checks in storage-hardening - an edit made while a snapshot is being written stays unsaved, and a quiet save still clears the flag (a guard that only asserted the first half would pass with `dirty` never cleared at all). Counterfactual: the unconditional `dirty = false` restored -> "an edit made while a snapshot is being written stays unsaved" reads `(false)`, 56 of 57. Suites: storage-hardening 57/57 (51 -> 57 checks; the 51 in f731555's body was a miscount - 57 is the measured number). svelte-check 357/47. Unit 98/98. check:storage clean. Build green with the dev server stopped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um --- src/lib/autosave.js | 7 +++++-- tests/e2e/storage-hardening.test.cjs | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 8ab0f6d1..d750552e 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -273,6 +273,11 @@ export function debugRequestSave() { } async function writeSnapshot() { + // What has changed BEFORE any of this runs. It has to be read HERE rather than + // beside the write: the GLTF export below is the slow part, so a change made + // during it is precisely the one that is NOT in the bytes we are about to store, + // and clearing `dirty` unconditionally at the end would mark it saved. + const markAtStart = get(dirtyPulse); // H1: persist EVERY graph document; orphan object graphs (owner object gone) // are pruned from the OUTPUT only. Legacy nodes/edges fields keep carrying the // scene graph so an old build can still restore this snapshot. @@ -348,8 +353,6 @@ async function writeSnapshot() { ? { position: camera.position.toArray(), target: controls?.target?.toArray() ?? [0, 0, 0] } : null }; - // what changed BEFORE the write; anything dirtied during it must survive the clear - const markAtStart = get(dirtyPulse); const bytes = estimateSnapshotBytes(snapshot); autosaveStatus.update((state) => ({ ...state, lastBytes: bytes })); try { diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs index 79db26f2..e5422261 100644 --- a/tests/e2e/storage-hardening.test.cjs +++ b/tests/e2e/storage-hardening.test.cjs @@ -257,6 +257,29 @@ h.run(async () => { `the live cadence is the one that measurement implies (${Math.round(derived.ms)}ms -> ${derived.debounce}ms)` ); + // A change made DURING a save is NOT in the bytes that save wrote, so the save must + // not mark it saved (the held-body `lastWritten` rule, one domain over). The window is + // real and it is the GLTF export, which is the slow part — which is also why the + // pulse has to be read before the export rather than beside the write. + const duringSave = await A.page.evaluate(async () => { + const a = window.__stores.autosave; + const settle = a.saveNow(); + // synchronously after the save has begun: `markAtStart` is already taken + a.markAnnotationsDirty(); + await settle; + return { dirty: a.isDirty() }; + }); + h.check( + duringSave.dirty === true, + `an edit made while a snapshot is being written stays unsaved (${duringSave.dirty})` + ); + // and the ordinary case still clears, or the flag would be stuck on forever + const afterSave = await A.page.evaluate(async () => { + await window.__stores.autosave.saveNow(); + return window.__stores.autosave.isDirty(); + }); + h.check(afterSave === false, `...while a quiet save does clear it (${afterSave})`); + // A FAILED AUTOSAVE IS SAID OUT LOUD. This used to reach `console.log` and stop there, // so a full disk meant crash recovery had silently switched itself off. The quota error // is raised through the idb seam because a headless origin is granted tens of gigabytes From 526624f57da17a6c1041ba43462ec25c3c1664c6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 08:55:51 +0300 Subject: [PATCH 17/27] [feat] 26-B: one poke per frame, an ingest queue that yields, and a windowed object list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 26 Stage 0 (hardening audit M6, M1, M2). The receive path ran an unbounded amount of work per incoming object; this bounds all three places it did so. WHAT - `pokeScene()` in sceneStore replaces all 117 `objectsGroup.update((v) => v)` call sites across 37 files. Eighteen subscribers hang off that store and several traverse the whole tree, so a 1,000-object handshake was ~8M node visits, synchronously, on the receive path. One flush per microtask normally; one per ~16ms while an ingest batch is open (`beginSceneBatch`/`endSceneBatch`, refcounted). The seam lives IN the store because all 37 files already import from it, so it costs no import edge — which matters when the pokers include peerHandler, flowRuntime, autosave and history. A microtask, not rAF: it lands before paint AND still runs in a hidden tab. Batch mode uses a timer for the same reason. `sceneRevision` is bumped on every flush, for a subscriber that wants to cache a traversal (and for 26-A's meter). - `createObject` is a QUEUE with an 8ms slice (`INGEST_SLICE_MS`). The dispatcher never awaited it, so N object messages started N overlapping main-thread GLTF parses in one task and their completion order was accidental. Objects now apply strictly in arrival order, the drainer yields a MACROtask every 8ms (a microtask chain never returns to the browser), and the whole drain holds a scene batch. `clearSceneLocal` drops the queue — roadmap section 5's "the ingest queue drops on clear". - Toasts' "Receiving objects" reconciliation was `getObjectByProperty` — a full tree walk — TWICE per outstanding uuid on EVERY poke. One traversal into a Set, then lookups: O(objects + outstanding) instead of O(both). - The object list VIRTUALISES above 500 visible rows: the same `Objects.svelte` row component in a new `flat` mode over the flattened `visibleObjectRows` — the one array the keyboard walker, Ctrl+A and the type-ahead already read their order from — with two spacer divs for what is off screen and a keyboard follow that moves the window. Below the threshold the recursive tree is unchanged. The scroller is found by real SCROLLABILITY (it is flowbite's Listgroup, whose element we do not own) and the row height is MEASURED, never assumed, because the spacers are in pixels. - audit M1: `sendObjects` builds its uuid list PER CALL (it was a module-level array that `countObjects` pushed onto and only the timer emptied, so two approvals 400ms apart cross-contaminated both joiners' `loading` lists and `count` was a running total), and resolves its connection INSIDE the 500ms timer, bailing when it is gone — `peer.connections[peerId]` is undefined mid-dial and closed when the joiner gave up, and both used to throw inside a timer where nothing catches it. - audit M2: a `loading` batch records its SENDER (local only — the message is unchanged), and is cleared by that peer's teardown, by a parse that rejects (`noteLoadFailed`), by a scene clear, and by a 60s stall. Nothing could clear it before: the only writer removed a uuid when its object APPEARED, and an object that never arrives never appears. COUNTERFACTUALS (suite `scene-poke`, 32 checks, one page) - Measured IN THE SAME RUN: the bare `objectsGroup.update((v) => v)` this replaced notifies 500 times for 500 calls where `pokeScene` notifies once. - Measured IN THE SAME RUN on the same payloads: the old ingest shape (parse + add + bare poke, 400 objects, one task) holds the main thread for 215ms and renders 2 frames; through the queue the worst hitch is 86ms and 7 frames render. - Broken then restored: `handleDisconnected`'s batch clear removed -> "the sender disconnecting clears the batch" red. `VIRTUAL_MIN` raised to 5000 -> "the list draws a WINDOW" reads 720 rows in the DOM, the spacers and the mode attribute go red too (4 checks). Both restored, suite green again. GATES - svelte-check 352 errors / 47 warnings, DOWN from the 357/47 floor — typing `loading` and `loadingcount` in appStore (they were written as arrays through a splice-in-place and inferred `never[]`) removed 5 pre-existing errors. `check-baseline.json` ratcheted with `--update`. - `npm run build` green with the dev server stopped. - Held suites at or above base: object-list-keys, objectlist-search, object-search, object-delete, selection-extras, clear-scene, inspector, dispose all green. `multi-select` ("member transforms replicated to B") and `undo` ("the placed position replicates") are red — and reproduce IDENTICALLY at base 7646fc2 with src reverted, so they are pre-existing and not this diff. Co-Authored-By: Claude Opus 5 --- check-baseline.json | 4 +- src/components/menu/Controls.svelte | 97 +++++++- src/components/menu/Inspector.svelte | 15 +- src/components/menu/Objects.svelte | 11 +- src/components/menu/Toasts.svelte | 25 +- src/lib/ai/tools.js | 4 +- src/lib/animatedImports.js | 10 +- src/lib/audioDevices.js | 10 +- src/lib/autosave.js | 6 +- src/lib/cameraObjects.js | 8 +- src/lib/cameraPreview.js | 4 +- src/lib/colliderEdit.js | 4 +- src/lib/commandsHandler.svelte.js | 211 ++++++++++++++--- src/lib/drawMode.js | 4 +- src/lib/environment.js | 6 +- src/lib/faceEdit.js | 10 +- src/lib/fileHandler.svelte.js | 10 +- src/lib/geometries.svelte.js | 14 +- src/lib/geometryEdit.js | 6 +- src/lib/history.js | 10 +- src/lib/materialsHandler.js | 18 +- src/lib/meshEdit.js | 5 +- src/lib/moveSmoothing.js | 6 +- src/lib/multiTransform.js | 6 +- src/lib/objectActions.js | 27 ++- src/lib/objectListNav.js | 6 +- src/lib/objectOrigin.js | 4 +- src/lib/objectPermissions.js | 4 +- src/lib/particleActions.js | 4 +- src/lib/peerHandler.svelte.js | 8 +- src/lib/physics.js | 12 +- src/lib/playInteract.js | 4 +- src/lib/prefabs.js | 4 +- src/lib/sessions.js | 8 +- src/lib/shaderGraph.js | 6 +- src/lib/splineTool.js | 6 +- src/lib/terrainSculpt.js | 4 +- src/lib/transientObjects.js | 6 +- src/lib/uvEditor.js | 20 +- src/lib/vrControls.js | 15 +- src/lib/vrSleeve.js | 7 +- src/stores/appStore.js | 9 +- src/stores/sceneStore.js | 97 ++++++++ tests/e2e/scene-poke.test.cjs | 330 +++++++++++++++++++++++++++ 44 files changed, 882 insertions(+), 203 deletions(-) create mode 100644 tests/e2e/scene-poke.test.cjs diff --git a/check-baseline.json b/check-baseline.json index 222d02fc..aaeba533 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 357, + "errors": 352, "warnings": 47, - "measured": "2026-09-12" + "measured": "2026-09-16" } diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 4e48ad99..46e07e28 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -411,6 +411,93 @@ }; } + // --- 26-B: LIST VIRTUALISATION (roadmap 26 Stage 0, audit M6) ------------------- + // The tree rendered EVERY visible row, recursively, and re-reconciled all of them on + // every scene poke. At 3,000 objects that is 3,000 component instances each carrying + // nine handlers and a Tooltip — the object list alone was several hundred ms of the + // reported freeze, and it is why deleting one object in a big scene felt worse than + // the delete itself. + // + // Above the threshold the SAME row component renders in `flat` mode over the + // flattened `visibleObjectRows` — the one array the keyboard walker, Ctrl+A and the + // type-ahead already read their order from — with two spacer divs standing in for + // what is off screen. Sharing that array is what keeps the arrows and the window + // agreeing by construction; deriving a second order would be a copy guaranteed to + // drift (the Explorer's `gridEntries` ruling, one panel over). + // + // Below the threshold NOTHING changes: the recursive tree renders exactly as it did, + // indent borders and all, so the common case is byte-identical. + const VIRTUAL_MIN = 500; + /** rows drawn beyond each edge, so a fast flick does not show blank space */ + const OVERSCAN = 12; + let rowH = $state(24); + let scrollTop = $state(0); + let viewportH = $state(0); + let treeScroller: HTMLElement | null = null; + const treeRows = $derived(viewMode ? [] : visibleObjectRows($objectsGroup, $expandedObjects, $objectFilter as any)); + const virtualising = $derived(treeRows.length > VIRTUAL_MIN); + const windowStart = $derived(virtualising ? Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN) : 0); + const windowEnd = $derived( + virtualising + ? Math.min(treeRows.length, Math.ceil((scrollTop + (viewportH || 400)) / rowH) + OVERSCAN) + : 0 + ); + const windowRows = $derived(virtualising ? treeRows.slice(windowStart, windowEnd) : []); + + /** Find the real scrolling ancestor by SCROLLABILITY, never by class name — the + * scroller is flowbite's `Listgroup`, whose element we do not own (the deep-link + * ruling in Section.svelte, same reason). */ + function trackTreeScroll(node: HTMLElement) { + let ro: any = null; + const read = () => { + if (!treeScroller) return; + scrollTop = treeScroller.scrollTop; + viewportH = treeScroller.clientHeight; + // measure ONE real row rather than trusting a constant: the spacers are in + // pixels, so a wrong height makes the window drift away from the scrollbar + const first = node.querySelector('[role="treeitem"] > div') as HTMLElement | null; + const h = first?.offsetHeight ?? 0; + if (h > 8 && Math.abs(h - rowH) > 0.5) rowH = h; + }; + let el: HTMLElement | null = node.parentElement; + while (el) { + const style = getComputedStyle(el); + if (/(auto|scroll)/.test(style.overflowY)) break; + el = el.parentElement; + } + treeScroller = el; + if (treeScroller) { + treeScroller.addEventListener('scroll', read, { passive: true }); + ro = new ResizeObserver(read); + ro.observe(treeScroller); + ro.observe(node); + } + read(); + return { + destroy() { + treeScroller?.removeEventListener('scroll', read); + ro?.disconnect(); + treeScroller = null; + } + }; + } + + // keyboard follow: in the window a selected row that is off screen has no element to + // scroll itself into view, so the WINDOW moves instead (the arrows would otherwise + // walk silently into nothing). + let lastFollowed = ''; + $effect(() => { + const uuid = $selectedObjects.length ? $selectedObjects[$selectedObjects.length - 1] : ''; + if (!virtualising || !uuid || uuid === lastFollowed) { lastFollowed = uuid; return; } + lastFollowed = uuid; + const index = treeRows.findIndex((r) => r.uuid === uuid); + if (index < 0 || !treeScroller) return; + const top = index * rowH; + const view = treeScroller.clientHeight; + if (top < treeScroller.scrollTop) treeScroller.scrollTop = top; + else if (top + rowH > treeScroller.scrollTop + view) treeScroller.scrollTop = top + rowH - view; + }); + // bottom status line: totals across the whole tree (N objects · M hidden) let objectCount = $state(0); let hiddenCount = $state(0); @@ -2199,8 +2286,14 @@ {#if $objectsGroup} -
- {#if $objectsGroup.children.length > 0} +
+ {#if virtualising} + + {#each windowRows as row (row.uuid)} + + {/each} + + {:else if $objectsGroup.children.length > 0} {#each $objectsGroup.children.filter((/** @type {any} */ c) => !c.userData?.__localOnly) as element} {/each} diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 238d2b3e..e01083d0 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -165,8 +165,7 @@ backgroundColor, globalCamera, viewMode, - showGrid - } from '../../stores/sceneStore'; + showGrid, pokeScene } from '../../stores/sceneStore'; // 16-P3: grid + snapping prefs (LOCAL, like the clip planes) import { gridSettings, setGrid, resetGrid, effectiveCell } from '$lib/gridSettings'; import { snapEnabled, snapSettings, surfaceSnap, snapTargets } from '$lib/snapping'; @@ -499,7 +498,7 @@ setShaderGraphFor(object.uuid, null); detachFrom(object); } - objectsGroup.update((v) => v); + pokeScene(); showToast(own.length === 1 ? 'Shader removed from this object' : 'Shader removed from ' + own.length + ' objects'); } @@ -1185,7 +1184,7 @@ } } selectedObject.update((s) => s); - objectsGroup.update((v) => v); + pokeScene(); } /** A picked image file → every selected material, decoded once. @param {File} file */ @@ -1199,7 +1198,7 @@ if (uuids.length > 1) endHistoryBatch(`Texture (${uuids.length})`); } selectedObject.update((s) => s); - objectsGroup.update((v) => v); + pokeScene(); } /** CL-A A4: which material preset matches the current values (else 'custom') @param {any} p */ @@ -1221,7 +1220,7 @@ } function sendName() { - objectsGroup.update((value) => value); // refresh the object list + pokeScene(); // refresh the object list $peers.send({ type: 'name', name: $selectedObject.name, uuid: $selectedObject.uuid }); } @@ -2578,7 +2577,7 @@ $selectedObject.uuid, selected?.name === 'Level Up' ? 'up' : val ); - objectsGroup.update((v) => v); + pokeScene(); rerenderSelectGroup = !rerenderSelectGroup; }} /> @@ -3353,7 +3352,7 @@ object.material.needsUpdate = true; $peers.send({ type: 'color', uuid: object.uuid, color: c.hex }); } - objectsGroup.update((v) => v); + pokeScene(); }} /> {/if} diff --git a/src/components/menu/Objects.svelte b/src/components/menu/Objects.svelte index d30407a3..a80328f7 100644 --- a/src/components/menu/Objects.svelte +++ b/src/components/menu/Objects.svelte @@ -1,7 +1,11 @@ + +{#if $statsOpen} +
+
+
+ +
+

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

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

Frame

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

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

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

Nothing sent or received yet.

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

Rendering paused

+

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

+ {#if $reducedObjects} +

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

+ {/if} +
+ + + +
+
+
+{/if} + + diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index ed57123f..0426788e 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -20,6 +20,7 @@ import { peers, loading, loadingcount, pendingApprovals, waitingForApproval, userdata, toastStore, fixLight, showSidebar, specatorMode, restorePanels, appNotice, connectDrawerOpen, connectDrawerTab, toastsInDrawerOnly, showInfoToast, dismissToastById } from '../../stores/appStore' import { restoreAvailable, restoreSnapshot, dismissRestore } from '$lib/autosave' import { ingestGate, resolveIngestGate } from '$lib/commandsHandler.svelte' + import { ingestVerdict, profileFor } from '$lib/sceneBudget' import { cancelOutboundRequest } from '$lib/peerApproval' // 27-B: the ONE sticky card for an uncaught error. This file already mirrors // state stores into sticky toasts (restoreAvailable below); diagnostics.js stays a @@ -261,6 +262,14 @@ $effect(() => { else dismissToastById('diagnostics-error'); }); +/** 26-G: the restore prompt's budget line reads the same verdict the ingest gate does. */ +function restoreLimit() { + return ingestVerdict(0, 1, profileFor(null)).limit; +} +function restoreOverBudget(objects: number) { + return ingestVerdict(0, Number(objects) || 0, profileFor(null)).gate; +} + // 26-C (roadmap 26 Stage 2): A SCENE BIGGER THAN THIS DEVICE'S BUDGET IS ARRIVING. // The objects are PARKED in the ingest queue, not applied, so this card is the only // thing between them and the scene — hence `noClose`: dismissing it with an X would @@ -289,6 +298,10 @@ $effect(() => { showInfoToast( 'restore-session', `Restore previous session? ${snap.objects} objects, saved ${new Date(snap.ts).toLocaleTimeString()}` + + // 26-G (roadmap 26 Stage 4, last bullet): say how the snapshot compares with + // this device's budget BEFORE restoring it. A phone that died restoring a + // 50MB scene comes back to this exact prompt, and the count is the reason. + (restoreOverBudget(snap.objects) ? ` — above the ${restoreLimit()} recommended for this device.` : '') + // 27-D: `risky` means the last attempt to restore THIS snapshot never // reached a clean flow tick. Auto-restore is already skipped for it; say // why, so pressing Restore again is a choice rather than a surprise. diff --git a/src/lib/overloadGuard.js b/src/lib/overloadGuard.js new file mode 100644 index 00000000..98d7dc50 --- /dev/null +++ b/src/lib/overloadGuard.js @@ -0,0 +1,218 @@ +import { writable, get } from 'svelte/store'; +import { objectsGroup, globalRenderer, pokeScene } from '../stores/sceneStore'; +import { BUDGETS, profileFor, registerFrameObserver } from './sceneBudget'; + +// 26-G (roadmap 26 section 4, Stages 3 and 4) — WHEN THE SCENE IS TOO HEAVY TO RUN. +// +// Stages 0-2 stop the app freezing on the way IN. This is what happens once a heavy +// scene is already here and the device cannot keep up: a simulation that takes longer +// to step than a frame lasts, or a render loop so slow the window stops answering. +// +// THE PRINCIPLE, the roadmap's: the main thread must never run an unbounded loop in +// response to input it did not schedule. Every stop here is ONCE per streak, REVERSIBLE, +// and SAYS SO — an automatic action the user cannot see and cannot undo is just a +// different kind of broken. +// +// The context-loss half of Stage 4 already shipped in 27-G (`ContextLostOverlay`, the +// canvas listeners, the recompile sweep). This does not rebuild it: a lost context is +// shown by that overlay, and the paused overlay stands down whenever it is up. +// +// A LEAF over svelte/store, the scene store and sceneBudget. physics.js imports the +// streak watch from here, which is why nothing here may reach the history family. + +/** + * A "N bad samples IN A ROW" detector that fires ONCE per streak. PURE given its inputs, + * so the rule is provable with no GPU and no physics world. + * + * Consecutive, not cumulative: one 300ms hitch while a texture uploads is not a scene + * that is too heavy, and a trigger that fired on it would stop somebody's simulation + * because they imported a picture. + * @param {{overMs: number, count: number}} opts + */ +export function createStreakWatch({ overMs, count }) { + let streak = 0; + let fired = false; + return { + /** @param {number} ms @returns {boolean} true exactly once, on the sample that completes a streak */ + note(ms) { + if (!(ms > overMs)) { + streak = 0; + fired = false; + return false; + } + streak++; + if (streak >= count && !fired) { + fired = true; + return true; + } + return false; + }, + reset() { + streak = 0; + fired = false; + }, + streak: () => streak + }; +} + +// --- Stage 3: the physics budget ------------------------------------------------- + +/** Step time past which a simulation is not keeping up: a 24ms step on a 16.7ms frame + * means every frame is late before rendering even starts. */ +export const PHYSICS_SLOW_MS = 24; +/** …for this many steps in a row (half a second at 60Hz). */ +export const PHYSICS_SLOW_STEPS = 30; + +// --- Stage 4: the render freeze --------------------------------------------------- + +/** A frame that takes this long is the window visibly not answering. */ +export const FREEZE_FRAME_MS = 250; +/** …for this many frames in a row, i.e. at least 2.5 seconds of a frozen tab. */ +export const FREEZE_FRAMES = 10; + +/** The pause, or null. `reason` says which trigger fired. LOCAL — a pause is about THIS + * device's GPU, so it never replicates. */ +/** @type {import('svelte/store').Writable<{reason: string, at: number} | null>} */ +export const renderPaused = writable(null); + +const freezeWatch = createStreakWatch({ overMs: FREEZE_FRAME_MS, count: FREEZE_FRAMES }); +/** After a resume the next few frames are expected to be slow (the first composer frame + * recompiles) — a grace window stops Resume from immediately re-pausing. */ +const RESUME_GRACE_MS = 3000; +let graceUntil = 0; +let wasHidden = false; + +/** + * Fed every frame by sceneBudget's loop. Three things are NOT a frozen scene and must + * never trip it: a backgrounded tab (the browser throttles rAF to ~1Hz on purpose), the + * first frame after the tab comes back (its delta spans the whole absence), and the + * seconds straight after a resume. + * @param {number} ms + */ +export function noteFrameForFreeze(ms) { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + wasHidden = true; + freezeWatch.reset(); + return false; + } + if (wasHidden) { + wasHidden = false; + freezeWatch.reset(); + return false; + } + if (get(renderPaused) || Date.now() < graceUntil) return false; + if (freezeWatch.note(ms)) { + pauseRendering('frozen'); + return true; + } + return false; +} + +/** @param {string} reason */ +export function pauseRendering(reason) { + if (get(renderPaused)) return; + renderPaused.set({ reason, at: Date.now() }); +} + +export function resumeRendering() { + renderPaused.set(null); + freezeWatch.reset(); + graceUntil = Date.now() + RESUME_GRACE_MS; +} + +// --- Reduce: take the scene down to the budget, LOCALLY ---------------------------- +// +// "Hiding the newest objects" — but NOT with `visible = false`. Autosave exports the +// scene through GLTFExporter with no options, and `onlyVisible` DEFAULTS TO TRUE, so a +// hidden object is silently DROPPED from the recovery snapshot. Reducing a scene would +// then quietly delete its newest objects from the one copy meant to survive a crash — +// and the overlay promises autosave keeps running. +// +// A render LAYER is invisible to every serializer (GLTFExporter never reads layers, +// toJSON writes the mask but nothing reads it back as visibility), never replicates, and +// is honoured by the camera's cull and the raycaster alike. So a reduced object is still +// in the scene, in the save, on the wire and in the undo stack — it is simply not drawn +// or picked HERE. The original masks live in a WeakMap, never on userData, so they cannot +// leak into a file. + +/** The layer reduced objects are moved to. 31 is the last of three's 32 layers and + * nothing in this app enables it on a camera. */ +export const REDUCED_LAYER = 31; + +/** @type {Map>} per reduced ROOT uuid, each node's mask */ +const reduced = new Map(); +export const reducedObjects = writable(0); + +/** @param {any} node */ +function countNodes(node) { + let n = 0; + node.traverse((/** @type {any} */ o) => { + n++; + }); + return n; +} + +/** + * Stop drawing the newest top-level objects until what is still drawn is inside the + * object budget for this device. Returns how many were set aside. + * @param {'desktop'|'vr'} [profile] + */ +export function reduceScene(profile) { + const group = get(objectsGroup); + if (!group) return 0; + const which = profile ?? profileFor(get(globalRenderer)); + const budget = BUDGETS.find((b) => b.key === 'objects'); + const limit = budget ? (which === 'vr' ? budget.vr[1] : budget.desktop[1]) : Infinity; + let drawn = 0; + for (const child of group.children) if (!reduced.has(child.uuid)) drawn += countNodes(child); + let setAside = 0; + // NEWEST FIRST: children are in append order, so the end of the list is what arrived + // last — most likely whatever tipped the scene over + for (let i = group.children.length - 1; i >= 0 && drawn > limit; i--) { + const root = group.children[i]; + if (reduced.has(root.uuid)) continue; + /** @type {WeakMap} */ + const masks = new WeakMap(); + root.traverse((/** @type {any} */ node) => { + masks.set(node, node.layers.mask); + node.layers.set(REDUCED_LAYER); + }); + reduced.set(root.uuid, masks); + drawn -= countNodes(root); + setAside++; + } + reducedObjects.set(reduced.size); + if (setAside) pokeScene(); + return setAside; +} + +/** Draw everything `reduceScene` set aside again, exactly as it was. */ +export function restoreReduced() { + const group = get(objectsGroup); + let restored = 0; + for (const [uuid, masks] of reduced) { + const root = group?.getObjectByProperty?.('uuid', uuid); + if (root) { + root.traverse((/** @type {any} */ node) => { + const mask = masks.get(node); + // a node added under a reduced root since (a child attached later) had no + // saved mask — give it the default layer rather than leaving it stranded + node.layers.mask = mask ?? 1; + }); + restored++; + } + } + reduced.clear(); + reducedObjects.set(0); + if (restored) pokeScene(); + return restored; +} + +/** Is this object set aside? For the suite and any list that wants to say so. @param {string} uuid */ +export function isReduced(uuid) { + return reduced.has(uuid); +} + +// The frame observer. Registered here, not imported by sceneBudget, so the budget +// module stays a leaf that knows nothing about pausing. +registerFrameObserver(noteFrameForFreeze); diff --git a/src/lib/physics.js b/src/lib/physics.js index 5df993db..cc27ca41 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -1,4 +1,6 @@ import * as THREE from 'three'; +// 26-G: the streak watch is a pure leaf (stores + sceneBudget) — no edge into history. +import { createStreakWatch, PHYSICS_SLOW_MS, PHYSICS_SLOW_STEPS } from './overloadGuard'; import { writable, get } from 'svelte/store'; import { flowGraphs, allNodes, allEdges, SCENE_GRAPH } from '../stores/flowStore'; import { objectsGroup, lockedObjects, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore'; @@ -1251,7 +1253,15 @@ const MAX_SUBSTEPS = 8; /** @param {number} now */ function step(now) { try { + const started = performance.now(); stepInner(now); + // 26-G (roadmap 26 Stage 3): A SIMULATION THAT CANNOT KEEP UP. 27-C catches a step + // that THROWS; nothing caught one that simply takes longer than the frame it runs + // in, which turns every frame late before rendering starts and reads as the app + // freezing. Streak-based and ONCE per streak (a single slow step while a big body + // is built is not a scene too heavy to simulate), and the toast carries Resume so + // the stop is never a dead end. + if (slowStepWatch.note(performance.now() - started)) stopForSlowSteps(); } catch (error) { console.warn('physics step failed, stopping the simulation', error); // stopSimulation clears the post-tick hook itself, so this cannot re-enter. @@ -1265,6 +1275,31 @@ function step(now) { } } +const slowStepWatch = createStreakWatch({ overMs: PHYSICS_SLOW_MS, count: PHYSICS_SLOW_STEPS }); + +/** ONE stop path for the slow-step streak, shared by the real step and the test hook so + * the two cannot drift apart. */ +function stopForSlowSteps() { + slowStepWatch.reset(); + stopSimulation({ reason: 'too slow' }); + showToast('Physics stopped — the simulation was too slow for this device (over ' + PHYSICS_SLOW_MS + 'ms a step). The scene is intact.', [ + { label: 'Resume', action: () => { void toggleSimulation(); } } + ]); +} + +/** TEST-ONLY: feed `n` step durations of `ms` through the SAME watch the real step uses, + * so the slow-step stop is provable without building a scene slow enough on the CI box. */ +export function noteSlowStepsForTest(/** @type {number} */ n, /** @type {number} */ ms) { + let fired = false; + for (let i = 0; i < n; i++) { + if (slowStepWatch.note(ms)) { + fired = true; + stopForSlowSteps(); + } + } + return fired; +} + /** TEST-ONLY: force the next step to throw, so the guard around it is provable. */ let throwOnNextStep = false; export function throwOnNextStepForTest() { diff --git a/src/lib/sceneBudget.js b/src/lib/sceneBudget.js index faae7885..cc446420 100644 --- a/src/lib/sceneBudget.js +++ b/src/lib/sceneBudget.js @@ -421,9 +421,33 @@ function sample() { sceneMetrics.set(metrics); } +/** @type {Set<(ms: number) => void>} */ +const frameObservers = new Set(); + +/** + * Hear every frame's duration. 26-G's freeze detector is the reader; it registers rather + * than being imported so this module keeps knowing nothing about pausing. An observer + * that throws is isolated — one bad observer must not end the sampler for everyone. + * @param {(ms: number) => void} fn @returns {() => void} unregister + */ +export function registerFrameObserver(fn) { + frameObservers.add(fn); + return () => frameObservers.delete(fn); +} + function loop() { const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); - if (lastFrameAt) noteFrame(now - lastFrameAt); + if (lastFrameAt) { + const ms = now - lastFrameAt; + noteFrame(ms); + for (const fn of frameObservers) { + try { + fn(ms); + } catch { + /* isolated — see registerFrameObserver */ + } + } + } lastFrameAt = now; if (now - lastSampleAt >= SAMPLE_MS) { lastSampleAt = now; diff --git a/tests/e2e/overload-guard.test.cjs b/tests/e2e/overload-guard.test.cjs new file mode 100644 index 00000000..e4c2d50d --- /dev/null +++ b/tests/e2e/overload-guard.test.cjs @@ -0,0 +1,266 @@ +// 26-G — Stages 3 and 4: the runtime auto-stops and the paused overlay +// (roadmap 26 section 4). +// +// Stages 0-2 stop the app freezing on the way IN. This is what happens once a heavy +// scene is already here and the device cannot keep up. +// +// What is asserted, in the order it matters: +// 1. the streak rule — consecutive, once per streak — because a single hitch must never +// stop anybody's simulation; +// 2. a simulation too slow to keep up is stopped ONCE, says so, and offers Resume; +// 3. a frozen render loop pauses drawing, but a backgrounded tab never does; +// 4. Reduce sets the newest objects aside ON THIS DEVICE ONLY, and — the hazard this +// design exists for — they STAY in the autosave export; +// 5. the restore prompt says how the snapshot compares with this device's budget. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- 1. the streak rule -------------------------------------------------- + const streak = await A.page.evaluate(() => { + const { createStreakWatch } = window.__stores.overloadGuard; + const w = createStreakWatch({ overMs: 24, count: 5 }); + const fires = []; + // four slow, one fast: the streak BREAKS and nothing fires + for (const ms of [30, 30, 30, 30, 10]) fires.push(w.note(ms)); + const brokenStreak = fires.every((f) => !f); + // five slow in a row fires exactly once, on the fifth + const run = [30, 30, 30, 30, 30, 30, 30].map((ms) => w.note(ms)); + const firedOnFifth = run[4] === true && run.filter(Boolean).length === 1; + // a sample exactly ON the threshold is not over it + const w2 = createStreakWatch({ overMs: 24, count: 2 }); + const onThreshold = [24, 24, 24].map((ms) => w2.note(ms)).some(Boolean); + // after a fast sample the watch re-arms and can fire again + w.note(10); + const again = [30, 30, 30, 30, 30].map((ms) => w.note(ms)).filter(Boolean).length === 1; + return { brokenStreak, firedOnFifth, onThreshold, again }; + }); + h.check(streak.brokenStreak, 'a streak broken by one fast sample fires nothing — a single hitch is not a heavy scene'); + h.check(streak.firedOnFifth, 'N slow samples in a row fire exactly once, and not again while the streak continues'); + h.check(!streak.onThreshold, 'a sample exactly on the threshold is not over it'); + h.check(streak.again, 'a fast sample re-arms the watch'); + + // ---- 2. physics: a simulation too slow to keep up ------------------------- + await A.page.evaluate(async () => { + const { commandsHandler } = window.__stores; + commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 400)); + }); + const started = await A.page.evaluate(async () => { + const { physics } = window.__stores; + // prewarm rapier (lazy wasm), then run + await physics.toggleSimulation(); + await new Promise((r) => setTimeout(r, 1500)); + let sim; const s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s(); + return sim; + }); + h.check(started === true, 'the simulation is running (premise)'); + await A.page.evaluate(() => window.__stores.toastStore.set([])); + const slow = await A.page.evaluate(async () => { + const { physics, overloadGuard } = window.__stores; + // one short of the streak must NOT stop it + const early = physics.noteSlowStepsForTest(overloadGuard.PHYSICS_SLOW_STEPS - 1, 40); + let sim; let s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s(); + const stillRunning = sim; + // a fast step breaks that streak, then a full one stops the run + physics.noteSlowStepsForTest(1, 5); + const fired = physics.noteSlowStepsForTest(overloadGuard.PHYSICS_SLOW_STEPS, 40); + s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s(); + return { early, stillRunning, fired, stopped: sim === false }; + }); + h.check(!slow.early && slow.stillRunning, 'one step short of the streak leaves the simulation running'); + h.check(slow.fired && slow.stopped, 'a full streak of slow steps STOPS the simulation'); + await h.eventually( + () => A.page.locator('.tp-toast', { hasText: 'too slow for this device' }).count(), + (n) => n > 0, + '…and says so, naming the reason' + ); + const resume = A.page.locator('.tp-toast', { hasText: 'too slow for this device' }).getByRole('button', { name: 'Resume' }); + h.check((await resume.count()) > 0, '…with a Resume button, so the stop is never a dead end'); + await resume.first().click(); + await h.eventually( + () => A.page.evaluate(() => { let v; const s = window.__stores.physics.simulating.subscribe((/** @type {any} */ x) => (v = x)); s(); return v; }), + (v) => v === true, + 'Resume starts the simulation again' + ); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + + // ---- 3. the render freeze ------------------------------------------------- + h.check((await A.page.locator('#render-paused').count()) === 0, 'the paused overlay starts hidden (premise)'); + const freeze = await A.page.evaluate(() => { + const g = window.__stores.overloadGuard; + g.resumeRendering(); + // resumeRendering starts a grace window; step past it for the test + const realNow = Date.now; + Date.now = () => realNow() + 10000; + const short = []; + for (let i = 0; i < g.FREEZE_FRAMES - 1; i++) short.push(g.noteFrameForFreeze(400)); + const notYet = !short.some(Boolean); + g.noteFrameForFreeze(16); // breaks it + let paused = false; + for (let i = 0; i < g.FREEZE_FRAMES; i++) paused = g.noteFrameForFreeze(400) || paused; + Date.now = realNow; + let state; const s = g.renderPaused.subscribe((/** @type {any} */ v) => (state = v)); s(); + return { notYet, paused, reason: state?.reason }; + }); + h.check(freeze.notYet, 'nine frozen frames do not pause — the rule is ten in a row'); + h.check(freeze.paused && freeze.reason === 'frozen', `ten frames over 250ms PAUSE drawing (reason: ${freeze.reason})`); + await A.page.waitForSelector('#render-paused', { timeout: 5000 }); + h.check(true, 'the "Rendering paused" overlay appears'); + const card = await A.page.locator('#render-paused').textContent(); + h.check(/Nothing is lost/.test(String(card)) && /autosave keeps running/.test(String(card)), 'it says nothing is lost and autosave carries on'); + + // the render loop really stops: renderer.info.render.frame stops advancing + const frozenFrames = await A.page.evaluate(async () => { + let renderer; const s = window.__stores.globalRenderer.subscribe((/** @type {any} */ r) => (renderer = r)); s(); + const a = renderer.info.render.frame; + await new Promise((r) => setTimeout(r, 600)); + return renderer.info.render.frame - a; + }); + h.check(frozenFrames === 0, `no frame is drawn while paused (${frozenFrames} frames in 600ms)`); + + await A.page.locator('#render-paused-resume').click(); + await A.page.waitForTimeout(700); + const liveFrames = await A.page.evaluate(async () => { + let renderer; const s = window.__stores.globalRenderer.subscribe((/** @type {any} */ r) => (renderer = r)); s(); + const a = renderer.info.render.frame; + await new Promise((r) => setTimeout(r, 600)); + return renderer.info.render.frame - a; + }); + h.check((await A.page.locator('#render-paused').count()) === 0, 'Resume closes the overlay'); + h.check(liveFrames > 5, `…and drawing starts again (${liveFrames} frames in 600ms)`); + + // a BACKGROUNDED tab throttles rAF to ~1Hz on purpose — that must never pause + const hidden = await A.page.evaluate(() => { + const g = window.__stores.overloadGuard; + g.resumeRendering(); + const realNow = Date.now; + Date.now = () => realNow() + 10000; + const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'visibilityState'); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + let paused = false; + for (let i = 0; i < 30; i++) paused = g.noteFrameForFreeze(1000) || paused; + // …and the FIRST frame back spans the whole absence + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); + const firstBack = g.noteFrameForFreeze(60000); + delete document.visibilityState; + if (desc) Object.defineProperty(Document.prototype, 'visibilityState', desc); + Date.now = realNow; + return { paused, firstBack }; + }); + h.check(!hidden.paused, 'thirty 1-second frames in a HIDDEN tab never pause — that is the browser throttling, not a heavy scene'); + h.check(!hidden.firstBack, 'the first frame after coming back is ignored — its delta is the whole absence'); + + // ---- 4. Reduce: newest set aside, LOCALLY, and still in the autosave ------ + const reduce = await A.page.evaluate(async () => { + const { THREE, objectsGroup, pokeScene, overloadGuard, autosave } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + group.clear(); + // 3,200 top-level objects: 200 past the desktop object budget (3,000). EMPTY GROUPS, + // not meshes: the budget counts tree NODES, so a Group counts exactly like a mesh, + // and it costs this memory-starved box no geometry and no GPU upload (a run with + // 3,200 real meshes died here with "Resulting promise was garbage collected"). + for (let i = 0; i < 3200; i++) { + const m = new THREE.Group(); + m.name = 'r' + i; + group.add(m); + } + pokeScene(); + await new Promise((r) => setTimeout(r, 300)); + const n = overloadGuard.reduceScene('desktop'); + const newest = group.children[group.children.length - 1]; + const oldest = group.children[0]; + // the camera draws layer 0; a reduced object is on the reduced layer + const cam = new THREE.PerspectiveCamera(); + return { + n, + newestReduced: overloadGuard.isReduced(newest.uuid) && !cam.layers.test(newest.layers), + oldestDrawn: !overloadGuard.isReduced(oldest.uuid) && cam.layers.test(oldest.layers), + stillVisibleFlag: newest.visible === true, + stillInScene: group.children.length, + hasExport: typeof autosave.exportScene === 'function' || typeof autosave.snapshotScene === 'function' + }; + }); + h.check(reduce.n === 200, `Reduce sets aside exactly the overflow, newest first (${reduce.n})`); + h.check(reduce.newestReduced, 'the NEWEST object is set aside and no longer drawn'); + h.check(reduce.oldestDrawn, 'the oldest is untouched'); + h.check(reduce.stillInScene === 3200, `nothing was removed from the scene (${reduce.stillInScene})`); + h.check( + reduce.stillVisibleFlag, + '`visible` is NOT touched — GLTFExporter drops invisible objects from autosave, and this must not' + ); + + // the hazard, proven: a GLTF export (autosave's serializer, no options) still carries + // a reduced object, where a `visible = false` hide would have dropped it + const exported = await A.page.evaluate(async () => { + const { THREE, GLTFExporterModule, objectsGroup, overloadGuard } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + const probe = new THREE.Group(); + const reducedOne = group.children[group.children.length - 1].clone(); + reducedOne.name = 'probe-reduced'; + reducedOne.layers.set(overloadGuard.REDUCED_LAYER); + const hiddenOne = group.children[0].clone(); + hiddenOne.name = 'probe-hidden'; + hiddenOne.visible = false; + probe.add(reducedOne, hiddenOne); + const json = await new Promise((resolve) => + new GLTFExporterModule.GLTFExporter().parse(probe, resolve, () => resolve(null)) + ); + const names = (json?.nodes ?? []).map((/** @type {any} */ n) => n.name); + return { reduced: names.includes('probe-reduced'), hidden: names.includes('probe-hidden') }; + }); + h.check(exported.reduced, 'a REDUCED object is still in a default GLTF export — autosave keeps it'); + h.check(!exported.hidden, '…while a `visible = false` one is dropped: the counterfactual, measured in the same export'); + + const restored = await A.page.evaluate(() => { + const { objectsGroup, overloadGuard } = window.__stores; + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + const n = overloadGuard.restoreReduced(); + const newest = group.children[group.children.length - 1]; + return { n, back: !overloadGuard.isReduced(newest.uuid) && newest.layers.mask === 1 }; + }); + h.check(restored.n === 200 && restored.back, `restoring puts every set-aside object back on its original layer (${restored.n})`); + + // the overlay's own Reduce button, end to end + await A.page.evaluate(() => { + const g = window.__stores.overloadGuard; + g.pauseRendering('frozen'); + window.__stores.toastStore.set([]); + }); + await A.page.waitForSelector('#render-paused-reduce', { timeout: 5000 }); + await A.page.locator('#render-paused-reduce').click(); + await A.page.waitForTimeout(400); + h.check((await A.page.locator('#render-paused').count()) === 0, 'Reduce resumes drawing'); + h.check( + (await A.page.locator('.tp-toast', { hasText: 'nothing was deleted' }).count()) > 0, + '…and says what it did, and that nothing was deleted' + ); + h.check( + (await A.page.locator('.tp-toast').getByRole('button', { name: 'Show them again' }).count()) > 0, + '…with a way to undo it' + ); + + // ---- 5. the restore prompt names the budget ------------------------------ + await A.page.evaluate(() => { + window.__stores.overloadGuard.restoreReduced(); + window.__stores.toastStore.set([]); + window.__stores.autosave.restoreAvailable.set({ objects: 4200, ts: Date.now() }); + }); + await h.eventually( + () => A.page.locator('.tp-toast', { hasText: 'Restore previous session?' }).textContent().catch(() => ''), + (t) => /4200 objects/.test(String(t)) && /above the 3000 recommended/.test(String(t)), + 'the restore prompt says the snapshot is above this device\'s budget' + ); + await A.page.evaluate(() => window.__stores.autosave.restoreAvailable.set({ objects: 40, ts: Date.now() })); + await h.eventually( + () => A.page.locator('.tp-toast', { hasText: 'Restore previous session?' }).textContent().catch(() => ''), + (t) => /40 objects/.test(String(t)) && !/recommended/.test(String(t)), + '…and says nothing about the budget for a small one' + ); + await A.page.evaluate(() => window.__stores.autosave.restoreAvailable.set(null)); + + h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`); + await h.finish(browser); +}); From ec9da2e904ff3c1b86b19e4b94cb460864d86eea Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 11:55:02 +0300 Subject: [PATCH 21/27] [fix] 26-G: a slow machine drawing a light scene is not an overloaded one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freeze trigger in d322e7a paused rendering on ANY ten consecutive frames over 250ms. A software-rendered page lives at ~2.5fps — 400ms frames, permanently — so the "Rendering paused" overlay appeared during ORDINARY non-GPU e2e suites and covered their clicks: `#render-paused intercepts pointer events` 23 times in one battery, turning physics-colliders red on a click timeout and adding one to physics-discoverability. The new suite could not see it, because it ran on a GPU page and drove the trigger directly — found by reading the held-suite logs. The same fault reaches a real user: a weak GPU drawing a scene of twelve boxes. Pausing that helps nothing. There is nothing heavy to set aside, Reduce would reduce nothing, and the person loses a window that was slow but still ANSWERING. WHAT - `sceneIsHeavy()`: objects, triangles or draw calls at amber or worse for this profile, read from sceneBudget's `sceneMetrics`. The streak only counts while it is true, so a light scene cannot build one at all. The scene-size axes only, never frame time — that would make the rule circular. An `unknown` reading (nothing sampled yet) is not heavy, so a freshly booted page can never be paused before the first sample. - This is also simply what the overlay already SAYS: "the scene is too heavy for this device". The trigger now agrees with its own copy. SUITE `overload-guard` 34 -> 36 checks - New guard: "forty 400ms frames on a LIGHT scene never pause" (+ its premise). - The freeze and hidden-tab checks now set a HEAVY reading first. The hidden-tab one would otherwise pass VACUOUSLY, since a light scene never pauses whatever the tab does. - The suite leaves a light scene behind after Reduce: 3,200 objects is heavy by definition, and on a saturated box the real frame loop is entitled to pause over it. COUNTERFACTUAL - `sceneIsHeavy` forced to always answer true -> "forty 400ms frames on a LIGHT scene never pause" and its premise go red. Restored; 36/36 green. REGRESSION CHECK (same battery that found it) - `render-paused intercepts pointer events`: 23 -> 0. - physics-colliders: red -> ALL PASS. - physics-discoverability: now runs to completion with no click timeout; its remaining FAILs are the standing pre-existing reds CLAUDE.md's 21-B entry names as A/B'd against base. Neither 26-G trigger fired anywhere in that run (0 "too slow" toasts, 0 pauses), so they cannot come from this lane. GATES: svelte-check 352/47 (the floor); `npm run build` green with the server stopped. Co-Authored-By: Claude Opus 5 --- src/lib/overloadGuard.js | 28 ++++++++++++++++++++++- tests/e2e/overload-guard.test.cjs | 37 ++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/lib/overloadGuard.js b/src/lib/overloadGuard.js index 98d7dc50..e226a3cf 100644 --- a/src/lib/overloadGuard.js +++ b/src/lib/overloadGuard.js @@ -1,6 +1,6 @@ import { writable, get } from 'svelte/store'; import { objectsGroup, globalRenderer, pokeScene } from '../stores/sceneStore'; -import { BUDGETS, profileFor, registerFrameObserver } from './sceneBudget'; +import { BUDGETS, profileFor, registerFrameObserver, sceneMetrics, tierOf } from './sceneBudget'; // 26-G (roadmap 26 section 4, Stages 3 and 4) — WHEN THE SCENE IS TOO HEAVY TO RUN. // @@ -101,6 +101,17 @@ export function noteFrameForFreeze(ms) { return false; } if (get(renderPaused) || Date.now() < graceUntil) return false; + // A SLOW MACHINE IS NOT AN OVERLOADED SCENE. A software-rendered page lives at + // ~2.5fps — 400ms frames, forever — and a real user on a weak GPU can too, drawing a + // scene of twelve boxes. Pausing that helps nothing: there is nothing heavy to set + // aside, Reduce would reduce nothing, and the person loses a window that was slow but + // ANSWERING. The first version of this trigger did exactly that and covered every + // non-GPU e2e suite with the overlay. So the streak only counts while the SCENE is + // heavy by its own measure; a light scene cannot build one at all. + if (!sceneIsHeavy()) { + freezeWatch.reset(); + return false; + } if (freezeWatch.note(ms)) { pauseRendering('frozen'); return true; @@ -108,6 +119,21 @@ export function noteFrameForFreeze(ms) { return false; } +/** The scene-size axes only — never frame time itself, which would make the rule + * circular. `unknown` (nothing sampled yet) is NOT heavy, so a freshly booted page can + * never be paused before the first reading. */ +const HEAVY_AXES = ['objects', 'triangles', 'calls']; + +/** Is the scene big enough that pausing it and setting part of it aside could help? */ +export function sceneIsHeavy() { + const metrics = get(sceneMetrics); + const profile = metrics?.profile === 'vr' ? 'vr' : 'desktop'; + return HEAVY_AXES.some((key) => { + const tier = tierOf(key, metrics?.[key], profile); + return tier === 'amber' || tier === 'red'; + }); +} + /** @param {string} reason */ export function pauseRendering(reason) { if (get(renderPaused)) return; diff --git a/tests/e2e/overload-guard.test.cjs b/tests/e2e/overload-guard.test.cjs index e4c2d50d..dd24d41f 100644 --- a/tests/e2e/overload-guard.test.cjs +++ b/tests/e2e/overload-guard.test.cjs @@ -89,8 +89,32 @@ h.run(async () => { // ---- 3. the render freeze ------------------------------------------------- h.check((await A.page.locator('#render-paused').count()) === 0, 'the paused overlay starts hidden (premise)'); + + // THE REGRESSION THIS RULE WAS REWRITTEN FOR: a SLOW MACHINE drawing a LIGHT scene. A + // software-rendered page lives at ~2.5fps — 400ms frames, forever — and the first + // version of this trigger paused it, covering every non-GPU e2e suite with the overlay + // ("#render-paused intercepts pointer events", 23 times in one battery). Pausing a + // light scene helps nothing: there is nothing heavy to set aside. + const light = await A.page.evaluate(() => { + const g = window.__stores.overloadGuard; + const b = window.__stores.sceneBudget; + g.resumeRendering(); + const realNow = Date.now; + Date.now = () => realNow() + 10000; + b.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 12, triangles: 144, calls: 12 }); + const heavy = g.sceneIsHeavy(); + let paused = false; + for (let i = 0; i < 40; i++) paused = g.noteFrameForFreeze(400) || paused; + Date.now = realNow; + return { heavy, paused }; + }); + h.check(!light.heavy, 'twelve boxes are not a heavy scene (premise)'); + h.check(!light.paused, 'forty 400ms frames on a LIGHT scene never pause — a slow machine is not an overloaded scene'); + const freeze = await A.page.evaluate(() => { const g = window.__stores.overloadGuard; + // a HEAVY reading: past the desktop object budget, so pausing could actually help + window.__stores.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 4200, triangles: 900000, calls: 2600 }); g.resumeRendering(); // resumeRendering starts a grace window; step past it for the test const realNow = Date.now; @@ -135,6 +159,8 @@ h.run(async () => { // a BACKGROUNDED tab throttles rAF to ~1Hz on purpose — that must never pause const hidden = await A.page.evaluate(() => { const g = window.__stores.overloadGuard; + // HEAVY, or this check passes vacuously — a light scene never pauses anyway + window.__stores.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 4200, triangles: 900000, calls: 2600 }); g.resumeRendering(); const realNow = Date.now; Date.now = () => realNow() + 10000; @@ -241,10 +267,19 @@ h.run(async () => { (await A.page.locator('.tp-toast').getByRole('button', { name: 'Show them again' }).count()) > 0, '…with a way to undo it' ); + // leave a LIGHT scene behind: 3,200 objects is heavy by definition, and the real frame + // loop would be entitled to pause over it on a saturated box while section 5 runs + await A.page.evaluate(() => { + const { objectsGroup, pokeScene, overloadGuard } = window.__stores; + overloadGuard.restoreReduced(); + let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); } + group.clear(); + pokeScene(); + overloadGuard.resumeRendering(); + }); // ---- 5. the restore prompt names the budget ------------------------------ await A.page.evaluate(() => { - window.__stores.overloadGuard.restoreReduced(); window.__stores.toastStore.set([]); window.__stores.autosave.restoreAvailable.set({ objects: 4200, ts: Date.now() }); }); From 37b4ecd88f36d5c4e44b6d20088c5906bbc5b1ae Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 08:54:25 +0300 Subject: [PATCH 22/27] [feat] 26-E: a scene-stress rig, and the meter finally counts a whole frame Roadmap 26 section 6. The section-2 budget numbers were estimates; this measures them. - tests/e2e/scene-stress.cjs: the manual rig (like net-stress.cjs). Per scene size: seed/import cost + long tasks, frame p50/p95/p99 idle AND orbiting, draw calls and triangles per display frame, geometries/textures, heap, object-list render ms, one autosave export (ms, bytes), optionally physics over the scene (bodies, step p50/p95, whether 26-G's stop fired) and a second peer joining (time-to-synced). Names the GPU and refuses to treat a software rasteriser as data. - tests/e2e/sceneStressProbe.cjs: the in-page probe both the rig and the suite drive, so the regression covers the real measurement code. Everything timed is timed in the page. - FOUND AND FIXED: the meter's triangles/calls read ONE fullscreen pass. renderer.info auto-resets per render() and a desktop frame is 13 render() calls, so 1,000 boxes read "1 call, 1 triangle": those budgets could never leave green and 26-G's sceneIsHeavy was asking about objects alone. sceneBudget now wraps the renderer instance's render and divides the sum by display frames. autoReset is untouched (VRStats, diagnostics and a reset-then-render test read what they always did) and it works in XR. Stopping the sampler hands back the original function. - FOUND AND FIXED: the loading stall timer (26-B M2) measured duration, not silence. A joiner receiving 3,000 boxes was still landing ~10/s when the bar cleared at 63s and a toast said 1,085 objects "never arrived"; all arrived by 180s. Every arrival re-arms it. - Metric sources the rig needed, registered from their own modules: bodies and physicsStepMs (physics.js), autosaveExportMs/autosaveBytes (autosave.js), syncMs and syncObjects (commandsHandler: announcement -> last object on the receiver's own clock; null for a batch closed unfinished). - BUDGETS retuned from the measurement (Radeon 890M, 1280x720): desktop calls [1000,2000] -> [2000,4500] (1,943 calls = 60fps; 4,446 = steady 30fps; 5,323 = p95 50ms) and triangles [1M,3M] -> [4M,8M] (6M/frame held 60fps). Required, not optional: the corrected counter reads ~2x objects (the shadow pass), so the old tiers would have made a 520-box scene "heavy" and armed 26-G's freeze streak in non-GPU suites. VR columns unchanged (owed on a headset). Full tables are in the lane handover. - Measured, not fixed here (handed to 26-D): ingest is FRAME-BOUND. 3,000 objects take ~180s to land while the joiner draws, 5.6s with drawing paused. Counterfactuals (each broken, suite red, restored): - countRenderCalls a no-op: 7 red (calls/triangles 0, not wrapped, doubling, stop/start) - 'bodies' source renamed: 4 red (no-sim reading, sim count, stopped run, rig row) - the sync `complete` flag forced true: 1 red (closed batch reports a fast sync) - 'autosaveExportMs' source renamed: 1 red - the stall re-arm removed: 1 red (batch given up on while still arriving) Suites: scene-stress (new) 28/28. Held, all green: scene-budget, overload-guard, ingest-gate, scene-poke, vr-stats, mesh-edit-materials, dispose, diagnostics, object-sync, net-handshake, physics-colliders, autosave-object-flows. svelte-check 352/47 (base 352/47). npm run build green. Co-Authored-By: Claude Opus 5 --- src/lib/autosave.js | 7 + src/lib/commandsHandler.svelte.js | 63 +++++- src/lib/physics.js | 30 +++ src/lib/sceneBudget.js | 104 ++++++++- tests/e2e/scene-stress.cjs | 256 ++++++++++++++++++++++ tests/e2e/scene-stress.test.cjs | 202 ++++++++++++++++++ tests/e2e/sceneStressProbe.cjs | 340 ++++++++++++++++++++++++++++++ 7 files changed, 997 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/scene-stress.cjs create mode 100644 tests/e2e/scene-stress.test.cjs create mode 100644 tests/e2e/sceneStressProbe.cjs diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 2b6f4848..ca7c37f4 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -32,6 +32,7 @@ import { log, registerDiagnosticsSection } from './diagnostics'; import { captureEditResume, applyEditResume } from './editResume'; import { disposeTree, keepSet } from './disposeTree'; import { safeStorage } from './safeStorage'; +import { registerMetricSource } from './sceneBudget'; // Crash safety: snapshots of the scene (GLTF json), the node graph and the // camera go to IndexedDB — debounced 30s after any change plus a 3-minute @@ -61,6 +62,12 @@ const MAX_DEBOUNCE_MS = 300_000; * debounceMs: number, lastSaveAt: number, writes: number, coalesced: number, * lastError: string | null}>} */ +// 26-E (roadmap 26 section 3): what the last snapshot cost, for the budget sampler and +// the stress rig. The status store already held both numbers; nothing sampled them. +// Registered, not imported by sceneBudget — that module stays a leaf. +registerMetricSource('autosaveExportMs', () => get(autosaveStatus).lastExportMs || null); +registerMetricSource('autosaveBytes', () => get(autosaveStatus).lastBytes || null); + export const autosaveStatus = writable({ lastExportMs: 0, lastBytes: 0, diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index a4d8a51c..8dba4266 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -417,7 +417,20 @@ let loadingStallTimer = null; * rejects. Nothing cleared it: the only writer was the Toasts effect, which removes a * uuid when its object APPEARS, and an object that never arrives never appears. */ const LOADING_STALL_MS = 60000; +let loadingStallMs = LOADING_STALL_MS; +/** TEST-ONLY: shorten the stall so "silence, not duration" is provable in seconds. + * @param {number} [ms] omit to restore the real value */ +export function setLoadingStallMsForTest(ms) { + loadingStallMs = Number.isFinite(ms) && /** @type {number} */ (ms) > 0 ? /** @type {number} */ (ms) : LOADING_STALL_MS; +} +// 26-E: THE STALL IS SILENCE, NOT DURATION. The timer was armed once, at the announcement, +// and never again — so any transfer that simply took longer than a minute was declared +// dead while it was still arriving. The stress rig measured exactly that: a joiner +// receiving 3,000 boxes on a real GPU was still landing ~10 objects a second at 63s when +// the bar cleared and the toast said "1085 objects never arrived"; all 3,000 arrived by +// 180s. Every uuid that lands now re-arms it (the `loading` subscription below), so the +// 60s is measured from the LAST sign of life, which is what M2 meant by a stall. function armLoadingStall() { clearTimeout(loadingStallTimer); loadingStallTimer = setTimeout(() => { @@ -426,11 +439,56 @@ function armLoadingStall() { console.log('Receiving objects: giving up on ' + left.length + ' that never arrived'); clearLoadingBatch(); showToast(left.length + ' object' + (left.length === 1 ? '' : 's') + ' never arrived.'); - }, LOADING_STALL_MS); + }, loadingStallMs); +} + +// 26-E (roadmap 26 section 3, "handshake time-to-synced"): how long the last RECEIVED +// batch took, from its `loading` announcement to the last object landing. The receive +// side is where the cost is felt, and it is the one moment both ends of the interval +// are known locally — no clock is compared across peers. LOCAL, never sent. +/** @type {number} */ +let loadingStartedAt = 0; +/** @type {number} */ +let loadingAnnounced = 0; +/** uuids still outstanding when the batch was CLOSED rather than finished (a stall, a + * departed sender, a cleared scene) — so a batch that never finished cannot report a + * sync time as though it had. */ +let loadingLeftAtClear = 0; +/** @type {{ms: number, objects: number, complete: boolean, at: number} | null} */ +let lastSync = null; +/** The last batch that ENDED (finished or closed), or null before one has run. */ +export function lastSyncStats() { + return lastSync; } +// Both ends of a batch pass through the store: the Toasts reconcile empties it as the +// last object appears, and `clearLoadingBatch` empties it on every other way out. +// Subscribing here sees both without touching either writer. +/** outstanding count at the last notification, so only PROGRESS re-arms the stall */ +let loadingLastLeft = 0; +loading.subscribe((/** @type {any} */ left) => { + const count = Array.isArray(left) ? left.length : 0; + // progress on an open batch: re-arm — but only a timer that is running, never one the + // ingest fork parked on purpose while its question is open + if (loadingStartedAt && count > 0 && count < loadingLastLeft && loadingStallTimer) armLoadingStall(); + loadingLastLeft = count; + if (!loadingStartedAt || count) return; + lastSync = { + ms: Math.round(performance.now() - loadingStartedAt), + objects: loadingAnnounced, + complete: loadingLeftAtClear === 0, + at: Date.now() + }; + loadingStartedAt = 0; + loadingLeftAtClear = 0; +}); +// only a batch that FINISHED has a sync time; a closed one says null rather than a +// number that would read as a fast join +registerMetricSource('syncMs', () => (lastSync?.complete ? lastSync.ms : null)); +registerMetricSource('syncObjects', () => (lastSync?.complete ? lastSync.objects : null)); /** Close the batch: the bar goes away, the stall timer disarms. Idempotent. */ export function clearLoadingBatch() { + if (loadingStartedAt) loadingLeftAtClear = /** @type {string[]} */ (get(loading)).length; clearTimeout(loadingStallTimer); loadingStallTimer = null; loadingSender = null; @@ -453,6 +511,9 @@ export async function createLoader(count, uuids, senderId) { loading.set(Array.isArray(uuids) ? uuids : []); loadingcount.set(count); loadingSender = senderId ?? null; + // an empty announcement opens nothing to finish, so it starts no clock + loadingStartedAt = Array.isArray(uuids) && uuids.length ? performance.now() : 0; + loadingAnnounced = Number(count) || 0; // 26-C: THE ONE MOMENT the size is known and nothing has been applied. Past it a // 4,000-object scene is simply happening to you. const verdict = ingestVerdict(liveObjectCount(), count, profileFor(get(globalRenderer))); diff --git a/src/lib/physics.js b/src/lib/physics.js index cc27ca41..9ef86c34 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -1,6 +1,7 @@ import * as THREE from 'three'; // 26-G: the streak watch is a pure leaf (stores + sceneBudget) — no edge into history. import { createStreakWatch, PHYSICS_SLOW_MS, PHYSICS_SLOW_STEPS } from './overloadGuard'; +import { registerMetricSource } from './sceneBudget'; import { writable, get } from 'svelte/store'; import { flowGraphs, allNodes, allEdges, SCENE_GRAPH } from '../stores/flowStore'; import { objectsGroup, lockedObjects, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore'; @@ -826,6 +827,7 @@ async function startSimulation() { // ColliderDesc.trimesh (fixed bodies only) and terrain from a heightfield — // both deferred; every collider today is a cuboid AABB or an opt-in hull. bodies = []; + stepTimes = []; // 26-E: a new run's cost is not the last run's beforeStates = []; suspendedForRun = []; fixedBodies = new Map(); @@ -1255,6 +1257,7 @@ function step(now) { try { const started = performance.now(); stepInner(now); + noteStepMs(performance.now() - started); // 26-G (roadmap 26 Stage 3): A SIMULATION THAT CANNOT KEEP UP. 27-C catches a step // that THROWS; nothing caught one that simply takes longer than the frame it runs // in, which turns every frame late before rendering starts and reads as the app @@ -1277,6 +1280,33 @@ function step(now) { const slowStepWatch = createStreakWatch({ overMs: PHYSICS_SLOW_MS, count: PHYSICS_SLOW_STEPS }); +// 26-E: what a simulation COSTS, for the budget sampler and the stress rig. Roadmap 26 +// section 2 budgets dynamic bodies (<200 desktop) and section 3 names the step time; +// neither was readable anywhere. Registered, never imported — sceneBudget is a leaf and +// physics sits in the history family. A step ring rather than the last value, because +// the question is the same as for frames: the step you FEEL is the slow one. +const STEP_RING = 120; +/** @type {number[]} */ +let stepTimes = []; +/** @param {number} ms */ +function noteStepMs(ms) { + stepTimes.push(ms); + if (stepTimes.length > STEP_RING) stepTimes.shift(); +} +/** p95 of the recent steps, or null when no simulation is running (a stale ring from a + * run that ended must not read as a live cost). */ +export function physicsStepStats() { + if (!world || !stepTimes.length) return null; + const sorted = [...stepTimes].sort((a, b) => a - b); + const at = (/** @type {number} */ q) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))]; + return { n: sorted.length, p50: at(0.5), p95: at(0.95), max: sorted[sorted.length - 1] }; +} +registerMetricSource('bodies', () => (world ? bodies.length : 0)); +registerMetricSource('physicsStepMs', () => { + const stats = physicsStepStats(); + return stats ? Math.round(stats.p95 * 100) / 100 : null; +}); + /** ONE stop path for the slow-step streak, shared by the real step and the test hook so * the two cannot drift apart. */ function stopForSlowSteps() { diff --git a/src/lib/sceneBudget.js b/src/lib/sceneBudget.js index cc446420..0ee6083d 100644 --- a/src/lib/sceneBudget.js +++ b/src/lib/sceneBudget.js @@ -46,11 +46,18 @@ export const BUDGETS = [ vr: [500, 1500], why: 'every object is at least one draw call, one wire message per joiner, one row in the tree and one node in every traversal' }, + // 26-E MEASURED the two render axes below (tests/e2e/scene-stress.cjs, Radeon 890M + // iGPU, 1280x720): they are counted per DISPLAY frame across every render() call now, + // which the starting numbers never were — a frame is ~13 calls with the composer, and + // the shadow pass draws each mesh again, so both read about TWICE the naive count. + // VR/mobile columns are still the starting estimates; they are owed on a headset. { key: 'triangles', label: 'Triangles / frame', unit: '', - desktop: [1000000, 3000000], + // 6.0M/frame (15 x 200k-tri models, shadow pass included) held a locked 60fps on + // an integrated GPU; the red edge above that is extrapolated, not measured + desktop: [4000000, 8000000], vr: [300000, 600000], why: 'vertex and fill cost, at 60Hz on a desktop against 72-90Hz on a headset' }, @@ -58,7 +65,10 @@ export const BUDGETS = [ key: 'calls', label: 'Draw calls / frame', unit: '', - desktop: [1000, 2000], + // measured: 1,943 calls (1,000 boxes) 60fps p95 16.7 · 2,799-3,625 p95 33 · + // 4,446 a steady 30fps · 5,323 p95 50 · 16,342 p95 133. Calls, not triangles, are + // what binds a many-object scene: it is CPU time per call + desktop: [2000, 4500], vr: [300, 500], why: 'there is no instancing or batching in core, so every call is CPU time' }, @@ -377,10 +387,89 @@ function walkScene() { return { objects, meshes, hidden }; } +// --- per-frame render totals (26-E) ----------------------------------------------- +// +// THE FINDING the stress rig made on its first run: 1,000 boxes on screen, and the meter +// read `triangles: 1, calls: 1`. `renderer.info` is AUTO-RESET at the start of every +// `renderer.render()` call, and a desktop frame is not one call — the EffectComposer +// renders the scene into a target, then N8AO, then the outline, then a fullscreen +// triangle to the canvas, each its own `render()`. Whatever reads `info` afterwards sees +// the LAST pass: one triangle, one call. So the triangle and draw-call budgets could +// never leave green, and 26-G's `sceneIsHeavy` was really asking about objects alone. +// +// The fix counts EVERY `render()` and divides by the display frames the sampler saw. +// Deliberately NOT `info.autoReset = false`: that changes what `info` means for every +// other reader (the VR stats plate, the diagnostics section, a test that resets and +// renders once), and inside a WebXR session `window.requestAnimationFrame` does not run, +// so nothing would ever reset it again and the plate would count up forever. A wrapper +// on the instance leaves `info` byte-identical for everyone and works in XR too. + +const renderAcc = { calls: 0, triangles: 0, renders: 0 }; +/** Display frames the sampler loop counted since the last sample. */ +let renderFrames = 0; + +/** + * Wrap this renderer's `render` so each call adds what it drew to the accumulator. Once + * per instance (a restored context can hand the store a NEW renderer, which gets its own). + * @param {any} renderer + */ +export function countRenderCalls(renderer) { + if (!renderer || typeof renderer.render !== 'function' || renderer.__budgetRender) return false; + const original = renderer.render; + renderer.__budgetRender = original; + renderer.render = function (/** @type {any[]} */ ...args) { + const info = this.info?.render; + // with autoReset ON (three's default) render() zeroes the counters itself, so the + // base is 0; with it OFF somebody is accumulating on purpose and we take the delta + const baseCalls = info && this.info.autoReset === false ? info.calls : 0; + const baseTris = info && this.info.autoReset === false ? info.triangles : 0; + const result = original.apply(this, args); + if (info) { + renderAcc.calls += info.calls - baseCalls; + renderAcc.triangles += info.triangles - baseTris; + renderAcc.renders++; + } + return result; + }; + return true; +} + +/** Undo `countRenderCalls` — the sampler stopping must leave the renderer as it found it. + * @param {any} renderer */ +export function uncountRenderCalls(renderer) { + if (!renderer?.__budgetRender) return; + renderer.render = renderer.__budgetRender; + delete renderer.__budgetRender; +} + +/** @type {{calls: number, triangles: number, rendersPerFrame: number} | null} */ +let lastTotals = null; + +/** Per display frame since the last call, then start a new window. A window with no + * frame in it (two forced readings back to back, a paused loop) keeps the previous + * reading rather than inventing a zero — "nothing measured" is not "nothing drawn". */ +function takeRenderTotals() { + const frames = renderFrames; + if (frames === 0) return lastTotals; + const out = { + calls: Math.round(renderAcc.calls / frames), + triangles: Math.round(renderAcc.triangles / frames), + rendersPerFrame: Math.round((renderAcc.renders / frames) * 10) / 10 + }; + lastTotals = out; + renderAcc.calls = 0; + renderAcc.triangles = 0; + renderAcc.renders = 0; + renderFrames = 0; + return out; +} + function sample() { /** @type {any} */ const renderer = get(globalRenderer); + if (running) countRenderCalls(renderer); const info = renderer?.info; + const totals = takeRenderTotals(); const profile = profileFor(renderer); const scene = walkScene(); const fps = frameStats(); @@ -403,8 +492,11 @@ function sample() { objects: scene.objects, meshes: scene.meshes, hidden: scene.hidden, - triangles: info?.render?.triangles ?? null, - calls: info?.render?.calls ?? null, + // per DISPLAY frame across every render() call; before the sampler has counted a + // frame (a forced reading straight after boot) fall back to the raw last pass + triangles: totals ? totals.triangles : (info?.render?.triangles ?? null), + calls: totals ? totals.calls : (info?.render?.calls ?? null), + rendersPerFrame: totals ? totals.rendersPerFrame : null, geometries: info?.memory?.geometries ?? null, textures: info?.memory?.textures ?? null, frameP50: fps.p50, @@ -449,6 +541,7 @@ function loop() { } } lastFrameAt = now; + renderFrames++; if (now - lastSampleAt >= SAMPLE_MS) { lastSampleAt = now; sample(); @@ -467,6 +560,8 @@ export function startSceneMetrics() { running = true; lastFrameAt = 0; lastSampleAt = 0; + renderFrames = 0; + countRenderCalls(get(globalRenderer)); startLongTasks(); rafId = requestAnimationFrame(loop); } @@ -476,6 +571,7 @@ export function stopSceneMetrics() { if (rafId != null) cancelAnimationFrame(rafId); rafId = null; stopLongTasks(); + uncountRenderCalls(get(globalRenderer)); } /** Force a reading now — the overlay opening, and the suite. */ diff --git a/tests/e2e/scene-stress.cjs b/tests/e2e/scene-stress.cjs new file mode 100644 index 00000000..d4cb0ca5 --- /dev/null +++ b/tests/e2e/scene-stress.cjs @@ -0,0 +1,256 @@ +// 26-E — THE SCENE-STRESS RIG (roadmap 26 section 6). A MEASUREMENT, run by hand. +// +// APP_URL=https://theprototype.app:5180/ node tests/e2e/scene-stress.cjs \ +// [--sizes 100,1000,3000,10000] [--dense 1,5,15] [--dense-tris 200000] \ +// [--physics 100,300,1000] [--sync 1000,3000] [--window 4000] \ +// [--view shaded-ao|shaded] [--out path.md] +// +// NOT a .test.cjs on purpose: a full sweep runs for many minutes. `npm run e2e -- +// scene-stress` runs the small REGRESSION suite instead (scene-stress.test.cjs), which +// drives the same probe (sceneStressProbe.cjs) at a tiny size. +// +// WHY IT EXISTS: roadmap 26 section 2's budget numbers were starting points reasoned from +// WebGL practice. The governor (26-D) and the auto-stops (26-G) steer by them, so they +// have to be MEASURED. Per scene size this records: +// - seed / import cost and the long tasks it caused +// - frame p50/p95/p99 idle and while ORBITING (navigation is when a heavy scene hurts) +// - draw calls and triangles per DISPLAY frame (see sceneBudget's render-totals note — +// the raw `renderer.info` reads one fullscreen pass and cannot be used) +// - GPU proxies (geometries/textures) and the JS heap +// - object-list render ms (and whether 26-B windowed it) +// - one autosave export: ms and bytes +// - optionally, physics over the same scene: body count, step p50/p95, whether 26-G's +// slow-step stop fired +// - optionally, a second peer JOINING: time-to-synced as the joiner's own +// `syncMs` reads it (announcement -> last object landed), plus the joiner's long tasks +// +// CAVEATS worth printing with every table: +// - the numbers belong to ONE GPU; the report names it (WEBGL_debug_renderer_info). A +// SwiftShader row (no GPU) measures the CPU rasteriser, not the app — the rig refuses +// to treat one as data and says so. +// - headless Chromium has no compositor pressure from other windows; a real desktop is +// worse, never better. +// - the two-peer sync uses whatever signaling the helpers use (PEER_CONFIG). It is ONE +// joiner and one handshake — not a flood. + +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); +const { measureScene, installProbe, summarize } = require('./sceneStressProbe.cjs'); + +const argv = process.argv.slice(2); +/** @param {string} name @param {string} fallback */ +function arg(name, fallback) { + const i = argv.indexOf('--' + name); + return i >= 0 && argv[i + 1] != null ? argv[i + 1] : fallback; +} +/** @param {string} value */ +const list = (value) => + value + .split(',') + .map((n) => parseInt(n, 10)) + .filter((n) => Number.isFinite(n) && n > 0); + +const SIZES = list(arg('sizes', '100,1000,3000,10000')); +const DENSE = list(arg('dense', '1,5,15')); +const DENSE_TRIS = parseInt(arg('dense-tris', '200000'), 10); +const PHYSICS = list(arg('physics', '')); +const SYNC = list(arg('sync', '')); +const WINDOW_MS = parseInt(arg('window', '4000'), 10); +const VIEW = arg('view', ''); +const OUT = arg('out', ''); +const storage = VIEW ? { viewMode: VIEW } : undefined; + +/** @param {any} x @param {number} [d] */ +const r = (x, d = 1) => (x == null || !Number.isFinite(Number(x)) ? '—' : Number(Number(x).toFixed(d))); + +/** + * A second peer joins a host already holding `size` boxes. The joiner's own `syncMs` + * metric (commandsHandler, 26-E) is the answer: announcement to last object, measured on + * one clock. + * @param {any} browser @param {number} size + */ +async function measureSync(browser, size) { + const host = await h.setupPage(browser, 'host-' + size, { storage }); + const joiner = await h.setupPage(browser, 'join-' + size, { storage }); + try { + await installProbe(host.page); + await installProbe(joiner.page); + await host.page.evaluate((n) => window.__stress.seedCubes(n), size); + const t0 = Date.now(); + await joiner.page.evaluate(() => (window.__stress.joinStarted = performance.now())); + await h.connect(joiner, host, 0); + // wait for the joiner to hold the scene AND for its batch to have closed + const deadline = Date.now() + 240000; + /** @type {any} */ + let got = null; + while (Date.now() < deadline) { + got = await joiner.page.evaluate(() => ({ + count: window.__stress.count(), + sync: window.__stores.commandsHandler.lastSyncStats(), + tasks: window.__stress.tasksSince(window.__stress.joinStarted) + })); + if (got.sync && got.count >= size) break; + await joiner.page.waitForTimeout(250); + } + return { + size, + wallMs: Date.now() - t0, + objects: got?.count ?? 0, + syncMs: got?.sync?.complete ? got.sync.ms : null, + complete: !!got?.sync?.complete, + joinerLongTasks: got?.tasks?.count ?? null, + joinerLongestTask: got?.tasks ? Math.round(got.tasks.longest) : null, + joinerBusyMs: got?.tasks ? Math.round(got.tasks.busy) : null + }; + } finally { + await host.ctx.close(); + await joiner.ctx.close(); + } +} + +/** @param {any[]} rows @param {any[]} dense @param {any[]} physics @param {any[]} sync */ +function report(rows, dense, physics, sync) { + const gpu = rows[0]?.gpu ?? dense[0]?.gpu ?? physics[0]?.gpu ?? 'unknown'; + const L = []; + L.push('# 26-E — scene stress, measured'); + L.push(''); + L.push('GPU: `' + gpu + '` · window ' + WINDOW_MS + 'ms per reading · 1280x720 · view ' + (VIEW || 'default')); + if (/swiftshader|llvmpipe|software/i.test(gpu)) + L.push('\n**WARNING: software rasteriser — these rows measure the CPU renderer, not the app. Do not fold them into the budget.**'); + L.push(''); + L.push('## Boxes (the real `/create box` path)'); + L.push(''); + L.push('| objects | seed ms | seed longest task | idle p50/p95/p99 | orbit p50/p95/p99 | orbit long tasks | calls/frame | tris/frame | renders/frame | geoms | textures | heap MB | list ms (rows, mode) | autosave ms / MB |'); + L.push('|---|---|---|---|---|---|---|---|---|---|---|---|---|---|'); + for (const w of rows) { + L.push( + '| ' + w.objects + + ' | ' + r(w.seedMs, 0) + + ' | ' + r(w.seedLongestTask, 0) + + ' | ' + r(w.idle.p50) + ' / ' + r(w.idle.p95) + ' / ' + r(w.idle.p99) + + ' | ' + r(w.orbit.p50) + ' / ' + r(w.orbit.p95) + ' / ' + r(w.orbit.p99) + + ' | ' + w.orbitLongTasks + ' (max ' + r(w.orbitLongestTask, 0) + ')' + + ' | ' + r(w.calls, 0) + + ' | ' + r(w.triangles, 0) + + ' | ' + r(w.rendersPerFrame) + + ' | ' + r(w.geometries, 0) + + ' | ' + r(w.textures, 0) + + ' | ' + r(w.heapMB, 0) + + ' | ' + r(w.listMs, 0) + ' (' + w.listRows + ', ' + w.listMode + ')' + + ' | ' + r(w.autosaveExportMs, 0) + ' / ' + r((w.autosaveBytes ?? 0) / 1048576, 2) + + (w.autosaveError ? ' ERR' : '') + + ' |' + ); + } + if (dense.length) { + L.push(''); + L.push('## Dense models (a ' + DENSE_TRIS + '-triangle GLB through the real import path)'); + L.push(''); + L.push('| models | import p50/max ms | import longest task | idle p50/p95/p99 | orbit p50/p95/p99 | tris/frame | calls/frame | heap MB | autosave ms / MB |'); + L.push('|---|---|---|---|---|---|---|---|---|'); + for (const w of dense) { + L.push( + '| ' + w.objects + + ' | ' + r(w.importMsP50, 0) + ' / ' + r(w.importMsMax, 0) + + ' | ' + r(w.importLongestTask, 0) + + ' | ' + r(w.idle.p50) + ' / ' + r(w.idle.p95) + ' / ' + r(w.idle.p99) + + ' | ' + r(w.orbit.p50) + ' / ' + r(w.orbit.p95) + ' / ' + r(w.orbit.p99) + + ' | ' + r(w.triangles, 0) + + ' | ' + r(w.calls, 0) + + ' | ' + r(w.heapMB, 0) + + ' | ' + r(w.autosaveExportMs, 0) + ' / ' + r((w.autosaveBytes ?? 0) / 1048576, 2) + + ' |' + ); + } + } + if (physics.length) { + L.push(''); + L.push('## Physics over N dynamic boxes (26-G stops a run at ' + '30 steps over 24ms)'); + L.push(''); + L.push('| boxes | bodies | step p50 / p95 ms | frame p50/p95 while simulating | auto-stopped |'); + L.push('|---|---|---|---|---|'); + for (const w of physics) { + L.push( + '| ' + w.size + + ' | ' + (w.physicsAutoStopped ? 'stopped' : r(w.bodies, 0)) + + ' | ' + r(w.stepP50) + ' / ' + r(w.stepP95) + + ' | ' + (w.physicsFrame ? r(w.physicsFrame.p50) + ' / ' + r(w.physicsFrame.p95) : '—') + + ' | ' + (w.physicsStarted ? (w.physicsAutoStopped ? 'YES' : 'no') : 'did not start') + + ' |' + ); + } + } + if (sync.length) { + L.push(''); + L.push('## A joiner receiving the scene (two peers, one handshake)'); + L.push(''); + L.push('| objects | joiner syncMs | wall ms (dial -> synced) | joiner long tasks | longest | busy ms |'); + L.push('|---|---|---|---|---|---|'); + for (const w of sync) { + L.push( + '| ' + w.objects + '/' + w.size + + ' | ' + (w.complete ? r(w.syncMs, 0) : 'INCOMPLETE') + + ' | ' + r(w.wallMs, 0) + + ' | ' + r(w.joinerLongTasks, 0) + + ' | ' + r(w.joinerLongestTask, 0) + + ' | ' + r(w.joinerBusyMs, 0) + + ' |' + ); + } + } + L.push(''); + L.push('```json'); + L.push(JSON.stringify({ rows, dense, physics, sync }, null, 1)); + L.push('```'); + return L.join('\n'); +} + +(async () => { + // precise-memory: without it performance.memory is bucketed and every size reads the same heap + const browser = await h.launch({ args: [...h.GPU_ARGS, '--enable-precise-memory-info'] }); + const rows = []; + const dense = []; + const physics = []; + const sync = []; + try { + for (const size of SIZES) { + console.log('\n==== ' + size + ' boxes ===='); + const row = await measureScene(h, browser, { kind: 'cubes', size, windowMs: WINDOW_MS, storage }); + console.log(JSON.stringify({ ...row, gpu: undefined })); + rows.push(row); + } + for (const size of DENSE) { + console.log('\n==== ' + size + ' dense models ===='); + const row = await measureScene(h, browser, { kind: 'dense', size, windowMs: WINDOW_MS, denseTris: DENSE_TRIS, storage }); + console.log(JSON.stringify({ ...row, gpu: undefined })); + dense.push(row); + } + for (const size of PHYSICS) { + console.log('\n==== physics over ' + size + ' boxes ===='); + const row = await measureScene(h, browser, { kind: 'cubes', size, windowMs: WINDOW_MS, physics: true, autosave: false, storage }); + console.log(JSON.stringify({ bodies: row.bodies, stepP50: row.stepP50, stepP95: row.stepP95, stopped: row.physicsAutoStopped, frame: row.physicsFrame })); + physics.push(row); + } + for (const size of SYNC) { + console.log('\n==== a joiner receiving ' + size + ' boxes ===='); + const row = await measureSync(browser, size); + console.log(JSON.stringify(row)); + sync.push(row); + } + const md = report(rows, dense, physics, sync); + console.log('\n' + md.split('```json')[0]); + if (OUT) { + const out = path.isAbsolute(OUT) ? OUT : path.resolve(process.cwd(), OUT); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, md); + console.log('written to ' + out); + } + } catch (err) { + console.error('STRESS RUN FAILED:', err && err.stack ? err.stack : err); + process.exitCode = 1; + } finally { + await browser.close(); + } + void summarize; +})(); diff --git a/tests/e2e/scene-stress.test.cjs b/tests/e2e/scene-stress.test.cjs new file mode 100644 index 00000000..34df9e67 --- /dev/null +++ b/tests/e2e/scene-stress.test.cjs @@ -0,0 +1,202 @@ +// 26-E — the scene-stress rig's REGRESSION suite (roadmap 26 section 6). +// +// `scene-stress.cjs` is the measurement rig, run by hand; this proves the machinery it +// stands on still measures what it says: +// 1. the percentile rule the rig reports is the meter's rule (pure, no browser) +// 2. THE FINDING: draw calls and triangles are counted per DISPLAY frame across every +// `renderer.render()` — the raw `renderer.info` reads one fullscreen pass (1 call, +// 1 triangle with 150 boxes on screen), so the triangle and draw-call budgets could +// never leave green +// 3. stopping the sampler hands the renderer back unwrapped, and starting re-wraps it +// 4. the metric sources the rig needed are registered from their own modules: physics +// bodies + step time, autosave export ms/bytes, and the receive-side sync time +// 4b. a loading stall is measured from the last ARRIVAL, not the announcement (the rig +// found a 3,000-object join declared dead at 63s while still landing) +// 5. the rig's per-size runner produces a COMPLETE row end to end at a tiny size, so +// the manual rig cannot rot unnoticed between the runs that feed the roadmap +// +// Run: APP_URL=https://theprototype.app:5180/ npm run e2e -- scene-stress +const h = require('./helpers.cjs'); +const { percentile, summarize, installProbe, measureScene } = require('./sceneStressProbe.cjs'); + +h.run(async () => { + // ---- 1. the pure part -------------------------------------------------------------- + const ring = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + h.check(percentile(ring, 0.5) === 50 && percentile(ring, 0.95) === 100, `nearest-rank percentiles (p50 ${percentile(ring, 0.5)}, p95 ${percentile(ring, 0.95)})`); + const s = summarize([5, 1, NaN, 3]); + h.check(s.n === 3 && s.p50 === 3 && s.max === 5, `summarize sorts, drops non-numbers and reports max (${JSON.stringify(s)})`); + h.check(summarize([]).p95 === null, 'an empty window reports null, never a zero that reads as a fast frame'); + + // GPU args: frame-time and per-frame render totals over real frames (the e2e skill's rule) + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const gpu = await installProbe(A.page); + console.log('renderer: ' + gpu); + + // the meter's own rule, on the same numbers, in the page + const meterRule = await A.page.evaluate((values) => { + const b = window.__stores.sceneBudget; + for (let i = 0; i < 300; i++) b.noteFrame(1000); // push the ring out of the way + for (const v of values) for (let i = 0; i < 24; i++) b.noteFrame(v); + return b.frameStats(); + }, ring); + h.check( + meterRule.p50 === percentile([...ring.flatMap((v) => Array(24).fill(v))], 0.5) && + meterRule.p95 === percentile([...ring.flatMap((v) => Array(24).fill(v))], 0.95), + `the rig's percentile is the meter's percentile (meter p50 ${meterRule.p50} p95 ${meterRule.p95})` + ); + + // ---- 2. per-frame render totals ---------------------------------------------------- + const seeded = await A.page.evaluate(() => window.__stress.seedCubes(150)); + h.check(seeded.count === 150, `premise: 150 real boxes in the scene (${seeded.count})`); + await A.page.evaluate(() => window.__stress.frameAll(150)); + const totals = await A.page.evaluate(async () => { + const { sceneBudget, globalRenderer } = window.__stores; + let r; + globalRenderer.subscribe((/** @type {any} */ v) => (r = v))(); + await new Promise((res) => setTimeout(res, 1200)); + const m = sceneBudget.sampleSceneMetrics(); + // what one reader of renderer.info sees at an arbitrary moment: the last pass + const lastPass = { calls: r.info.render.calls, triangles: r.info.render.triangles }; + return { calls: m.calls, triangles: m.triangles, rendersPerFrame: m.rendersPerFrame, lastPass, wrapped: !!r.__budgetRender }; + }); + h.check(totals.wrapped, 'the sampler wraps the live renderer'); + h.check( + totals.rendersPerFrame > 1, + `premise: a desktop frame is SEVERAL render() calls, not one (${totals.rendersPerFrame} per frame)` + ); + h.check( + totals.lastPass.calls < 150, + `premise: raw renderer.info reads only the last pass (${totals.lastPass.calls} calls, ${totals.lastPass.triangles} triangles)` + ); + h.check(totals.calls >= 150, `draw calls per frame count every box (${totals.calls} for 150 boxes)`); + h.check(totals.triangles >= 150 * 12, `triangles per frame count every box (${totals.triangles} >= ${150 * 12})`); + + // more objects must move the reading — the axis is live, not a constant + await A.page.evaluate(() => window.__stress.seedCubes(150)); + await A.page.evaluate(() => window.__stress.frameAll(300)); + const doubled = await A.page.evaluate(async () => { + await new Promise((res) => setTimeout(res, 1200)); + return window.__stores.sceneBudget.sampleSceneMetrics(); + }); + h.check( + doubled.calls > totals.calls * 1.5 && doubled.triangles > totals.triangles * 1.5, + `doubling the boxes roughly doubles the reading (calls ${totals.calls} -> ${doubled.calls}, tris ${totals.triangles} -> ${doubled.triangles})` + ); + + // ---- 3. stop hands the renderer back, start re-wraps ------------------------------ + const cycle = await A.page.evaluate(async () => { + const { sceneBudget, globalRenderer } = window.__stores; + let r; + globalRenderer.subscribe((/** @type {any} */ v) => (r = v))(); + // three defines `render` as an OWN property in its constructor, so "restored" means the + // very same function object is back, not that the property is gone + const original = r.__budgetRender; + sceneBudget.stopSceneMetrics(); + const stopped = { wrapped: !!r.__budgetRender, same: !!original && r.render === original }; + sceneBudget.startSceneMetrics(); + await new Promise((res) => setTimeout(res, 800)); + return { stopped, restarted: !!r.__budgetRender }; + }); + h.check(!cycle.stopped.wrapped && cycle.stopped.same, `stopping the sampler restores the renderer's own render (${JSON.stringify(cycle.stopped)})`); + h.check(cycle.restarted, 'starting it again re-wraps the renderer'); + + // ---- 4. the metric sources --------------------------------------------------------- + const save = await A.page.evaluate(async () => { + await window.__stores.autosave.saveNow(); + const m = window.__stores.sceneBudget.sampleSceneMetrics(); + return { exportMs: m.autosaveExportMs, bytes: m.autosaveBytes }; + }); + h.check(save.exportMs > 0 && save.bytes > 1000, `autosave export ms and bytes reach the sampler (${save.exportMs}ms, ${save.bytes} bytes)`); + + const phys = await A.page.evaluate(async () => { + const { physics, sceneBudget } = window.__stores; + const before = sceneBudget.sampleSceneMetrics(); + const run = await window.__stress.physics(1500); + const after = sceneBudget.sampleSceneMetrics(); + return { beforeBodies: before.bodies, beforeStep: before.physicsStepMs, run, afterBodies: after.bodies, afterStep: after.physicsStepMs, stats: physics.physicsStepStats() }; + }); + h.check(phys.beforeBodies === 0 && phys.beforeStep === null, `no simulation: 0 bodies and no step time (${phys.beforeBodies}, ${phys.beforeStep})`); + h.check(phys.run.startedOk, 'premise: the simulation started'); + h.check(phys.run.bodies === 300, `while simulating the sampler counts the bodies (${phys.run.bodies} for 300 dynamic boxes)`); + h.check(phys.run.step && phys.run.step.n > 20 && phys.run.step.p95 > 0, `…and the step time (${JSON.stringify(phys.run.step)})`); + h.check( + phys.afterBodies === 0 && phys.afterStep === null && phys.stats === null, + `a stopped run reads as no run, never a stale cost (${phys.afterBodies}, ${phys.afterStep})` + ); + + const sync = await A.page.evaluate(async () => { + const { commandsHandler, sceneBudget } = window.__stores; + // a batch that FINISHES: every announced uuid counted as arrived + await commandsHandler.createLoader(2, ['stress-a', 'stress-b'], 'nobody'); + await new Promise((res) => setTimeout(res, 300)); + commandsHandler.noteLoadFailed(['stress-a', 'stress-b']); + const finished = { stats: commandsHandler.lastSyncStats(), metric: sceneBudget.sampleSceneMetrics().syncMs }; + // a batch that is CLOSED before it finishes (the sender left, a scene clear) + await commandsHandler.createLoader(2, ['stress-c', 'stress-d'], 'nobody'); + await new Promise((res) => setTimeout(res, 100)); + commandsHandler.clearLoadingBatch(); + const closed = { stats: commandsHandler.lastSyncStats(), metric: sceneBudget.sampleSceneMetrics().syncMs }; + return { finished, closed }; + }); + h.check( + sync.finished.stats?.complete === true && sync.finished.metric >= 280 && sync.finished.stats.objects === 2, + `a finished batch reports its sync time, on one clock (${JSON.stringify(sync.finished)})` + ); + h.check( + sync.closed.stats?.complete === false && sync.closed.metric === null, + `a batch closed before it finished reports NO sync time, not a fast one (${JSON.stringify(sync.closed)})` + ); + // ---- 4b. a stall is SILENCE, not duration ------------------------------------------ + // The rig's finding: the 60s stall timer was armed once at the announcement, so a + // 3,000-object join still landing ~10 objects a second was declared dead at 63s. Here + // the stall is 800ms and the batch keeps making progress past it. + const stall = await A.page.evaluate(async () => { + const { commandsHandler, loading } = window.__stores; + const read = () => { + let v; + loading.subscribe((/** @type {any} */ x) => (v = x))(); + return v.length; + }; + const sleep = (/** @type {number} */ ms) => new Promise((r) => setTimeout(r, ms)); + commandsHandler.setLoadingStallMsForTest(800); + try { + await commandsHandler.createLoader(4, ['st-1', 'st-2', 'st-3', 'st-4'], 'nobody'); + const trace = []; + // one arrival every 500ms: total 1.5s, never 800ms of silence + for (const uuid of ['st-1', 'st-2', 'st-3']) { + await sleep(500); + commandsHandler.noteLoadFailed([uuid]); + trace.push(read()); + } + const aliveAfter1500 = read(); + // …then silence: the stall must still fire + await sleep(1300); + return { trace, aliveAfter1500, afterSilence: read(), sync: commandsHandler.lastSyncStats() }; + } finally { + commandsHandler.setLoadingStallMsForTest(); + } + }); + h.check( + stall.aliveAfter1500 === 1, + `a batch still arriving past the stall window is NOT given up on (${JSON.stringify(stall.trace)} left after 1.5s)` + ); + h.check( + stall.afterSilence === 0 && stall.sync?.complete === false, + `…and real silence still ends it, reported as incomplete (${stall.afterSilence} left, ${JSON.stringify(stall.sync)})` + ); + + h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`); + await A.ctx.close(); + + // ---- 5. the rig's runner, end to end, at a tiny size ------------------------------- + const row = await measureScene(h, browser, { kind: 'cubes', size: 40, windowMs: 1000, physics: true }); + const numeric = ['seedMs', 'objects', 'triangles', 'calls', 'rendersPerFrame', 'geometries', 'textures', 'listMs', 'autosaveExportMs', 'autosaveBytes', 'bodies', 'stepP95']; + const missing = numeric.filter((key) => !Number.isFinite(row[key])); + h.check(missing.length === 0, `the rig produces a complete row (missing: ${JSON.stringify(missing)})`); + h.check(row.idle.n > 20 && row.orbit.n > 20 && row.idle.p95 > 0, `…with real frame windows (idle ${row.idle.n} frames, orbit ${row.orbit.n})`); + h.check(row.objects === 40 && row.listRows === 40 && row.bodies === 40, `…measuring the scene it built (${row.objects} objects, ${row.listRows} rows, ${row.bodies} bodies)`); + h.check(row.pageErrors === 0, `…with no page errors (${row.pageErrors})`); + + await h.finish(browser); +}); diff --git a/tests/e2e/sceneStressProbe.cjs b/tests/e2e/sceneStressProbe.cjs new file mode 100644 index 00000000..569da473 --- /dev/null +++ b/tests/e2e/sceneStressProbe.cjs @@ -0,0 +1,340 @@ +// 26-E — THE SCENE-STRESS PROBE, shared by the manual rig and its regression suite. +// +// `scene-stress.cjs` is the measurement rig (a many-minute sweep, run by hand, like +// `net-stress.cjs`). `scene-stress.test.cjs` is the quick regression that proves the rig +// still measures what it says it measures. Both drive THIS file, so the suite covers the +// real measurement code rather than a copy of it that can drift. +// +// Everything that is timed is timed INSIDE the page. A CDP round trip is several +// milliseconds on this box, which is a third of a frame — a frame time measured across +// the bridge is a measurement of the bridge. +// +// Not a `.test.cjs`, so the runner never picks it up on its own. + +/** + * Nearest-rank percentile — the SAME rule `sceneBudget.frameStats` uses, so a number the + * rig reports and a number the meter reports mean the same thing. PURE. + * @param {number[]} sorted ascending @param {number} q 0..1 + */ +function percentile(sorted, q) { + if (!sorted.length) return null; + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1)); + return sorted[index]; +} + +/** @param {number[]} values */ +function summarize(values) { + const sorted = [...values].filter(Number.isFinite).sort((a, b) => a - b); + return { + n: sorted.length, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + p99: percentile(sorted, 0.99), + max: sorted.length ? sorted[sorted.length - 1] : null + }; +} + +/** + * Install `window.__stress` in the page. Idempotent. Returns the renderer string, so a + * report can say what GPU its numbers came from (they are meaningless without it). + * @param {any} page + */ +async function installProbe(page) { + return page.evaluate(() => { + /** @type {any} */ + const w = window; + const s = w.__stores; + /** @param {any} store */ + const read = (store) => { + let v; + store.subscribe((/** @type {any} */ x) => (v = x))(); + return v; + }; + /** @param {number} ms */ + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const nextFrame = () => new Promise((r) => requestAnimationFrame(r)); + + if (!w.__stress) { + /** @type {any} */ + const ns = (w.__stress = { tasks: [] }); + try { + ns.observer = new PerformanceObserver((list) => { + for (const e of list.getEntries()) ns.tasks.push({ at: e.startTime, ms: e.duration }); + }); + ns.observer.observe({ entryTypes: ['longtask'] }); + ns.longTasksAvailable = true; + } catch { + ns.longTasksAvailable = false; + } + + /** Long tasks that STARTED inside [from, now]. */ + ns.tasksSince = (/** @type {number} */ from) => { + const hit = ns.tasks.filter((/** @type {any} */ t) => t.at >= from); + return { + count: hit.length, + longest: hit.reduce((m, /** @type {any} */ t) => Math.max(m, t.ms), 0), + busy: hit.reduce((m, /** @type {any} */ t) => m + t.ms, 0) + }; + }; + + /** Frame deltas for `ms`, optionally doing `each(dt)` every frame. */ + ns.frames = async (/** @type {number} */ ms, /** @type {any} */ each) => { + /** @type {number[]} */ + const deltas = []; + const started = performance.now(); + let last = await nextFrame(); + while (performance.now() - started < ms) { + const now = /** @type {number} */ (await nextFrame()); + deltas.push(now - last); + if (each) each(now - last); + last = now; + } + return { deltas, tasks: ns.tasksSince(started), elapsed: performance.now() - started }; + }; + + ns.renderer = () => { + const r = read(s.globalRenderer); + try { + const gl = r.getContext(); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + return dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); + } catch { + return 'unknown'; + } + }; + + ns.count = () => read(s.objectsGroup)?.children?.length ?? 0; + + /** + * Seed `n` boxes through the REAL create command (history, palette colour, + * shadow defaults, the poke) laid out on a square grid so every one is in + * frame. In chunks, yielding between them, because the thing being measured + * is the scene afterwards — not how badly a 10,000-iteration loop blocks. + */ + ns.seedCubes = async (/** @type {number} */ n, /** @type {number} */ chunk = 250) => { + const started = performance.now(); + const side = Math.ceil(Math.sqrt(n)); + const gap = 1.6; + const base = ns.count(); + for (let i = 0; i < n; i++) { + s.commandsHandler.sceneCommand('/create box'); + const o = read(s.selectedObject); + if (o?.position) o.position.set((i % side) * gap - (side * gap) / 2, 0.5, Math.floor(i / side) * gap - (side * gap) / 2); + if (i % chunk === chunk - 1) await sleep(0); + } + // the creations are synchronous, but the palette/shadow sweeps ride pokes + for (let t = 0; t < 200 && ns.count() < base + n; t++) await sleep(50); + s.selectedObjects?.set?.([]); + s.flushScenePokes?.(); + await nextFrame(); + return { ms: performance.now() - started, tasks: ns.tasksSince(started), count: ns.count() - base }; + }; + + /** Point the editor camera at the whole grid, from above and to one side. */ + ns.frameAll = (/** @type {number} */ n) => { + const side = Math.ceil(Math.sqrt(Math.max(1, n))) * 1.6; + const cam = read(s.globalCamera); + const controls = read(s.orbitControls); + const d = Math.max(12, side * 0.9); + cam.position.set(d * 0.6, d * 0.7, d * 0.8); + cam.far = Math.max(cam.far, d * 6); + cam.updateProjectionMatrix(); + controls?.target?.set?.(0, 0, 0); + controls?.update?.(); + }; + + /** A continuous orbit: what "the scene is heavy" feels like while navigating. */ + ns.orbit = (/** @type {number} */ ms) => { + const controls = read(s.orbitControls); + return ns.frames(ms, () => { + if (controls?._rotateLeft) controls._rotateLeft(0.02); + else if (controls?.rotateLeft) controls.rotateLeft(0.02); + controls?.update?.(); + }); + }; + + /** The budget sampler's own reading, after it has seen at least one window. */ + ns.metrics = async () => { + await sleep(600); + return s.sceneBudget.sampleSceneMetrics(); + }; + + /** One autosave snapshot through the real writer. */ + ns.autosave = async () => { + const started = performance.now(); + await s.autosave.saveNow(); + const status = read(s.autosave.autosaveStatus); + return { + wallMs: performance.now() - started, + exportMs: status.lastExportMs, + bytes: status.lastBytes, + error: status.lastError, + tasks: ns.tasksSince(started) + }; + }; + + /** + * Object list: close it, reopen it, and time until rows are in the DOM plus + * one painted frame. Above 500 rows 26-B windows the list, so the row count is + * reported too — a small count at 10k is the virtualisation working. + */ + ns.listRender = async () => { + s.objectListClose.set(true); + for (let t = 0; t < 40 && document.querySelector('#object-tree [role="treeitem"]'); t++) await sleep(25); + await nextFrame(); + const started = performance.now(); + s.objectListClose.set(false); + let rows = 0; + for (let t = 0; t < 400; t++) { + rows = document.querySelectorAll('#object-tree [role="treeitem"]').length; + if (rows > 0) break; + await new Promise((r) => setTimeout(r, 0)); + } + await nextFrame(); + return { ms: performance.now() - started, rows, mode: document.querySelector('[data-object-rows]')?.getAttribute('data-object-rows') ?? null }; + }; + + /** + * Import a dense model through the real GLB import path: a UV sphere of about + * `tris` triangles, exported to binary glTF in the page, handed to + * `fileHandler.importFile`. Timed until the object is in the scene. + */ + ns.importDense = async (/** @type {number} */ tris) => { + const THREE = s.THREE; + const Exporter = s.GLTFExporterModule.GLTFExporter; + // a UV sphere of w x h segments has about 2*w*(h-1) triangles + const h = Math.max(4, Math.round(Math.sqrt(tris / 2))); + const wSeg = Math.max(4, Math.round(tris / (2 * (h - 1)))); + const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, wSeg, h), new THREE.MeshStandardMaterial({ color: 0x8899aa })); + mesh.name = 'dense'; + const glb = await new Promise((resolve, reject) => + new Exporter().parse(mesh, resolve, reject, { binary: true }) + ); + mesh.geometry.dispose(); + const file = new File([/** @type {any} */ (glb)], 'dense.glb', { type: 'model/gltf-binary' }); + const before = ns.count(); + const started = performance.now(); + await s.fileHandler.importFile(file, 'dense', 'glb', [ (before % 6) * 2.5 - 6, 1, Math.floor(before / 6) * 2.5 - 6 ]); + for (let t = 0; t < 600 && ns.count() <= before; t++) await sleep(20); + s.selectedObjects?.set?.([]); + return { ms: performance.now() - started, bytes: /** @type {any} */ (glb).byteLength, landed: ns.count() > before, tasks: ns.tasksSince(started) }; + }; + + /** Run the simulation over what is in the scene for `ms`. */ + ns.physics = async (/** @type {number} */ ms) => { + const physics = s.physics; + if (!read(physics.simulating)) await physics.toggleSimulation(); + for (let t = 0; t < 100 && !read(physics.simulating); t++) await sleep(50); + const startedOk = !!read(physics.simulating); + await sleep(400); // the first steps build the world + const run = await ns.frames(ms); + const step = physics.physicsStepStats?.() ?? null; + const metrics = s.sceneBudget.sampleSceneMetrics(); + const stillRunning = !!read(physics.simulating); + if (stillRunning) physics.stopSimulation(); + return { startedOk, stillRunning, step, bodies: metrics.bodies ?? null, run }; + }; + } + return w.__stress.renderer(); + }); +} + +/** + * The per-size measurement, on a FRESH page so one size's heap and GPU state never + * colours the next. Returns one report row. Every field is a number or null; nothing is + * a string that a table would have to parse. + * @param {any} h helpers.cjs + * @param {any} browser + * @param {{kind: 'cubes'|'dense', size: number, windowMs?: number, physics?: boolean, autosave?: boolean, denseTris?: number, storage?: Record, viewport?: {width: number, height: number}}} opts + */ +async function measureScene(h, browser, opts) { + const windowMs = opts.windowMs ?? 4000; + const peer = await h.setupPage(browser, opts.kind + '-' + opts.size, { + context: { viewport: opts.viewport ?? { width: 1280, height: 720 } }, + storage: opts.storage + }); + try { + const gpu = await installProbe(peer.page); + /** @type {any} */ + const row = { kind: opts.kind, size: opts.size, gpu }; + const emptyIdle = await peer.page.evaluate((ms) => window.__stress.frames(ms), Math.min(2000, windowMs)); + row.emptyFrame = summarize(emptyIdle.deltas); + + if (opts.kind === 'cubes') { + const seed = await peer.page.evaluate((n) => window.__stress.seedCubes(n), opts.size); + row.seedMs = Math.round(seed.ms); + row.seedLongTasks = seed.tasks.count; + row.seedLongestTask = Math.round(seed.tasks.longest); + row.objects = seed.count; + await peer.page.evaluate((n) => window.__stress.frameAll(n), opts.size); + } else { + const tris = opts.denseTris ?? 200000; + /** @type {number[]} */ + const imports = []; + let bytes = 0; + let landed = 0; + let longest = 0; + for (let i = 0; i < opts.size; i++) { + const one = await peer.page.evaluate((t) => window.__stress.importDense(t), tris); + imports.push(one.ms); + bytes = one.bytes; + if (one.landed) landed++; + longest = Math.max(longest, one.tasks.longest); + } + row.importMsP50 = Math.round(summarize(imports).p50 ?? 0); + row.importMsMax = Math.round(summarize(imports).max ?? 0); + row.importLongestTask = Math.round(longest); + row.glbBytes = bytes; + row.objects = landed; + await peer.page.evaluate(() => window.__stress.frameAll(36)); + } + await peer.page.waitForTimeout(1200); + + const idle = await peer.page.evaluate((ms) => window.__stress.frames(ms), windowMs); + row.idle = summarize(idle.deltas); + row.idleLongTasks = idle.tasks.count; + const orbit = await peer.page.evaluate((ms) => window.__stress.orbit(ms), windowMs); + row.orbit = summarize(orbit.deltas); + row.orbitLongTasks = orbit.tasks.count; + row.orbitLongestTask = Math.round(orbit.tasks.longest); + + const m = await peer.page.evaluate(() => window.__stress.metrics()); + row.triangles = m.triangles; + row.calls = m.calls; + row.rendersPerFrame = m.rendersPerFrame ?? null; + row.geometries = m.geometries; + row.textures = m.textures; + row.heapMB = m.heap ? Math.round(m.heap / 1048576) : null; + row.meterP95 = m.frameP95; + + const list = await peer.page.evaluate(() => window.__stress.listRender()); + row.listMs = Math.round(list.ms); + row.listRows = list.rows; + row.listMode = list.mode; + + if (opts.autosave !== false) { + const save = await peer.page.evaluate(() => window.__stress.autosave()); + row.autosaveExportMs = save.exportMs ? Math.round(save.exportMs) : null; + row.autosaveWallMs = Math.round(save.wallMs); + row.autosaveBytes = save.bytes || null; + row.autosaveLongestTask = Math.round(save.tasks.longest); + row.autosaveError = save.error || null; + } + + if (opts.physics) { + const run = await peer.page.evaluate((ms) => window.__stress.physics(ms), windowMs); + row.physicsStarted = run.startedOk; + row.physicsAutoStopped = run.startedOk && !run.stillRunning; + row.bodies = run.bodies; + row.stepP50 = run.step ? Math.round(run.step.p50 * 10) / 10 : null; + row.stepP95 = run.step ? Math.round(run.step.p95 * 10) / 10 : null; + row.physicsFrame = summarize(run.run.deltas); + } + row.pageErrors = h.pageErrors(peer).length; + return row; + } finally { + await peer.ctx.close(); + } +} + +module.exports = { percentile, summarize, installProbe, measureScene }; From 898738a5a496baf8e09a3058f85f5eee1a1afafa Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 08:59:11 +0300 Subject: [PATCH 23/27] [feat] 25-E: one clock for the session, and the stamps that read it Roadmap 25 section 4, audit M8. Every stamp another peer compares was that machine's own Date.now(), so a joiner whose clock ran 90 s fast won every latest-wins merge for 90 s (a host's LATER edit to the sky was refused on the joiner and overwritten on the host), its flow clock and game timer ran 90 s out of phase, and its pulses arrived from the future. The 23-A2 estimator already measured the skew and was deliberately applied to nothing. - NEW leaf sessionClock.js (svelte/store only): the estimator moved out of musicClock, plus sessionNow() = the session HOST's wall clock, estimated per connection. Transitive (a pong carries the responder's own offset `so` and whose clock it keeps `ref`), a loop guard refuses a clock handed back by a peer that follows us, a 50 ms hysteresis keeps noise from jumping it, a gross skew (>1 s) is corrected on the first sample. Kept when the host departs (everyone left shares it); reset only by leaving the session. - NEW clockSync.js: the ping/pong wire half moved out of musicClock (re-exported there for its callers), an immediate ping ahead of the burst, and ONE toast per peer >2 s off. - 70 Date.now() reads in 24 modules move onto sessionNow(): every latest-wins changedAt (environment, scenePhysics, sceneMusic, scenePost, hudDocs + hud values, shaderGraph, gameState, projectManifest, audioPatch, animation docs, transport, roomanchor), the synced flow/animation/shader/particle clocks and module runtimeNow, game startedAt/ pausedAt/elapsed, atscene + sceneadopt at, peerVars sentAt, sharedLibrary row stamps, device note stamps and audioTimeFor. Local timing (debounces, TTLs, retries) stays put. - A JUMP IS NOT FREE: a joiner records its trigger-history epoch and every action node's first-seen time during the handshake, before its first pong. A -90 s correction left them 90 s in the future, so every live pulse would be refused as stale for a minute and a half. onSessionClockJump shifts them by the jump, and the handshake now sends the clock ping second, ahead of the full-state requests. - cloudHooks ALWAYS_ALLOWED += clockping, clockpong. Wire additive: an older pong has no so/ref and reads as a raw clock; an older peer ignores the fields. Counterfactuals (suite session-clock, C's Date.now pushed +90 s): - adoption disabled (reconsider returns): 7 red - C's clock never lands, offset 0, sessionNow 90000 apart, C keeps its own EARLIER sky, flow time and game elapsed 90 s apart. - environment commit back on Date.now: red "C takes A's later edit" (sunset kept). - gameState startedAt/elapsed back on Date.now: red "round's elapsed agrees" (90.000 s). - moduleSDK runtimeNow back on Date.now: red "synced flow time agrees" (90.000 s). - jump listener removed: red "a 30 s correction moves the epoch" (0.000 for 29.998). - jump listener removed AND the ping moved back to the end of the handshake: red "epoch not in the future" (epoch 20260.98 vs now 20178.90) - the hazard is real; with the listener restored and the late ping the same check is green (listener alone suffices). - SKEW_TOAST_MS raised: red on both skew toasts. - clockping/clockpong off the floor: red floor check. - resetSessionClock call removed from resetSession: red "leaving hands C its clock back". - pong so/ref removed: red "a pong carries the responder's session offset". Suites vs base (this worktree): session-clock NEW 26/26; unit sessionClock NEW 14/14 (all unit 112/112); approval-timeout 17=17, connect-states 28=28, music-clock 61=61, net-handshake 9=9, trigger-log-sync 56 green, game-state 45 green. svelte-check 352/47 = baseline. npm run build green (server stopped). Co-Authored-By: Claude Opus 5 --- src/lib/animationPreview.js | 11 +- src/lib/audioDevices.js | 3 +- src/lib/audioEngine.js | 10 +- src/lib/audioPatch.js | 5 +- src/lib/clockSync.js | 157 ++++++++++++++++ src/lib/cloudHooks.js | 5 + src/lib/colocation.js | 3 +- src/lib/connectionState.js | 5 + src/lib/environment.js | 7 +- src/lib/flowRuntime.js | 19 +- src/lib/gameState.js | 17 +- src/lib/gameSync.js | 3 +- src/lib/hudDocs.js | 7 +- src/lib/hudSync.js | 3 +- src/lib/levels.js | 3 +- src/lib/moduleSDK.js | 3 +- src/lib/musicClock.js | 213 ++++------------------ src/lib/particleActions.js | 3 +- src/lib/peerHandler.svelte.js | 19 +- src/lib/peerScenes.js | 3 +- src/lib/peerVars.js | 5 +- src/lib/projectManifest.js | 3 +- src/lib/sceneMusic.js | 17 +- src/lib/scenePhysics.js | 5 +- src/lib/scenePost.js | 7 +- src/lib/sessionClock.js | 300 +++++++++++++++++++++++++++++++ src/lib/shaderGraph.js | 7 +- src/lib/shaderSync.js | 3 +- src/lib/sharedLibrary.js | 11 +- tests/e2e/session-clock.test.cjs | 185 +++++++++++++++++++ tests/unit/sessionClock.test.js | 165 +++++++++++++++++ 31 files changed, 958 insertions(+), 249 deletions(-) create mode 100644 src/lib/clockSync.js create mode 100644 src/lib/sessionClock.js create mode 100644 tests/e2e/session-clock.test.cjs create mode 100644 tests/unit/sessionClock.test.js diff --git a/src/lib/animationPreview.js b/src/lib/animationPreview.js index e214ba02..1cfa2e9d 100644 --- a/src/lib/animationPreview.js +++ b/src/lib/animationPreview.js @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { writable, get } from 'svelte/store'; import { objectsGroup } from '../stores/sceneStore'; import { peers, showToast } from '../stores/appStore'; @@ -122,7 +123,7 @@ function newId() { * flowRuntime's syncedNow / moduleSDK's runtimeNow (neither is exported; both * read this store). Wall clock wrapped daily to keep float precision. */ function syncedNow() { - return get(syncedAnimations) ? (Date.now() % 86400000) / 1000 : performance.now() / 1000; + return get(syncedAnimations) ? (sessionNow() % 86400000) / 1000 : performance.now() / 1000; } /** @param {string} uuid */ @@ -1040,7 +1041,7 @@ registerHistoryKind('anim', (entry, state) => { if (!set) stop(entry.uuid); animations.update((map) => { const next = { ...map }; - if (set) next[entry.uuid] = { ...set, changedAt: Date.now() }; + if (set) next[entry.uuid] = { ...set, changedAt: sessionNow() }; else delete next[entry.uuid]; return next; }); @@ -1061,7 +1062,7 @@ function editSet(uuid, fn) { const next = fn(structuredClone(set)); if (!next) return map; changed = true; - return { ...map, [uuid]: { ...next, changedAt: Date.now() } }; + return { ...map, [uuid]: { ...next, changedAt: sessionNow() } }; }); if (!changed || gesture) return; recordAnimEntry(uuid, before, get(animations)[uuid]); @@ -2082,7 +2083,7 @@ export function copyAnimationsFrom(set, toUuid) { // clip ids are per object, so they can stay as they are const copy = normalizeAnimSet(structuredClone(source)); if (!copy) return false; - animations.update((map) => ({ ...map, [toUuid]: { ...copy, changedAt: Date.now() } })); + animations.update((map) => ({ ...map, [toUuid]: { ...copy, changedAt: sessionNow() } })); broadcastAnim(toUuid); return true; } @@ -2174,7 +2175,7 @@ function parkedPosition(clip, p) { function setPlay(uuid, patch, replicate = false) { playback.update((map) => ({ ...map, - [uuid]: { ...playOf(uuid), ...patch, changedAt: patch.changedAt ?? Date.now() } + [uuid]: { ...playOf(uuid), ...patch, changedAt: patch.changedAt ?? sessionNow() } })); if (replicate) broadcastPlay(uuid); } diff --git a/src/lib/audioDevices.js b/src/lib/audioDevices.js index b61d56e0..f694628b 100644 --- a/src/lib/audioDevices.js +++ b/src/lib/audioDevices.js @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { writable, get } from 'svelte/store'; import { objectsGroup, pokeScene } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; @@ -608,7 +609,7 @@ export function deviceHandle(uuid) { */ export function noteDevice(uuid, note = {}, opts = {}) { const object = get(objectsGroup)?.getObjectByProperty('uuid', uuid); - const event = { note: 60, velocity: 1, ...note, at: typeof note.at === 'number' ? note.at : Date.now() }; + const event = { note: 60, velocity: 1, ...note, at: typeof note.at === 'number' ? note.at : sessionNow() }; deliverNote(object, event); if (opts.replicate === false) return event; /** @type {any} */ diff --git a/src/lib/audioEngine.js b/src/lib/audioEngine.js index dcdedc82..4ae9db4d 100644 --- a/src/lib/audioEngine.js +++ b/src/lib/audioEngine.js @@ -1,6 +1,8 @@ +// 25-E: wall stamps on the wire are SESSION time; `sessionClock` is itself a store-only leaf +import { sessionNow } from './sessionClock'; // The audio ENGINE (roadmap #22 A1, cloud plans-core/pending/22-a-audio-engine.md). // -// A deliberate LEAF: it imports nothing of ours, so `peerHandler` / `sessions` / +// A deliberate LEAF: it imports nothing of ours (bar the store-only `sessionClock`), so `peerHandler` / `sessions` / // `autosave` can all reach it and so its maths is testable with no GL context and // no scene. That is the `scenePost` rule, and it is what keeps the whole audio // stack out of the TDZ cycle family around `history`. @@ -170,8 +172,8 @@ function audioClockOffset() { } /** - * Map a WALL-CLOCK stamp (a `Date.now()` value, which is what every replicated - * message carries) onto this context's `currentTime`, so a "play at beat 4" + * Map a WALL-CLOCK stamp (a `sessionNow()` value since 25-E, which is what every + * replicated message carries) onto this context's `currentTime`, so a "play at beat 4" * message can become an `osc.start(t)`. * * Through the clock filter above, so two stamps a beat apart map to audio times a @@ -185,7 +187,7 @@ function audioClockOffset() { */ export function audioTimeFor(wallMs) { const off = audioClockOffset(); - return off + (performance.now() + (wallMs - Date.now())) / 1000; + return off + (performance.now() + (wallMs - sessionNow())) / 1000; } /** This context's own clock. @returns {number} */ diff --git a/src/lib/audioPatch.js b/src/lib/audioPatch.js index 01a2e0fc..5eae850c 100644 --- a/src/lib/audioPatch.js +++ b/src/lib/audioPatch.js @@ -1,5 +1,6 @@ // @ts-ignore - no bundled three type declarations (project-wide) import * as THREE from 'three'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { writable, get } from 'svelte/store'; import { globalScene, objectsGroup } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; @@ -119,7 +120,7 @@ let applyingHistory = false; function commit(fn, opts = {}) { const before = get(patch); const next = normalizePatch(fn(before)); - next.changedAt = Math.max(Date.now(), (before.changedAt || 0) + 1); + next.changedAt = Math.max(sessionNow(), (before.changedAt || 0) + 1); patch.set(next); if (opts.record !== false && !applyingHistory) recordPatchEntry(before, next); broadcastPatch(); @@ -308,7 +309,7 @@ export function patchSnapshot(opts = {}) { */ export function patchRestore(payload, replicate = false) { const next = normalizePatch(payload); - next.changedAt = Math.max(Date.now(), (get(patch).changedAt || 0) + 1); + next.changedAt = Math.max(sessionNow(), (get(patch).changedAt || 0) + 1); patch.set(next); if (replicate) broadcastPatch(); return next; diff --git a/src/lib/clockSync.js b/src/lib/clockSync.js new file mode 100644 index 00000000..b1ee3526 --- /dev/null +++ b/src/lib/clockSync.js @@ -0,0 +1,157 @@ +import { get } from 'svelte/store'; +import { peers, showToast } from '../stores/appStore'; +import { sessionHost } from './connectionState'; +import { + recordClockSample, + noteRemoteSessionClock, + setClockReference, + setClockSelf, + sessionOffset, + peerClocks, + MIN_SAMPLES +} from './sessionClock'; + +/** + * 25-E — THE WIRE HALF OF THE SESSION CLOCK (the round trip moved here from musicClock, + * where 23-A2 built it; `sessionClock.js` is the leaf that turns its answers into + * `sessionNow()`). + * + * Additive on the wire, both ways: a pong gains `so` (the responder's own session offset) + * and `ref` (whose clock that is). An OLDER peer answers without them, which reads as "my + * raw clock", exactly what it is keeping — and an older peer receiving the extra fields + * ignores them. `clockping`/`clockpong` sit on cloudHooks' ALWAYS_ALLOWED floor: a plugin + * gating them would silently put a viewer's every stamp out of step with the room. + */ + +/** how many pings the connect burst sends, how far apart, and how long after the + * handshake it starts. MEASURED (23-A2): samples taken during the connect storm (the + * joiner is receiving objects, compiling shaders, first-painting) carried 100+ ms of + * one-sided main-thread delay and pulled a 6-sample median to +427 ms on a true +300 — + * so the burst waits for the storm to pass, and the filter discounts what it catches. */ +const BURST = 6; +const BURST_GAP_MS = 250; +const BURST_DELAY_MS = 2000; +/** steady-state re-measure, so a drifting clock is tracked and storm samples age out */ +const RESYNC_MS = 5000; +/** 25-E: a peer this far off our RAW clock gets one toast — the session corrects for it, + * but a device whose date and time are wrong is wrong for every other app too */ +export const SKEW_TOAST_MS = 2000; + +/** @param {string} peerId @returns {any} the stable OUTGOING conn, or null */ +function connFor(peerId) { + /** @type {any} */ + const peer = get(peers); + const conn = peer?.connections?.[peerId]; + return conn && conn.open ? conn : null; +} + +/** One ping. Returns false when there is no open conn to send it on. @param {string} peerId */ +export function sendClockPing(peerId) { + const conn = connFor(peerId); + if (!conn) return false; + /** @type {any} */ + const peer = get(peers); + setClockSelf(peer?.peer?.id ?? null); + conn.send({ type: 'clockping', sender: peer.peer.id, t0: Date.now() }); + return true; +} + +/** + * Answer a ping. Stamped on receipt (t1) and again on send (t2) so the responder's + * own processing time is subtracted out of the round trip. The four stamps stay on the + * RAW clock — the estimate is of the machines — and `so`/`ref` say what we do with ours. + * Replies over our stable OUTGOING conn to the sender (golden rule 9), falling back to + * the conn it arrived on while the dance is still settling. + * @param {any} data @param {any} [arrivedOn] + */ +export function answerClockPing(data, arrivedOn) { + const t1 = Date.now(); + if (!data || typeof data.t0 !== 'number') return; + /** @type {any} */ + const peer = get(peers); + const conn = connFor(data.sender) ?? (arrivedOn && arrivedOn.open ? arrivedOn : null); + if (!conn) return; + conn.send({ + type: 'clockpong', + sender: peer?.peer?.id ?? '', + t0: data.t0, + t1, + t2: Date.now(), + so: sessionOffset(), + ref: get(sessionHost) ?? null + }); +} + +/** Fold a pong into the sender's estimate. @param {any} data */ +export function applyClockPong(data) { + const t3 = Date.now(); + if (!data || typeof data.t0 !== 'number' || typeof data.t1 !== 'number' || typeof data.t2 !== 'number') return; + if (!data.sender) return; + const sender = String(data.sender); + const rtt = t3 - data.t0 - (data.t2 - data.t1); + const offset = (data.t1 - data.t0 + (data.t2 - t3)) / 2; + // the remote clock first, so the sample that follows decides with both halves known + noteRemoteSessionClock(sender, data.so, data.ref); + recordClockSample(sender, offset, rtt); + maybeWarnSkew(sender); +} + +/** @type {Set} peers we have already told the user about, for this tab */ +const warnedSkew = new Set(); + +/** @param {number} ms */ +function describe(ms) { + const s = Math.round(Math.abs(ms) / 1000); + if (s < 120) return s + ' s'; + const m = Math.round(s / 60); + return m < 120 ? m + ' min' : Math.round(m / 60) + ' h'; +} + +/** + * One toast per peer, once the estimate has something behind it. Says which way and by + * how much, and that the session already copes — the useful act is fixing the device. + * @param {string} peerId + */ +function maybeWarnSkew(peerId) { + if (warnedSkew.has(peerId)) return; + const est = get(peerClocks)[peerId]; + if (!est || est.samples < MIN_SAMPLES || Math.abs(est.offset) <= SKEW_TOAST_MS) return; + warnedSkew.add(peerId); + const label = String(peerId).slice(0, 6).toUpperCase(); + showToast( + label + + "'s clock is " + + describe(est.offset) + + (est.offset > 0 ? ' ahead of' : ' behind') + + ' this device. Shared timings follow the session clock, but check the date and time settings on whichever device is wrong.' + ); +} + +/** @type {any} */ +let resyncTimer = null; + +/** + * Start measuring a peer: one ping at once (a gross skew is corrected on its first + * sample, before the joiner writes much), a short burst once the connect storm has + * passed (the median needs several samples before it means anything), then a steady + * re-measure every RESYNC_MS for as long as the conn is open. Called from + * `sendHandshake`, the one place a conn is known to be OPEN (golden rule 2). + * @param {string} peerId + */ +export function startClockSync(peerId) { + if (typeof setTimeout === 'undefined') return; + sendClockPing(peerId); + for (let i = 0; i < BURST; i++) setTimeout(() => sendClockPing(peerId), BURST_DELAY_MS + i * BURST_GAP_MS); + if (resyncTimer == null) { + resyncTimer = setInterval(() => { + /** @type {any} */ + const peer = get(peers); + for (const id of Object.keys(peer?.connections ?? {})) sendClockPing(id); + }, RESYNC_MS); + } +} + +/** The peer whose session we joined is the peer we keep time by. Declared last: the + * subscribe runs synchronously at module eval (the module-level-subscribe rule) and + * every name it reaches is an import, so nothing here can be read before it exists. */ +sessionHost.subscribe((host) => setClockReference(host)); diff --git a/src/lib/cloudHooks.js b/src/lib/cloudHooks.js index d7f24dfb..4bc43e6c 100644 --- a/src/lib/cloudHooks.js +++ b/src/lib/cloudHooks.js @@ -36,6 +36,11 @@ const ALWAYS_ALLOWED = new Set([ // everything), i.e. it would relax the gate by tightening one message. It is presence // besides, which is this floor's own family. 'atscene', + // 25-E: the session clock's round trip. Protocol, not content — a plugin that gated it + // for a viewer would leave that viewer stamping on its own machine's clock, so every + // latest-wins write it made would sort wrongly against the room's. + 'clockping', + 'clockpong', // DEVX #18: the flow trigger log. On the floor beside `getnodes` for the same reason // the list gives — answering a full-state REQUEST is how a peer ever syncs, and this // one decides whether a joiner sees a collected world or a reset one. The `triggers` diff --git a/src/lib/colocation.js b/src/lib/colocation.js index f41eb035..09c68364 100644 --- a/src/lib/colocation.js +++ b/src/lib/colocation.js @@ -79,6 +79,7 @@ // different building) would place the content somewhere arbitrary. import { writable, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import * as THREE from 'three'; import { worldRig } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; @@ -311,7 +312,7 @@ export function setRoomAnchor(patch) { const record = normalizeRoomAnchor({ ...base, ...(patch ?? {}), - at: Math.max(Date.now(), (current?.at ?? 0) + 1) + at: Math.max(sessionNow(), (current?.at ?? 0) + 1) }); roomAnchor.set(record); /** @type {any} */ diff --git a/src/lib/connectionState.js b/src/lib/connectionState.js index 3709f3ed..7eac7ab4 100644 --- a/src/lib/connectionState.js +++ b/src/lib/connectionState.js @@ -1,5 +1,9 @@ import { writable, get } from 'svelte/store'; import { safeStorage } from './safeStorage'; +// 25-E: the session clock is a sibling leaf; re-exported here because this is where peer +// code already looks for "what session am I in", and resetSession must reset it too +import { resetSessionClock } from './sessionClock'; +export { sessionNow, sessionClock, sessionClockDebug } from './sessionClock'; /** * Session-connection state (roadmap #14 CN). STORE-ONLY module (svelte/store only) @@ -153,6 +157,7 @@ export function resetSession() { sessionHost.set(null); peerJoinedAt.set({}); approvalStartedAt.set({}); // 27-E: no request survives leaving the session + resetSessionClock(); // 25-E: our own clock is the only one left } /** diff --git a/src/lib/environment.js b/src/lib/environment.js index e47ab28e..87bc0daa 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { writable, get } from 'svelte/store'; import { globalScene, globalRenderer, objectsGroup, backgroundColor, TControls, passthroughActive, pokeScene } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; @@ -293,7 +294,7 @@ export function applyEnvironment() { /** Apply a state change locally, persist and replicate @param {any} partial */ function commit(partial) { - const state = { ...get(environment), ...partial, changedAt: Date.now() }; + const state = { ...get(environment), ...partial, changedAt: sessionNow() }; environment.set(state); applyEnvironment(); /** @type {any} */ @@ -584,9 +585,9 @@ export function environmentRestore(payload, replicate = false) { exposure: payload.exposure ?? 1, customPreset: payload.customPreset ?? null, lights: payload.lights ?? [], - changedAt: Date.now() + changedAt: sessionNow() } - : { ...DEFAULT_STATE, changedAt: Date.now() }; + : { ...DEFAULT_STATE, changedAt: sessionNow() }; environment.set(state); applyEnvironment(); if (!replicate) return; diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 0ac824f1..a927a19a 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { sessionNow, onSessionClockJump } from './sessionClock'; // 25-E: the synced clock is the SESSION's import { get } from 'svelte/store'; import { flowGraphs, mutedFlowObjects, syncedAnimations, flowValues, flowTriggers, SCENE_GRAPH, startGraphMirror, allNodes, allEdges, flowPaused} from '../stores/flowStore'; // 21-F2: `isLocked` is the LOCAL play substate the recipe gate reads — see gamePlayActive @@ -153,6 +154,18 @@ export function triggerHistoryEpoch() { return triggerHistoryAt; } +// 25-E: THE CUTOFFS FOLLOW THE CLOCK. The epoch above and every `actionSeenAt` entry are +// SESSION seconds recorded as local cutoffs, and a joiner records most of them during its +// handshake — before its clock has been corrected onto the host's. A -90 s correction +// would then leave every one of them 90 s in the future, so every live pulse would be +// refused as older than the node acting on it. Shift them by the jump instead. A callback +// registration, not a subscribe, so nothing here runs at module eval. +onSessionClockJump((deltaMs) => { + const d = deltaMs / 1000; + if (triggerHistoryAt) triggerHistoryAt += d; + for (const [id, seen] of actionSeenAt) actionSeenAt.set(id, seen + d); +}); + /** * Register an action node's first-seen moment. Called for EVERY action node on EVERY * tick, whether or not a stamp exists — the cutoff has to be set by mere PRESENCE. The @@ -2456,7 +2469,7 @@ export function speedOf(uuid) { /** Synced seconds — same formula as the tick clock. */ function syncedNow() { - return synced ? (Date.now() % 86400000) / 1000 : performance.now() / 1000; + return synced ? (sessionNow() % 86400000) / 1000 : performance.now() / 1000; } /** @@ -2875,7 +2888,7 @@ function applyAnimation(object, base, anim, time, ctx) { { note: Number.isFinite(+data.note) ? +data.note : 60, velocity: typeof data.velocity === 'number' ? data.velocity : 0.9, - at: Math.floor(Date.now() / 86400000) * 86400000 + stamp * 1000 + at: Math.floor(sessionNow() / 86400000) * 86400000 + stamp * 1000 }, { replicate: false } ); @@ -2984,7 +2997,7 @@ function runTick(now) { // now lands in THIS tick's trigger snapshot, exactly as a keydown arriving between // frames would. (It also rides pumpFlowTick, so a pad works in a headset for free.) inputRuntimeRef?.pollGamepads(); - const time = synced ? (Date.now() % 86400000) / 1000 : now / 1000; + const time = synced ? (sessionNow() % 86400000) / 1000 : now / 1000; const ctx = runtimeCtx(); // 134: scene + trigger state for the evaluators // collect active animations per scene object diff --git a/src/lib/gameState.js b/src/lib/gameState.js index 265f771f..73b74e1a 100644 --- a/src/lib/gameState.js +++ b/src/lib/gameState.js @@ -27,6 +27,7 @@ // state enters `playing`, so all views converge with no new message and no forced viewpoint. import { writable, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares /** The states a game moves through. `over` carries an `outcome` string, which is how * win/lose is expressed without a node of its own. */ @@ -120,7 +121,7 @@ export function commitGameState(patch, opts = {}) { const after = normalizeGameState({ ...before, ...patch, - changedAt: opts.stamp ?? Math.max(Date.now(), (before.changedAt ?? 0) + 1) + changedAt: opts.stamp ?? Math.max(sessionNow(), (before.changedAt ?? 0) + 1) }); gameState.set(after); if (!opts.silent) { @@ -145,16 +146,16 @@ export function setGameState(state, opts = {}) { // resuming FROM a pause keeps the round and its startedAt; a fresh start (from // menu/over) re-stamps and bumps the round. The pause span is banked either way. if (before.state === 'paused') { - patch.pausedMs = before.pausedMs + (before.pausedAt ? Date.now() - before.pausedAt : 0); + patch.pausedMs = before.pausedMs + (before.pausedAt ? sessionNow() - before.pausedAt : 0); patch.pausedAt = 0; } else { - patch.startedAt = Date.now(); + patch.startedAt = sessionNow(); patch.round = opts.round ?? before.round + 1; patch.pausedAt = 0; patch.pausedMs = 0; } } - if (state === 'paused' && entering) patch.pausedAt = Date.now(); + if (state === 'paused' && entering) patch.pausedAt = sessionNow(); if (state !== 'paused' && state !== 'playing' && entering) { // leaving the round entirely closes any live pause span patch.pausedAt = 0; @@ -170,8 +171,8 @@ export function gameElapsed() { // counting through - which it measurably did. const { startedAt, pausedAt, pausedMs } = get(gameState); if (!startedAt) return 0; - const live = pausedAt ? Date.now() - pausedAt : 0; - return Math.max(0, (Date.now() - startedAt - pausedMs - live) / 1000); + const live = pausedAt ? sessionNow() - pausedAt : 0; + return Math.max(0, (sessionNow() - startedAt - pausedMs - live) / 1000); } // ---- 21-F2: what "a round" means to everything derived from it ------------------- @@ -258,11 +259,11 @@ export function gameStateSnapshot() { export function gameStateRestore(payload, replicate = false) { if (!payload) { // a scene with no game field resets, or the previous scene's round would leak in - gameState.set({ ...DEFAULT, changedAt: Date.now() }); + gameState.set({ ...DEFAULT, changedAt: sessionNow() }); if (replicate && broadcastHook) broadcastHook(get(gameState)); return; } - commitGameState(normalizeGameState(payload), { silent: !replicate, stamp: Date.now() }); + commitGameState(normalizeGameState(payload), { silent: !replicate, stamp: sessionNow() }); } /** Test/serializer seam. */ diff --git a/src/lib/gameSync.js b/src/lib/gameSync.js index 2138b125..772ec3d9 100644 --- a/src/lib/gameSync.js +++ b/src/lib/gameSync.js @@ -10,6 +10,7 @@ // keyed-document one: one message, one stamp, no per-key map. import { get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; import { registerHistoryKind, recordEntry } from './history'; import { @@ -76,7 +77,7 @@ registerHistoryKind('game', (/** @type {any} */ entry, /** @type {any} */ state) // silently restored `before`). const target = state === entry.before ? entry.before : entry.after; // through the single write path, so an undo replicates exactly like an edit - commitGameState(normalizeGameState(target), { stamp: Date.now() }); + commitGameState(normalizeGameState(target), { stamp: sessionNow() }); return true; }); diff --git a/src/lib/hudDocs.js b/src/lib/hudDocs.js index ad828591..8b38b473 100644 --- a/src/lib/hudDocs.js +++ b/src/lib/hudDocs.js @@ -22,6 +22,7 @@ // PURPOSE — one player on the start menu while another plays. import { writable, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares // 21-D1: the kind REGISTRY. hudKinds imports nothing, so this stays a leaf. import { HUD_KINDS as REGISTERED_KINDS, defaultsForKind, styleDefaultsForKind, kindDef } from './hudKinds'; // 21-D6: a screen can follow the GAME STATE. gameState is a leaf too, so this closes no @@ -163,7 +164,7 @@ export function setHudValue(id, value, opts = {}) { if (opts.at < held) return; valueStamps[key] = opts.at; } else if (opts.shared) { - valueStamps[key] = Math.max(Date.now(), (valueStamps[key] ?? 0) + 1); + valueStamps[key] = Math.max(sessionNow(), (valueStamps[key] ?? 0) + 1); } hudValues.update((all) => (all[key] === value ? all : { ...all, [key]: value })); if (opts.shared && !opts.silent) valueBroadcastHook?.(key, value, valueStamps[key]); @@ -592,7 +593,7 @@ export function setHudDocFor(key, patch, opts = {}) { // those edits share a bare Date.now() and the receiver's latest-wins guard // drops every one after the first — measured in the shader round: the drag // AND the undo after it silently failed to replicate. - changedAt: opts.stamp ?? Math.max(Date.now(), (all[key]?.changedAt ?? 0) + 1) + changedAt: opts.stamp ?? Math.max(sessionNow(), (all[key]?.changedAt ?? 0) + 1) }); next[key] = after; } @@ -719,7 +720,7 @@ export function hudDocsRestore(map, replace = false, replicate = false) { clearHudRuntimeRows(); } if (!map || typeof map !== 'object') return; - const stamp = Date.now(); + const stamp = sessionNow(); let i = 0; for (const [key, doc] of Object.entries(map)) { if (!doc) continue; diff --git a/src/lib/hudSync.js b/src/lib/hudSync.js index 8697cb18..2f1af1d7 100644 --- a/src/lib/hudSync.js +++ b/src/lib/hudSync.js @@ -21,6 +21,7 @@ // No `handleDisconnected` cleanup: documents are SCENE data, not per-peer state. import { get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; import { registerHistoryKind, recordEntry } from './history'; import { @@ -51,7 +52,7 @@ function broadcast(key, doc) { const peer = get(peers); if (!peer) return; if (doc) peer.send({ type: 'hud', key, doc: wireDoc(doc) }); - else peer.send({ type: 'huddelete', key, changedAt: Date.now() }); + else peer.send({ type: 'huddelete', key, changedAt: sessionNow() }); } /** diff --git a/src/lib/levels.js b/src/lib/levels.js index 6afac655..2bb9e299 100644 --- a/src/lib/levels.js +++ b/src/lib/levels.js @@ -29,6 +29,7 @@ // would close the TDZ cycle (the moduleSDK rule). import { writable, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { showToast, showInfoToast, dismissToastById, peers } from '../stores/appStore'; // R22 round 34: the adopt message names the peer who saved. `sessions.js` — which this // module already imports — imports lockControl too, so this closes no new edge. @@ -1179,7 +1180,7 @@ async function announceSceneName(cameFrom, name, hash, opts) { try { /** @type {any} */ const peer = get(peers); - peer.send({ type: 'sceneadopt', name, hash, peerId: peer.peer.id, at: Date.now() }); + peer.send({ type: 'sceneadopt', name, hash, peerId: peer.peer.id, at: sessionNow() }); } catch { return { told: 0, note }; } diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 055a861b..1fb11ddc 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -1,4 +1,5 @@ import { keyOf, letterOf } from './keyOf'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked } from '../stores/sceneStore'; @@ -1469,7 +1470,7 @@ export function registerModuleAssets(id, assets) { * with this so time-based effects agree across peers. */ export function runtimeNow() { - return get(syncedAnimations) ? (Date.now() % 86400000) / 1000 : performance.now() / 1000; + return get(syncedAnimations) ? (sessionNow() % 86400000) / 1000 : performance.now() / 1000; } /** diff --git a/src/lib/musicClock.js b/src/lib/musicClock.js index 86768bfc..d47cde25 100644 --- a/src/lib/musicClock.js +++ b/src/lib/musicClock.js @@ -1,4 +1,6 @@ import { writable, get } from 'svelte/store'; +// 25-E: the transport keeps time by the SESSION clock, like every other stamp site +import { sessionNow, peerClocks, clockSamples } from './sessionClock'; import { peers } from '../stores/appStore'; // The 'transport' history kind. Safe as a static import for the same reason // scenePost's is: history's own subtree is three/stores/flowRuntime/editOverlays/ @@ -32,7 +34,8 @@ import { syncedAnimations } from '../stores/flowStore'; // and `sceneMusic` all assume every peer's `Date.now()` agrees. A `clockping` / // `clockpong` round trip estimates it NTP-style, median of the last N. // -// ONE CLOCK BASIS (finding 5). Beats are `(Date.now() - startedAt) / 1000 * bpm / 60` +// ONE CLOCK BASIS (finding 5). Beats are `(sessionNow() - startedAt) / 1000 * bpm / 60` +// (25-E: `Date.now()` until the session clock existed — see the offset section below) // — the `sceneMusic` basis, which has no daily wrap. `flowRuntime`'s // `Date.now() % 86400000 / 1000` is LEFT ALONE ON PURPOSE: it is fine for a sine LFO // and fatal for a transport, because a loop whose duration does not divide 86 400 s @@ -138,7 +141,7 @@ export const transport = writable(normalizeTransport(null)); * through this one function. * @param {Transport} state @param {number} [wallMs] */ -export function beatAt(state, wallMs = Date.now()) { +export function beatAt(state, wallMs = sessionNow()) { if (!state.playing || !state.startedAt) return 0; return Math.max(0, ((wallMs - state.startedAt) / 1000) * (state.bpm / 60)); } @@ -175,7 +178,7 @@ export function swungBeat(beat, swing) { /** A read of the transport for a HUD or a value node: `{bpm, beat, bar, step, phase, * playing, loopBeats}`. `phase` is the position inside the current loop in 0..1. */ -export function transportNow(wallMs = Date.now()) { +export function transportNow(wallMs = sessionNow()) { const state = get(transport); const beat = beatAt(state, wallMs); const loop = loopBeats(state); @@ -200,7 +203,7 @@ registerModuleValueNode( 'transportbeat', (data, time) => { const synced = get(syncedAnimations) && typeof time === 'number'; - const wallMs = synced ? Math.floor(Date.now() / 86400000) * 86400000 + time * 1000 : Date.now(); + const wallMs = synced ? Math.floor(sessionNow() / 86400000) * 86400000 + time * 1000 : sessionNow(); const t = transportNow(wallMs); switch (data?.read) { case 'bar': @@ -238,7 +241,7 @@ let applyingHistory = false; function commit(fn) { const before = get(transport); const next = normalizeTransport(fn(before)); - next.changedAt = Math.max(Date.now(), (before.changedAt || 0) + 1); + next.changedAt = Math.max(sessionNow(), (before.changedAt || 0) + 1); transport.set(next); if (!applyingHistory) recordTransportEntry(before, next); broadcastTransport(); @@ -278,7 +281,7 @@ export function setTransport(patch) { /** @type {any} */ const merged = { ...state, ...(patch ?? {}) }; if (state.playing && typeof patch?.bpm === 'number' && patch.bpm !== state.bpm) { - const now = Date.now(); + const now = sessionNow(); const bpm = num(patch.bpm, 20, 300, state.bpm); merged.bpm = bpm; merged.startedAt = now - (beatAt(state, now) * 60000) / bpm; @@ -304,7 +307,7 @@ export function setBarsPerLoop(bars) { /** Start from beat 0 at `at` (default now). Every peer starts inside the same beat * from the same stamp — the `sceneMusic` loop-phase model. @param {number} [at] */ -export function playTransport(at = Date.now()) { +export function playTransport(at = sessionNow()) { return commit((state) => ({ ...state, playing: true, startedAt: at })); } @@ -397,13 +400,13 @@ export function transportRestore(payload, replicate = false, opts = {}) { const resume = opts.resume !== false; const next = normalizeTransport(payload); if (next.playing) { - if (resume) next.startedAt = Date.now(); + if (resume) next.startedAt = sessionNow(); else next.playing = false; } // a restore is an authoritative local write, so it must WIN over whatever changedAt // the file carries (an old file's stamp is in the past) — and stay monotonic, since // it can land in the same millisecond as the write before it - next.changedAt = Math.max(Date.now(), (get(transport).changedAt || 0) + 1); + next.changedAt = Math.max(sessionNow(), (get(transport).changedAt || 0) + 1); transport.set(next); if (replicate) broadcastTransport(); return next; @@ -521,13 +524,13 @@ transport.subscribe((state) => { const run = runKey(state); if (run === seenRun) return; seenRun = run; - runSeenAt = Date.now(); + runSeenAt = sessionNow(); if (events.length) tick(runSeenAt); }); /** One look-ahead pass. Exported for the suite, which drives it by hand to prove the * horizon and the no-double-fire rule without waiting on real time. */ -export function tick(wallMs = Date.now()) { +export function tick(wallMs = sessionNow()) { // feed the engine's clock filter every tick, so `audioTimeFor` sees many phases of // the device callback (see the clock section of audioEngine.js) sampleAudioClock(); @@ -580,177 +583,23 @@ function fire(state, event, beat, late) { // ---- peer clock offset (finding 6) ---------------------------------------------- // -// NTP's four-stamp round trip, over the data channel the peers already share: -// t0 we send `clockping` (our clock) -// t1 they receive it (their clock) -// t2 they send `clockpong` (their clock) -// t3 we receive it (our clock) -// rtt = (t3 - t0) - (t2 - t1) -// offset = ((t1 - t0) + (t2 - t3)) / 2 their clock minus ours -// The error of one sample is bounded by the round trip's ASYMMETRY, at most rtt/2. -// The estimate is the MEDIAN of the last N: a median rejects the one sample that -// went through a slow relay, a mean does not. - -/** samples kept per peer */ -const CLOCK_RING = 12; -/** how many pings the connect burst sends, how far apart, and how long after the - * handshake it starts. MEASURED: samples taken during the connect storm (the joiner is - * receiving objects, compiling shaders, first-painting) carried 100+ ms of one-sided - * main-thread delay and pulled a 6-sample median to +427 ms on a true +300 — so the - * burst waits for the storm to pass, and the filter below discounts what it catches. */ -const BURST = 6; -const BURST_GAP_MS = 250; -const BURST_DELAY_MS = 2000; -/** steady-state re-measure, so a drifting clock is tracked and storm samples age out */ -const RESYNC_MS = 5000; - -/** @type {Record} */ -const clockSamples = {}; - -/** peerId -> `{offset, rtt, samples}` — offset is THEIR clock minus OURS, in ms. - * Local, derived, never replicated (the `peerQuality` precedent). - * @type {import('svelte/store').Writable>} */ -export const peerClocks = writable({}); - -/** @param {number[]} arr */ -function median(arr) { - const s = [...arr].sort((a, b) => a - b); - const m = Math.floor(s.length / 2); - return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; -} - -/** - * The estimate from a ring of samples: the MEDIAN OFFSET OF THE LOWEST-RTT HALF. - * - * A sample's error is its round trip's asymmetry, and asymmetry comes from queueing — - * a packet that waited (in the network, or on a busy main thread before the handler - * ran) is late on ONE leg. The samples with the shortest round trips waited the least, - * so NTP's clock filter keeps the minimum-delay sample; taking the median of the best - * half keeps that bias-rejection while still outvoting a single odd reading. Pure, - * exported for the suite. @param {{offsets: number[], rtts: number[]}} ring - */ -export function estimateFromSamples(ring) { - const n = ring.offsets.length; - if (!n) return null; - const order = ring.rtts.map((rtt, i) => i).sort((a, b) => ring.rtts[a] - ring.rtts[b]); - const best = order.slice(0, Math.max(1, Math.ceil(n / 2))); - return { - offset: median(best.map((i) => ring.offsets[i])), - rtt: median(best.map((i) => ring.rtts[i])), - samples: n - }; -} - -/** - * Fold one measurement into a peer's ring and republish the median. Pure enough to - * test without a connection. @param {string} peerId @param {number} offset @param {number} rtt - */ -export function recordClockSample(peerId, offset, rtt) { - if (!Number.isFinite(offset) || !Number.isFinite(rtt) || rtt < 0) return; - const ring = (clockSamples[peerId] ??= { offsets: [], rtts: [] }); - ring.offsets.push(offset); - ring.rtts.push(rtt); - while (ring.offsets.length > CLOCK_RING) { - ring.offsets.shift(); - ring.rtts.shift(); - } - const estimate = estimateFromSamples(ring); - if (estimate) peerClocks.update((map) => ({ ...map, [peerId]: estimate })); -} - -/** The estimated offset of a peer's clock from ours (ms, theirs minus ours), or null - * before the first sample lands. @param {string} peerId */ -export function peerClockOffset(peerId) { - return get(peerClocks)[peerId]?.offset ?? null; -} - -/** - * A stamp taken on `peerId`'s clock, expressed on OURS. The primitive for the - * colocated case (see the header): only meaningful when the GRID is corrected by the - * same rule, so nothing in core applies it by default. Unknown peer = unchanged. - * @param {string} peerId @param {number} wallMs - */ -export function correctRemoteStamp(peerId, wallMs) { - const offset = peerClockOffset(peerId); - return offset == null ? wallMs : wallMs - offset; -} - -/** Drop a peer's samples (handleDisconnected — golden rule 3). @param {string} peerId */ -export function dropPeerClock(peerId) { - delete clockSamples[peerId]; - peerClocks.update((map) => { - if (!(peerId in map)) return map; - const next = { ...map }; - delete next[peerId]; - return next; - }); -} - -/** @param {string} peerId @returns {any} the stable OUTGOING conn, or null */ -function connFor(peerId) { - /** @type {any} */ - const peer = get(peers); - const conn = peer?.connections?.[peerId]; - return conn && conn.open ? conn : null; -} - -/** One ping. Returns false when there is no open conn to send it on. @param {string} peerId */ -export function sendClockPing(peerId) { - const conn = connFor(peerId); - if (!conn) return false; - /** @type {any} */ - const peer = get(peers); - conn.send({ type: 'clockping', sender: peer.peer.id, t0: Date.now() }); - return true; -} - -/** - * Answer a ping. Stamped on receipt (t1) and again on send (t2) so the responder's - * own processing time is subtracted out of the round trip. Replies over our stable - * OUTGOING conn to the sender (golden rule 9), falling back to the conn it arrived on - * while the dance is still settling. @param {any} data @param {any} [arrivedOn] - */ -export function answerClockPing(data, arrivedOn) { - const t1 = Date.now(); - if (!data || typeof data.t0 !== 'number') return; - /** @type {any} */ - const peer = get(peers); - const conn = connFor(data.sender) ?? (arrivedOn && arrivedOn.open ? arrivedOn : null); - if (!conn) return; - conn.send({ type: 'clockpong', sender: peer?.peer?.id ?? '', t0: data.t0, t1, t2: Date.now() }); -} - -/** Fold a pong into the sender's estimate. @param {any} data */ -export function applyClockPong(data) { - const t3 = Date.now(); - if (!data || typeof data.t0 !== 'number' || typeof data.t1 !== 'number' || typeof data.t2 !== 'number') return; - if (!data.sender) return; - const rtt = t3 - data.t0 - (data.t2 - data.t1); - const offset = (data.t1 - data.t0 + (data.t2 - t3)) / 2; - recordClockSample(String(data.sender), offset, rtt); -} - -/** @type {any} */ -let resyncTimer = null; - -/** - * Start measuring a peer: a short burst now (so an estimate exists within a second of - * connecting — the median needs several samples before it means anything), then a - * steady re-measure every RESYNC_MS for as long as the conn is open. Called from - * `sendHandshake`, which is the one place a conn is known to be OPEN (golden rule 2). - * @param {string} peerId - */ -export function startClockSync(peerId) { - if (typeof setTimeout === 'undefined') return; - for (let i = 0; i < BURST; i++) setTimeout(() => sendClockPing(peerId), BURST_DELAY_MS + i * BURST_GAP_MS); - if (resyncTimer == null) { - resyncTimer = setInterval(() => { - /** @type {any} */ - const peer = get(peers); - for (const id of Object.keys(peer?.connections ?? {})) sendClockPing(id); - }, RESYNC_MS); - } -} +// 25-E MOVED THE ESTIMATOR OUT. It was built here for the transport and then applied to +// nothing, because only the music line knew it existed; the session needed it far more +// (every latest-wins stamp, every trigger pulse, the synced flow clock). The four-stamp +// maths and the ring live in the `sessionClock` leaf, the round trip in `clockSync`, +// and this module now keeps time by `sessionNow()` like every other stamp site — which +// is the "correct BOTH the grid and the stamp" rule from the header, applied to the grid +// (`startedAt`) and every note stamp at once. Re-exported so the suite and any caller +// that learned the names here keep working. +export { + peerClocks, + estimateFromSamples, + recordClockSample, + peerClockOffset, + correctRemoteStamp, + dropPeerClock +} from './sessionClock'; +export { sendClockPing, answerClockPing, applyClockPong, startClockSync } from './clockSync'; // ---- debug ---------------------------------------------------------------------- diff --git a/src/lib/particleActions.js b/src/lib/particleActions.js index ce11dedb..7ce4b07e 100644 --- a/src/lib/particleActions.js +++ b/src/lib/particleActions.js @@ -1,4 +1,5 @@ import { get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { objectsGroup, selectedObject, pokeScene } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; import { recordEntry } from './history'; @@ -68,7 +69,7 @@ export function removeObjectParticles(uuid) { * @param {string} uuid */ export function burstObjectParticles(uuid) { - const t = (Date.now() % 86400000) / 1000; // same formula as the flow tick clock + const t = (sessionNow() % 86400000) / 1000; // same formula as the flow tick clock applyBurst(uuid, t); /** @type {any} */ const peer = get(peers); diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index db242582..97c1e669 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -66,7 +66,10 @@ import { applyRemoteColocation, dropPeerColocation, sendColocationState } from ' import { applyRemoteScenePost, scenePostStates, sendScenePost } from '$lib/scenePost'; // 23-A2: the musical transport (a latest-wins singleton like scenephysics) and the // peer clock-offset estimate it carries alongside -import { applyRemoteTransport, transportState, sendTransport, answerClockPing, applyClockPong, startClockSync } from '$lib/musicClock'; +import { applyRemoteTransport, transportState, sendTransport } from '$lib/musicClock'; +// 25-E: the clock round trip is the SESSION's now, not the music line's (sessionClock.js) +import { answerClockPing, applyClockPong, startClockSync } from '$lib/clockSync'; +import { sessionNow } from '$lib/sessionClock'; import { applyRemoteDeviceNote } from '$lib/audioDevices'; // 23-A4: the patch (cables between device ports), a latest-wins singleton import { applyRemotePatch, patchState, sendPatch } from '$lib/audioPatch'; @@ -701,7 +704,9 @@ export class PeerConnection { if (sameRoomOrUnknown(conn.peer)) sendTransport(data.sender); } else if(data.type == 'clockping') { // 23-A2: the peer clock-offset round trip. Answered over the stable OUTGOING - // conn to the sender (golden rule 9), this conn only as the fallback. + // conn to the sender (golden rule 9), this conn only as the fallback. 25-E: + // the pong now also says which clock WE keep, so a joiner of a joiner + // inherits the session's time (clockSync.js). answerClockPing(data, conn); } else if(data.type == 'clockpong') { applyClockPong(data); @@ -1091,7 +1096,7 @@ export class PeerConnection { // scene privately it answers `{scene:'', hash:'', private:true}`, so the very first // message of a handshake is where the name stops. Reading `myScene()` here (which is // the SCREEN's answer, name and all) would leak it to every peer that ever connects. - conn.send({ type: 'atscene', peerId: this.peer.id, ...mySceneWire(), at: Date.now() }); + conn.send({ type: 'atscene', peerId: this.peer.id, ...mySceneWire(), at: sessionNow() }); } /** @@ -1168,6 +1173,11 @@ export class PeerConnection { // A1: WHERE WE ARE, ahead of everything else — the reasoning lives on // `sendMyScene`, which A2 shares with the arrival re-sync for the same reason. this.sendMyScene(conn); + // 25-E: the clock round trip goes out SECOND — ahead of every full-state request — + // so on an ordered conn the host's first pong lands before its content does, and a + // grossly wrong joiner clock is corrected before the history it would mis-stamp + // arrives (flowRuntime shifts its cutoffs for whatever still lands first). + startClockSync(peerId); // R22 round 35: `locked` is ROOM_SCOPED and this is a DIRECT send, so the broadcast // gate never sees it — a private peer would hand a stranger the uuids it is holding in // a scene that stranger cannot see. Our own table is stale while private anyway (every @@ -1241,9 +1251,6 @@ export class PeerConnection { if (getobjects && !holdContent) conn.send({type: 'getnodedefs', sender: this.peer.id}) // join them into the voice mesh if our mic is live voicePeerConnected(peerId); - // 23-A2: start estimating their clock's offset from ours — here because this is - // the one place the conn is known to be OPEN (golden rule 2) - startClockSync(peerId); } connectToPeer(peerId, getobjects = true, id = this.peer.id) { diff --git a/src/lib/peerScenes.js b/src/lib/peerScenes.js index b0a06263..88713320 100644 --- a/src/lib/peerScenes.js +++ b/src/lib/peerScenes.js @@ -33,6 +33,7 @@ // (currentLevel only) and appStore. Nothing here registers a history kind. import { writable, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; import { lockedObjects, selectedObject } from '../stores/sceneStore'; import { currentLevel } from './levels'; @@ -121,7 +122,7 @@ function broadcast(where) { // a monotonic-enough stamp: this is latest-wins per SENDER and only that sender // ever writes the row, so a plain clock is sufficient and ordering across peers // is never compared - at: Date.now() + at: sessionNow() }); } diff --git a/src/lib/peerVars.js b/src/lib/peerVars.js index 697072d8..8960d125 100644 --- a/src/lib/peerVars.js +++ b/src/lib/peerVars.js @@ -44,6 +44,7 @@ // `flowRuntime` imports it statically. import { writable, derived, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers, userdata } from '../stores/appStore'; /** How many names one peer may hold. A leaderboard, not a database — and a bound is @@ -125,7 +126,7 @@ export function broadcastPeerVars(force = false) { const peer = get(peers); const id = peer?.peer?.id; if (!id) return false; - sentAt = Math.max(Date.now(), sentAt + 1); + sentAt = Math.max(sessionNow(), sentAt + 1); peer.send({ type: 'peervars', peerId: id, vars: { ...vars }, at: sentAt }); return true; } @@ -359,7 +360,7 @@ export function clearPeerVars(announce = true) { const id = peer?.peer?.id; if (id) { sentJson = '{}'; - sentAt = Math.max(Date.now(), sentAt + 1); + sentAt = Math.max(sessionNow(), sentAt + 1); peer.send({ type: 'peervars', peerId: id, vars: {}, at: sentAt }); return; } diff --git a/src/lib/projectManifest.js b/src/lib/projectManifest.js index f2594d4d..3bd44a22 100644 --- a/src/lib/projectManifest.js +++ b/src/lib/projectManifest.js @@ -25,6 +25,7 @@ // bytes serves them. import { writable, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers, showToast, explorerClose, revealExplorerItem } from '../stores/appStore'; import { bottomDockActive } from './bottomDock'; import { showChoice } from './confirmDialog'; @@ -441,7 +442,7 @@ async function persist() { function commitManifest(next, opts = {}) { const before = get(projectManifest); const doc = normalizeManifest(next); - doc.changedAt = Math.max(Date.now(), (before.changedAt ?? 0) + 1, (opts.above ?? 0) + 1); + doc.changedAt = Math.max(sessionNow(), (before.changedAt ?? 0) + 1, (opts.above ?? 0) + 1); projectManifest.set(doc); void persist(); if (opts.replicate !== false) { diff --git a/src/lib/sceneMusic.js b/src/lib/sceneMusic.js index a3af2b68..4f50eac9 100644 --- a/src/lib/sceneMusic.js +++ b/src/lib/sceneMusic.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; import { ensureAudioContext, bus } from './audioEngine'; import { itemByHash, itemBlob } from './explorer'; @@ -101,7 +102,7 @@ function startSource(state) { src.loop = true; src.connect(gain); // synced phase: everyone starts inside the same loop cycle - const offset = ((Date.now() - (state.startedAt || Date.now())) / 1000) % buffer.duration; + const offset = ((sessionNow() - (state.startedAt || sessionNow())) / 1000) % buffer.duration; src.start(0, Math.max(0, offset)); source = src; startedKey = state.hash + '|' + state.startedAt; @@ -138,7 +139,7 @@ function reconcile() { /** Apply a change locally + replicate. @param {any} partial */ export function commitMusic(partial) { - const state = { ...get(music), ...partial, changedAt: Date.now() }; + const state = { ...get(music), ...partial, changedAt: sessionNow() }; music.set(state); reconcile(); /** @type {any} */ @@ -149,13 +150,13 @@ export function commitMusic(partial) { /** Set (or clear) the shared track by content hash; pushes the bytes to peers. * @param {string|null} hash @param {string} name */ export function setMusicTrack(hash, name = '') { - commitMusic({ hash, name, playing: !!hash, startedAt: hash ? Date.now() : 0 }); + commitMusic({ hash, name, playing: !!hash, startedAt: hash ? sessionNow() : 0 }); if (hash) sendAsset(hash); } /** Transport: play (restarts the synced phase) / stop. @param {boolean} playing */ export function setMusicPlaying(playing) { - commitMusic({ playing, startedAt: playing ? Date.now() : get(music).startedAt }); + commitMusic({ playing, startedAt: playing ? sessionNow() : get(music).startedAt }); } /** Shared volume (0..1) — adjusts gain without restarting. @param {number} v */ @@ -210,10 +211,10 @@ export function musicRestore(payload, replicate = false) { name: payload.name ?? '', volume: payload.volume ?? 0.8, playing: !!payload.playing, - startedAt: payload.playing ? Date.now() : 0, - changedAt: Date.now() + startedAt: payload.playing ? sessionNow() : 0, + changedAt: sessionNow() } - : { ...DEFAULT, changedAt: Date.now() }; + : { ...DEFAULT, changedAt: sessionNow() }; music.set(state); reconcile(); if (!replicate) return; @@ -252,7 +253,7 @@ export function startSceneMusic() { /** test/debug view of the live music chain */ export function musicDebug() { const state = get(music); - const offset = buffer && state.startedAt ? ((Date.now() - state.startedAt) / 1000) % buffer.duration : 0; + const offset = buffer && state.startedAt ? ((sessionNow() - state.startedAt) / 1000) % buffer.duration : 0; return { hash: state.hash, playing: state.playing, diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 08b8ebe8..c9a3141e 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -1,4 +1,5 @@ import { writable, derived, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; // CL-A A6 / 21-B B1: scene-wide physics settings. ONE shared object for the @@ -192,7 +193,7 @@ export function setScenePhysics(partial) { // previous stamp so the sequence stays strictly increasing const state = normalizeScenePhysics({ ...merged, - changedAt: Math.max(Date.now(), (current.changedAt ?? 0) + 1) + changedAt: Math.max(sessionNow(), (current.changedAt ?? 0) + 1) }); scenePhysicsState_.set(state); /** @type {any} */ @@ -253,7 +254,7 @@ export function scenePhysicsRestore(payload, replicate = false) { // changedAt the save happens to carry (an old file's stamp is in the past). // Monotonic for the same reason setScenePhysics is: a restore can land in the // same millisecond as the write before it, and an equal stamp is a coin toss. - next.changedAt = Math.max(Date.now(), (get(scenePhysicsState_).changedAt ?? 0) + 1); + next.changedAt = Math.max(sessionNow(), (get(scenePhysicsState_).changedAt ?? 0) + 1); scenePhysicsState_.set(next); if (replicate) { /** @type {any} */ diff --git a/src/lib/scenePost.js b/src/lib/scenePost.js index 5a1f77a3..0583233b 100644 --- a/src/lib/scenePost.js +++ b/src/lib/scenePost.js @@ -1,4 +1,5 @@ import { writable, derived, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; import { viewportOverrides } from './viewportOverrides'; // L2: the 'look' history kind. Safe as a static import — history's own subtree is @@ -415,7 +416,7 @@ function commit(fn, key = POST_SCENE_KEY) { // MONOTONIC per key (the shaderGraph lesson): a gesture writes several times in one // millisecond, so a bare Date.now() gives those edits the SAME stamp and a receiver // guarding with <= drops all but the first. - next.changedAt = Math.max(Date.now(), (postStackFor(key).changedAt || 0) + 1); + next.changedAt = Math.max(sessionNow(), (postStackFor(key).changedAt || 0) + 1); postStacks.update((map) => ({ ...map, [key]: next })); if (gesture) return next; // the gesture owns the entry and the broadcast if (before) recordLookEntry(before, next, key); @@ -468,7 +469,7 @@ registerHistoryKind('look', (entry, state) => { applyingHistory = true; try { const next = normalizeScenePost(target); - next.changedAt = Math.max(Date.now(), (postStackFor(key).changedAt || 0) + 1); + next.changedAt = Math.max(sessionNow(), (postStackFor(key).changedAt || 0) + 1); postStacks.update((map) => ({ ...map, [key]: next })); broadcastScenePost(key); } finally { @@ -652,7 +653,7 @@ export function scenePostRestore(payload, replicate = false) { : { [POST_SCENE_KEY]: payload }; /** @type {Record} */ const next = {}; - let stamp = Date.now(); + let stamp = sessionNow(); for (const key of Object.keys(source)) { const doc = normalizeScenePost(source[key]); // a restore is an authoritative local write, so it must WIN over whatever diff --git a/src/lib/sessionClock.js b/src/lib/sessionClock.js new file mode 100644 index 00000000..b91f0cda --- /dev/null +++ b/src/lib/sessionClock.js @@ -0,0 +1,300 @@ +import { writable, get } from 'svelte/store'; + +/** + * 25-E — ONE CLOCK FOR THE SESSION (audit M8). + * + * Every stamp that crosses the wire was a `Date.now()` on SOME peer's machine, and every + * receiver compared it against its OWN `Date.now()`. Two machines rarely agree: a phone + * drifts by seconds, a locked-down laptop by minutes. So a peer whose clock ran 90 s fast + * won every latest-wins merge for the next 90 s (its sky, its gravity, its game state + * could not be overwritten by anybody else's LATER edit), its flow pulses arrived "from + * the future", and every deterministic animation ran 90 s out of phase with the room. + * + * `sessionNow()` is the answer: the wall clock of the peer whose session we JOINED + * (`sessionHost`), estimated NTP-style over the data channel, and our own `Date.now()` + * while we host. It is transitive — a joiner that approves somebody else hands on the + * clock it adopted, because a pong carries the responder's own session offset — so the + * whole mesh keeps ONE time however it was formed. Local-only timing (a debounce, a + * toast's life, a retry backoff) stays on `Date.now()`: only a number another machine + * will compare needs to be on the session's clock. + * + * A LEAF on purpose (svelte/store only): flowRuntime, environment, gameState and a dozen + * other stamp sites import it, several of them inside the history-cycle family, and + * `connectionState` re-exports it so peer code reaches it where it already looks. The + * WIRE half — the ping/pong round trip, the connect burst, the skew toast — is + * `clockSync.js`, which needs `peers` and may therefore not be imported from here. + * + * The estimator itself moved here from `musicClock` (23-A2 measured it: noise floor + * under 5 ms at true skew 0, convergence within 10 ms on an injected +300 ms). It was + * built and then deliberately applied to NOTHING; this module is what applies it. + */ + +// ---- the estimator (moved from musicClock, 23-A2) ----------------------------------- +// +// NTP's four-stamp round trip, over the data channel the peers already share: +// t0 we send `clockping` (our clock) +// t1 they receive it (their clock) +// t2 they send `clockpong` (their clock) +// t3 we receive it (our clock) +// rtt = (t3 - t0) - (t2 - t1) +// offset = ((t1 - t0) + (t2 - t3)) / 2 their clock minus ours +// The error of one sample is bounded by the round trip's ASYMMETRY, at most rtt/2. + +/** samples kept per peer */ +export const CLOCK_RING = 12; + +/** @type {Record} */ +export const clockSamples = {}; + +/** peerId -> `{offset, rtt, samples}` — offset is THEIR RAW clock minus OURS, in ms. + * Local, derived, never replicated (the `peerQuality` precedent). + * @type {import('svelte/store').Writable>} */ +export const peerClocks = writable({}); + +/** @param {number[]} arr */ +function median(arr) { + const s = [...arr].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +} + +/** + * The estimate from a ring of samples: the MEDIAN OFFSET OF THE LOWEST-RTT HALF. + * + * A sample's error is its round trip's asymmetry, and asymmetry comes from queueing — + * a packet that waited (in the network, or on a busy main thread before the handler + * ran) is late on ONE leg. The samples with the shortest round trips waited the least, + * so NTP's clock filter keeps the minimum-delay sample; taking the median of the best + * half keeps that bias-rejection while still outvoting a single odd reading. Pure. + * @param {{offsets: number[], rtts: number[]}} ring + */ +export function estimateFromSamples(ring) { + const n = ring.offsets.length; + if (!n) return null; + const order = ring.rtts.map((rtt, i) => i).sort((a, b) => ring.rtts[a] - ring.rtts[b]); + const best = order.slice(0, Math.max(1, Math.ceil(n / 2))); + return { + offset: median(best.map((i) => ring.offsets[i])), + rtt: median(best.map((i) => ring.rtts[i])), + samples: n + }; +} + +/** + * Fold one measurement into a peer's ring and republish the median — and, when that + * peer is the one we keep time by, re-decide the session offset. Pure enough to test + * without a connection. @param {string} peerId @param {number} offset @param {number} rtt + */ +export function recordClockSample(peerId, offset, rtt) { + if (!Number.isFinite(offset) || !Number.isFinite(rtt) || rtt < 0) return; + const ring = (clockSamples[peerId] ??= { offsets: [], rtts: [] }); + ring.offsets.push(offset); + ring.rtts.push(rtt); + while (ring.offsets.length > CLOCK_RING) { + ring.offsets.shift(); + ring.rtts.shift(); + } + const estimate = estimateFromSamples(ring); + if (estimate) peerClocks.update((map) => ({ ...map, [peerId]: estimate })); + if (peerId === reference) reconsider(); +} + +/** The estimated RAW offset of a peer's clock from ours (ms, theirs minus ours), or + * null before the first sample lands. @param {string} peerId */ +export function peerClockOffset(peerId) { + return get(peerClocks)[peerId]?.offset ?? null; +} + +/** + * A stamp taken on `peerId`'s RAW clock, expressed on OURS. Kept for the colocated + * music case (musicClock's header); session stamps need no correction at all, which is + * the point of `sessionNow`. Unknown peer = unchanged. + * @param {string} peerId @param {number} wallMs + */ +export function correctRemoteStamp(peerId, wallMs) { + const offset = peerClockOffset(peerId); + return offset == null ? wallMs : wallMs - offset; +} + +/** + * Drop a peer's samples (handleDisconnected — golden rule 3). The SESSION OFFSET is kept + * even when the departing peer was our reference: everybody still here keeps time by the + * same clock, and snapping back to our own would put every stamp we write from now on + * out of step with theirs. Only leaving the session resets it. + * @param {string} peerId + */ +export function dropPeerClock(peerId) { + delete clockSamples[peerId]; + delete remoteSession[peerId]; + peerClocks.update((map) => { + if (!(peerId in map)) return map; + const next = { ...map }; + delete next[peerId]; + return next; + }); +} + +// ---- the session clock --------------------------------------------------------------- + +/** Below this, a better estimate is noise and the clock is left alone: every adoption + * is a small JUMP in every stamp and every flow `time`, and the estimator's own noise + * floor on a real network is several milliseconds. */ +export const ADOPT_THRESHOLD_MS = 50; +/** A gross skew is corrected on the FIRST sample (storm samples carry ~100 ms of error, + * which is nothing against 90 s); a small one waits for the filter to have something to + * filter. */ +export const GROSS_SKEW_MS = 1000; +export const MIN_SAMPLES = 3; + +/** ms to add to our `Date.now()` to read the session's clock */ +let offset = 0; +/** the peer we keep time by — `sessionHost`, null while we host */ +/** @type {string | null} */ +let reference = null; +/** what each peer's pong said about ITS session clock: `{so, ref}` — `so` is the offset + * it adds to its own Date.now, `ref` whose clock that is (the loop guard) + * @type {Record} */ +const remoteSession = {}; +/** our own peer id, for the loop guard — handed in by the wire half */ +/** @type {string | null} */ +let myId = null; + +/** + * What the session clock is doing, for the Statistics/diagnostics surfaces and suites. + * @type {import('svelte/store').Writable<{offset: number, reference: string|null, adoptedAt: number, adoptions: number}>} + */ +export const sessionClock = writable({ offset: 0, reference: null, adoptedAt: 0, adoptions: 0 }); + +/** + * THE session time, in epoch milliseconds. Use it for every stamp another peer will + * compare (a latest-wins `changedAt`, a trigger pulse, a game's `startedAt`) and for every + * clock two peers must agree on (the synced flow `time`, the musical transport). + * @returns {number} + */ +export function sessionNow() { + return Date.now() + offset; +} + +/** The current session offset in ms (session minus our raw clock). */ +export function sessionOffset() { + return offset; +} + +/** @param {string | null} id */ +export function setClockSelf(id) { + myId = id || null; +} + +/** + * Keep time by `peerId` (the session host), or by ourselves with null. A NEW reference + * with no estimate yet leaves the current offset in place until its first sample lands; + * null does NOT reset the offset (see `dropPeerClock`) — `resetSessionClock` does. + * @param {string | null} peerId + */ +export function setClockReference(peerId) { + const next = peerId || null; + if (next === reference) return; + reference = next; + publish(); + reconsider(); +} + +/** + * A pong told us about the responder's own session clock. Folded in only when that peer + * is our reference. @param {string} peerId @param {any} so @param {any} ref + */ +export function noteRemoteSessionClock(peerId, so, ref) { + if (typeof so !== 'number' || !Number.isFinite(so)) return; // an older peer: raw clock + remoteSession[peerId] = { so, ref: typeof ref === 'string' && ref ? ref : null }; + if (peerId === reference) reconsider(); +} + +/** + * The offset the session clock SHOULD have right now, or null when there is no + * trustworthy answer yet. Pure over the module's state; exported for the suites. + * @returns {number | null} + */ +export function targetOffset() { + if (!reference) return null; + const est = get(peerClocks)[reference]; + if (!est) return null; + const remote = remoteSession[reference]; + // THE LOOP GUARD: a reference that keeps time by US would hand our own clock back + // with its estimation error added, and two peers doing that to each other random-walk + // forever. Its RAW clock is still a better answer than nothing, so use that alone. + const so = remote && remote.ref !== myId ? remote.so : 0; + const target = est.offset + so; + if (est.samples < MIN_SAMPLES && Math.abs(target - offset) < GROSS_SKEW_MS) return null; + return target; +} + +/** @type {Set<(deltaMs: number) => void>} */ +const jumpListeners = new Set(); + +/** + * Be told when the session clock JUMPS, with the jump in ms (new minus old). + * + * Anything that recorded a session time as a LOCAL cutoff needs this. The case that forced + * it: a joiner's handshake lands the trigger log and the graph BEFORE the first pong, so + * flowRuntime records its history epoch and every action node's first-seen time on the + * joiner's OWN clock — and when that clock is then corrected by -90 s, every live pulse + * reads as 90 s older than the node that would act on it and is refused for a minute and + * a half. Shifting the cutoffs by the jump keeps them meaning what they meant. + * @param {(deltaMs: number) => void} fn @returns {() => void} + */ +export function onSessionClockJump(fn) { + jumpListeners.add(fn); + return () => jumpListeners.delete(fn); +} + +/** @param {number} next */ +function jumpTo(next) { + const delta = next - offset; + offset = next; + const s = get(sessionClock); + sessionClock.set({ offset, reference, adoptedAt: Date.now(), adoptions: s.adoptions + 1 }); + for (const fn of jumpListeners) { + try { + fn(delta); + } catch (error) { + console.warn('[sessionClock] a jump listener threw', error); + } + } +} + +function reconsider() { + const target = targetOffset(); + if (target == null) return; + if (Math.abs(target - offset) < ADOPT_THRESHOLD_MS) return; + jumpTo(Math.round(target)); +} + +function publish() { + const s = get(sessionClock); + if (s.reference === reference && s.offset === offset) return; + sessionClock.set({ ...s, offset, reference }); +} + +/** Leaving the session: our own clock is the only one left. Samples are per-peer and + * are dropped by their own teardown, so this only resets the session half. */ +export function resetSessionClock() { + reference = null; + for (const k of Object.keys(remoteSession)) delete remoteSession[k]; + if (offset !== 0) jumpTo(0); + sessionClock.set({ ...get(sessionClock), offset: 0, reference: null, adoptedAt: 0 }); +} + +/** Everything a suite wants in one read. */ +export function sessionClockDebug() { + return { + now: sessionNow(), + offset, + reference, + myId, + target: targetOffset(), + remote: JSON.parse(JSON.stringify(remoteSession)), + peers: JSON.parse(JSON.stringify(get(peerClocks))), + samples: JSON.parse(JSON.stringify(clockSamples)), + state: get(sessionClock) + }; +} diff --git a/src/lib/shaderGraph.js b/src/lib/shaderGraph.js index 206e6866..714f0105 100644 --- a/src/lib/shaderGraph.js +++ b/src/lib/shaderGraph.js @@ -14,6 +14,7 @@ // tracks the scene's light set, which ShaderFrog silently does not). import { writable, get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { objectsGroup, globalScene, globalCamera, globalRenderer, pokeScene } from '../stores/sceneStore.js'; import { compileShaderGraphToIR } from './shaderCompile.js'; import { compileShaderGraph, INJECT_SHADER_BACKEND, forgetShaderContext } from './shaderBackends.js'; @@ -166,7 +167,7 @@ export function setShaderGraphFor(key, patch, opts = {}) { // millisecond, and with a bare Date.now() those edits share a stamp — the // receiver's latest-wins guard then drops every one after the first, so a // drag (and the undo that follows it) silently failed to replicate. - changedAt: opts.stamp ?? Math.max(Date.now(), (all[key]?.changedAt ?? 0) + 1) + changedAt: opts.stamp ?? Math.max(sessionNow(), (all[key]?.changedAt ?? 0) + 1) }); next[key] = after; } @@ -381,7 +382,7 @@ export function stopReconcile() { /** Wall clock wrapped daily to keep float precision. @returns {number} */ export function shaderClockNow() { - return (Date.now() % 86400000) / 1000; + return (sessionNow() % 86400000) / 1000; } /** @type {number|null} */ @@ -705,7 +706,7 @@ export function shaderGraphsRestore(map, replace = false) { for (const [key, doc] of Object.entries(map)) { if (!doc) continue; // silent: a restore is not an undo step and must not re-broadcast - setShaderGraphFor(key, normalizeShaderGraph(doc), { silent: true, stamp: Date.now() }); + setShaderGraphFor(key, normalizeShaderGraph(doc), { silent: true, stamp: sessionNow() }); } reconcileShaderGraphs(); } diff --git a/src/lib/shaderSync.js b/src/lib/shaderSync.js index 5294f660..ace4875c 100644 --- a/src/lib/shaderSync.js +++ b/src/lib/shaderSync.js @@ -11,6 +11,7 @@ // re-broadcasts (golden rule 1); a late joiner pulls the whole map (golden rule 3). import { get } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; import { registerHistoryKind, recordEntry } from './history'; import { @@ -37,7 +38,7 @@ function broadcast(key, doc) { const peer = get(peers); if (!peer) return; if (doc) peer.send({ type: 'shadergraph', key, doc: wireDoc(doc) }); - else peer.send({ type: 'shadergraphdelete', key, changedAt: Date.now() }); + else peer.send({ type: 'shadergraphdelete', key, changedAt: sessionNow() }); } /** diff --git a/src/lib/sharedLibrary.js b/src/lib/sharedLibrary.js index 28b46da3..76039524 100644 --- a/src/lib/sharedLibrary.js +++ b/src/lib/sharedLibrary.js @@ -83,6 +83,7 @@ // `publishSharedIndex`, which refuses for a viewer. import { get, writable } from 'svelte/store'; +import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers, userdata, showToast } from '../stores/appStore'; import { explorerFolders, @@ -248,7 +249,7 @@ export function pullSharedItem(hash) { function projection() { const doc = get(projectManifest); const owner = meAsOwner(); - const now = Date.now(); + const now = sessionNow(); /** * `at` MUST BE STABLE FOR AN UNCHANGED ROW, or `publishSharedIndex`'s content compare @@ -655,7 +656,7 @@ function tomb(keys) { const doc = get(projectManifest); /** @type {any} */ const prev = doc.removed ?? {}; - const at = Date.now(); + const at = sessionNow(); /** @type {any} */ const next = { items: { ...(prev.items ?? {}) }, folders: { ...(prev.folders ?? {}) } }; for (const hash of keys.items ?? []) next.items[hash] = at; @@ -1046,7 +1047,7 @@ export function logLocalDeletion(spec) { hash, name: String(spec.name ?? hash), kind: String(spec.kind ?? 'text'), - at: Date.now(), + at: sessionNow(), by: meAsOwner(), localOnly: true, ...(spec.folderId === undefined ? {} : { folderId: spec.folderId ?? null }), @@ -1169,7 +1170,7 @@ export function deleteItemsToBin(ids) { const keepRow = get(recycleBinEnabled) || get(deletedLogEnabled); const log = [...(doc.deleted ?? [])]; const tombs = tombsOf(doc); - const at = Date.now(); + const at = sessionNow(); const by = meAsOwner(); /** @type {Set} */ const gone = new Set(); @@ -1207,7 +1208,7 @@ export function deleteFolderToBin(id) { const keepRow = get(recycleBinEnabled) || get(deletedLogEnabled); const log = [...(doc.deleted ?? [])]; const tombs = tombsOf(doc); - const at = Date.now(); + const at = sessionNow(); const by = meAsOwner(); // THE ITEMS FIRST, while the folder records still exist: `folderPath` reads the live // tree, so a row written after the removal would carry an empty path — and the path is diff --git a/tests/e2e/session-clock.test.cjs b/tests/e2e/session-clock.test.cjs new file mode 100644 index 00000000..7fdb6a92 --- /dev/null +++ b/tests/e2e/session-clock.test.cjs @@ -0,0 +1,185 @@ +// 25-E (roadmap 25 section 4, audit M8) — ONE CLOCK FOR THE SESSION. +// +// Every stamp another peer compares used to be that machine's own Date.now(). A joiner +// whose clock runs 90 s fast therefore WON every latest-wins merge for the next 90 s — +// a host's LATER edit to the sky was refused on the joiner and overwritten on the host — +// its flow clock ran 90 s out of phase, and its game timer read a round 90 s older. +// +// What this suite pins, with C's Date.now pushed +90 s by an init script (the music-clock +// 6b recipe) and A its honest host: +// 1. premise: the skew is real, and a lone peer's session clock is its own +// 2. the joiner ADOPTS the host's clock (sessionNow agrees across the two machines) +// 3. a LATER edit wins on both sides even though the earlier one came from the fast clock +// 4. the synced flow clock and a game's elapsed time agree across the two +// 5. one toast per skewed peer, and the round trip is on the capability floor +// 6. the wire is additive (a pong carries so/ref; an older pong without them still folds) +// 7. leaving the session hands the joiner its own clock back; the host drops the samples +// +// Measured against the REAL clock (`new Date().getTime()`, which the init script leaves +// alone), so evaluate lag between two pages cannot pass or fail a check by itself. +// +// Run: APP_URL=https://theprototype.app:5175/ PEER_CONFIG=... npm run e2e -- session-clock +const h = require('./helpers.cjs'); + +const SKEW = 90000; + +/** run a snippet with `s = window.__stores` in scope */ +const inPage = (peer, body, arg) => + peer.page.evaluate(([src, a]) => Object.getPrototypeOf(async function () {}).constructor('s', 'arg', src)(window.__stores, a), [body, arg ?? null]); + +/** the session clock minus the REAL clock, in ms — 0 on a machine keeping true time */ +const sessionError = (peer) => inPage(peer, 'return s.connectionState.sessionNow() - new Date().getTime()'); +const debug = (peer) => inPage(peer, 'return s.connectionState.sessionClockDebug()'); +const notes = (peer) => + inPage(peer, 'let v = []; s.notifications.subscribe((x) => (v = x))(); return v.map((n) => String(n.text ?? n.message ?? n))'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const C = await h.setupPage(browser, 'C'); + await C.ctx.addInitScript((skew) => { + const real = Date.now; + Date.now = () => real() + skew; + }, SKEW); + await h.freshReload(C); + C.id = await C.page.evaluate(() => new Promise((r) => window.__stores.peers.subscribe((p) => r(p?.peer?.id))())); + console.log('A id: ' + A.id + ' C id (skewed +90s): ' + C.id); + + // ---- 1. premise ------------------------------------------------------------------------ + console.log('\n=== 1. premise ==='); + const raw = await inPage(C, 'return Date.now() - new Date().getTime()'); + h.check(raw >= SKEW - 5 && raw <= SKEW + 5, `C's raw Date.now runs ${SKEW} ms ahead of the real clock (${raw})`); + const alone = await debug(C); + h.check(alone.offset === 0 && alone.reference === null, `a peer on its own keeps its own clock (offset ${alone.offset}, reference ${alone.reference})`); + const aloneErr = await sessionError(C); + h.check(Math.abs(aloneErr - SKEW) < 50, `…so an unconnected C reads its own fast clock (${aloneErr})`); + + // ---- 2. the joiner adopts the host's clock ------------------------------------------------ + console.log('\n=== 2. adoption ==='); + await h.connect(C, A, 3000); + await h.eventually( + () => sessionError(C), + (e) => Math.abs(e) < 250, + "C's session clock lands on A's (the host keeps true time here)", + 20000 + ); + const cDbg = await debug(C); + const aDbg = await debug(A); + console.log(' C: ' + JSON.stringify({ offset: cDbg.offset, reference: cDbg.reference, state: cDbg.state })); + console.log(' A: ' + JSON.stringify({ offset: aDbg.offset, reference: aDbg.reference })); + h.check(cDbg.reference === A.id, `C keeps time by the peer whose session it joined (${cDbg.reference})`); + h.check(Math.abs(cDbg.offset + SKEW) < 250, `C's offset is the negative of its skew (${cDbg.offset})`); + h.check(aDbg.offset === 0 && aDbg.reference === null, `the HOST never moves its clock toward a joiner (offset ${aDbg.offset})`); + const [ea, ec] = await Promise.all([sessionError(A), sessionError(C)]); + h.check(Math.abs(ea - ec) < 250, `sessionNow agrees across the two machines (A ${ea}, C ${ec})`); + + // ---- 3. the later edit wins ------------------------------------------------------------- + console.log('\n=== 3. a later edit wins everywhere ==='); + // C edits FIRST; A edits a beat later. With raw clocks C's stamp is ~90 s newer, so A's + // later edit is refused on C and C's earlier one overwrites A. On the session clock the + // order of the stamps is the order things happened in. + await inPage(C, 'const e = s.environment; let st; e.environment.subscribe((v) => (st = v))(); e.setEnvironment("sunset", 1)'); + await h.eventually( + () => inPage(A, 'let st; s.environment.environment.subscribe((v) => (st = v))(); return st.preset'), + (p) => p === 'sunset', + "premise: C's edit reaches A", + 10000 + ); + await A.page.waitForTimeout(400); + await inPage(A, 's.environment.setEnvironment("night", 1)'); + await A.page.waitForTimeout(2500); + const presets = await Promise.all( + [A, C].map((p) => inPage(p, 'let st; s.environment.environment.subscribe((v) => (st = v))(); return { preset: st.preset, changedAt: st.changedAt }')) + ); + console.log(' A ' + JSON.stringify(presets[0]) + ' C ' + JSON.stringify(presets[1])); + h.check(presets[0].preset === 'night', `A keeps its own later edit (${presets[0].preset})`); + h.check(presets[1].preset === 'night', `C takes A's later edit over its own earlier one (${presets[1].preset})`); + + // ---- 4. the shared runtime clocks -------------------------------------------------------- + console.log('\n=== 4. the flow clock and the game timer ==='); + const [ta, tc] = await Promise.all([A, C].map((p) => inPage(p, 'return { t: s.moduleSDK.runtimeNow(), real: new Date().getTime() }'))); + // correct for the two evaluations landing at different real instants + const flowDiff = tc.t - ta.t - (tc.real - ta.real) / 1000; + h.check(Math.abs(flowDiff) < 0.3, `the synced flow time agrees to ${flowDiff.toFixed(3)} s (it was 90 s apart)`); + + // The history epoch and every action node's first-seen time are SESSION seconds taken + // as local cutoffs, mostly during the joiner's handshake. Recorded on the fast clock and + // never corrected, they sit 90 s in the future and every live pulse is refused as stale. + const syncedS = 'return { epoch: s.flowRuntime.triggerHistoryEpoch(), now: (s.connectionState.sessionNow() % 86400000) / 1000 }'; + const ep = await inPage(C, syncedS); + h.check(ep.epoch > 0, `premise: the joiner received trigger history and marked its epoch (${ep.epoch})`); + h.check(ep.epoch <= ep.now + 0.5, `the joiner's history epoch is not in the future of its corrected clock (epoch ${ep.epoch.toFixed(2)}, now ${ep.now.toFixed(2)})`); + // …and a jump that happens AFTER the epoch was taken moves it by exactly the jump. Force + // one by feeding C's ring zero-RTT samples 30 s away from the truth, then put it back. + const jump = await inPage(C, ` + const mc = s.musicClock; + const est = s.connectionState.sessionClockDebug().peers[arg].offset; + const e0 = s.flowRuntime.triggerHistoryEpoch(); + const o0 = s.connectionState.sessionClockDebug().offset; + for (let i = 0; i < 12; i++) mc.recordClockSample(arg, est + 30000, 0); + const e1 = s.flowRuntime.triggerHistoryEpoch(); + const moved = s.connectionState.sessionClockDebug().offset; + for (let i = 0; i < 12; i++) mc.recordClockSample(arg, est, 0); + const o2 = s.connectionState.sessionClockDebug().offset; + return { d: e1 - e0, want: (moved - o0) / 1000, back: s.flowRuntime.triggerHistoryEpoch() - e0, wantBack: (o2 - o0) / 1000 };`, A.id); + h.check(Math.abs(jump.want - 30) < 0.1 && Math.abs(jump.d - jump.want) < 0.001, `a 30 s clock correction moves the epoch by exactly the jump (${jump.d.toFixed(3)} for ${jump.want.toFixed(3)})`); + h.check(Math.abs(jump.back - jump.wantBack) < 0.001, `…and putting the clock back puts the epoch back (${jump.back.toFixed(3)} for ${jump.wantBack.toFixed(3)})`); + + await inPage(A, 's.gameState.setGameState("playing")'); + await h.eventually( + () => inPage(C, 'let g; s.gameState.gameState.subscribe((v) => (g = v))(); return g.state'), + (st) => st === 'playing', + 'premise: the game start reaches C', + 10000 + ); + const [ga, gc] = await Promise.all([A, C].map((p) => inPage(p, 'return { e: s.gameState.gameElapsed(), real: new Date().getTime() }'))); + const gameDiff = gc.e - ga.e - (gc.real - ga.real) / 1000; + h.check(Math.abs(gameDiff) < 0.3, `a round's elapsed time agrees to ${gameDiff.toFixed(3)} s on the fast joiner`); + await inPage(A, 's.gameState.setGameState("menu")'); + + // ---- 5. the toast and the floor ---------------------------------------------------------- + console.log('\n=== 5. the skew toast and the capability floor ==='); + await h.eventually( + () => notes(A), + (list) => list.some((t) => t.includes(String(C.id).slice(0, 6).toUpperCase()) && /clock is 90 s ahead/.test(t)), + "A is told C's clock is 90 s ahead", + 20000 + ); + await h.eventually( + () => notes(C), + (list) => list.some((t) => /clock is 90 s behind/.test(t)), + "C is told A's clock is 90 s behind", + 20000 + ); + const once = await notes(A); + h.check(once.filter((t) => /clock is 90 s ahead/.test(t)).length === 1, 'once per peer, not once per resync'); + const floor = await inPage(A, 's.cloudHooks.setCapabilityProvider(() => false); const r = { ping: s.cloudHooks.canApply("x", "clockping"), pong: s.cloudHooks.canApply("x", "clockpong"), other: s.cloudHooks.canApply("x", "environment") }; s.cloudHooks.setCapabilityProvider(null); return r'); + h.check(floor.ping && floor.pong && !floor.other, `clockping/clockpong sit on the ALWAYS_ALLOWED floor (${JSON.stringify(floor)})`); + + // ---- 6. the wire is additive ------------------------------------------------------------- + console.log('\n=== 6. additive wire ==='); + const pong = await inPage(C, ` + let pc; s.peers.subscribe((v) => (pc = v))(); + const conn = pc.connections[arg]; + const seen = []; + const orig = conn.send.bind(conn); + conn.send = (m) => { if (m?.type === 'clockpong') seen.push(m); return orig(m); }; + s.musicClock.answerClockPing({ type: 'clockping', sender: arg, t0: Date.now() }); + conn.send = orig; + return seen[0] ?? null;`, A.id); + h.check(!!pong && typeof pong.so === 'number' && pong.ref === A.id, `a pong carries the responder's session offset and whose clock it is (so ${pong?.so}, ref ${pong?.ref})`); + const old = await inPage(A, ` + const before = s.connectionState.sessionClockDebug().samples[arg]?.offsets.length ?? 0; + const now = Date.now(); + s.musicClock.applyClockPong({ type: 'clockpong', sender: arg, t0: now - 10, t1: now - 5, t2: now - 5 }); + return { before, after: s.connectionState.sessionClockDebug().samples[arg]?.offsets.length ?? 0 };`, C.id); + h.check(old.after === Math.min(old.before + 1, 12), `an OLDER peer's pong (no so/ref) still folds into the estimate (${old.before} -> ${old.after})`); + + // ---- 7. leaving ------------------------------------------------------------------------- + console.log('\n=== 7. leaving ==='); + await inPage(C, 'let p; s.peers.subscribe((v) => (p = v))(); p.leaveSession()'); + await h.eventually(() => debug(C), (d) => d.offset === 0 && d.reference === null, 'leaving hands C its own clock back', 10000); + await h.eventually(() => debug(A), (d) => !(C.id in d.peers), "the host drops the departed joiner's samples", 15000); + + return h.finish(browser); +}); diff --git a/tests/unit/sessionClock.test.js b/tests/unit/sessionClock.test.js new file mode 100644 index 00000000..d7f3344b --- /dev/null +++ b/tests/unit/sessionClock.test.js @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + sessionNow, + sessionOffset, + setClockReference, + setClockSelf, + recordClockSample, + noteRemoteSessionClock, + dropPeerClock, + resetSessionClock, + estimateFromSamples, + targetOffset, + onSessionClockJump, + ADOPT_THRESHOLD_MS, + GROSS_SKEW_MS, + MIN_SAMPLES +} from '../../src/lib/sessionClock.js'; + +// 25-E. The session clock is a pure decision over a handful of samples: WHICH peer we +// keep time by, WHEN an estimate is trustworthy enough to move the clock, and the two +// traps — a reference that keeps time by us, and a departure that must not snap every +// later stamp back onto a clock nobody else uses. + +let n = 0; +/** a fresh peer id per test, because the sample rings are module state */ +const fresh = () => 'peer' + ++n; + +beforeEach(() => { + resetSessionClock(); + setClockSelf('me'); +}); + +describe('sessionNow', () => { + it('is our own clock while we host', () => { + const a = Date.now(); + const s = sessionNow(); + expect(s - a).toBeGreaterThanOrEqual(0); + expect(s - a).toBeLessThan(20); + expect(sessionOffset()).toBe(0); + }); + + it('adopts a GROSS skew from its reference on the very first sample', () => { + const host = fresh(); + setClockReference(host); + recordClockSample(host, 90_000, 20); + expect(sessionOffset()).toBe(90_000); + expect(Math.abs(sessionNow() - (Date.now() + 90_000))).toBeLessThan(20); + }); + + it('waits for MIN_SAMPLES before moving on a small skew', () => { + const host = fresh(); + setClockReference(host); + for (let i = 0; i < MIN_SAMPLES - 1; i++) recordClockSample(host, 300, 10); + expect(sessionOffset()).toBe(0); + recordClockSample(host, 300, 10); + expect(sessionOffset()).toBe(300); + }); + + it('ignores samples from anybody but the reference', () => { + setClockReference(fresh()); + const other = fresh(); + for (let i = 0; i < 6; i++) recordClockSample(other, 90_000, 10); + expect(sessionOffset()).toBe(0); + }); + + it('does not chase noise under the adoption threshold', () => { + const host = fresh(); + setClockReference(host); + for (let i = 0; i < 6; i++) recordClockSample(host, ADOPT_THRESHOLD_MS - 5, 10); + expect(sessionOffset()).toBe(0); + }); + + it('is TRANSITIVE: a reference that follows its own host hands that clock on', () => { + const joiner = fresh(); + setClockReference(joiner); + // the joiner's raw clock is 10 s ahead of ours, and it keeps time by a host that + // is 5 s behind IT — so the session is 5 s ahead of us + noteRemoteSessionClock(joiner, -5_000, 'the-host'); + for (let i = 0; i < MIN_SAMPLES; i++) recordClockSample(joiner, 10_000, 10); + expect(sessionOffset()).toBe(5_000); + expect(targetOffset()).toBe(5_000); + }); + + it('refuses a clock handed back by a reference that follows US (the loop guard)', () => { + const loop = fresh(); + setClockReference(loop); + noteRemoteSessionClock(loop, 7_000, 'me'); + for (let i = 0; i < MIN_SAMPLES; i++) recordClockSample(loop, 2_000, 10); + // its RAW clock is still used — only the part it copied from us is dropped + expect(sessionOffset()).toBe(2_000); + }); + + it('an older peer (no `so` on the pong) reads as its raw clock', () => { + const old = fresh(); + setClockReference(old); + noteRemoteSessionClock(old, undefined, undefined); + for (let i = 0; i < MIN_SAMPLES; i++) recordClockSample(old, 4_000, 10); + expect(sessionOffset()).toBe(4_000); + }); + + it('KEEPS the offset when the reference departs, and drops it only on leaving', () => { + const host = fresh(); + setClockReference(host); + recordClockSample(host, 60_000, 10); + dropPeerClock(host); + setClockReference(null); + expect(sessionOffset()).toBe(60_000); + resetSessionClock(); + expect(sessionOffset()).toBe(0); + }); + + it('the gross-skew fast path is exactly GROSS_SKEW_MS', () => { + const host = fresh(); + setClockReference(host); + recordClockSample(host, GROSS_SKEW_MS - 1, 10); + expect(sessionOffset()).toBe(0); + const other = fresh(); + setClockReference(other); + recordClockSample(other, GROSS_SKEW_MS + 1, 10); + expect(sessionOffset()).toBe(GROSS_SKEW_MS + 1); + }); +}); + +describe('onSessionClockJump', () => { + it('reports each adoption as the jump it made, and a reset as the jump back', () => { + /** @type {number[]} */ + const jumps = []; + const off = onSessionClockJump((d) => jumps.push(d)); + const host = fresh(); + setClockReference(host); + recordClockSample(host, -90_000, 10); + recordClockSample(host, -90_000, 10); + recordClockSample(host, -89_900, 10); // median unchanged: no second jump + resetSessionClock(); + off(); + expect(jumps).toEqual([-90_000, 90_000]); + }); + + it('a throwing listener does not stop the clock or the others', () => { + /** @type {number[]} */ + const seen = []; + const a = onSessionClockJump(() => { + throw new Error('boom'); + }); + const b = onSessionClockJump((d) => seen.push(d)); + const host = fresh(); + setClockReference(host); + recordClockSample(host, 5_000, 10); + a(); + b(); + expect(sessionOffset()).toBe(5_000); + expect(seen).toEqual([5_000]); + }); +}); + +describe('estimateFromSamples (moved from musicClock)', () => { + it('takes the median of the lowest-RTT half', () => { + const est = estimateFromSamples({ offsets: [300, 310, 900, 305], rtts: [10, 12, 400, 11] }); + expect(est?.offset).toBe(302.5); + expect(est?.samples).toBe(4); + }); + it('answers null for an empty ring', () => { + expect(estimateFromSamples({ offsets: [], rtts: [] })).toBe(null); + }); +}); From d9a15ed7348c3fc6a4001170fe612dc531dce1e8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 09:22:16 +0300 Subject: [PATCH 24/27] [feat] 25-G: the mesh regression runs on four peers and its own signaling, and the rig measures presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 25 section 3d, and the N=4 regression 27-I's brief asked for. - tests/e2e/localSignal.cjs: the local `peer` server on :9001 (extracted from the rig), REUSED when one already answers (the port is machine-wide), plus LOCAL_PEER_STORAGE = peerServerConfig {mode:'local'}. Seeding the pages is what keeps them off production; the old "APP_URL must be localhost" check is now "must resolve to this machine", so a lane serving theprototype.app via /etc/hosts can run the rig. - net-stress.test.cjs: FOUR peers on the local server (was three on the shared box). With three, the host's `hosts` roster only ever names one other peer, so a fill that mishandled a longer list still passed; with four, six of the twelve links come from the fill alone. Checks: every ordered pair open (pair-complete), host broadcast whole to all three, all four blasting at once (12/12 pairs whole, counters reset first — the running-maximum trap), fan-out bounded, and NEW: the presence stream while all four orbit, stated as messages/s per sender against a premise that every sender drew well above the 20/s gate (so a per-frame sender would be visible), plus long tasks. - net-stress.cjs: default sizes 8,10,12,16; `--presence N` (every peer orbits for N seconds, each counts camera messages RECEIVED per sender); a long-tasks/min column on every load step; a presence table. Measured (Radeon 890M box, ALL peers on one machine, local signaling, 20 objects): - full mesh at 8, 10, 12 and 16 peers - 0% loss up to 3,360 (N=8), 10,800 (N=10) and 7,920 (N=12) mesh msgs/s; 0.24% at 15,840 (N=12); at N=16 0.07% even at 10Hz, 1.87% at 28,800 msgs/s — the box is saturated there (16 GPU contexts, idle 34fps, echo RTT p95 550ms) - presence received per peer: 132/s (N=8), 169/s (N=10), 207/s (N=12), 260/s (N=16); 0.31-0.36 messages per sender frame at 60fps, i.e. the 25-C gate holds at scale - long tasks/min 0 at N<=12; frame drop with N is GPU/compositor contention, not the main thread Counterfactuals (each broken, suite red, restored): - mesh fill disabled (hosts -> no connectToPeer): 6 of 12 pairs missing, 6/12 pairs deliver under four-way load - camera gate removed (camGapMs 0): 59.9 msgs/s per sender against 18.2 with it Suites: net-stress.test 15/15 (was 10/10 on three peers). svelte-check 352/47 (base 352/47). npm run build green. Co-Authored-By: Claude Opus 5 --- tests/e2e/localSignal.cjs | 74 +++++++++ tests/e2e/net-stress.cjs | 206 ++++++++++++++++++------- tests/e2e/net-stress.test.cjs | 280 +++++++++++++++++++++++----------- 3 files changed, 416 insertions(+), 144 deletions(-) create mode 100644 tests/e2e/localSignal.cjs diff --git a/tests/e2e/localSignal.cjs b/tests/e2e/localSignal.cjs new file mode 100644 index 00000000..2d918b67 --- /dev/null +++ b/tests/e2e/localSignal.cjs @@ -0,0 +1,74 @@ +// A LOCAL PeerJS signaling server for the multi-peer stress runs (net-stress rig and its +// regression suite). Flooding the production signaling box with a mesh sweep is abuse, +// and a shared box on a saturated machine is also the most common source of a two-peer +// red that has nothing to do with the diff — so these runs bring their own. +// +// Port 9001 is MACHINE-WIDE: two lanes share it. Everything that starts it runs under the +// e2e flock, and a server already listening is REUSED rather than fought over. +// +// Pages reach it through `peerServerConfig = {mode:'local'}` (peerServer.js), seeded with +// `LOCAL_PEER_STORAGE` — never by guessing from the page's hostname. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawn } = require('child_process'); + +const SIGNAL_PORT = 9001; +const ROOT = path.resolve(__dirname, '..', '..'); +const LOCAL_PEER_STORAGE = { peerServerConfig: JSON.stringify({ mode: 'local' }) }; + +/** @param {number} ms */ +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function signalUp() { + return new Promise((resolve) => { + const req = https.get( + { host: 'localhost', port: SIGNAL_PORT, path: '/', rejectUnauthorized: false, timeout: 1500 }, + (res) => { + res.resume(); + resolve(res.statusCode === 200); + } + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** Start the server unless one already answers. Returns the child to stop, or null. */ +async function ensureSignalServer() { + if (await signalUp()) return null; + const bin = path.join(ROOT, 'node_modules', 'peer', 'dist', 'bin', 'peerjs.js'); + const key = path.join(ROOT, 'certs', 'localhost.key'); + const crt = path.join(ROOT, 'certs', 'localhost.crt'); + if (!fs.existsSync(bin)) throw new Error('the `peer` devDependency is missing — run npm ci'); + if (!fs.existsSync(key)) throw new Error('certs/localhost.key missing — copy certs/ from another checkout'); + const child = spawn(process.execPath, [bin, '--port', String(SIGNAL_PORT), '--sslkey', key, '--sslcert', crt], { + cwd: ROOT, + stdio: 'ignore' + }); + for (let i = 0; i < 40; i++) { + await sleep(250); + if (await signalUp()) return child; + } + try { + child.kill(); + } catch { + /* already gone */ + } + throw new Error('local PeerJS server did not come up on :' + SIGNAL_PORT); +} + +/** @param {any} child */ +function stopSignalServer(child) { + if (!child) return; + try { + child.kill(); + } catch { + /* already gone */ + } +} + +module.exports = { SIGNAL_PORT, LOCAL_PEER_STORAGE, signalUp, ensureSignalServer, stopSignalServer }; diff --git a/tests/e2e/net-stress.cjs b/tests/e2e/net-stress.cjs index 3c776ab2..e80de918 100644 --- a/tests/e2e/net-stress.cjs +++ b/tests/e2e/net-stress.cjs @@ -1,7 +1,7 @@ // B5 — mesh network stress harness (LOCAL PeerJS ONLY). // -// node tests/e2e/net-stress.cjs [--peers 4,6,8,10] [--load 20] [--objects 20] -// [--out docs/net-stress.md] [--hz 10] +// node tests/e2e/net-stress.cjs [--peers 8,10,12,16] [--load 20] [--objects 20] +// [--out docs/net-stress.md] [--hz 10] [--presence 10] // // NOT a .test.cjs on purpose: a full sweep runs for many minutes, well past the // runner's per-suite timeout. `npm run e2e -- net-stress` runs the small @@ -14,20 +14,25 @@ // - message loss — sequence numbers over a synthetic mutation load // - fan-out cost — wall time of one PeerConnection.send() across N-1 conns // - renderer FPS — idle baseline vs under load (relative; see the caveat below) +// - long tasks/min — main-thread blocks over 50ms per peer under the load (25-G) +// - presence — with --presence N, every peer orbits its camera for N seconds and +// each counts the `camera` messages it RECEIVES per sender: the +// audit-H7 stream, now rate-gated (25-C), at mesh scale (25-G) // // HARD RULE: local signaling server only. Pointing a 10-peer flood at the public -// or self-hosted production box is abuse, so the harness refuses any APP_URL that -// isn't localhost and spawns its own `peer` server on :9001 (the same one the -// .vscode "peerjs" task starts). +// or self-hosted production box is abuse, so the harness spawns its own `peer` server on +// :9001 (localSignal.cjs) and SEEDS every page with `peerServerConfig = {mode:'local'}` — +// which is what actually keeps the pages off production, whatever the app's hostname. +// The APP_URL must still resolve to this machine (a lane serves theprototype.app via +// /etc/hosts), so the dev server being flooded is our own. // // CAVEAT on FPS: N headless Chromium contexts each render a WebGL scene on the -// same machine (SwiftShader, no GPU), so absolute FPS says more about the host -// than about the protocol. Only the idle-vs-load DELTA at a given N is meaningful. +// same machine, so absolute FPS says more about the host than about the protocol. +// Only the idle-vs-load DELTA at a given N is meaningful. The rig launches with +// GPU_ARGS; on a box without a GPU that silently falls back to SwiftShader. const fs = require('fs'); const path = require('path'); -const https = require('https'); -const { spawn } = require('child_process'); // ---------------------------------------------------------------- arguments const argv = process.argv.slice(2); @@ -36,7 +41,7 @@ function arg(name, fallback) { const i = argv.indexOf('--' + name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; } -const SIZES = arg('peers', '4,6,8,10') +const SIZES = arg('peers', '8,10,12,16') .split(',') .map((n) => parseInt(n, 10)) .filter((n) => n >= 2); @@ -44,6 +49,8 @@ const LOAD_SECS = parseInt(arg('load', '20'), 10); const HZ = parseInt(arg('hz', '10'), 10); const OBJECTS = parseInt(arg('objects', '20'), 10); const OUT = arg('out', ''); +// 25-G: seconds of continuous camera motion on every peer; 0 = skip the presence phase +const PRESENCE_SECS = parseInt(arg('presence', '0'), 10); // --logs echoes each page's own console (peerHandler is chatty about the connect // dance) with a ms stamp, which is the only way to see WHY a join stalls const LOGS = argv.includes('--logs'); @@ -53,52 +60,17 @@ const T0 = Date.now(); const APP_URL = process.env.APP_URL || 'https://localhost:5185/'; process.env.APP_URL = APP_URL; const host = new URL(APP_URL).hostname; -if (!/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(host)) { - console.error( - 'REFUSING to run: APP_URL host is "' + host + '".\n' + - 'The stress harness floods the signaling server and must only ever point at a\n' + - 'LOCAL dev server (which routes PeerJS to localhost:9001). See the file header.' - ); - process.exit(2); -} - const h = require('./helpers.cjs'); - -// ------------------------------------------------------- local peerjs server -const SIGNAL_PORT = 9001; - -function signalUp() { - return new Promise((resolve) => { - const req = https.get( - { host: 'localhost', port: SIGNAL_PORT, path: '/', rejectUnauthorized: false, timeout: 1500 }, - (res) => { - res.resume(); - resolve(res.statusCode === 200); - } - ); - req.on('error', () => resolve(false)); - req.on('timeout', () => { req.destroy(); resolve(false); }); - }); -} - -async function ensureSignalServer() { - if (await signalUp()) return null; - const bin = path.join(ROOT, 'node_modules', 'peer', 'dist', 'bin', 'peerjs.js'); - const key = path.join(ROOT, 'certs', 'localhost.key'); - const crt = path.join(ROOT, 'certs', 'localhost.crt'); - if (!fs.existsSync(bin)) throw new Error('the `peer` devDependency is missing — run npm ci'); - if (!fs.existsSync(key)) throw new Error('certs/localhost.key missing — run npm run certs'); - console.log('starting local PeerJS server on :' + SIGNAL_PORT); - const child = spawn(process.execPath, [bin, '--port', String(SIGNAL_PORT), '--sslkey', key, '--sslcert', crt], { - cwd: ROOT, - stdio: 'ignore' - }); - for (let i = 0; i < 40; i++) { - await sleep(250); - if (await signalUp()) return child; - } - try { child.kill(); } catch { /* already gone */ } - throw new Error('local PeerJS server did not come up on :' + SIGNAL_PORT); +const { SIGNAL_PORT, LOCAL_PEER_STORAGE, ensureSignalServer } = require('./localSignal.cjs'); + +/** Does the APP_URL host resolve to this machine? @param {string} name */ +function isLoopback(name) { + if (/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(name)) return Promise.resolve(true); + return new Promise((resolve) => + require('dns').lookup(name, { all: true }, (err, addrs) => + resolve(!err && addrs.length > 0 && addrs.every((a) => a.address === '127.0.0.1' || a.address === '::1')) + ) + ); } // ------------------------------------------------------------------- utils @@ -147,9 +119,43 @@ function installProbe(peer) { // per-type traffic accounting — ON during joins (where the interesting // asymmetry is), OFF under load so the sizing cost can't skew FPS accounting: true, - traffic: { count: 0, bytes: 0, byType: {} } + traffic: { count: 0, bytes: 0, byType: {} }, + // 25-G: camera messages received per SENDER, and the main thread's long tasks + cam: {}, + tasks: [] }); ns.pc = pc; + if (!ns.taskObserver) { + try { + ns.taskObserver = new PerformanceObserver((list) => { + for (const e of list.getEntries()) ns.tasks.push(e.startTime); + }); + ns.taskObserver.observe({ entryTypes: ['longtask'] }); + } catch { + ns.taskObserver = null; + } + } + /** long tasks that started in the last `ms` */ + ns.tasksIn = (/** @type {number} */ ms) => ns.tasks.filter((/** @type {number} */ t) => t >= performance.now() - ms).length; + /** orbit the editor camera every frame until stopped — the presence stream's source */ + ns.orbitStart = () => { + let controls; + w.__stores.orbitControls.subscribe((/** @type {any} */ c) => (controls = c))(); + ns.orbitFrames = 0; + ns.orbiting = true; + const tick = () => { + if (!ns.orbiting) return; + if (controls?._rotateLeft) controls._rotateLeft(0.03); + controls?.update?.(); + ns.orbitFrames++; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }; + ns.orbitStop = () => { + ns.orbiting = false; + return ns.orbitFrames; + }; /** rough wire size; binarypack is compact but relative sizes are what matter */ ns.sizeOf = (/** @type {any} */ d) => { @@ -196,6 +202,7 @@ function installProbe(peer) { ns.hooked.add(c); added++; c.on('data', (/** @type {any} */ d) => { + if (d && d.type === 'camera' && d.peerId) ns.cam[d.peerId] = (ns.cam[d.peerId] || 0) + 1; if (ns.accounting && d) { const t = typeof d === 'string' ? 'string' : d.type || 'unknown'; const tr = ns.traffic; @@ -364,7 +371,7 @@ async function runSize(N) { // measures render starvation. We want the NETWORK to be the bottleneck. const p = await h.setupPage(browser, 'P' + i, { context: { viewport: { width: 800, height: 600 } }, - storage: { viewMode: 'shaded' } + storage: { viewMode: 'shaded', ...LOCAL_PEER_STORAGE } }); if (LOGS) { const tag = 'P' + i + '/' + p.id; @@ -499,12 +506,65 @@ async function runSize(N) { // --- load: every peer broadcasts `move` at hz for `secs`, then a ramp to // find where the mesh actually starts hurting + /** + * 25-G: every peer orbits its camera for `secs`, and each counts the `camera` messages + * it RECEIVES per sender. The rate is per sender per receiver, stated beside the + * sender's own frame count — a 50ms gate at 60fps is ~0.33 messages a frame. + * @param {number} secs + */ + const presencePhase = async (secs) => { + for (const p of peers) await p.page.evaluate(() => window.__ns.hook()); + for (const p of peers) await p.page.evaluate(() => { window.__ns.cam = {}; }); + for (const p of peers) await p.page.evaluate(() => window.__ns.orbitStart()); + await sleep(secs * 1000); + const frames = []; + for (const p of peers) frames.push(await p.page.evaluate(() => window.__ns.orbitStop())); + const longPerMin = []; + for (const p of peers) longPerMin.push(await p.page.evaluate((ms) => window.__ns.tasksIn(ms), secs * 1000)); + await sleep(1000); + /** received camera msgs/s per peer, summed over every sender */ + const receivedPerPeer = []; + /** per sender->receiver pair, msgs per sender frame */ + const perFrame = []; + let pairsSilent = 0; + for (let i = 0; i < N; i++) { + const cam = await peers[i].page.evaluate(() => ({ ...window.__ns.cam })); + let total = 0; + for (let j = 0; j < N; j++) { + if (i === j) continue; + const got = cam[peers[j].id] || 0; + total += got; + if (!got) pairsSilent++; + if (frames[j]) perFrame.push(got / frames[j]); + } + receivedPerPeer.push(total / secs); + } + const out = { + secs, + senderFps: median(frames.map((f) => f / secs)), + receivedPerPeerPerSec: median(receivedPerPeer), + maxReceivedPerPeerPerSec: Math.max(...receivedPerPeer), + msgsPerSenderFrame: stats(perFrame), + pairsSilent, + longTasksPerMin: median(longPerMin.map((n) => (n * 60) / secs)), + maxLongTasksPerMin: Math.max(...longPerMin.map((n) => (n * 60) / secs)) + }; + console.log( + ' presence: ' + r(out.receivedPerPeerPerSec) + ' camera msgs/s received per peer (max ' + r(out.maxReceivedPerPeerPerSec) + ')' + + ', ' + r(out.msgsPerSenderFrame.p50, 2) + ' msgs per sender frame, sender fps ' + r(out.senderFps) + + ', silent pairs ' + pairsSilent + ', long tasks/min ' + r(out.longTasksPerMin) + ' (max ' + r(out.maxLongTasksPerMin) + ')' + ); + return out; + }; + /** @param {number} hz @param {number} secs */ const loadPhase = async (hz, secs) => { for (const p of peers) await p.page.evaluate(() => window.__ns.hook()); for (const p of peers) await p.page.evaluate(() => window.__ns.fpsStart()); for (const p of peers) await p.page.evaluate(([u, z]) => window.__ns.startLoad(u, z), [uuid, hz]); await sleep(secs * 1000); + const longPerMin = []; + for (const p of peers) longPerMin.push(await p.page.evaluate((ms) => window.__ns.tasksIn(ms), secs * 1000)); const sent = []; for (const p of peers) sent.push(await p.page.evaluate(() => window.__ns.stopLoad())); const fps = []; @@ -549,6 +609,7 @@ async function runSize(N) { sendMs: stats(sendMs), oneWay: stats(lat), fps: median(fps), + longTasksPerMin: median(longPerMin.map((n) => (n * 60) / secs)), msgs: { expected, got, lossPct: expected ? (100 * (expected - got)) / expected : 0 }, meshMsgsPerSec: hz * N * (N - 1) }; @@ -564,6 +625,7 @@ async function runSize(N) { }; row.steady = await loadPhase(HZ, LOAD_SECS); + if (PRESENCE_SECS > 0) row.presence = await presencePhase(PRESENCE_SECS); row.ramp = []; for (const hz of [30, 60, 120]) row.ramp.push(await loadPhase(hz, 8)); @@ -624,8 +686,8 @@ function report(rows) { lines.push(''); lines.push('## Load ramp (8s per step; "emitted" = what the send timer actually managed)'); lines.push(''); - lines.push('| N | Hz/peer | mesh msgs/s | loss | one-way p50/p95 | send() p95 | fps | emitted/wanted |'); - lines.push('|---|---|---|---|---|---|---|---|'); + lines.push('| N | Hz/peer | mesh msgs/s | loss | one-way p50/p95 | send() p95 | fps | long tasks/min | emitted/wanted |'); + lines.push('|---|---|---|---|---|---|---|---|---|'); for (const w of rows) { for (const s of [w.steady, ...(w.ramp || [])]) { if (!s) continue; @@ -634,10 +696,29 @@ function report(rows) { ' | ' + r(s.oneWay.p50) + ' / ' + r(s.oneWay.p95) + ' | ' + r(s.sendMs.p95, 2) + ' | ' + r(s.fps) + + ' | ' + r(s.longTasksPerMin) + ' | ' + r(s.sentPerPeer, 0) + '/' + s.wantedPerPeer + ' |' ); } } + if (rows.some((w) => w.presence)) { + lines.push(''); + lines.push('## Presence (25-G): every peer orbiting for ' + PRESENCE_SECS + 's'); + lines.push(''); + lines.push('| N | sender fps | camera msgs/s received per peer (median / max) | msgs per sender frame p50/max | silent pairs | long tasks/min (median / max) |'); + lines.push('|---|---|---|---|---|---|'); + for (const w of rows) { + const p = w.presence; + if (!p) continue; + lines.push( + '| ' + w.N + ' | ' + r(p.senderFps) + + ' | ' + r(p.receivedPerPeerPerSec) + ' / ' + r(p.maxReceivedPerPeerPerSec) + + ' | ' + r(p.msgsPerSenderFrame.p50, 2) + ' / ' + r(p.msgsPerSenderFrame.max, 2) + + ' | ' + p.pairsSilent + + ' | ' + r(p.longTasksPerMin) + ' / ' + r(p.maxLongTasksPerMin) + ' |' + ); + } + } lines.push(''); lines.push('```json'); lines.push(JSON.stringify(rows, null, 1)); @@ -649,6 +730,13 @@ function report(rows) { (async () => { let server = null; try { + if (!(await isLoopback(host))) { + console.error( + 'REFUSING to run: APP_URL host "' + host + '" does not resolve to this machine.\n' + + 'The rig floods its dev server and must only ever point at a LOCAL one. See the file header.' + ); + process.exit(2); + } server = await ensureSignalServer(); console.log('app: ' + APP_URL + ' signaling: https://localhost:' + SIGNAL_PORT); const rows = []; diff --git a/tests/e2e/net-stress.test.cjs b/tests/e2e/net-stress.test.cjs index 8dfb67d1..ef68d23b 100644 --- a/tests/e2e/net-stress.test.cjs +++ b/tests/e2e/net-stress.test.cjs @@ -1,35 +1,53 @@ -// 27-I — THE SMALL MESH REGRESSION SUITE. +// 27-I + 25-G — THE MESH REGRESSION SUITE, on FOUR peers and a LOCAL signaling server. // -// `net-stress.cjs` beside this file is the MEASUREMENT RIG: a many-minute sweep across -// mesh sizes 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. +// `net-stress.cjs` beside this file is the MEASUREMENT RIG (a many-minute sweep across +// mesh sizes). This is the quick check that would catch a real regression in what the rig +// measures. 27-I shipped it on three peers against the shared signaling box; 25-G makes it +// what that brief asked for: +// - N=4, because with three peers the host's `hosts` roster only ever names ONE other +// peer, so a fill that mishandled a list longer than one (only the first id, only the +// last) would still pass. With four, each fill has to reach two peers that never +// dialled each other — six of the twelve links come from the fill alone. +// - a LOCAL `peer` server on :9001 (localSignal.cjs), so a signaling hiccup on a shared +// box can no longer masquerade as a mesh regression, and nothing floods production. // -// What this pins, 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 +// What this pins: +// 1. the mesh FILLS — every one of the 12 ordered pairs is open (pair-complete: a link +// that never formed is exactly the loss a user feels) // 2. a broadcast reaches every peer with NO loss, by sequence number -// 3. 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 +// 3. all FOUR broadcasting at once: every ordered pair delivers whole — nobody starves +// 4. one send's fan-out cost stays bounded +// 5. the PRESENCE stream (roadmap 25 3c/3d, audit H7): four peers orbiting at display +// rate send `camera` at the gated rate (20/s desktop), NOT once per frame — with a +// premise that every sender drew well above the gate, so per-frame would be visible +// 6. the main thread under that load: long tasks per peer are recorded and bounded // -// The probe rides a REAL `move` payload with additive `__ns` fields, 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. +// The probe rides a REAL `move` payload with additive `__ns` fields: 27-A validates every +// incoming message, so a made-up uuid would be rejected — the probe carries a real uuid. // -// Run: APP_URL=https://theprototype.app:5175/ npm run e2e -- net-stress.test +// Run: APP_URL=https://theprototype.app:5180/ npm run e2e -- net-stress.test const h = require('./helpers.cjs'); +const { LOCAL_PEER_STORAGE, ensureSignalServer, stopSignalServer } = require('./localSignal.cjs'); -/** A reduced installProbe: hook every conn, count probe messages per sender by seq. */ +/** Hook every conn; count probe messages per sender by seq, and presence per sender. */ const installProbe = (peer) => peer.page.evaluate((myId) => { const w = window; let pc; w.__stores.peers.subscribe((p) => (pc = p))(); - const ns = (w.__probe = w.__probe || { myId, hooked: new WeakSet(), rx: {}, sendMs: [], seq: 0 }); + const ns = (w.__probe = w.__probe || { myId, hooked: new WeakSet(), rx: {}, cam: {}, sendMs: [], tasks: [] }); 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 + if (!ns.observer) { + try { + ns.observer = new PerformanceObserver((list) => { + for (const e of list.getEntries()) ns.tasks.push({ at: e.startTime, ms: e.duration }); + }); + ns.observer.observe({ entryTypes: ['longtask'] }); + } catch { + ns.observer = null; + } + } + // the app's outgoing map AND peerjs's own, which also holds INBOUND conns ns.allConns = () => { const seen = new Set(); const out = []; @@ -48,7 +66,9 @@ const installProbe = (peer) => if (ns.hooked.has(c)) continue; ns.hooked.add(c); c.on('data', (d) => { - if (!d || d.__ns !== 'probe') return; + if (!d) return; + if (d.type === 'camera' && d.peerId) ns.cam[d.peerId] = (ns.cam[d.peerId] || 0) + 1; + if (d.__ns !== 'probe') return; const s = ns.rx[d.__from] || (ns.rx[d.__from] = { count: 0, maxSeq: -1 }); s.count++; if (d.__seq > s.maxSeq) s.maxSeq = d.__seq; @@ -56,7 +76,6 @@ const installProbe = (peer) => } 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) => { @@ -74,10 +93,11 @@ const installProbe = (peer) => 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. + // sends fewer messages than an earlier one cannot lower either — every section that + // counts starts from a reset, or it passes on numbers left over from the last one. ns.reset = () => { ns.rx = {}; + ns.cam = {}; }; ns.blast = async (uuid, count, gapMs) => { ns.sendMs = []; @@ -87,6 +107,28 @@ const installProbe = (peer) => } return { sent: count, maxSendMs: Math.max(...ns.sendMs) }; }; + // orbit the editor camera every frame for `ms`, counting our own frames — the + // presence stream's send rate is stated against THIS number + ns.orbit = async (ms) => { + let controls; + w.__stores.orbitControls.subscribe((c) => (controls = c))(); + const started = performance.now(); + let frames = 0; + const taskFrom = ns.tasks.length; + while (performance.now() - started < ms) { + await new Promise((r) => requestAnimationFrame(r)); + if (controls?._rotateLeft) controls._rotateLeft(0.03); + controls?.update?.(); + frames++; + } + const tasks = ns.tasks.slice(taskFrom); + return { + frames, + elapsed: performance.now() - started, + longTasks: tasks.length, + longest: tasks.reduce((m, t) => Math.max(m, t.ms), 0) + }; + }; return true; }, peer.id); @@ -96,81 +138,149 @@ const received = (peer, fromId) => return s ? { count: s.count, maxSeq: s.maxSeq } : { count: 0, maxSeq: -1 }; }, fromId); -const openConns = (peer) => +const openPeers = (peer) => peer.page.evaluate(() => { let pc; window.__stores.peers.subscribe((p) => (pc = p))(); - return pc?.openedPeers?.size ?? 0; + return [...(pc?.openedPeers ?? [])]; }); 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'); + /** @type {any} */ + let browserRef = null; + const signal = await ensureSignalServer(); + try { + // GPU args: section 5 is a RATE claim against display frames, and a SwiftShader page + // at ~2.5fps can never exercise a 50ms gate (the e2e skill's rule) + const browser = (browserRef = await h.launch({ args: h.GPU_ARGS })); + const opts = { storage: LOCAL_PEER_STORAGE, context: { viewport: { width: 800, height: 600 } } }; + const peers = []; + for (const name of ['A', 'B', 'C', 'D']) peers.push(await h.setupPage(browser, name, opts)); + const [A, B, C, D] = peers; + const server = await A.page.evaluate(() => { + let s; + window.__stores.peerServer.peerServerStatus.subscribe((v) => (s = v))(); + return s; + }); + h.check(server?.kind === 'local', `premise: the peers signal through the LOCAL server (${JSON.stringify(server)})`); - // ---- 1. the mesh fills ------------------------------------------------------------- - 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); + // ---- 1. the mesh fills ----------------------------------------------------------- + // a CONNECTED peer's pill has no dial input, so every joiner dials the HOST + await h.connect(B, A); + await h.connect(C, A); + await h.connect(D, A); + /** every ordered pair (i sees j open) */ + const pairState = async () => { + const lists = []; + for (const p of peers) lists.push(await openPeers(p)); + const missing = []; + peers.forEach((p, i) => + peers.forEach((q, j) => { + if (i !== j && !lists[i].includes(q.id)) missing.push(`${'ABCD'[i]}->${'ABCD'[j]}`); + }) + ); + return missing; + }; + await h.eventually(pairState, (m) => m.length === 0, 'all 12 ordered pairs of a four-peer mesh are open', 45000); + const missing = await pairState(); + h.check( + missing.length === 0, + `the mesh is FULL — B, C and D each dialled only the host (missing: ${JSON.stringify(missing)})` + ); - // a REAL object, so the probe's `move` survives the 27-A wire validator - const uuid = await A.page.evaluate(() => { - window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [3, 0.5, -2]); - return new Promise((resolve) => - window.__stores.objectsGroup.subscribe((g) => { - const o = g.children[g.children.length - 1]; - resolve(o ? o.uuid : null); - })() + // a REAL object, so the probe's `move` survives the 27-A wire validator + const uuid = await A.page.evaluate(() => { + window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [3, 0.5, -2]); + return new Promise((resolve) => + window.__stores.objectsGroup.subscribe((g) => { + const o = g.children[g.children.length - 1]; + resolve(o ? o.uuid : null); + })() + ); + }); + h.check(!!uuid, `premise: a real object to address, so the probe is not rejected as malformed (${uuid})`); + await h.eventually( + () => D.page.evaluate((u) => !!window.__stores.objectsGroup && (() => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); return !!g.getObjectByProperty('uuid', u); })(), uuid), + (ok) => ok, + 'premise: the object reached the last joiner', + 15000 ); - }); - 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); + for (const p of peers) await installProbe(p); + await A.page.waitForTimeout(600); - // ---- 2. a broadcast reaches everyone, with no loss ---------------------------------- - const blast = await A.page.evaluate( - ([u, n, gap]) => window.__probe.blast(u, n, gap), - [uuid, 60, 25] - ); - h.check(blast.sent === 60, `premise: the host sent 60 probe messages (${blast.sent})`); - await A.page.waitForTimeout(1200); + // ---- 2. a broadcast reaches everyone, with no loss ------------------------------- + const blast = await A.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 60, 25]); + h.check(blast.sent === 60, `premise: the host sent 60 probe messages (${blast.sent})`); + await A.page.waitForTimeout(1200); + for (const p of [B, C, D]) { + const got = await received(p, A.id); + h.check(got.count === 60 && got.maxSeq === 59, `${p === B ? 'B' : p === C ? 'C' : 'D'} got every host message, tail included (${got.count}/60, maxSeq ${got.maxSeq})`); + } - 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})` - ); + // ---- 4. fan-out cost stays bounded ------------------------------------------------ + h.check(blast.maxSendMs < 250, `one broadcast's fan-out stays bounded (worst send ${blast.maxSendMs.toFixed(1)}ms across 3 conns)`); - // ---- 3. 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)` - ); + // ---- 3. all four at once: every ordered pair whole -------------------------------- + for (const p of peers) await p.page.evaluate(() => window.__probe.reset()); + await Promise.all(peers.map((p) => p.page.evaluate(([u, n, gap]) => window.__probe.blast(u, n, gap), [uuid, 40, 25]))); + await A.page.waitForTimeout(1800); + let pairsWhole = 0; + const broken = []; + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) { + if (i === j) continue; + const got = await received(peers[i], peers[j].id); + if (got.count === 40 && got.maxSeq === 39) pairsWhole++; + else broken.push(`${'ABCD'[j]}->${'ABCD'[i]} ${got.count}/40`); + } + } + h.check(pairsWhole === 12, `under four-way load every ordered pair delivered whole (${pairsWhole}/12 ${JSON.stringify(broken)})`); - // ---- 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})` - ); + // ---- 5. the presence stream is throttled, not per frame --------------------------- + for (const p of peers) await p.page.evaluate(() => window.__probe.reset()); + const runs = await Promise.all(peers.map((p) => p.page.evaluate((ms) => window.__probe.orbit(ms), 3000))); + // read at once: OrbitControls damping keeps the camera drifting (and sending) after the + // orbit loop stops, and those messages belong to no measured frame window + const cams = []; + for (const p of peers) cams.push(await p.page.evaluate(() => ({ ...window.__probe.cam }))); + // the claim is only testable when a per-frame sender would EXCEED the gate: 25-C gates + // the desktop camera at 50ms (20/s), so every peer must be drawing well above that + h.check( + runs.every((r) => (r.frames * 1000) / r.elapsed >= 40), + `premise: every peer ran well above the 20/s gate while orbiting (${runs.map((r) => Math.round((r.frames * 1000) / r.elapsed)).join(', ')} fps)` + ); + // per SENDER, as seen by every other peer, in messages per second of orbit + const rates = []; + let flowing = true; + peers.forEach((sender, j) => { + peers.forEach((_, i) => { + if (i === j) return; + const got = cams[i][sender.id] || 0; + if (got < 10) flowing = false; + rates.push(Math.round((got / (runs[j].elapsed / 1000)) * 10) / 10); + }); + }); + h.check(flowing, `premise: the camera stream flows between every pair while orbiting (${JSON.stringify(cams.map((c) => Object.values(c)))})`); + const worst = Math.max(...rates); + const slowestFps = Math.min(...runs.map((r) => (r.frames * 1000) / r.elapsed)); + // 20/s plus slack for in-flight messages at the cut; a per-frame sender + // would read at its frame rate, which the premise put at 40 or more + h.check( + worst <= 25 && worst < slowestFps * 0.65, + `presence is gated, not per frame: worst ${worst} msgs/s per sender against >= ${Math.round(slowestFps)} fps (all ${JSON.stringify(rates)})` + ); - await h.finish(browser); + // ---- 6. the main thread under the load -------------------------------------------- + const longest = Math.max(...runs.map((r) => r.longest)); + console.log('long tasks while four peers orbit: ' + JSON.stringify(runs.map((r) => ({ n: r.longTasks, longest: Math.round(r.longest) })))); + h.check(longest < 1000, `no peer froze while four orbit and stream presence (longest task ${Math.round(longest)}ms)`); + } catch (error) { + stopSignalServer(signal); + throw error; + } + // `finish` exits the process, so the server we started is stopped BEFORE it — a + // leftover listener on the machine-wide :9001 would be reused by the next lane's run + stopSignalServer(signal); + await h.finish(browserRef); }); From 961c77dbc04584c4ca9d58befabf4c0942d1f4e6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 09:39:43 +0300 Subject: [PATCH 25/27] [feat] 25-F: a refused join is told, and a full session says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 25 section 2c. An incoming connection from the host WAS the approval signal and a refusal had no channel: the host closes a stranger's conn before it opens. So Reject left the joiner on "Requesting" for the whole 90 s window and then said the host "did not answer", and a full session said exactly the same thing. - THE ANSWER RIDES DIAL METADATA. Every dial now carries `{jr: 1}` ("I understand a join result"). The host answers a refusal with a short dial whose metadata is `{joinresult: 'denied' | 'full'}`; it arrives at the joiner's `connection` event through signaling, so no ICE is needed — two peers that could never open a data channel still hear "declined". The refusal dial is never added to the mesh, never wired, closed by the joiner at once and by the host after 15 s whatever happens, and its trailing peer-unavailable is not toasted to the host. - ADDITIVE BOTH WAYS. A joiner without `jr` is an older build that reads ANY incoming conn from the host as approval, so it is never sent a refusal dial (the card records `hearsNo`; it keeps the old silence and its own 90 s expiry). An older host sends no result, and its plain dial-back is still the approval. - The approve dial-back says it is one: metadata `joinresult: approved` and a `{type:'joinresult', result:'approved'}` message FIRST in its handshake. The message is dispatched too — a refusal on an open conn ends the request the same way — and `joinresult` is on cloudHooks' ALWAYS_ALLOWED floor. - peerApproval: denyPeer(peerId, result) tells a joiner that can hear it; approvePeer past the hard cap refuses with `full` (the VR panel's yes used to approve straight past the cap); applyJoinRefusal ends the request like a cancel and toasts "AB12 declined your connection request." or "AB12's session is full (16 people)." with Try again. - connectionState: isRefusal / joinRefusal store (cleared by the next dial, a dismiss, 20 s, or leaving). Connect: a chip beside the idle pill — red "AB12 declined", amber "AB12's session is full (16)". Toasts (the host card): Reject goes through the shared denyPeer, Approve through approveDialBack, and at the cap the card offers "Tell them it's full" while Approve stays disabled (27-E). - The autoaccept path (a cloud auth provider) refuses with `full` past the cap instead of auto-approving a joiner that can hear it. - svelte-check: handleConnection gained `@this {any}`, which also clears 11 pre-existing implicit-this errors: 352 -> 341, baseline ratcheted with --update. Counterfactuals (suite join-result): - the refusal branch in handleConnection disabled: red - the refusal dial is wired and adopted, the whitelist row stays, a session host is set, no "declined" toast or chip, a full room is not recorded, the real two-peer Reject is not heard. - joinresult removed from the floor: red floor check. - the cap check in peerApproval.approvePeer removed: red "the shared approve refuses past the cap" (it dialled joinresult: approved). - the handshake's joinresult message removed: red "its handshake OPENS with joinresult". - the "Tell them it's full" button hidden: red at-cap card checks and the real-peer full. - denyPeer's hearsNo gate removed: red "an older joiner is NOT dialled" (1 dial). - the joinresult dispatch branch removed: red "a joinresult message ends the request". - the Connect chip removed: red on both chip checks and the real-peer pill check. - `jr` removed from dialOptions: red "the joiner's dial says it can hear", and the real two-peer Reject is not heard (the host sees a card that cannot hear a refusal). Suites vs base (this worktree, PASS lines): join-result NEW 41/41 (incl. a real two-peer Reject and full over the self-hosted box); session-clock 26=26; approval-timeout 17=17, connect-states 28=28, net-handshake 9=9, connect-decision 46=46, controls-roster 108=108, vr-peer-approve 8 green. Unit 112/112. svelte-check 341/47 (baseline 352/47, ratcheted). npm run build green (server stopped). Co-Authored-By: Claude Opus 5 --- check-baseline.json | 4 +- src/components/menu/Connect.svelte | 51 ++++++- src/components/menu/Toasts.svelte | 21 ++- src/lib/cloudHooks.js | 4 + src/lib/connectionState.js | 46 ++++++ src/lib/peerApproval.js | 67 ++++++++- src/lib/peerHandler.svelte.js | 116 ++++++++++++++- tests/e2e/join-result.test.cjs | 218 +++++++++++++++++++++++++++++ 8 files changed, 505 insertions(+), 22 deletions(-) create mode 100644 tests/e2e/join-result.test.cjs diff --git a/check-baseline.json b/check-baseline.json index aaeba533..f39d4b38 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 352, + "errors": 341, "warnings": 47, - "measured": "2026-09-16" + "measured": "2026-09-17" } diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 49db56a6..103dacbc 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -6,7 +6,7 @@ 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 { signalingRetry, approvalStartedAt, approvalRemaining, APPROVAL_WINDOW_MS, joinRefusal, clearJoinRefusal, HARD_PEER_CAP } from '$lib/connectionState'; import { cancelOutboundRequest, requestConnect } from '$lib/peerApproval'; import { sessionHost } from '$lib/connectionState'; import { connectSlot, drawerSlot } from '$lib/cloudHooks'; @@ -42,6 +42,25 @@ // from $userdata.length: the roster is populated optimistically at DIAL time. const remoteOpen = $derived($peers ? [...$peers.openedPeers] : []); const pendingOut = $derived($waitingForApproval.filter((w) => w[1] === 'pending')); + + // 25-F: the host's answer, when it was no. A chip beside the idle pill for a while, + // because the toast that also says it can be missed or routed into the drawer — and + // "declined" and "full" call for different next moves. + const REFUSAL_CHIP_MS = 20000; + const refusalText = $derived( + $joinRefusal + ? String($joinRefusal.peerId).slice(0, 6).toUpperCase() + + ($joinRefusal.result === 'full' ? "'s session is full (" + HARD_PEER_CAP + ')' : ' declined') + : '' + ); + $effect(() => { + const at = $joinRefusal?.at; + if (!at) return; + const t = setTimeout(() => { + if ($joinRefusal?.at === at) clearJoinRefusal(); + }, REFUSAL_CHIP_MS); + return () => clearTimeout(t); + }); // 27-E: the pill COUNTS DOWN. A request that hangs with no end is the worst of the // three states a dial can be in — a refusal at least finishes — so the wait is visible // and bounded. One 1s tick only while something is pending; the clock itself lives in @@ -337,6 +356,18 @@ > {/if} + {#if $joinRefusal && connState === 'idle'} + + + {/if} + @@ -502,6 +533,24 @@ background: #fbbf24; } + /* 25-F: the refusal chip — red for declined, amber for a full room (a wait, not a no) */ + .cx-refused { + align-self: center; + white-space: nowrap; + border: 0; + border-radius: 9999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + color: #fff; + background: #dc2626; + cursor: pointer; + } + .cx-refused[data-result='full'] { + color: #78350f; + background: #fbbf24; + } + .cx-toggle :global(.cx-chevron) { transition: transform 0.2s ease; } diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index 0426788e..f82dad58 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -21,7 +21,7 @@ import { restoreAvailable, restoreSnapshot, dismissRestore } from '$lib/autosave' import { ingestGate, resolveIngestGate } from '$lib/commandsHandler.svelte' import { ingestVerdict, profileFor } from '$lib/sceneBudget' - import { cancelOutboundRequest } from '$lib/peerApproval' + import { cancelOutboundRequest, denyPeer } from '$lib/peerApproval' // 27-B: the ONE sticky card for an uncaught error. This file already mirrors // state stores into sticky toasts (restoreAvailable below); diagnostics.js stays a // leaf by publishing a store instead of importing the toast pipeline itself. @@ -207,12 +207,15 @@ function approvePeer(approval, role) { $userdata.push([approval.peerId, '', '']); } $peers.send({ type: 'userdata', userdata: $userdata }); - $peers.connectToPeer(approval.peerId, true); + // 25-F: an approval dial-back says it is one (a retry is a re-dial, not an approval) + if (approval.status !== 'retry' && typeof $peers.approveDialBack === 'function') $peers.approveDialBack(approval.peerId); + else $peers.connectToPeer(approval.peerId, true); if (role && $rolesInfo?.setRole) $rolesInfo.setRole(approval.peerId, role); } -function rejectPeer(approval) { - $pendingApprovals = $pendingApprovals.filter((p) => p.peerId !== approval.peerId); - try { $peers.connections[approval.peerId]?.close?.(); } catch {} +// 25-F: through the shared deny, so the joiner HEARS it (and VR and the card agree) +const hearsNo = (approval: any) => !!approval?.hearsNo; +function rejectPeer(approval, result: 'denied' | 'full' = 'denied') { + denyPeer(approval.peerId, result); } // professional toast card: manual close (✕) + auto-dismiss timer (kept from before) @@ -543,7 +546,12 @@ style="z-index: var(--z-toast); pointer-events: none;" {:else} {/if} - + + {#if roomFull && hearsNo(approval)} + + {/if} +
@@ -729,6 +737,7 @@ style="z-index: var(--z-toast-low); pointer-events: none;" .cxreq-editor:hover { background: #1d4ed8; } .cxreq-reject { background: transparent; border: 1px solid rgb(248 113 113 / 0.4); color: #f87171; } .cxreq-reject:hover { background: rgb(220 38 38 / 0.15); } + .cxreq-full { background: #b45309; } /* professional notification toast (replaces the flowbite green toast) */ .tp-toast { pointer-events: auto; diff --git a/src/lib/cloudHooks.js b/src/lib/cloudHooks.js index 4bc43e6c..7b4b1332 100644 --- a/src/lib/cloudHooks.js +++ b/src/lib/cloudHooks.js @@ -41,6 +41,10 @@ const ALWAYS_ALLOWED = new Set([ // latest-wins write it made would sort wrongly against the room's. 'clockping', 'clockpong', + // 25-F: whether a join was approved, declined or refused as full. Protocol about the + // connection itself, sent before any content — gating it would put a joiner back to + // waiting out a 90 s window for an answer that already arrived. + 'joinresult', // DEVX #18: the flow trigger log. On the floor beside `getnodes` for the same reason // the list gives — answering a full-state REQUEST is how a peer ever syncs, and this // one decides whether a joiner sees a collected world or a reset one. The `triggers` diff --git a/src/lib/connectionState.js b/src/lib/connectionState.js index 7eac7ab4..aeaaa46a 100644 --- a/src/lib/connectionState.js +++ b/src/lib/connectionState.js @@ -152,12 +152,58 @@ export function approvalRemaining(startedAt) { return Math.max(0, APPROVAL_WINDOW_MS - (Date.now() - (startedAt || 0))); } +/** + * 25-F — A REAL "NO". Until this, an incoming connection from the host WAS the approval + * signal, and a refusal had no channel at all: the host closes a stranger's conn before + * it opens, so a Reject left the joiner on "Requesting" for the full 90 s window and then + * told it the host "did not answer" — which is false, and a full room said the same. + * + * The answer rides the CONNECTION METADATA of a short dial from the host, so it arrives + * at the joiner's `connection` event through the signaling server with no ICE at all — + * a pair of peers that could never open a data channel still hears "declined". + * + * joiner dials with `{jr: 1}` "I understand a join result" (an older joiner sends + * nothing, and is never sent a refusal dial, because + * it would read ANY incoming conn from the host as + * approval — the whole reason the capability exists) + * host dials `{joinresult: R}` R = 'approved' on the approve dial-back (and a + * `joinresult` data message first in its handshake), + * 'denied' / 'full' on a refusal dial that is never + * added to the mesh and closes itself + * + * Absent means the old behaviour on both sides: an incoming conn from a peer we are + * waiting on is an approval. + */ +export const JOIN_RESULTS = /** @type {const} */ (['approved', 'denied', 'full']); + +/** @param {any} v @returns {v is 'denied' | 'full'} */ +export function isRefusal(v) { + return v === 'denied' || v === 'full'; +} + +/** + * The last refusal this joiner received, for the Connect pill: `{peerId, result, at}`, or + * null. Cleared by the next dial, by dismissing it, and by leaving the session. + * @type {import('svelte/store').Writable<{peerId: string, result: 'denied' | 'full', at: number} | null>} + */ +export const joinRefusal = writable(null); + +/** @param {string} peerId @param {'denied' | 'full'} result */ +export function noteJoinRefusal(peerId, result) { + joinRefusal.set({ peerId, result, at: Date.now() }); +} + +export function clearJoinRefusal() { + if (get(joinRefusal)) joinRefusal.set(null); +} + /** Full reset — leaving the session / cancelling out. */ export function resetSession() { sessionHost.set(null); peerJoinedAt.set({}); approvalStartedAt.set({}); // 27-E: no request survives leaving the session resetSessionClock(); // 25-E: our own clock is the only one left + joinRefusal.set(null); // 25-F } /** diff --git a/src/lib/peerApproval.js b/src/lib/peerApproval.js index 8698ef1d..0a050c9e 100644 --- a/src/lib/peerApproval.js +++ b/src/lib/peerApproval.js @@ -3,8 +3,13 @@ import { peers, userdata, pendingApprovals, waitingForApproval, showToast } from import { sessionHost, APPROVAL_WINDOW_MS, + HARD_PEER_CAP, noteApprovalStarted, - clearApprovalStarted + clearApprovalStarted, + roomIsFull, + isRefusal, + noteJoinRefusal, + clearJoinRefusal } from './connectionState'; // Pending-connection approval (211). Kept in its own store-only module so VR @@ -18,26 +23,75 @@ import { * and connect back (the requester already whitelisted us). @param {string} peerId */ export function approvePeer(peerId) { + /** @type {any} */ + const peer = get(peers); + // 25-F: past the hard cap an approval is a refusal the joiner can HEAR. The desktop card + // offers "Tell them it's full" itself; this is the path every other caller takes (the + // VR panel's yes, a plugin), which used to approve straight past the cap. + if (peer && roomIsFull(peer)) { + denyPeer(peerId, 'full'); + showToast('This session is full (' + HARD_PEER_CAP + ' people) — ' + label(peerId) + ' was told.'); + return; + } pendingApprovals.set(get(pendingApprovals).filter((/** @type {any} */ p) => p.peerId !== peerId)); + clearApprovalStarted(peerId); const users = /** @type {any[]} */ (get(userdata)); if (!users.some((/** @type {any} */ u) => u[0] === peerId)) users.push([peerId, '', '']); userdata.set(/** @type {any} */ (users)); - /** @type {any} */ - const peer = get(peers); if (!peer) return; peer.send({ type: 'userdata', userdata: get(userdata) }); - peer.connectToPeer(peerId, true); + // 25-F: the dial-back SAYS it is an approval (older peers still read the conn alone) + if (typeof peer.approveDialBack === 'function') peer.approveDialBack(peerId); + else peer.connectToPeer(peerId, true); +} + +/** @param {string} peerId */ +function label(peerId) { + return String(peerId).slice(0, 6).toUpperCase(); } /** * Deny a pending request: drop it from the queue and close any lingering incoming - * connection. The peer stays off the whitelist. @param {string} peerId + * connection. The peer stays off the whitelist. + * + * 25-F: and TELL them, when their dial said they can hear it (`hearsNo` on the card) — a + * short refusal dial whose metadata is the answer. A joiner that did not say so is an + * older build, which reads ANY incoming conn from the host as an approval, so it gets the + * old silence and its own 90 s expiry rather than a false "approved". + * @param {string} peerId @param {'denied' | 'full'} [result] */ -export function denyPeer(peerId) { +export function denyPeer(peerId, result = 'denied') { + const card = /** @type {any[]} */ (get(pendingApprovals)).find((p) => p.peerId === peerId); pendingApprovals.set(get(pendingApprovals).filter((/** @type {any} */ p) => p.peerId !== peerId)); + clearApprovalStarted(peerId); /** @type {any} */ const peer = get(peers); peer?.connections?.[peerId]?.close?.(); + if (card?.hearsNo && typeof peer?.sendJoinResult === 'function') peer.sendJoinResult(peerId, result); +} + +/** + * 25-F — the JOINER's half: the host said no (or that the room is full). End the request + * exactly as a cancel does (the waiting row, the optimistic whitelist row, the timer, the + * never-open conn) and say which it was. A refusal from a peer we are NOT waiting on — + * a second approver after we joined, or an answer that outlived our own 90 s expiry — is + * ignored: nothing is pending, so there is nothing to end and nobody to tell. + * @param {string} peerId @param {any} result @returns {boolean} whether it ended a request + */ +export function applyJoinRefusal(peerId, result) { + if (!isRefusal(result)) return false; + const waiting = /** @type {any[]} */ (get(waitingForApproval)); + if (!waiting.some((/** @type {any} */ w) => w[0] === peerId && w[1] === 'pending')) return false; + cancelOutboundRequest(peerId); + noteJoinRefusal(peerId, result); + if (result === 'full') { + showToast(label(peerId) + "'s session is full (" + HARD_PEER_CAP + ' people). Try again when someone leaves.', [ + { label: 'Try again', action: () => requestConnect(peerId) } + ]); + } else { + showToast(label(peerId) + ' declined your connection request.'); + } + return true; } /** @@ -168,6 +222,7 @@ function dial(peerId) { /** @type {any} */ const peer = get(peers); if (!peer) return; + clearJoinRefusal(); // 25-F: a new request replaces the last answer on the pill const users = /** @type {any[]} */ (get(userdata)); if (!users.some((/** @type {any} */ u) => u[0] === peerId)) { users.push([peerId, '', '']); diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 97c1e669..8cafb7b5 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -21,6 +21,9 @@ import { resolvePeerOptions, describePeerServer, peerServerStatus, parseInviteHa // 27-B/27-G integration: the RECOVERY story belongs in the copyable bundle, not in a // console nobody reads. diagnostics.js is a zero-dependency leaf, so this closes no cycle. import { log } from '$lib/diagnostics'; +// 25-F: the join result (peerApproval is store-only, so a static edge closes no cycle) +import { isRefusal } from '$lib/connectionState'; +import { applyJoinRefusal } from '$lib/peerApproval'; import { sessionHost, markPeerJoined, resetSession, signalingRetry, noteSignalingRetry, clearSignalingRetry, noteApprovalStarted, clearApprovalStarted, approvalStartedAt, APPROVAL_WINDOW_MS, MAX_PENDING_APPROVALS, HARD_PEER_CAP, roomIsFull } from '$lib/connectionState'; import { canApply, getAuthProvider, dispatchCloudMessage, rolesInfo } from '$lib/cloudHooks'; // 27-A (audit H1): shape validation + per-peer failure counters. Both are LEAVES, so the @@ -133,6 +136,22 @@ userdata.subscribe(value => { users = value }); * Pure presence, re-sent continuously, useless to somebody in a different world. */ const STREAM_TYPES = new Set(['camera', 'vrhands']); +/** + * 25-F: what every dial carries. `jr: 1` says this build understands a join RESULT, which + * is what lets a host send a refusal without an older joiner mistaking the refusal dial + * for an approval (see `JOIN_RESULTS` in connectionState). `result` is set only by the + * host's approve dial-back. + * @param {string} [result] @returns {{metadata: Record}} + */ +function dialOptions(result) { + return { metadata: result ? { jr: 1, joinresult: result } : { jr: 1 } }; +} + +/** How long a refusal dial may hang before it is closed regardless. The answer is in its + * metadata, which the joiner has at its `connection` event — the conn never needs to + * open, and a joiner that closes it at once is the normal case. */ +const REFUSAL_DIAL_MS = 15000; + /** * 27-E: keep the pending queue bounded, dropping the EXPIRED first and only then the * oldest still-live request. A missed request is worse than a stale card, so nothing is @@ -183,6 +202,11 @@ export class PeerConnection { * dials (the `hosts` flow) don't request state, and an adopted inbound * conn requests it only when it stands in for one of these (B5) */ this.wantsStateFrom = new Set(); + /** 25-F: peers whose NEXT dial is an approval dial-back, so its handshake opens with + * `joinresult: approved` @type {Set} */ + this.approvedDialBacks = new Set(); + /** 25-F: peers a refusal dial is out to — their `peer-unavailable` is not news @type {Set} */ + this.refusalDials = new Set(); // CN-3: an invite link can pin the signaling world (#A1B2C~srv=…). Parse it // HERE, before resolvePeerOptions runs — the peer.on('open') hash flow below @@ -392,6 +416,8 @@ export class PeerConnection { // 27-E: end the request this names. The pill used to sit on "Requesting" // beside this very toast, and the optimistic whitelist row never went away. const id = String(err.message ?? '').match(/[0-9a-z]{3,}/i)?.[0] ?? ''; + // 25-F: a refusal to somebody who already gave up and left is not news + if (id && this.refusalDials.has(id)) return; if (id) import('$lib/peerApproval').then((m) => m.abandonOutboundRequest(id)).catch(() => {}); showToast('Peer is unreachable. Check the ID and ask them to stay online.'); } else if (err.type === 'unavailable-id') { @@ -438,8 +464,19 @@ export class PeerConnection { // peer may send back over OUR outgoing conn, so those wire it too (P-A). this.wireData = handleData.bind(this); + /** @this {any} @param {any} conn */ function handleConnection(conn) { + // 25-F: A REFUSAL DIAL. Its metadata IS the answer, so it is read here, before + // anything below can treat an incoming conn from the host as the approval it + // used to mean. Never whitelisted, never adopted, never wired — closed at once. + if (isRefusal(conn?.metadata?.joinresult)) { + const ended = applyJoinRefusal(conn.peer, conn.metadata.joinresult); + log('info', 'net', 'join refused', { peer: conn.peer, result: conn.metadata.joinresult, ended }); + try { conn.close(); } catch {} + return; + } + // Update approval status on expected connections let waiting = get(waitingForApproval); waiting.forEach(element => { @@ -480,7 +517,14 @@ export class PeerConnection { if (!found) { const auth = getAuthProvider(); try { - if (auth && typeof auth.authorize === 'function' && auth.authorize(conn.peer)) { + const authorized = !!auth && typeof auth.authorize === 'function' && auth.authorize(conn.peer); + if (authorized && roomIsFull(this) && conn?.metadata?.jr) { + // 25-F: a plugin would let them in, but the mesh cannot take one more — + // say so rather than auto-approving past the hard cap + this.sendJoinResult(conn.peer, 'full'); + conn.close(); + return; + } else if (authorized) { found = true; // AUTO-APPROVE == the manual Approve: whitelist the peer, broadcast the // roster, and DIAL BACK. The joiner only leaves its "waiting for @@ -492,7 +536,7 @@ export class PeerConnection { userdata.set(roster); } get(peers).send({ type: 'userdata', userdata: get(userdata) }); - get(peers).connectToPeer(conn.peer, true); + this.approveDialBack(conn.peer); } } catch (e) { console.error('cloud auth provider threw:', e); @@ -502,8 +546,15 @@ export class PeerConnection { if (!found) { // If peer is not found, add it to the pending approvals var approvals = get(pendingApprovals); - if (!approvals.some(toast => toast.peerId === conn.peer)) { - approvals.push({ peerId: conn.peer }); + // 25-F: remember whether this dial can HEAR a refusal (see denyPeer). A re-dial + // refreshes the answer on the card that is already there. + const hearsNo = !!conn?.metadata?.jr; + const known = approvals.find(toast => toast.peerId === conn.peer); + if (known && known.hearsNo !== hearsNo) { + pendingApprovals.set(/** @type {any} */ (approvals.map((/** @type {any} */ a) => (a.peerId === conn.peer ? { ...a, hearsNo } : a)))); + } + if (!known) { + approvals.push({ peerId: conn.peer, hearsNo }); // 27-E: stamp the SAME clock the joiner's countdown uses, so the card's // age and their pill agree; and BOUND the queue — a host who walked away // used to collect a card per dial with nothing dropping them (audit H3). @@ -702,6 +753,12 @@ export class PeerConnection { // the room gate every full-state reply here takes: a peer standing in // another scene must not be handed this one's tempo if (sameRoomOrUnknown(conn.peer)) sendTransport(data.sender); + } else if(data.type == 'joinresult') { + // 25-F: the answer to our join request as a MESSAGE. A refusal normally + // arrives as dial metadata and never reaches here; this path is the same + // answer from a sender that has an open conn to say it on. 'approved' needs + // nothing — the conn it arrived on already approved us (handleConnection). + if (isRefusal(data.result)) applyJoinRefusal(conn.peer, data.result); } else if(data.type == 'clockping') { // 23-A2: the peer clock-offset round trip. Answered over the stable OUTGOING // conn to the sender (golden rule 9), this conn only as the fallback. 25-E: @@ -1160,6 +1217,11 @@ export class PeerConnection { // Must only be called once the connection is open — messages sent earlier are dropped by peerjs. /** @param {any} conn @param {string} peerId @param {boolean} getobjects @param {string} id */ sendHandshake(conn, peerId, getobjects, id) { + // 25-F: an approval dial-back SAYS it is one, ahead of everything else (the roadmap's + // "first message"). The metadata already carried it; this is the same answer for a + // peer that reads messages rather than metadata, and it is idempotent on a joiner + // that has already moved on. + if (this.approvedDialBacks.delete(peerId)) conn.send({ type: 'joinresult', result: 'approved' }); // a conn opened to them — whatever goodbye they once sent is history this.gracefulLeft.delete(peerId); let hosts = [id]; @@ -1253,13 +1315,53 @@ export class PeerConnection { voicePeerConnected(peerId); } + /** + * 25-F: approve and dial back, marking the dial as the approval so the joiner is told + * rather than left to infer it. @param {string} peerId + */ + approveDialBack(peerId) { + this.approvedDialBacks.add(peerId); + this.connectToPeer(peerId, true); + } + + /** + * 25-F: tell a would-be joiner "no" (or "full"). A short dial whose METADATA is the + * answer — delivered through signaling with the offer, so it arrives even where a data + * channel could never open — never added to `connections`, never wired, closed by the + * joiner at once and by us after REFUSAL_DIAL_MS whatever happens. Callers only send + * this to a dial that advertised `jr` (see denyPeer). @param {string} peerId + * @param {'denied' | 'full'} result @returns {boolean} whether a dial went out + */ + sendJoinResult(peerId, result) { + if (!isRefusal(result) || !this.peer?.open) return false; + const conn = this.peer.connect(peerId, dialOptions(result)); + if (!conn) return false; + this.refusalDials.add(peerId); + let done = false; + const finish = () => { + if (done) return; + done = true; + try { conn.close(); } catch {} + // keep the quiet-unavailable mark a little longer: the error can trail the close + setTimeout(() => this.refusalDials.delete(peerId), 5000); + }; + conn.on?.('close', finish); + conn.on?.('error', finish); + conn.on?.('open', () => setTimeout(finish, 1000)); + setTimeout(finish, REFUSAL_DIAL_MS); + log('info', 'net', 'join result sent', { peer: peerId, result }); + return true; + } + connectToPeer(peerId, getobjects = true, id = this.peer.id) { // remember the intent: if this dial dies and an adopted inbound conn takes // its place, the adoption still owes them the full-state requests (B5) if (getobjects) this.wantsStateFrom.add(peerId); if (!this.connections[peerId]) { console.log("Connecting to " + peerId); - const conn = this.peer.connect(peerId); + // 25-F: an approval dial-back says so in its metadata; every dial says it can + // hear a join result + const conn = this.peer.connect(peerId, dialOptions(this.approvedDialBacks.has(peerId) ? 'approved' : undefined)); // peer.connect returns undefined when the signaling link is down // (disconnected peer) — bail instead of throwing on conn.on below (CN) if (!conn) { @@ -1342,7 +1444,7 @@ export class PeerConnection { try { stale.close(); } catch {} delete this.connections[peerId]; } - const conn = this.peer.connect(peerId); + const conn = this.peer.connect(peerId, dialOptions()); if (!conn) { log('error', 'net', 'restore failed: signaling link is down', { peer: peerId }); return; @@ -1434,7 +1536,7 @@ export class PeerConnection { // a dial started now would be torn down before it could ever open const lastCheck = backoffDelay(attempt + 1, { base: 500, max: 5 }) === null; if (!this.connections[peerId] && !lastCheck && this.peer.id < peerId) { - const conn = this.peer.connect(peerId); + const conn = this.peer.connect(peerId, dialOptions()); if (conn) { /** @type {any} */ (conn).__dialedAt = Date.now(); this.connections[peerId] = conn; diff --git a/tests/e2e/join-result.test.cjs b/tests/e2e/join-result.test.cjs new file mode 100644 index 00000000..07ce3c83 --- /dev/null +++ b/tests/e2e/join-result.test.cjs @@ -0,0 +1,218 @@ +// 25-F (roadmap 25 section 2c) — A REAL "NO", AND A FULL ROOM THAT SAYS SO. +// +// An incoming connection from the host WAS the approval signal, and a refusal had no +// channel at all: the host closes a stranger's conn before it opens. So Reject left the +// joiner on "Requesting" for the whole 90 s window and then told it the host "did not +// answer", and a full session said exactly the same thing. +// +// The answer now rides the METADATA of a short dial from the host (arriving through +// signaling, no ICE needed), gated on the joiner having advertised `jr` — an older joiner +// would read ANY incoming conn from the host as an approval. +// +// What this suite pins: +// 1-5 the JOINER: denied and full end the request and are told apart (toast + chip); +// an older host's plain dial-back is still an approval; a refusal nobody is +// waiting for is ignored; the `joinresult` MESSAGE carries the same answer +// 6 `joinresult` is on the capability floor +// 7-10 the HOST: Reject tells a joiner that can hear it and stays silent to one that +// cannot; at the cap the card offers "Tell them it's full"; an approval dial-back +// says it is one, in its metadata AND as the first handshake message +// 11 two real peers over signaling: declined, then full +// +// Run: APP_URL=https://theprototype.app:5175/ PEER_CONFIG=... npm run e2e -- join-result +const h = require('./helpers.cjs'); + +/** run a snippet with `s = window.__stores` and `pc` (the PeerConnection) in scope */ +const inPage = (peer, body, arg) => + peer.page.evaluate( + ([src, a]) => { + let pc = null; + window.__stores.peers.subscribe((v) => (pc = v))(); + return Object.getPrototypeOf(async function () {}).constructor('s', 'pc', 'arg', src)(window.__stores, pc, a); + }, + [body, arg ?? null] + ); +const read = (peer, store) => inPage(peer, `let v; s.${store}.subscribe((x) => (v = x))(); return v;`); +const notes = (peer) => inPage(peer, 'let v = []; s.notifications.subscribe((x) => (v = x))(); return v.map((n) => String(n.text));'); + +/** an incoming conn as peerjs hands it to the `connection` event */ +const EMIT = ` + const fake = { peer: arg.peer, metadata: arg.metadata, open: false, closed: false, handlers: {}, + on(ev, fn) { this.handlers[ev] = fn; }, close() { this.closed = true; }, send() {} }; + window.__lastFake = fake; + pc.peer.emit('connection', fake); + return { closed: fake.closed, wired: Object.keys(fake.handlers) };`; + +/** dial through the real pill, against a stubbed peer.connect that records every call */ +const DIAL_STUB = ` + window.__dials = []; + Object.defineProperty(pc.peer, 'open', { value: true, configurable: true }); + pc.peer.connect = (id, opts) => { + const conn = { peer: id, open: false, sent: [], handlers: {}, on(ev, fn) { (this.handlers[ev] ??= []).push(fn); }, close() { this.closed = true; }, send(m) { this.sent.push(m); } }; + window.__dials.push({ id, opts: JSON.parse(JSON.stringify(opts ?? null)), conn }); + return conn; + };`; + +/** element reads that answer null instead of throwing, so one missing element is one red + * check rather than the end of the suite */ +const attr = (loc, name) => loc.getAttribute(name, { timeout: 3000 }).catch(() => null); +const text = (loc) => loc.textContent({ timeout: 3000 }).catch(() => ''); +const click = (loc, timeout = 5000) => loc.click({ timeout }).then(() => true, () => false); + +async function dialVia(peer, id) { + await peer.page.locator('input[placeholder="Enter peer ID to connect"]').fill(id); + await peer.page.getByRole('button', { name: 'Connect', exact: true }).click(); + await peer.page.waitForTimeout(400); +} + +h.run(async () => { + const browser = await h.launch(); + const J = await h.setupPage(browser, 'joiner'); + await J.page.waitForFunction(() => !!window.__stores?.connectionState?.isRefusal, { timeout: 30000 }); + await inPage(J, DIAL_STUB); + + // ---- 1. declined ----------------------------------------------------------------- + console.log('\n=== 1. declined ==='); + await dialVia(J, 'aaaa1'); + const dial = await inPage(J, 'return window.__dials.map((d) => ({ id: d.id, opts: d.opts }))'); + h.check(dial.some((d) => d.id === 'aaaa1' && d.opts?.metadata?.jr === 1), `the joiner's dial says it can hear a join result (${JSON.stringify(dial)})`); + h.check((await read(J, 'waitingForApproval')).some((w) => w[0] === 'aaaa1'), 'premise: the request is pending'); + const refused = await inPage(J, EMIT, { peer: 'aaaa1', metadata: { joinresult: 'denied' } }); + h.check(refused.closed && refused.wired.length === 0, `the refusal dial is closed at once and never wired (${JSON.stringify(refused)})`); + h.check(!(await read(J, 'waitingForApproval')).some((w) => w[0] === 'aaaa1'), 'the request is over — no pending row'); + h.check(!(await read(J, 'userdata')).some((u) => u[0] === 'aaaa1'), 'the optimistic whitelist row is taken back'); + h.check((await read(J, 'connectionState.sessionHost')) === null, 'a refusal is NOT an approval: no session host'); + const r1 = await read(J, 'connectionState.joinRefusal'); + h.check(r1?.peerId === 'aaaa1' && r1?.result === 'denied', `the refusal is recorded as denied (${JSON.stringify(r1)})`); + h.check((await notes(J)).some((t) => t.includes('AAAA1 declined your connection request')), 'the joiner is TOLD it was declined'); + h.check(!(await notes(J)).some((t) => /AAAA1 has approved/.test(t)), '…and never told it was approved'); + const chip1 = J.page.locator('#connect-refusal-chip'); + await chip1.waitFor({ timeout: 5000 }).catch(() => {}); + h.check((await chip1.count()) === 1 && /AAAA1 declined/.test(await text(chip1)) && (await attr(chip1, 'data-result')) === 'denied', 'the pill shows "declined" beside the idle input'); + + // ---- 2. full --------------------------------------------------------------------- + console.log('\n=== 2. full ==='); + await dialVia(J, 'bbbb1'); + h.check((await J.page.locator('#connect-refusal-chip').count()) === 0, 'a new dial clears the last answer from the pill'); + await inPage(J, EMIT, { peer: 'bbbb1', metadata: { jr: 1, joinresult: 'full' } }); + const r2 = await read(J, 'connectionState.joinRefusal'); + h.check(r2?.result === 'full', `a full room is recorded as FULL, not denied (${r2?.result})`); + h.check((await notes(J)).some((t) => /BBBB1's session is full \(16 people\)/.test(t)), 'the toast says the session is full, with its size'); + const chip2 = J.page.locator('#connect-refusal-chip'); + h.check((await attr(chip2, 'data-result')) === 'full' && /session is full \(16\)/.test(await text(chip2)), 'the chip says full, told apart from declined'); + await click(chip2); + h.check((await J.page.locator('#connect-refusal-chip').count()) === 0 && (await read(J, 'connectionState.joinRefusal')) === null, 'the chip dismisses'); + + // ---- 3. an older host ------------------------------------------------------------ + console.log('\n=== 3. an older host (no result on its dial-back) ==='); + await dialVia(J, 'cccc1'); + const plain = await inPage(J, EMIT, { peer: 'cccc1', metadata: undefined }); + h.check(!plain.closed, 'a plain dial-back is not closed'); + h.check((await read(J, 'connectionState.sessionHost')) === 'cccc1', 'a plain dial-back from the host is still the approval'); + h.check((await read(J, 'connectionState.joinRefusal')) === null, '…and records no refusal'); + await inPage(J, 's.connectionState.resetSession(); s.userdata.set([]); s.waitingForApproval.set([]);'); + + // ---- 4. nobody is waiting ---------------------------------------------------------- + console.log('\n=== 4. a refusal nobody is waiting for ==='); + const before = (await notes(J)).length; + const stray = await inPage(J, EMIT, { peer: 'zzzz1', metadata: { joinresult: 'denied' } }); + h.check(stray.closed, 'a stray refusal dial is closed'); + h.check((await notes(J)).length === before && (await read(J, 'connectionState.joinRefusal')) === null, 'and tells nobody anything — there was no request to end'); + + // ---- 5. the message -------------------------------------------------------------- + console.log('\n=== 5. the joinresult MESSAGE ==='); + await dialVia(J, 'dddd1'); + await inPage(J, ` + const fake = { peer: 'dddd1', open: true, handlers: {}, on(ev, fn) { this.handlers[ev] = fn; }, close() {}, send() {} }; + pc.wireData(fake); + fake.handlers.data({ type: 'joinresult', result: 'denied' });`); + const r5 = await read(J, 'connectionState.joinRefusal'); + h.check(r5?.peerId === 'dddd1' && r5?.result === 'denied', `a joinresult message ends the request the same way (${JSON.stringify(r5)})`); + + // ---- 6. the floor ------------------------------------------------------------------ + const floor = await inPage(J, 's.cloudHooks.setCapabilityProvider(() => false); const r = { jr: s.cloudHooks.canApply("x", "joinresult"), other: s.cloudHooks.canApply("x", "environment") }; s.cloudHooks.setCapabilityProvider(null); return r'); + h.check(floor.jr && !floor.other, `joinresult sits on the ALWAYS_ALLOWED floor (${JSON.stringify(floor)})`); + await J.ctx.close(); + + // ---- 7. host: Reject tells them ----------------------------------------------------- + console.log('\n=== 7. host: Reject ==='); + const H = await h.setupPage(browser, 'host'); + await H.page.waitForFunction(() => !!window.__stores?.connectionState?.isRefusal, { timeout: 30000 }); + await inPage(H, DIAL_STUB); + await inPage(H, EMIT, { peer: 'eeee1', metadata: { jr: 1 } }); + const cards = await read(H, 'pendingApprovals'); + h.check(cards.some((c) => c.peerId === 'eeee1' && c.hearsNo === true), `the card remembers the dial can hear a refusal (${JSON.stringify(cards)})`); + const card = H.page.locator('.tp-toast--req', { hasText: 'EEEE1' }); + await click(card.locator('.cxreq-reject')); + await H.page.waitForTimeout(300); + const dials7 = await inPage(H, 'return window.__dials.map((d) => ({ id: d.id, opts: d.opts }))'); + h.check(dials7.some((d) => d.id === 'eeee1' && d.opts?.metadata?.joinresult === 'denied'), `Reject dials back with joinresult: denied (${JSON.stringify(dials7)})`); + h.check(!(await read(H, 'pendingApprovals')).some((c) => c.peerId === 'eeee1'), 'the card is gone'); + h.check(!(await inPage(H, 'return Object.keys(pc.connections)')).includes('eeee1'), 'the refusal dial never joins the mesh'); + + // ---- 8. host: an older joiner hears nothing ------------------------------------------ + console.log('\n=== 8. host: an older joiner ==='); + await inPage(H, EMIT, { peer: 'ffff1', metadata: undefined }); + h.check((await read(H, 'pendingApprovals')).some((c) => c.peerId === 'ffff1' && c.hearsNo === false), 'a dial without jr makes a card that cannot hear a refusal'); + await click(H.page.locator('.tp-toast--req', { hasText: 'FFFF1' }).locator('.cxreq-reject')); + await H.page.waitForTimeout(300); + const dials8 = await inPage(H, 'return window.__dials.filter((d) => d.id === "ffff1").length'); + h.check(dials8 === 0, `an older joiner is NOT dialled — it would read the refusal as an approval (${dials8} dials)`); + + // ---- 9. host: full ------------------------------------------------------------------- + console.log('\n=== 9. host: the cap ==='); + await inPage(H, EMIT, { peer: 'gggg1', metadata: { jr: 1 } }); + await inPage(H, 'window.__realOpened = pc.openedPeers; pc.openedPeers = new Set(Array.from({ length: 15 }, (_, i) => "fake" + i)); s.peers.update((v) => v);'); + const gcard = H.page.locator('.tp-toast--req', { hasText: 'GGGG1' }); + const fullBtn = gcard.locator('.cxreq-full'); + await fullBtn.waitFor({ timeout: 5000 }).catch(() => {}); + h.check((await fullBtn.count()) === 1, 'at the cap the card offers "Tell them it\'s full"'); + h.check(await gcard.locator('button', { hasText: 'Approve' }).isDisabled({ timeout: 3000 }).catch(() => false), 'and Approve stays disabled (27-E)'); + await click(fullBtn); + await H.page.waitForTimeout(300); + const dials9 = await inPage(H, 'return window.__dials.filter((d) => d.id === "gggg1").map((d) => d.opts)'); + h.check(dials9.some((o) => o?.metadata?.joinresult === 'full'), `…which dials back with joinresult: full (${JSON.stringify(dials9)})`); + // the VR panel's yes goes through peerApproval.approvePeer, which used to approve past the cap + await inPage(H, EMIT, { peer: 'gggg2', metadata: { jr: 1 } }); + await inPage(H, 's.peerApproval.approvePeer("gggg2")'); + const dials9b = await inPage(H, 'return window.__dials.filter((d) => d.id === "gggg2").map((d) => d.opts)'); + h.check(dials9b.length === 1 && dials9b[0]?.metadata?.joinresult === 'full', `the shared approve refuses past the cap and says full (${JSON.stringify(dials9b)})`); + await inPage(H, 'pc.openedPeers = window.__realOpened; s.peers.update((v) => v);'); + + // ---- 10. host: approval says so ------------------------------------------------------- + console.log('\n=== 10. host: an approval says it is one ==='); + await inPage(H, EMIT, { peer: 'hhhh1', metadata: { jr: 1 } }); + await click(H.page.locator('.tp-toast--req', { hasText: 'HHHH1' }).getByRole('button', { name: 'Approve' })); + await H.page.waitForTimeout(300); + const first = await inPage(H, ` + const d = window.__dials.find((x) => x.id === 'hhhh1'); + if (!d) return null; + d.conn.open = true; + try { for (const fn of d.conn.handlers.open ?? []) fn(); } catch (e) { return { opts: d.opts, error: String(e), sent: d.conn.sent.map((m) => m?.type) }; } + return { opts: d.opts, sent: d.conn.sent.map((m) => ({ type: m?.type, result: m?.result })) };`); + console.log(' ' + JSON.stringify(first)); + h.check(first?.opts?.metadata?.joinresult === 'approved', 'the approve dial-back carries joinresult: approved in its metadata'); + h.check(first?.sent?.[0]?.type === 'joinresult' && first?.sent?.[0]?.result === 'approved', 'and its handshake OPENS with the joinresult message'); + await H.ctx.close(); + + // ---- 11. two real peers ----------------------------------------------------------------- + console.log('\n=== 11. two real peers ==='); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await dialVia(B, A.id); + const acard = A.page.locator('.tp-toast--req', { hasText: String(B.id).slice(0, 6).toUpperCase() }); + h.check(await click(acard.locator('.cxreq-reject'), 30000), "premise: A gets B's request card and rejects it"); + await h.eventually(() => read(B, 'connectionState.joinRefusal'), (r) => r?.result === 'denied' && r?.peerId === A.id, 'B hears the real Reject as declined', 20000); + h.check(!(await read(B, 'waitingForApproval')).some((w) => w[0] === A.id), "B's request is over"); + h.check(!(await notes(A)).some((t) => /unreachable/.test(t)), 'A is not told the refused joiner is unreachable'); + + await dialVia(B, A.id); + await inPage(A, 'window.__realOpened = pc.openedPeers; pc.openedPeers = new Set(Array.from({ length: 15 }, (_, i) => "fake" + i)); s.peers.update((v) => v);'); + h.check(await click(acard.locator('.cxreq-full'), 30000), 'premise: at the cap A tells B it is full'); + await inPage(A, 'pc.openedPeers = window.__realOpened; s.peers.update((v) => v);'); + await h.eventually(() => read(B, 'connectionState.joinRefusal'), (r) => r?.result === 'full', 'B hears the full room as FULL', 20000); + const chip = B.page.locator('#connect-refusal-chip'); + h.check((await attr(chip, 'data-result')) === 'full', 'and its pill says so'); + + await h.finish(browser); +}); From cf1c200d28a65cfb8618785231208160f978bf24 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 10:07:32 +0300 Subject: [PATCH 26/27] [feat] 26-D: a heavy scene gives up shadows before it gives up frames, and a joiner stops starving its own download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 26 section 4, Stage 1, steered by what 26-E measured. - src/lib/qualityGovernorCore.js (PURE, import-free): the decision rule. p95 over 2s above the trigger (or >2 long tasks in 5s) on a HEAVY scene takes one step, held 3s; 10s of p95 under 20ms walks one back. A step up within 20s of a walk down doubles the next recovery hold (flapping), capped at 80s. A 600ms settle window after every change, because the change itself is a hitch (a shadow toggle recompiles every lit material). - src/lib/qualityGovernor.js: the wiring. Frames from sceneBudget's loop, published as a LOCAL qualityOverrides store every consumer reads; never writes a preference, a document or a message. Hidden tab / 26-G pause = no evidence. - THE STEP ORDER IS THE MEASUREMENT'S, not the roadmap's: shadows first (the shadow pass is the second copy of every mesh; calls, not fill, bind a many-object scene), then resolution 85/72%, AO, 61%, the post stack, 50%, the particle cap (Stage 3's third bullet), the presence send gap. Consumers: lightParams + environment through one `shadowsDisabled()` (environment re-asserted the saved preference on every apply and undid the override within a frame — found by the suite), threlte's own dpr (Scene), AO/post filtered in Outline and the composer re-sized on a dpr change, particleRuntime, Scene's camera gap. - The desktop trigger is 35ms, not 33: frames are vsync-quantised, so a steady 30fps reads 33.3-33.4ms and a 33ms trigger would walk it to the bottom of the ladder. - Light scenes are never governed (the 26-G ruling: a slow machine is not an overloaded scene), which also keeps SwiftShader suites untouched. - NOT FIGHTING 26-G: the first step records the size readings (qualityBaseline) and sceneIsHeavy judges by the larger of now and then until full quality returns — else turning shadows off halves the calls and talks the freeze guard out of a scene that is still too heavy. A scene that really shrinks (<70% of the baseline objects) drops it. - THE INGEST DRAW GAP (26-E's biggest finding): while a received batch drains through slow frames (backlog > 50, p95 > 20ms) the renderer draws 4 frames a second, sticky for the drain. MEASURED with the rig, joiner time-to-synced for 1,000 / 2,000 / 3,000 boxes: 8.0s / 112s / ~180s before, 1.8s / 3.4s / 6.2s after, zero long tasks. - UI: a chip beside the object count ("Reduced quality (scene is heavy)" — click to hold, click again for full quality with a 60s snooze), a one-time toast with the same two actions, and Settings > "Reduce quality when the scene is heavy" (LOCAL, default on). Measured end to end in the suite on real frames (Radeon 890M): 3,000 real boxes engage the governor on their own, it takes ONE step (shadows off), draw calls 5,312 -> 2,930, frame p95 50ms -> 33.4ms, and it stops there. Counterfactuals (each broken, red, restored): - sceneIsHeavy ignoring the baseline: 2 red (26-G no longer judges heavy; no pause) - environment re-asserting shadowMap.enabled: 2 red (shadows stay on; real calls 5,312 -> 5,310) - Outline's composer not following dpr: 1 red (composer buffer 1280 -> 1280) - the draw gap early return removed: 1 red (780 render calls/s against 780) - desktop trigger back to 33ms: unit red (steady 30fps read as overloaded) - settle window 0: unit red (the recompile's long tasks take a second step) Suites: perf-governor (new) 39/39; unit qualityGovernor (new) 21, all unit 119/119. Held, green: scene-stress, overload-guard, scene-budget, ingest-gate, scene-poke, object-sync, view-mode, shadows, environment, environment-v2, env-preset-broadcast, scene-post, scene-post-ui, post-play-mode, particles, flow-particle, net-stress, camera-pip, settings-toasts-ux, settings-labels; net-handshake red once on a two-peer join then green on re-run. scene-post-effects 4.5 ("assigning a LUT PUSHES its bytes") is red IDENTICALLY with this diff reverted to HEAD — pre-existing, not chased. svelte-check 352/47 (base 352/47). npm run build green. Co-Authored-By: Claude Opus 5 --- src/App.svelte | 7 +- src/components/Outline.svelte | 35 ++- src/components/Scene.svelte | 23 +- src/components/menu/Controls.svelte | 51 +++- src/components/menu/Settings.svelte | 5 + src/lib/environment.js | 7 +- src/lib/lightParams.js | 20 +- src/lib/overloadGuard.js | 19 +- src/lib/particleRuntime.js | 8 +- src/lib/qualityGovernor.js | 242 ++++++++++++++++++ src/lib/qualityGovernorCore.js | 265 ++++++++++++++++++++ src/lib/sceneBudget.js | 60 +++++ tests/e2e/perf-governor.test.cjs | 370 ++++++++++++++++++++++++++++ tests/unit/qualityGovernor.test.js | 192 +++++++++++++++ 14 files changed, 1274 insertions(+), 30 deletions(-) create mode 100644 src/lib/qualityGovernor.js create mode 100644 src/lib/qualityGovernorCore.js create mode 100644 tests/e2e/perf-governor.test.cjs create mode 100644 tests/unit/qualityGovernor.test.js diff --git a/src/App.svelte b/src/App.svelte index a52b9cec..70a4490e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -454,9 +454,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/wireErrors'), import('./lib/safeStorage'), import('./lib/sceneBudget'), - import('./lib/overloadGuard') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib } + import('./lib/overloadGuard'), + import('./lib/qualityGovernor') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib } }) } }) diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 2abcba88..d60fda87 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -38,6 +38,7 @@ } from 'postprocessing'; import { onMount, onDestroy, untrack } from 'svelte'; import { renderPaused } from '$lib/overloadGuard'; + import { qualityOverrides, ingestDrawGap } from '$lib/qualityGovernor'; // 16-Q4: the camera preview window renders as an inset viewport of THIS renderer import { pipRect, pipTarget, glRect } from '$lib/cameraPip'; import { buildCamera } from '$lib/cameraObjects'; @@ -46,7 +47,7 @@ let outlineEffectSelected: OutlineEffect | null = null; let outlineEffectLocked: OutlineEffect | null = null; - const { scene, renderer, camera, size, autoRender, renderStage } = useThrelte(); + const { scene, renderer, camera, size, autoRender, renderStage, dpr } = useThrelte(); const composer = new EffectComposer(renderer); composer.removeAllPasses(); const renderPass = new RenderPass(scene, camera.current); @@ -215,10 +216,13 @@ // displays, so its output was upsampled and read as a shifted "ghost" of the // shading offset from the objects. The per-kind `resize` hook carries that // lesson in the registry rather than hardcoded here. - const dpr = renderer.getPixelRatio ? renderer.getPixelRatio() : 1; + // 26-D: the governor changes the pixel ratio WITHOUT changing the CSS size, so the + // composer has to follow the dpr too or its targets stay at the old resolution + void $dpr; + const pixelRatio = renderer.getPixelRatio ? renderer.getPixelRatio() : 1; composer.setSize($size.width, $size.height); for (const instance of stackInstances) - instance.def?.resize?.(instance.object, $size.width, $size.height, dpr); + instance.def?.resize?.(instance.object, $size.width, $size.height, pixelRatio); }); // L4: the capability gate now covers the WHOLE stack, not just AO (see // viewMode.postSupported for the three-r185 + Chromium<=150 story, why the @@ -253,14 +257,18 @@ // changes (measured: setting a camera to No files replaced rendered nothing new). void $postStacks; void $lookOverride; + // 26-D: the quality governor's post steps — AO first (the personal chip reads as plain + // shaded, an authored AO entry is dropped), then the whole stack. LOCAL overrides: the + // authored document is never touched, so a peer's look is unchanged + const reduced = $qualityOverrides; const entries = effectivePostStack({ stack: resolvedDoc(POST_SCENE_KEY), cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera) : null), - mode: $viewMode, - localEnabled: $postEnabledLocal, + mode: reduced.aoOff && $viewMode === 'shaded-ao' ? 'shaded' : $viewMode, + localEnabled: $postEnabledLocal && !reduced.postOff, postOk, postWarm - }); + }).filter((entry) => !(reduced.aoOff && entry.kind === 'ao')); const signature = postStackSignature(entries); if (signature === stackSignature) return; stackSignature = signature; @@ -330,9 +338,22 @@ let renderIsPaused = false; const stopPauseWatch = renderPaused.subscribe((value) => (renderIsPaused = !!value)); onDestroy(stopPauseWatch); + // 26-D THE INGEST DRAW GAP (26-E's finding): while a big received scene drains through + // slow frames, draw at most one frame per gap — every object's parse waits for a frame to + // pass, so a joiner redrawing a 2,000-object scene 30 times a second was starving its own + // receive queue (3,000 objects: ~180s drawing, 5.6s not). Never in XR, like the pause. + let drawGapMs = 0; + let lastDrawAt = 0; + const stopGapWatch = ingestDrawGap.subscribe((value) => (drawGapMs = value)); + onDestroy(stopGapWatch); useTask( (delta) => { if (renderIsPaused && !renderer.xr.isPresenting) return; + if (drawGapMs > 0 && !renderer.xr.isPresenting) { + const drawNow = performance.now(); + if (drawNow - lastDrawAt < drawGapMs) return; + lastDrawAt = drawNow; + } // In WebXR the EffectComposer can't be used: its passes render to canvas-sized // targets, not the XR framebuffer, so blitting them mismatches sizes // (GL_INVALID_FRAMEBUFFER_OPERATION) and nothing reaches the headset (dark @@ -460,6 +481,8 @@ return index >= 0 ? 'stack:' + (stackPlan[index]?.kinds ?? []).join('+') : 'other'; }), composerPasses: ((composer as any).passes ?? []).length, + // 26-D: the composer's own buffer, which must follow a governor dpr change + composerBufferWidth: (composer as any).inputBuffer?.width ?? null, outlinedSelected: outlineEffectSelected?.selection.size ?? 0, outlinedLocked: outlineEffectLocked?.selection.size ?? 0, stackPasses: stackPasses.length, diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 1e8a8915..f47c9396 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -1,6 +1,7 @@