From 2faa46b7690bfa85453922f23114feb7b3f0fe3e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 05:46:13 +0300 Subject: [PATCH 01/11] [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/11] [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/11] [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/11] [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/11] [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/11] [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/11] [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/11] [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/11] [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/11] [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/11] [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(() => {