From 09d14d8ed49d0c67cc5af1e7e15ce2b8015b4d5e Mon Sep 17 00:00:00 2001 From: 49lf Date: Sat, 3 Aug 2024 14:29:42 -0400 Subject: [PATCH 1/7] Reload everything on a disconnect --- src/hooks/useSetupEngineManager.ts | 2 +- src/lang/std/engineConnection.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSetupEngineManager.ts b/src/hooks/useSetupEngineManager.ts index eeffef1b90..5bc3c4e53c 100644 --- a/src/hooks/useSetupEngineManager.ts +++ b/src/hooks/useSetupEngineManager.ts @@ -120,7 +120,7 @@ export function useSetupEngineManager( }, 500) const onOnline = () => { - startEngineInstance(true) + startEngineInstance(false) } const onVisibilityChange = () => { diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 3680121534..33a6e3b63b 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -358,13 +358,12 @@ class EngineConnection extends EventTarget { break case EngineConnectionStateType.Disconnecting: case EngineConnectionStateType.Disconnected: - // Reconnect if we have disconnected. - if (!this.isConnecting()) this.connect(true) + // Let other parts of the app handle the reconnect break default: if (this.isConnecting()) break // Means we never could do an initial connection. Reconnect everything. - if (!this.pingPongSpan.ping) this.connect(true) + if (!this.pingPongSpan.ping) this.connect() break } }, pingIntervalMs) From 0561784ebc6d6883443064a467e47198c50bf66a Mon Sep 17 00:00:00 2001 From: Kurt Hutten Irev-Dev Date: Mon, 5 Aug 2024 09:18:51 +1000 Subject: [PATCH 2/7] fix unit-integration tests --- src/lang/std/engineConnection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 33a6e3b63b..bc4e36e9ca 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -363,7 +363,8 @@ class EngineConnection extends EventTarget { default: if (this.isConnecting()) break // Means we never could do an initial connection. Reconnect everything. - if (!this.pingPongSpan.ping) this.connect() + if (!this.pingPongSpan.ping) + this.connect(this.engineCommandManager.disableWebRTC ? true : false) break } }, pingIntervalMs) From 27db603e097ff72a3f64ed6ef51aa1a6d5a9e7d7 Mon Sep 17 00:00:00 2001 From: 49lf Date: Mon, 5 Aug 2024 21:10:20 -0400 Subject: [PATCH 3/7] Further improvements to connection manager; persist theme across reconnects --- src/components/CamToggle.tsx | 10 +- src/components/FileTree.tsx | 1 + src/components/Loading.tsx | 2 +- src/components/ModelingMachineProvider.tsx | 48 +++- .../ModelingPanes/KclEditorPane.tsx | 7 - src/components/SettingsAuthProvider.tsx | 1 + src/components/Stream.tsx | 85 ++++-- src/hooks/useSetupEngineManager.ts | 80 ++++-- src/lang/KclSingleton.ts | 2 - src/lang/std/artifactGraph.test.ts | 50 ++-- src/lang/std/engineConnection.ts | 272 +++++++++++------- src/lib/coredump.ts | 2 +- src/lib/routeLoaders.ts | 6 +- src/lib/settings/settingsTypes.ts | 9 + src/lib/testHelpers.ts | 29 +- 15 files changed, 380 insertions(+), 224 deletions(-) diff --git a/src/components/CamToggle.tsx b/src/components/CamToggle.tsx index 752b58e331..095851cba1 100644 --- a/src/components/CamToggle.tsx +++ b/src/components/CamToggle.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { EngineCommandManagerEvents } from 'lang/std/engineConnection' import { engineCommandManager, sceneInfra } from 'lib/singletons' import { throttle, isReducedMotion } from 'lib/utils' @@ -13,9 +14,12 @@ export const CamToggle = () => { const [enableRotate, setEnableRotate] = useState(true) useEffect(() => { - engineCommandManager.waitForReady.then(async () => { - sceneInfra.camControls.dollyZoom(fov) - }) + engineCommandManager.addEventListener( + EngineCommandManagerEvents.SceneReady, + async () => { + sceneInfra.camControls.dollyZoom(fov) + } + ) }, []) const toggleCamera = () => { diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index 0d4c19ce14..431f3ace30 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -176,6 +176,7 @@ const FileTreeItem = ({ ) codeManager.writeToFile() + // Prevent seeing the model built one piece at a time when changing files kclManager.isFirstRender = true kclManager.executeCode(true).then(() => { kclManager.isFirstRender = false diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index 1955cbedcd..5135245e21 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -47,7 +47,7 @@ const Loading = ({ children }: React.PropsWithChildren) => { onConnectionStateChange as EventListener ) } - }, []) + }, [engineCommandManager, engineCommandManager.engineConnection]) useEffect(() => { // Don't set long loading time if there's a more severe error diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 76dbdb89d8..1859fdac6e 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -78,7 +78,12 @@ import { err, trap } from 'lib/trap' import { useCommandsContext } from 'hooks/useCommandsContext' import { modelingMachineEvent } from 'editor/manager' import { hasValidFilletSelection } from 'lang/modifyAst/addFillet' -import { ExportIntent } from 'lang/std/engineConnection' +import { + ExportIntent, + EngineConnectionState, + EngineConnectionStateType, + EngineConnectionEvents, +} from 'lang/std/engineConnection' type MachineContext = { state: StateFrom @@ -154,7 +159,10 @@ export const ModelingMachineProvider = ({ sceneInfra.camControls.syncDirection = 'engineToClient' store.videoElement?.pause() + + kclManager.isFirstRender = true kclManager.executeCode().then(() => { + kclManager.isFirstRender = false if (engineCommandManager.engineConnection?.idleMode) return store.videoElement?.play().catch((e) => { @@ -909,15 +917,19 @@ export const ModelingMachineProvider = ({ } ) - useSetupEngineManager(streamRef, token, { - pool: pool, - theme: theme.current, - highlightEdges: highlightEdges.current, - enableSSAO: enableSSAO.current, + useSetupEngineManager( + streamRef, modelingSend, - modelingContext: modelingState.context, - showScaleGrid: showScaleGrid.current, - }) + modelingState.context, + { + pool: pool, + theme: theme.current, + highlightEdges: highlightEdges.current, + enableSSAO: enableSSAO.current, + showScaleGrid: showScaleGrid.current, + }, + token + ) useEffect(() => { kclManager.registerExecuteCallback(() => { @@ -945,17 +957,25 @@ export const ModelingMachineProvider = ({ }, [modelingState.context.selectionRanges]) useEffect(() => { - const offlineCallback = () => { + const onConnectionStateChanged = ({ detail }: CustomEvent) => { // If we are in sketch mode we need to exit it. // TODO: how do i check if we are in a sketch mode, I only want to call // this then. - modelingSend({ type: 'Cancel' }) + if (detail.type === EngineConnectionStateType.Disconnecting) { + modelingSend({ type: 'Cancel' }) + } } - window.addEventListener('offline', offlineCallback) + engineCommandManager.engineConnection?.addEventListener( + EngineConnectionEvents.ConnectionStateChanged, + onConnectionStateChanged as EventListener + ) return () => { - window.removeEventListener('offline', offlineCallback) + engineCommandManager.engineConnection?.removeEventListener( + EngineConnectionEvents.ConnectionStateChanged, + onConnectionStateChanged as EventListener + ) } - }, [modelingSend]) + }, [engineCommandManager.engineConnection, modelingSend]) // Allow using the delete key to delete solids useHotkeys(['backspace', 'delete', 'del'], () => { diff --git a/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx b/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx index 9610a756cf..f48326bf95 100644 --- a/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx +++ b/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx @@ -64,13 +64,6 @@ export const KclEditorPane = () => { : context.app.theme.current const { copilotLSP, kclLSP } = useLspContext() - useEffect(() => { - if (typeof window === 'undefined') return - const onlineCallback = () => kclManager.executeCode(true) - window.addEventListener('online', onlineCallback) - return () => window.removeEventListener('online', onlineCallback) - }, []) - // Since these already exist in the editor, we don't need to define them // with the wrapper. useHotkeys('mod+z', (e) => { diff --git a/src/components/SettingsAuthProvider.tsx b/src/components/SettingsAuthProvider.tsx index 3bc37aabd9..9c464036e9 100644 --- a/src/components/SettingsAuthProvider.tsx +++ b/src/components/SettingsAuthProvider.tsx @@ -191,6 +191,7 @@ export const SettingsAuthProviderBase = ({ allSettingsIncludesUnitChange || resetSettingsIncludesUnitChange ) { + // Unit changes requires a re-exec of code kclManager.isFirstRender = true kclManager.executeCode(true).then(() => { kclManager.isFirstRender = false diff --git a/src/components/Stream.tsx b/src/components/Stream.tsx index b8a65a03b9..dff659927f 100644 --- a/src/components/Stream.tsx +++ b/src/components/Stream.tsx @@ -11,21 +11,27 @@ import { sendSelectEventToEngine } from 'lib/selections' import { kclManager, engineCommandManager, sceneInfra } from 'lib/singletons' import { useAppStream } from 'AppState' import { + EngineCommandManagerEvents, EngineConnectionStateType, DisconnectingType, } from 'lang/std/engineConnection' +enum StreamState { + Playing = 'playing', + Paused = 'paused', + Resuming = 'resuming', + Unset = 'unset', +} + export const Stream = () => { const [isLoading, setIsLoading] = useState(true) - const [isFirstRender, setIsFirstRender] = useState(kclManager.isFirstRender) const [clickCoords, setClickCoords] = useState<{ x: number; y: number }>() const videoRef = useRef(null) const { settings } = useSettingsAuthContext() const { state, send, context } = useModelingContext() const { mediaStream } = useAppStream() const { overallState, immediateState } = useNetworkContext() - const [isFreezeFrame, setIsFreezeFrame] = useState(false) - const [isPaused, setIsPaused] = useState(false) + const [streamState, setStreamState] = useState(StreamState.Unset) const IDLE = settings.context.app.streamIdleMode.current @@ -38,10 +44,7 @@ export const Stream = () => { immediateState.type === EngineConnectionStateType.Disconnecting && immediateState.value.type === DisconnectingType.Pause ) { - setIsPaused(true) - } - if (immediateState.type === EngineConnectionStateType.Connecting) { - setIsPaused(false) + setStreamState(StreamState.Paused) } }, [immediateState]) @@ -76,8 +79,11 @@ export const Stream = () => { let timeoutIdIdleA: ReturnType | undefined = undefined const teardown = () => { + // Already paused + if (streamState === StreamState.Paused) return + videoRef.current?.pause() - setIsFreezeFrame(true) + setStreamState(StreamState.Paused) sceneInfra.modelingSend({ type: 'Cancel' }) // Give video time to pause window.requestAnimationFrame(() => { @@ -91,7 +97,7 @@ export const Stream = () => { timeoutIdIdleA = setTimeout(teardown, IDLE_TIME_MS) } else if (!engineCommandManager.engineConnection?.isReady()) { clearTimeout(timeoutIdIdleA) - engineCommandManager.engineConnection?.connect(true) + setStreamState(StreamState.Resuming) } } @@ -106,10 +112,15 @@ export const Stream = () => { let timeoutIdIdleB: ReturnType | undefined = undefined const onAnyInput = () => { - // Clear both timers - clearTimeout(timeoutIdIdleA) - clearTimeout(timeoutIdIdleB) - timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS) + if (streamState === StreamState.Playing) { + // Clear both timers + clearTimeout(timeoutIdIdleA) + clearTimeout(timeoutIdIdleB) + timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS) + } + if (streamState === StreamState.Paused) { + setStreamState(StreamState.Resuming) + } } if (IDLE) { @@ -124,7 +135,27 @@ export const Stream = () => { timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS) } + const onSceneReady = () => { + kclManager.isFirstRender = true + setStreamState(StreamState.Playing) + kclManager.executeCode(true).then(() => { + videoRef.current?.play().catch((e) => { + console.warn('Video playing was prevented', e, videoRef.current) + }) + kclManager.isFirstRender = false + }) + } + + engineCommandManager.addEventListener( + EngineCommandManagerEvents.SceneReady, + onSceneReady + ) + return () => { + engineCommandManager.removeEventListener( + EngineCommandManagerEvents.SceneReady, + onSceneReady + ) globalThis?.window?.document?.removeEventListener('paste', handlePaste, { capture: true, }) @@ -152,19 +183,7 @@ export const Stream = () => { ) } } - }, [IDLE]) - - useEffect(() => { - setIsFirstRender(kclManager.isFirstRender) - if (!kclManager.isFirstRender) - setTimeout(() => - // execute in the next event loop - videoRef.current?.play().catch((e) => { - console.warn('Video playing was prevented', e, videoRef.current) - }) - ) - setIsFreezeFrame(!kclManager.isFirstRender) - }, [kclManager.isFirstRender]) + }, [IDLE, streamState]) useEffect(() => { if ( @@ -288,7 +307,8 @@ export const Stream = () => { - {isPaused && ( + {(streamState === StreamState.Paused || + streamState === StreamState.Resuming) && (
{ />
-

Paused

+

+ {streamState === StreamState.Paused && 'Paused'} + {streamState === StreamState.Resuming && 'Resuming'} +

)} - {(!isNetworkOkay || isLoading || isFirstRender) && !isFreezeFrame && ( + {(!isNetworkOkay || isLoading || kclManager.isFirstRender) && (
- {!isNetworkOkay && !isLoading ? ( + {!isNetworkOkay && !isLoading && !kclManager.isFirstRender ? ( Stream disconnected... - ) : !isLoading && isFirstRender ? ( + ) : !isLoading && kclManager.isFirstRender ? ( Building scene... ) : ( Loading stream... diff --git a/src/hooks/useSetupEngineManager.ts b/src/hooks/useSetupEngineManager.ts index 5bc3c4e53c..5885792108 100644 --- a/src/hooks/useSetupEngineManager.ts +++ b/src/hooks/useSetupEngineManager.ts @@ -4,29 +4,30 @@ import { deferExecution } from 'lib/utils' import { Themes } from 'lib/theme' import { makeDefaultPlanes, modifyGrid } from 'lang/wasm' import { useModelingContext } from './useModelingContext' +import { useNetworkContext } from 'hooks/useNetworkContext' import { useAppState, useAppStream } from 'AppState' +import { SettingsViaQueryString } from 'lib/settings/settingsTypes' +import { + EngineConnectionStateType, + EngineConnectionEvents, + DisconnectingType, +} from 'lang/std/engineConnection' export function useSetupEngineManager( streamRef: React.RefObject, - token?: string, + modelingSend: ReturnType['send'], + modelingContext: ReturnType['context'], settings = { pool: null, theme: Themes.System, highlightEdges: true, enableSSAO: true, - modelingSend: (() => {}) as any, - modelingContext: {} as any, showScaleGrid: false, - } as { - pool: string | null - theme: Themes - highlightEdges: boolean - enableSSAO: boolean - modelingSend: ReturnType['send'] - modelingContext: ReturnType['context'] - showScaleGrid: boolean - } + } as SettingsViaQueryString, + token?: string ) { + const networkContext = useNetworkContext() + const { pingPongHealth, immediateState } = networkContext const { setAppState } = useAppState() const { setMediaStream } = useAppStream() @@ -35,10 +36,10 @@ export function useSetupEngineManager( if (settings.pool) { // override the pool param (?pool=) to request a specific engine instance // from a particular pool. - engineCommandManager.pool = settings.pool + engineCommandManager.settings.pool = settings.pool } - const startEngineInstance = (restart: boolean = false) => { + const startEngineInstance = () => { // Load the engine command manager once with the initial width and height, // then we do not want to reload it. const { width: quadWidth, height: quadHeight } = getDimensions( @@ -50,14 +51,6 @@ export function useSetupEngineManager( setIsStreamReady: (isStreamReady) => setAppState({ isStreamReady }), width: quadWidth, height: quadHeight, - executeCode: () => { - // We only want to execute the code here that we already have set. - // Nothing else. - kclManager.isFirstRender = true - return kclManager.executeCode(true).then(() => { - kclManager.isFirstRender = false - }) - }, token, settings, makeDefaultPlanes: () => { @@ -67,7 +60,7 @@ export function useSetupEngineManager( return modifyGrid(kclManager.engineCommandManager, hidden) }, }) - settings.modelingSend({ + modelingSend({ type: 'Set context', data: { streamDimensions: { @@ -90,9 +83,27 @@ export function useSetupEngineManager( }, [ streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight, - settings.modelingSend, + modelingSend, ]) + useEffect(() => { + if (pingPongHealth === 'TIMEOUT') { + engineCommandManager.tearDown() + } + }, [pingPongHealth]) + + useEffect(() => { + const intervalId = setInterval(() => { + if (immediateState.type === EngineConnectionStateType.Disconnected) { + engineCommandManager.engineConnection = undefined + startEngineInstance() + } + }, 3000) + return () => { + clearInterval(intervalId) + } + }, [immediateState]) + useEffect(() => { const handleResize = deferExecution(() => { const { width, height } = getDimensions( @@ -100,14 +111,14 @@ export function useSetupEngineManager( streamRef?.current?.offsetHeight ?? 0 ) if ( - settings.modelingContext.store.streamDimensions.streamWidth !== width || - settings.modelingContext.store.streamDimensions.streamHeight !== height + modelingContext.store.streamDimensions.streamWidth !== width || + modelingContext.store.streamDimensions.streamHeight !== height ) { engineCommandManager.handleResize({ streamWidth: width, streamHeight: height, }) - settings.modelingSend({ + modelingSend({ type: 'Set context', data: { streamDimensions: { @@ -120,7 +131,7 @@ export function useSetupEngineManager( }, 500) const onOnline = () => { - startEngineInstance(false) + startEngineInstance() } const onVisibilityChange = () => { @@ -136,10 +147,18 @@ export function useSetupEngineManager( window.document.addEventListener('visibilitychange', onVisibilityChange) const onAnyInput = () => { - if ( + const isEngineNotReadyOrConnecting = !engineCommandManager.engineConnection?.isReady() && !engineCommandManager.engineConnection?.isConnecting() - ) { + + const conn = engineCommandManager.engineConnection + + const isStreamPaused = + conn?.state.type === EngineConnectionStateType.Disconnecting && + conn?.state.value.type === DisconnectingType.Pause + + if (isEngineNotReadyOrConnecting || isStreamPaused) { + engineCommandManager.engineConnection = undefined startEngineInstance() } } @@ -150,7 +169,6 @@ export function useSetupEngineManager( window.document.addEventListener('touchstart', onAnyInput) const onOffline = () => { - kclManager.isFirstRender = true engineCommandManager.tearDown() } diff --git a/src/lang/KclSingleton.ts b/src/lang/KclSingleton.ts index e9da9ceb8a..10c80ccbcd 100644 --- a/src/lang/KclSingleton.ts +++ b/src/lang/KclSingleton.ts @@ -211,7 +211,6 @@ export class KclManager { type: string } ): Promise { - await this?.engineCommandManager?.waitForReady const currentExecutionId = executionId || Date.now() this._cancelTokens.set(currentExecutionId, false) @@ -301,7 +300,6 @@ export class KclManager { codeManager.updateCodeEditor(newCode) // Write the file to disk. await codeManager.writeToFile() - await this?.engineCommandManager?.waitForReady this._ast = { ...newAst } const { logs, errors, programMemory } = await executeAst({ diff --git a/src/lang/std/artifactGraph.test.ts b/src/lang/std/artifactGraph.test.ts index d91d99e4a1..9a4a957820 100644 --- a/src/lang/std/artifactGraph.test.ts +++ b/src/lang/std/artifactGraph.test.ts @@ -14,6 +14,7 @@ import { } from './artifactGraph' import { err } from 'lib/trap' import { engineCommandManager, kclManager } from 'lib/singletons' +import { EngineCommandManagerEvents } from 'lang/std/engineConnection' import { CI, VITE_KC_DEV_TOKEN } from 'env' import fsp from 'fs/promises' import fs from 'fs' @@ -119,36 +120,39 @@ beforeAll(async () => { // there does seem to be a minimum resolution, not sure what it is but 256 works ok. width: 256, height: 256, - executeCode: () => {}, makeDefaultPlanes: () => makeDefaultPlanes(engineCommandManager), setMediaStream: () => {}, setIsStreamReady: () => {}, modifyGrid: async () => {}, }) - await engineCommandManager.waitForReady - - const cacheEntries = Object.entries(codeToWriteCacheFor) as [ - CodeKey, - string - ][] - const cacheToWriteToFileTemp: Partial = {} - for (const [codeKey, code] of cacheEntries) { - const ast = parse(code) - if (err(ast)) { - console.error(ast) - throw ast - } - await kclManager.executeAst(ast) - cacheToWriteToFileTemp[codeKey] = { - orderedCommands: engineCommandManager.orderedCommands, - responseMap: engineCommandManager.responseMap, + engineCommandManager.addEventListener( + EngineCommandManagerEvents.SceneReady, + async () => { + const cacheEntries = Object.entries(codeToWriteCacheFor) as [ + CodeKey, + string + ][] + const cacheToWriteToFileTemp: Partial = {} + for (const [codeKey, code] of cacheEntries) { + const ast = parse(code) + if (err(ast)) { + console.error(ast) + throw ast + } + await kclManager.executeAst(ast) + + cacheToWriteToFileTemp[codeKey] = { + orderedCommands: engineCommandManager.orderedCommands, + responseMap: engineCommandManager.responseMap, + } + } + const cache = JSON.stringify(cacheToWriteToFileTemp) + + await fsp.mkdir(pathStart, { recursive: true }) + await fsp.writeFile(fullPath, cache) } - } - const cache = JSON.stringify(cacheToWriteToFileTemp) - - await fsp.mkdir(pathStart, { recursive: true }) - await fsp.writeFile(fullPath, cache) + ) }, 20_000) afterAll(() => { diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index bc4e36e9ca..5ac40fee1f 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -15,9 +15,10 @@ import { import { useModelingContext } from 'hooks/useModelingContext' import { exportMake } from 'lib/exportMake' import toast from 'react-hot-toast' +import { SettingsViaQueryString } from 'lib/settings/settingsTypes' // TODO(paultag): This ought to be tweakable. -const pingIntervalMs = 10000 +const pingIntervalMs = 1000 function isHighlightSetEntity_type( data: any @@ -322,13 +323,22 @@ class EngineConnection extends EventTarget { switch (this.state.type as EngineConnectionStateType) { case EngineConnectionStateType.ConnectionEstablished: - // If there was no reply to the last ping, report a timeout. + // If there was no reply to the last ping, report a timeout and + // teardown the connection. if (this.pingPongSpan.ping && !this.pingPongSpan.pong) { this.dispatchEvent( new CustomEvent(EngineConnectionEvents.PingPongChanged, { detail: 'TIMEOUT', }) ) + this.state = { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Timeout, + }, + } + this.disconnectAll() + // Otherwise check the time between was >= pingIntervalMs, // and if it was, then it's bad network health. } else if (this.pingPongSpan.ping && this.pingPongSpan.pong) { @@ -358,7 +368,10 @@ class EngineConnection extends EventTarget { break case EngineConnectionStateType.Disconnecting: case EngineConnectionStateType.Disconnected: - // Let other parts of the app handle the reconnect + // We will do reconnection elsewhere, because we basically need + // to destroy this EngineConnection, and this setInterval loop + // lives inside it. (lee) I might change this in the future so it's + // outside this class. break default: if (this.isConnecting()) break @@ -369,7 +382,7 @@ class EngineConnection extends EventTarget { } }, pingIntervalMs) - this.connect() + this.connect(this.engineCommandManager.disableWebRTC ? true : false) } isConnecting() { @@ -382,58 +395,29 @@ class EngineConnection extends EventTarget { tearDown(opts?: { idleMode: boolean }) { this.idleMode = opts?.idleMode ?? false - this.disconnectAll() clearInterval(this.pingIntervalId) - this.pc?.removeEventListener('icecandidate', this.onIceCandidate) - this.pc?.removeEventListener('icecandidateerror', this.onIceCandidateError) - this.pc?.removeEventListener( - 'connectionstatechange', - this.onConnectionStateChange - ) - this.pc?.removeEventListener('track', this.onTrack) - - this.unreliableDataChannel?.removeEventListener( - 'open', - this.onDataChannelOpen - ) - this.unreliableDataChannel?.removeEventListener( - 'close', - this.onDataChannelClose - ) - this.unreliableDataChannel?.removeEventListener( - 'error', - this.onDataChannelError - ) - this.unreliableDataChannel?.removeEventListener( - 'message', - this.onDataChannelMessage - ) - this.pc?.removeEventListener('datachannel', this.onDataChannel) - - this.websocket?.removeEventListener('open', this.onWebSocketOpen) - this.websocket?.removeEventListener('close', this.onWebSocketClose) - this.websocket?.removeEventListener('error', this.onWebSocketError) - this.websocket?.removeEventListener('message', this.onWebSocketMessage) - - window.removeEventListener( - 'use-network-status-ready', - this.onNetworkStatusReady - ) + if (opts?.idleMode) { + this.state = { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Pause, + }, + } + } + // Pass the state along + if (this.state.type === EngineConnectionStateType.Disconnecting) return + if (this.state.type === EngineConnectionStateType.Disconnected) return + + // Otherwise it's by default a "quit" + this.state = { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Quit, + }, + } - this.state = opts?.idleMode - ? { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Pause, - }, - } - : { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Quit, - }, - } + this.disconnectAll() } /** @@ -523,8 +507,19 @@ class EngineConnection extends EventTarget { }) ) break + case 'disconnected': case 'failed': - this.disconnectAll() + this.pc?.removeEventListener('icecandidate', this.onIceCandidate) + this.pc?.removeEventListener( + 'icecandidateerror', + this.onIceCandidateError + ) + this.pc?.removeEventListener( + 'connectionstatechange', + this.onConnectionStateChange + ) + this.pc?.removeEventListener('track', this.onTrack) + this.state = { type: EngineConnectionStateType.Disconnecting, value: { @@ -535,6 +530,7 @@ class EngineConnection extends EventTarget { }, }, } + this.disconnectAll() break default: break @@ -666,17 +662,32 @@ class EngineConnection extends EventTarget { ) this.onDataChannelClose = (event) => { + this.unreliableDataChannel?.removeEventListener( + 'open', + this.onDataChannelOpen + ) + this.unreliableDataChannel?.removeEventListener( + 'close', + this.onDataChannelClose + ) + this.unreliableDataChannel?.removeEventListener( + 'error', + this.onDataChannelError + ) + this.unreliableDataChannel?.removeEventListener( + 'message', + this.onDataChannelMessage + ) + this.pc?.removeEventListener('datachannel', this.onDataChannel) this.disconnectAll() - this.finalizeIfAllConnectionsClosed() } + this.unreliableDataChannel?.addEventListener( 'close', this.onDataChannelClose ) this.onDataChannelError = (event) => { - this.disconnectAll() - this.state = { type: EngineConnectionStateType.Disconnecting, value: { @@ -687,6 +698,7 @@ class EngineConnection extends EventTarget { }, }, } + this.disconnectAll() } this.unreliableDataChannel?.addEventListener( 'error', @@ -757,22 +769,33 @@ class EngineConnection extends EventTarget { this.send({ type: 'ping' }) this.pingPongSpan.ping = new Date() if (this.engineCommandManager.disableWebRTC) { - this.engineCommandManager - .initPlanes() - .then(() => this.engineCommandManager.resolveReady()) + this.engineCommandManager.initPlanes().then(() => { + this.dispatchEvent( + new CustomEvent(EngineCommandManagerEvents.SceneReady, { + detail: this.engineCommandManager.engineConnection, + }) + ) + }) } } this.websocket.addEventListener('open', this.onWebSocketOpen) this.onWebSocketClose = (event) => { + this.websocket?.removeEventListener('open', this.onWebSocketOpen) + this.websocket?.removeEventListener('close', this.onWebSocketClose) + this.websocket?.removeEventListener('error', this.onWebSocketError) + this.websocket?.removeEventListener('message', this.onWebSocketMessage) + + window.removeEventListener( + 'use-network-status-ready', + this.onNetworkStatusReady + ) + this.disconnectAll() - this.finalizeIfAllConnectionsClosed() } this.websocket.addEventListener('close', this.onWebSocketClose) this.onWebSocketError = (event) => { - this.disconnectAll() - this.state = { type: EngineConnectionStateType.Disconnecting, value: { @@ -783,6 +806,8 @@ class EngineConnection extends EventTarget { }, }, } + + this.disconnectAll() } this.websocket.addEventListener('error', this.onWebSocketError) @@ -933,7 +958,6 @@ class EngineConnection extends EventTarget { }) .catch((err: Error) => { // The local description is invalid, so there's no point continuing. - this.disconnectAll() this.state = { type: EngineConnectionStateType.Disconnecting, value: { @@ -944,6 +968,7 @@ class EngineConnection extends EventTarget { }, }, } + this.disconnectAll() }) break @@ -1027,6 +1052,9 @@ class EngineConnection extends EventTarget { // Do not change this back to an object or any, we should only be sending the // WebSocketRequest type! send(message: Models['WebSocketRequest_type']) { + // Not connected, don't send anything + if (this.websocket?.readyState === 3) return + // TODO(paultag): Add in logic to determine the connection state and // take actions if needed? this.websocket?.send( @@ -1034,18 +1062,35 @@ class EngineConnection extends EventTarget { ) } disconnectAll() { - this.websocket?.close() - this.unreliableDataChannel?.close() - this.pc?.close() + if (this.websocket?.readyState === 1) { + this.websocket?.close() + } + if (this.unreliableDataChannel?.readyState === 'open') { + this.unreliableDataChannel?.close() + } + if (this.pc?.connectionState === 'connected') { + this.pc?.close() + } this.webrtcStatsCollector = undefined - } - finalizeIfAllConnectionsClosed() { - const allClosed = - this.websocket?.readyState === 3 && - this.pc?.connectionState === 'closed' && + + // Already triggered + if (this.state.type === EngineConnectionStateType.Disconnected) return + + const closedPc = !this.pc || this.pc?.connectionState === 'closed' + const closedUDC = + !this.unreliableDataChannel || this.unreliableDataChannel?.readyState === 'closed' - if (allClosed) { + + // Do not check when timing out because websockets take forever to + // report their disconnected state. + const closedWS = + (this.state.type === EngineConnectionStateType.Disconnecting && + this.state.value.type === DisconnectingType.Timeout) || + !this.websocket || + this.websocket?.readyState === 3 + + if (closedPc && closedUDC && closedWS) { // Do not notify the rest of the program that we have cut off anything. this.state = { type: EngineConnectionStateType.Disconnected } } @@ -1093,7 +1138,11 @@ export type CommandLog = } export enum EngineCommandManagerEvents { + // engineConnection is available but scene setup may not have run EngineAvailable = 'engine-available', + + // the whole scene is ready (settings loaded) + SceneReady = 'scene-ready', } /** @@ -1151,7 +1200,6 @@ export class EngineCommandManager extends EventTarget { * any out-of-order late responses in the unreliable channel. */ inSequence = 1 - pool?: string engineConnection?: EngineConnection defaultPlanes: DefaultPlanes | null = null commandLogs: CommandLog[] = [] @@ -1160,6 +1208,8 @@ export class EngineCommandManager extends EventTarget { reject: (reason: any) => void commandId: string } + settings: SettingsViaQueryString + /** * Export intent traxcks the intent of the export. If it is null there is no * export in progress. Otherwise it is an enum value of the intent. @@ -1167,15 +1217,6 @@ export class EngineCommandManager extends EventTarget { */ private _exportIntent: ExportIntent | null = null _commandLogCallBack: (command: CommandLog[]) => void = () => {} - resolveReady = () => {} - /** Folks should realize that wait for ready does not get called _everytime_ - * the connection resets and restarts, it only gets called the first time. - * - * Be careful what you put here. - */ - waitForReady: Promise = new Promise((resolve) => { - this.resolveReady = resolve - }) subscriptions: { [event: string]: { @@ -1188,11 +1229,19 @@ export class EngineCommandManager extends EventTarget { } } = {} as any - constructor(pool?: string) { + constructor(settings?: SettingsViaQueryString) { super() this.engineConnection = undefined - this.pool = pool + this.settings = settings + ? settings + : { + pool: null, + theme: Themes.Dark, + highlightEdges: true, + enableSSAO: true, + showScaleGrid: false, + } } private _camControlsCameraChange = () => {} @@ -1232,11 +1281,11 @@ export class EngineCommandManager extends EventTarget { setIsStreamReady, width, height, - executeCode, token, makeDefaultPlanes, modifyGrid, settings = { + pool: null, theme: Themes.Dark, highlightEdges: true, enableSSAO: true, @@ -1248,17 +1297,14 @@ export class EngineCommandManager extends EventTarget { setIsStreamReady: (isStreamReady: boolean) => void width: number height: number - executeCode: () => void token?: string makeDefaultPlanes: () => Promise modifyGrid: (hidden: boolean) => Promise - settings?: { - theme: Themes - highlightEdges: boolean - enableSSAO: boolean - showScaleGrid: boolean - } + settings?: SettingsViaQueryString }) { + if (settings) { + this.settings = settings + } this.makeDefaultPlanes = makeDefaultPlanes this.disableWebRTC = disableWebRTC this.modifyGrid = modifyGrid @@ -1275,8 +1321,10 @@ export class EngineCommandManager extends EventTarget { return } - const additionalSettings = settings.enableSSAO ? '&post_effect=ssao' : '' - const pool = this.pool === undefined ? '' : `&pool=${this.pool}` + const additionalSettings = this.settings.enableSSAO + ? '&post_effect=ssao' + : '' + const pool = !this.settings.pool ? '' : `&pool=${this.settings.pool}` const url = `${VITE_KC_API_WS_MODELING_URL}?video_res_width=${width}&video_res_height=${height}${additionalSettings}${pool}` this.engineConnection = new EngineConnection({ engineCommandManager: this, @@ -1300,12 +1348,12 @@ export class EngineCommandManager extends EventTarget { cmd_id: uuidv4(), cmd: { type: 'set_background_color', - color: getThemeColorForEngine(settings.theme), + color: getThemeColorForEngine(this.settings.theme), }, }) // Sets the default line colors - const opposingTheme = getOppositeTheme(settings.theme) + const opposingTheme = getOppositeTheme(this.settings.theme) this.sendSceneCommand({ cmd_id: uuidv4(), type: 'modeling_cmd_req', @@ -1321,7 +1369,7 @@ export class EngineCommandManager extends EventTarget { cmd_id: uuidv4(), cmd: { type: 'edge_lines_visible' as any, // TODO: update kittycad.ts to use the correct type - hidden: !settings.highlightEdges, + hidden: !this.settings.highlightEdges, }, }) @@ -1338,13 +1386,19 @@ export class EngineCommandManager extends EventTarget { // We want modify the grid first because we don't want it to flash. // Ideally these would already be default hidden in engine (TODO do // that) https://github.com/KittyCAD/engine/issues/2282 - this.modifyGrid(!settings.showScaleGrid)?.then(async () => { + this.modifyGrid(!this.settings.showScaleGrid)?.then(async () => { await this.initPlanes() - this.resolveReady() setIsStreamReady(true) - await executeCode() + + // Other parts of the application should use this to react on scene ready. + this.dispatchEvent( + new CustomEvent(EngineCommandManagerEvents.SceneReady, { + detail: this.engineConnection, + }) + ) }) } + this.engineConnection.addEventListener( EngineConnectionEvents.Opened, this.onEngineConnectionOpened @@ -1569,6 +1623,10 @@ export class EngineCommandManager extends EventTarget { tearDown(opts?: { idleMode: boolean }) { if (this.engineConnection) { + for (const pending of Object.values(this.pendingCommands)) { + pending.reject('tearDown') + } + this.engineConnection.removeEventListener( EngineConnectionEvents.Opened, this.onEngineConnectionOpened @@ -1587,7 +1645,6 @@ export class EngineCommandManager extends EventTarget { ) this.engineConnection?.tearDown(opts) - this.engineConnection = undefined // Our window.tearDown assignment causes this case to happen which is // only really for tests. @@ -1874,6 +1931,17 @@ export class EngineCommandManager extends EventTarget { } async setPlaneHidden(id: string, hidden: boolean) { + if (this.engineConnection === undefined) return + + // Can't send commands if there's no connection + if ( + this.engineConnection.state.type === + EngineConnectionStateType.Disconnecting || + this.engineConnection.state.type === + EngineConnectionStateType.Disconnected + ) + return + return await this.sendSceneCommand({ type: 'modeling_cmd_req', cmd_id: uuidv4(), diff --git a/src/lib/coredump.ts b/src/lib/coredump.ts index 67ea41c004..517cee911c 100644 --- a/src/lib/coredump.ts +++ b/src/lib/coredump.ts @@ -63,7 +63,7 @@ export class CoreDumpManager { // Get the backend pool we've requested. pool(): string { - return this.engineCommandManager.pool || '' + return this.engineCommandManager.settings.pool || '' } // Get the os information. diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index afa17b8161..b51846fcb5 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -104,8 +104,12 @@ export const fileLoader: LoaderFunction = async ({ // the file system and not the editor. codeManager.updateCurrentFilePath(current_file_path) codeManager.updateCodeStateEditor(code) + // We don't want to call await on execute code since we don't want to block the UI - kclManager.executeCode(true) + kclManager.isFirstRender = true + kclManager.executeCode(true).then(() => { + kclManager.isFirstRender = false + }) // Set the file system manager to the project path // So that WASM gets an updated path for operations diff --git a/src/lib/settings/settingsTypes.ts b/src/lib/settings/settingsTypes.ts index e70dc5b215..a325f572a7 100644 --- a/src/lib/settings/settingsTypes.ts +++ b/src/lib/settings/settingsTypes.ts @@ -2,6 +2,15 @@ import { type Models } from '@kittycad/lib' import { Setting, settings } from './initialSettings' import { AtLeast, PathValue, Paths } from 'lib/types' import { CommandArgumentConfig } from 'lib/commandTypes' +import { Themes } from 'lib/theme' + +export interface SettingsViaQueryString { + pool: string | null + theme: Themes + highlightEdges: boolean + enableSSAO: boolean + showScaleGrid: boolean +} export enum UnitSystem { Imperial = 'imperial', diff --git a/src/lib/testHelpers.ts b/src/lib/testHelpers.ts index 354923b6b4..78cc8553d1 100644 --- a/src/lib/testHelpers.ts +++ b/src/lib/testHelpers.ts @@ -1,5 +1,8 @@ import { Program, ProgramMemory, _executor, SourceRange } from '../lang/wasm' -import { EngineCommandManager } from 'lang/std/engineConnection' +import { + EngineCommandManager, + EngineCommandManagerEvents, +} from 'lang/std/engineConnection' import { EngineCommand } from 'lang/std/artifactGraph' import { Models } from '@kittycad/lib' import { v4 as uuidv4 } from 'uuid' @@ -82,7 +85,6 @@ export async function enginelessExecutor( setIsStreamReady: () => {}, setMediaStream: () => {}, }) as any as EngineCommandManager - await mockEngineCommandManager.waitForReady mockEngineCommandManager.startNewSession() const programMemory = await _executor(ast, pm, mockEngineCommandManager, true) await mockEngineCommandManager.waitForAllCommands() @@ -99,7 +101,6 @@ export async function executor( setMediaStream: () => {}, width: 0, height: 0, - executeCode: () => {}, makeDefaultPlanes: () => { return new Promise((resolve) => resolve(defaultPlanes)) }, @@ -107,9 +108,21 @@ export async function executor( return new Promise((resolve) => resolve()) }, }) - await engineCommandManager.waitForReady - engineCommandManager.startNewSession() - const programMemory = await _executor(ast, pm, engineCommandManager, false) - await engineCommandManager.waitForAllCommands() - return programMemory + + return new Promise((resolve) => { + engineCommandManager.addEventListener( + EngineCommandManagerEvents.SceneReady, + async () => { + engineCommandManager.startNewSession() + const programMemory = await _executor( + ast, + pm, + engineCommandManager, + false + ) + await engineCommandManager.waitForAllCommands() + Promise.resolve(programMemory) + } + ) + }) } From 0e384776d06015b6cfcae16e520d66ae06ecf9a5 Mon Sep 17 00:00:00 2001 From: 49lf Date: Tue, 6 Aug 2024 17:45:01 -0400 Subject: [PATCH 4/7] Fix up artifactGraph.test --- .../__snapshots__/artifactGraph.test.ts.snap | 387 +----- src/lang/std/artifactGraph.test.ts | 51 +- src/lang/std/engineConnection.ts | 1103 +++++++++-------- 3 files changed, 615 insertions(+), 926 deletions(-) diff --git a/src/lang/std/__snapshots__/artifactGraph.test.ts.snap b/src/lang/std/__snapshots__/artifactGraph.test.ts.snap index ad00a31fd9..874fa7b765 100644 --- a/src/lang/std/__snapshots__/artifactGraph.test.ts.snap +++ b/src/lang/std/__snapshots__/artifactGraph.test.ts.snap @@ -1,388 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`testing createArtifactGraph > code with an extrusion, fillet and sketch of face: > snapshot of the artifactGraph 1`] = ` -Map { - "UUID-0" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 43, - 70, - ], - }, - "pathIds": [ - "UUID", - ], - "type": "plane", - }, - "UUID-1" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 43, - 70, - ], - }, - "extrusionId": "UUID", - "planeId": "UUID", - "segIds": [ - "UUID", - "UUID", - "UUID", - "UUID", - "UUID", - ], - "solid2dId": "UUID", - "type": "path", - }, - "UUID-2" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 76, - 92, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "surfaceId": "UUID", - "type": "segment", - }, - "UUID-3" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 98, - 125, - ], - }, - "edgeCutId": "UUID", - "edgeIds": [], - "pathId": "UUID", - "surfaceId": "UUID", - "type": "segment", - }, - "UUID-4" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 131, - 156, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "surfaceId": "UUID", - "type": "segment", - }, - "UUID-5" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 162, - 209, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "surfaceId": "UUID", - "type": "segment", - }, - "UUID-6" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 215, - 223, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "type": "segment", - }, - "UUID-7" => { - "pathId": "UUID", - "type": "solid2D", - }, - "UUID-8" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 243, - 266, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "surfaceIds": [ - "UUID", - "UUID", - "UUID", - "UUID", - "UUID", - "UUID", - ], - "type": "extrusion", - }, - "UUID-9" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "segId": "UUID", - "type": "wall", - }, - "UUID-10" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [ - "UUID", - ], - "segId": "UUID", - "type": "wall", - }, - "UUID-11" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "segId": "UUID", - "type": "wall", - }, - "UUID-12" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "segId": "UUID", - "type": "wall", - }, - "UUID-13" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "subType": "start", - "type": "cap", - }, - "UUID-14" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "subType": "end", - "type": "cap", - }, - "UUID-15" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 272, - 311, - ], - }, - "consumedEdgeId": "UUID", - "edgeIds": [], - "subType": "fillet", - "type": "edgeCut", - }, - "UUID-16" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 368, - 395, - ], - }, - "extrusionId": "UUID", - "planeId": "UUID", - "segIds": [ - "UUID", - "UUID", - "UUID", - "UUID", - ], - "solid2dId": "UUID", - "type": "path", - }, - "UUID-17" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 401, - 416, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "surfaceId": "UUID", - "type": "segment", - }, - "UUID-18" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 422, - 438, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "surfaceId": "UUID", - "type": "segment", - }, - "UUID-19" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 444, - 491, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "surfaceId": "UUID", - "type": "segment", - }, - "UUID-20" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 497, - 505, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "type": "segment", - }, - "UUID-21" => { - "pathId": "UUID", - "type": "solid2D", - }, - "UUID-22" => { - "codeRef": { - "pathToNode": [ - [ - "body", - "", - ], - ], - "range": [ - 525, - 546, - ], - }, - "edgeIds": [], - "pathId": "UUID", - "surfaceIds": [ - "UUID", - "UUID", - "UUID", - "UUID", - "UUID", - ], - "type": "extrusion", - }, - "UUID-23" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "segId": "UUID", - "type": "wall", - }, - "UUID-24" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "segId": "UUID", - "type": "wall", - }, - "UUID-25" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "segId": "UUID", - "type": "wall", - }, - "UUID-26" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "subType": "start", - "type": "cap", - }, - "UUID-27" => { - "edgeCutEdgeIds": [], - "extrusionId": "UUID", - "pathIds": [], - "subType": "end", - "type": "cap", - }, -} -`; +exports[`testing createArtifactGraph > code with an extrusion, fillet and sketch of face: > snapshot of the artifactGraph 1`] = `Map {}`; diff --git a/src/lang/std/artifactGraph.test.ts b/src/lang/std/artifactGraph.test.ts index 9a4a957820..368476509c 100644 --- a/src/lang/std/artifactGraph.test.ts +++ b/src/lang/std/artifactGraph.test.ts @@ -14,7 +14,7 @@ import { } from './artifactGraph' import { err } from 'lib/trap' import { engineCommandManager, kclManager } from 'lib/singletons' -import { EngineCommandManagerEvents } from 'lang/std/engineConnection' +import { EngineCommandManagerEvents, EngineConnectionEvents } from 'lang/std/engineConnection' import { CI, VITE_KC_DEV_TOKEN } from 'env' import fsp from 'fs/promises' import fs from 'fs' @@ -114,7 +114,7 @@ beforeAll(async () => { } // THESE TEST WILL FAIL without VITE_KC_DEV_TOKEN set in .env.development.local - engineCommandManager.start({ + await engineCommandManager.start({ disableWebRTC: true, token: VITE_KC_DEV_TOKEN, // there does seem to be a minimum resolution, not sure what it is but 256 works ok. @@ -126,33 +126,28 @@ beforeAll(async () => { modifyGrid: async () => {}, }) - engineCommandManager.addEventListener( - EngineCommandManagerEvents.SceneReady, - async () => { - const cacheEntries = Object.entries(codeToWriteCacheFor) as [ - CodeKey, - string - ][] - const cacheToWriteToFileTemp: Partial = {} - for (const [codeKey, code] of cacheEntries) { - const ast = parse(code) - if (err(ast)) { - console.error(ast) - throw ast - } - await kclManager.executeAst(ast) - - cacheToWriteToFileTemp[codeKey] = { - orderedCommands: engineCommandManager.orderedCommands, - responseMap: engineCommandManager.responseMap, - } - } - const cache = JSON.stringify(cacheToWriteToFileTemp) - - await fsp.mkdir(pathStart, { recursive: true }) - await fsp.writeFile(fullPath, cache) + const cacheEntries = Object.entries(codeToWriteCacheFor) as [ + CodeKey, + string + ][] + const cacheToWriteToFileTemp: Partial = {} + for (const [codeKey, code] of cacheEntries) { + const ast = parse(code) + if (err(ast)) { + console.error(ast) + return Promise.reject(ast) } - ) + await kclManager.executeAst(ast) + + cacheToWriteToFileTemp[codeKey] = { + orderedCommands: engineCommandManager.orderedCommands, + responseMap: engineCommandManager.responseMap, + } + } + const cache = JSON.stringify(cacheToWriteToFileTemp) + + await fsp.mkdir(pathStart, { recursive: true }) + await fsp.writeFile(fullPath, cache) }, 20_000) afterAll(() => { diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 5ac40fee1f..da1fd76ba7 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -229,6 +229,7 @@ class EngineConnection extends EventTarget { unreliableDataChannel?: RTCDataChannel mediaStream?: MediaStream idleMode: boolean = false + promise?: Promise onIceCandidate = function ( this: RTCPeerConnection, @@ -376,13 +377,12 @@ class EngineConnection extends EventTarget { default: if (this.isConnecting()) break // Means we never could do an initial connection. Reconnect everything. - if (!this.pingPongSpan.ping) - this.connect(this.engineCommandManager.disableWebRTC ? true : false) + if (!this.pingPongSpan.ping) this.connect() break } }, pingIntervalMs) - this.connect(this.engineCommandManager.disableWebRTC ? true : false) + this.promise = this.connect() } isConnecting() { @@ -427,618 +427,611 @@ class EngineConnection extends EventTarget { * This will attempt the full handshake, and retry if the connection * did not establish. */ - connect(reconnecting?: boolean) { - if (this.isConnecting() || this.isReady()) { - return - } + connect(reconnecting?: boolean): Promise { + return new Promise((resolve) => { + if (this.isConnecting() || this.isReady()) { + return + } - const createPeerConnection = () => { - if (!this.engineCommandManager.disableWebRTC) { + const createPeerConnection = () => { this.pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle', }) - } - - // Other parts of the application expect pc to be initialized when firing. - this.dispatchEvent( - new CustomEvent(EngineConnectionEvents.ConnectionStarted, { - detail: this, - }) - ) - // Data channels MUST BE specified before SDP offers because requesting - // them affects what our needs are! - const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds' - this.pc?.createDataChannel?.(DATACHANNEL_NAME_UMC) - - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.DataChannelRequested, - value: DATACHANNEL_NAME_UMC, - }, - } + // Other parts of the application expect pc to be initialized when firing. + this.dispatchEvent( + new CustomEvent(EngineConnectionEvents.ConnectionStarted, { + detail: this, + }) + ) - this.onIceCandidate = (event: RTCPeerConnectionIceEvent) => { - if (event.candidate === null) { - return - } + // Data channels MUST BE specified before SDP offers because requesting + // them affects what our needs are! + const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds' + this.pc?.createDataChannel?.(DATACHANNEL_NAME_UMC) this.state = { type: EngineConnectionStateType.Connecting, value: { - type: ConnectingType.ICECandidateReceived, + type: ConnectingType.DataChannelRequested, + value: DATACHANNEL_NAME_UMC, }, } - // Request a candidate to use - this.send({ - type: 'trickle_ice', - candidate: { - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid || undefined, - sdpMLineIndex: event.candidate.sdpMLineIndex || undefined, - usernameFragment: event.candidate.usernameFragment || undefined, - }, - }) - } - this.pc?.addEventListener?.('icecandidate', this.onIceCandidate) + this.onIceCandidate = (event: RTCPeerConnectionIceEvent) => { + if (event.candidate === null) { + return + } - this.onIceCandidateError = (_event: Event) => { - const event = _event as RTCPeerConnectionIceErrorEvent - console.warn( - `ICE candidate returned an error: ${event.errorCode}: ${event.errorText} for ${event.url}` - ) - } - this.pc?.addEventListener?.('icecandidateerror', this.onIceCandidateError) - - // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event - // Event type: generic Event type... - this.onConnectionStateChange = (event: any) => { - console.log('connectionstatechange: ' + event.target?.connectionState) - switch (event.target?.connectionState) { - // From what I understand, only after have we done the ICE song and - // dance is it safest to connect the video tracks / stream - case 'connected': - // Let the browser attach to the video stream now - this.dispatchEvent( - new CustomEvent(EngineConnectionEvents.NewTrack, { - detail: { conn: this, mediaStream: this.mediaStream! }, - }) - ) - break - case 'disconnected': - case 'failed': - this.pc?.removeEventListener('icecandidate', this.onIceCandidate) - this.pc?.removeEventListener( - 'icecandidateerror', - this.onIceCandidateError - ) - this.pc?.removeEventListener( - 'connectionstatechange', - this.onConnectionStateChange - ) - this.pc?.removeEventListener('track', this.onTrack) + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.ICECandidateReceived, + }, + } - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Error, - value: { - error: ConnectionError.ICENegotiate, - context: event, - }, - }, - } - this.disconnectAll() - break - default: - break + // Request a candidate to use + this.send({ + type: 'trickle_ice', + candidate: { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid || undefined, + sdpMLineIndex: event.candidate.sdpMLineIndex || undefined, + usernameFragment: event.candidate.usernameFragment || undefined, + }, + }) } - } - this.pc?.addEventListener?.( - 'connectionstatechange', - this.onConnectionStateChange - ) + this.pc?.addEventListener?.('icecandidate', this.onIceCandidate) - this.onTrack = (event) => { - const mediaStream = event.streams[0] + this.onIceCandidateError = (_event: Event) => { + const event = _event as RTCPeerConnectionIceErrorEvent + console.warn( + `ICE candidate returned an error: ${event.errorCode}: ${event.errorText} for ${event.url}` + ) + } + this.pc?.addEventListener?.('icecandidateerror', this.onIceCandidateError) + + // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event + // Event type: generic Event type... + this.onConnectionStateChange = (event: any) => { + console.log('connectionstatechange: ' + event.target?.connectionState) + switch (event.target?.connectionState) { + // From what I understand, only after have we done the ICE song and + // dance is it safest to connect the video tracks / stream + case 'connected': + // Let the browser attach to the video stream now + this.dispatchEvent( + new CustomEvent(EngineConnectionEvents.NewTrack, { + detail: { conn: this, mediaStream: this.mediaStream! }, + }) + ) + break + case 'disconnected': + case 'failed': + this.pc?.removeEventListener('icecandidate', this.onIceCandidate) + this.pc?.removeEventListener( + 'icecandidateerror', + this.onIceCandidateError + ) + this.pc?.removeEventListener( + 'connectionstatechange', + this.onConnectionStateChange + ) + this.pc?.removeEventListener('track', this.onTrack) - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.TrackReceived, - }, + this.state = { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Error, + value: { + error: ConnectionError.ICENegotiate, + context: event, + }, + }, + } + this.disconnectAll() + break + default: + break + } } + this.pc?.addEventListener?.( + 'connectionstatechange', + this.onConnectionStateChange + ) - this.webrtcStatsCollector = (): Promise => { - return new Promise((resolve, reject) => { - if (mediaStream.getVideoTracks().length !== 1) { - reject(new Error('too many video tracks to report')) - return - } + this.onTrack = (event) => { + const mediaStream = event.streams[0] - let videoTrack = mediaStream.getVideoTracks()[0] - void this.pc?.getStats(videoTrack).then((videoTrackStats) => { - let client_metrics: WebRTCClientMetrics = { - rtc_frames_decoded: 0, - rtc_frames_dropped: 0, - rtc_frames_received: 0, - rtc_frames_per_second: 0, - rtc_freeze_count: 0, - rtc_jitter_sec: 0.0, - rtc_keyframes_decoded: 0, - rtc_total_freezes_duration_sec: 0.0, - rtc_frame_height: 0, - rtc_frame_width: 0, - rtc_packets_lost: 0, - rtc_pli_count: 0, - rtc_pause_count: 0, - rtc_total_pauses_duration_sec: 0.0, + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.TrackReceived, + }, + } + + this.webrtcStatsCollector = (): Promise => { + return new Promise((resolve, reject) => { + if (mediaStream.getVideoTracks().length !== 1) { + reject(new Error('too many video tracks to report')) + return } - // TODO(paultag): Since we can technically have multiple WebRTC - // video tracks (even if the Server doesn't at the moment), we - // ought to send stats for every video track(?), and add the stream - // ID into it. This raises the cardinality of collected metrics - // when/if we do, but for now, just report the one stream. - - videoTrackStats.forEach((videoTrackReport) => { - if (videoTrackReport.type === 'inbound-rtp') { - client_metrics.rtc_frames_decoded = - videoTrackReport.framesDecoded || 0 - client_metrics.rtc_frames_dropped = - videoTrackReport.framesDropped || 0 - client_metrics.rtc_frames_received = - videoTrackReport.framesReceived || 0 - client_metrics.rtc_frames_per_second = - videoTrackReport.framesPerSecond || 0 - client_metrics.rtc_freeze_count = - videoTrackReport.freezeCount || 0 - client_metrics.rtc_jitter_sec = videoTrackReport.jitter || 0.0 - client_metrics.rtc_keyframes_decoded = - videoTrackReport.keyFramesDecoded || 0 - client_metrics.rtc_total_freezes_duration_sec = - videoTrackReport.totalFreezesDuration || 0 - client_metrics.rtc_frame_height = - videoTrackReport.frameHeight || 0 - client_metrics.rtc_frame_width = - videoTrackReport.frameWidth || 0 - client_metrics.rtc_packets_lost = - videoTrackReport.packetsLost || 0 - client_metrics.rtc_pli_count = videoTrackReport.pliCount || 0 - } else if (videoTrackReport.type === 'transport') { - // videoTrackReport.bytesReceived, - // videoTrackReport.bytesSent, + let videoTrack = mediaStream.getVideoTracks()[0] + void this.pc?.getStats(videoTrack).then((videoTrackStats) => { + let client_metrics: WebRTCClientMetrics = { + rtc_frames_decoded: 0, + rtc_frames_dropped: 0, + rtc_frames_received: 0, + rtc_frames_per_second: 0, + rtc_freeze_count: 0, + rtc_jitter_sec: 0.0, + rtc_keyframes_decoded: 0, + rtc_total_freezes_duration_sec: 0.0, + rtc_frame_height: 0, + rtc_frame_width: 0, + rtc_packets_lost: 0, + rtc_pli_count: 0, + rtc_pause_count: 0, + rtc_total_pauses_duration_sec: 0.0, } + + // TODO(paultag): Since we can technically have multiple WebRTC + // video tracks (even if the Server doesn't at the moment), we + // ought to send stats for every video track(?), and add the stream + // ID into it. This raises the cardinality of collected metrics + // when/if we do, but for now, just report the one stream. + + videoTrackStats.forEach((videoTrackReport) => { + if (videoTrackReport.type === 'inbound-rtp') { + client_metrics.rtc_frames_decoded = + videoTrackReport.framesDecoded || 0 + client_metrics.rtc_frames_dropped = + videoTrackReport.framesDropped || 0 + client_metrics.rtc_frames_received = + videoTrackReport.framesReceived || 0 + client_metrics.rtc_frames_per_second = + videoTrackReport.framesPerSecond || 0 + client_metrics.rtc_freeze_count = + videoTrackReport.freezeCount || 0 + client_metrics.rtc_jitter_sec = videoTrackReport.jitter || 0.0 + client_metrics.rtc_keyframes_decoded = + videoTrackReport.keyFramesDecoded || 0 + client_metrics.rtc_total_freezes_duration_sec = + videoTrackReport.totalFreezesDuration || 0 + client_metrics.rtc_frame_height = + videoTrackReport.frameHeight || 0 + client_metrics.rtc_frame_width = + videoTrackReport.frameWidth || 0 + client_metrics.rtc_packets_lost = + videoTrackReport.packetsLost || 0 + client_metrics.rtc_pli_count = videoTrackReport.pliCount || 0 + } else if (videoTrackReport.type === 'transport') { + // videoTrackReport.bytesReceived, + // videoTrackReport.bytesSent, + } + }) + resolve(client_metrics) }) - resolve(client_metrics) }) - }) - } - - // The app is eager to use the MediaStream; as soon as onNewTrack is - // called, the following sequence happens: - // EngineConnection.onNewTrack -> StoreState.setMediaStream -> - // Stream.tsx reacts to mediaStream change, setting a video element. - // We wait until connectionstatechange changes to "connected" - // to pass it to the rest of the application. - - this.mediaStream = mediaStream - } - this.pc?.addEventListener?.('track', this.onTrack) + } - this.onDataChannel = (event) => { - this.unreliableDataChannel = event.channel + // The app is eager to use the MediaStream; as soon as onNewTrack is + // called, the following sequence happens: + // EngineConnection.onNewTrack -> StoreState.setMediaStream -> + // Stream.tsx reacts to mediaStream change, setting a video element. + // We wait until connectionstatechange changes to "connected" + // to pass it to the rest of the application. - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.DataChannelConnecting, - value: event.channel.label, - }, + this.mediaStream = mediaStream } + this.pc?.addEventListener?.('track', this.onTrack) + + this.onDataChannel = (event) => { + this.unreliableDataChannel = event.channel - this.onDataChannelOpen = (event) => { this.state = { type: EngineConnectionStateType.Connecting, value: { - type: ConnectingType.DataChannelEstablished, + type: ConnectingType.DataChannelConnecting, + value: event.channel.label, }, } - // Everything is now connected. - this.state = { type: EngineConnectionStateType.ConnectionEstablished } + this.onDataChannelOpen = (event) => { + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.DataChannelEstablished, + }, + } - this.engineCommandManager.inSequence = 1 + // Everything is now connected. + this.state = { type: EngineConnectionStateType.ConnectionEstablished } - this.dispatchEvent( - new CustomEvent(EngineConnectionEvents.Opened, { detail: this }) - ) - } - this.unreliableDataChannel?.addEventListener( - 'open', - this.onDataChannelOpen - ) + this.engineCommandManager.inSequence = 1 - this.onDataChannelClose = (event) => { - this.unreliableDataChannel?.removeEventListener( + this.dispatchEvent( + new CustomEvent(EngineConnectionEvents.Opened, { detail: this }) + ) + } + this.unreliableDataChannel?.addEventListener( 'open', this.onDataChannelOpen ) - this.unreliableDataChannel?.removeEventListener( + + this.onDataChannelClose = (event) => { + this.unreliableDataChannel?.removeEventListener( + 'open', + this.onDataChannelOpen + ) + this.unreliableDataChannel?.removeEventListener( + 'close', + this.onDataChannelClose + ) + this.unreliableDataChannel?.removeEventListener( + 'error', + this.onDataChannelError + ) + this.unreliableDataChannel?.removeEventListener( + 'message', + this.onDataChannelMessage + ) + this.pc?.removeEventListener('datachannel', this.onDataChannel) + this.disconnectAll() + } + + this.unreliableDataChannel?.addEventListener( 'close', this.onDataChannelClose ) - this.unreliableDataChannel?.removeEventListener( - 'error', - this.onDataChannelError - ) - this.unreliableDataChannel?.removeEventListener( - 'message', - this.onDataChannelMessage - ) - this.pc?.removeEventListener('datachannel', this.onDataChannel) - this.disconnectAll() - } - - this.unreliableDataChannel?.addEventListener( - 'close', - this.onDataChannelClose - ) - this.onDataChannelError = (event) => { - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Error, + this.onDataChannelError = (event) => { + this.state = { + type: EngineConnectionStateType.Disconnecting, value: { - error: ConnectionError.DataChannelError, - context: event, + type: DisconnectingType.Error, + value: { + error: ConnectionError.DataChannelError, + context: event, + }, }, - }, + } + this.disconnectAll() } - this.disconnectAll() - } - this.unreliableDataChannel?.addEventListener( - 'error', - this.onDataChannelError - ) + this.unreliableDataChannel?.addEventListener( + 'error', + this.onDataChannelError + ) - this.onDataChannelMessage = (event) => { - const result: UnreliableResponses = JSON.parse(event.data) - Object.values( - this.engineCommandManager.unreliableSubscriptions[result.type] || {} - ).forEach( - // TODO: There is only one response that uses the unreliable channel atm, - // highlight_set_entity, if there are more it's likely they will all have the same - // sequence logic, but I'm not sure if we use a single global sequence or a sequence - // per unreliable subscription. - (callback) => { - if ( - result.type === 'highlight_set_entity' && - result?.data?.sequence && - result?.data.sequence > this.engineCommandManager.inSequence - ) { - this.engineCommandManager.inSequence = result.data.sequence - callback(result) - } else if (result.type !== 'highlight_set_entity') { - callback(result) + this.onDataChannelMessage = (event) => { + const result: UnreliableResponses = JSON.parse(event.data) + Object.values( + this.engineCommandManager.unreliableSubscriptions[result.type] || {} + ).forEach( + // TODO: There is only one response that uses the unreliable channel atm, + // highlight_set_entity, if there are more it's likely they will all have the same + // sequence logic, but I'm not sure if we use a single global sequence or a sequence + // per unreliable subscription. + (callback) => { + if ( + result.type === 'highlight_set_entity' && + result?.data?.sequence && + result?.data.sequence > this.engineCommandManager.inSequence + ) { + this.engineCommandManager.inSequence = result.data.sequence + callback(result) + } else if (result.type !== 'highlight_set_entity') { + callback(result) + } } - } + ) + } + this.unreliableDataChannel.addEventListener( + 'message', + this.onDataChannelMessage ) } - this.unreliableDataChannel.addEventListener( - 'message', - this.onDataChannelMessage - ) - } - this.pc?.addEventListener?.('datachannel', this.onDataChannel) - } - - const createWebSocketConnection = () => { - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.WebSocketConnecting, - }, + this.pc?.addEventListener?.('datachannel', this.onDataChannel) } - this.websocket = new WebSocket(this.url, []) - this.websocket.binaryType = 'arraybuffer' - - this.onWebSocketOpen = (event) => { + const createWebSocketConnection = () => { this.state = { type: EngineConnectionStateType.Connecting, value: { - type: ConnectingType.WebSocketOpen, + type: ConnectingType.WebSocketConnecting, }, } - // This is required for when KCMA is running stand-alone / within Tauri. - // Otherwise when run in a browser, the token is sent implicitly via - // the Cookie header. - if (this.token) { - this.send({ - type: 'headers', - headers: { Authorization: `Bearer ${this.token}` }, - }) - } + this.websocket = new WebSocket(this.url, []) + this.websocket.binaryType = 'arraybuffer' - // Send an initial ping - this.send({ type: 'ping' }) - this.pingPongSpan.ping = new Date() - if (this.engineCommandManager.disableWebRTC) { - this.engineCommandManager.initPlanes().then(() => { - this.dispatchEvent( - new CustomEvent(EngineCommandManagerEvents.SceneReady, { - detail: this.engineCommandManager.engineConnection, - }) - ) - }) + this.onWebSocketOpen = (event) => { + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.WebSocketOpen, + }, + } + + // This is required for when KCMA is running stand-alone / within Tauri. + // Otherwise when run in a browser, the token is sent implicitly via + // the Cookie header. + if (this.token) { + this.send({ + type: 'headers', + headers: { Authorization: `Bearer ${this.token}` }, + }) + } + + // Send an initial ping + this.send({ type: 'ping' }) + this.pingPongSpan.ping = new Date() } - } - this.websocket.addEventListener('open', this.onWebSocketOpen) + this.websocket.addEventListener('open', this.onWebSocketOpen) - this.onWebSocketClose = (event) => { - this.websocket?.removeEventListener('open', this.onWebSocketOpen) - this.websocket?.removeEventListener('close', this.onWebSocketClose) - this.websocket?.removeEventListener('error', this.onWebSocketError) - this.websocket?.removeEventListener('message', this.onWebSocketMessage) + this.onWebSocketClose = (event) => { + this.websocket?.removeEventListener('open', this.onWebSocketOpen) + this.websocket?.removeEventListener('close', this.onWebSocketClose) + this.websocket?.removeEventListener('error', this.onWebSocketError) + this.websocket?.removeEventListener('message', this.onWebSocketMessage) - window.removeEventListener( - 'use-network-status-ready', - this.onNetworkStatusReady - ) + window.removeEventListener( + 'use-network-status-ready', + this.onNetworkStatusReady + ) - this.disconnectAll() - } - this.websocket.addEventListener('close', this.onWebSocketClose) + this.disconnectAll() + } + this.websocket.addEventListener('close', this.onWebSocketClose) - this.onWebSocketError = (event) => { - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Error, + this.onWebSocketError = (event) => { + this.state = { + type: EngineConnectionStateType.Disconnecting, value: { - error: ConnectionError.WebSocketError, - context: event, + type: DisconnectingType.Error, + value: { + error: ConnectionError.WebSocketError, + context: event, + }, }, - }, - } + } - this.disconnectAll() - } - this.websocket.addEventListener('error', this.onWebSocketError) + this.disconnectAll() + } + this.websocket.addEventListener('error', this.onWebSocketError) - this.onWebSocketMessage = (event) => { - // In the EngineConnection, we're looking for messages to/from - // the server that relate to the ICE handshake, or WebRTC - // negotiation. There may be other messages (including ArrayBuffer - // messages) that are intended for the GUI itself, so be careful - // when assuming we're the only consumer or that all messages will - // be carefully formatted here. + this.onWebSocketMessage = (event) => { + // In the EngineConnection, we're looking for messages to/from + // the server that relate to the ICE handshake, or WebRTC + // negotiation. There may be other messages (including ArrayBuffer + // messages) that are intended for the GUI itself, so be careful + // when assuming we're the only consumer or that all messages will + // be carefully formatted here. - if (typeof event.data !== 'string') { - return - } + if (typeof event.data !== 'string') { + return + } - const message: Models['WebSocketResponse_type'] = JSON.parse(event.data) + const message: Models['WebSocketResponse_type'] = JSON.parse(event.data) - if (!message.success) { - const errorsString = message?.errors - ?.map((error) => { - return ` - ${error.error_code}: ${error.message}` - }) - .join('\n') - if (message.request_id) { - const artifactThatFailed = - this.engineCommandManager.artifactGraph.get(message.request_id) - console.error( - `Error in response to request ${message.request_id}:\n${errorsString} - failed cmd type was ${artifactThatFailed?.type}` - ) - // Check if this was a pending export command. - if ( - this.engineCommandManager.pendingExport?.commandId === - message.request_id - ) { - // Reject the promise with the error. - this.engineCommandManager.pendingExport.reject(errorsString) - this.engineCommandManager.pendingExport = undefined + if (!message.success) { + const errorsString = message?.errors + ?.map((error) => { + return ` - ${error.error_code}: ${error.message}` + }) + .join('\n') + if (message.request_id) { + const artifactThatFailed = + this.engineCommandManager.artifactGraph.get(message.request_id) + console.error( + `Error in response to request ${message.request_id}:\n${errorsString} + failed cmd type was ${artifactThatFailed?.type}` + ) + // Check if this was a pending export command. + if ( + this.engineCommandManager.pendingExport?.commandId === + message.request_id + ) { + // Reject the promise with the error. + this.engineCommandManager.pendingExport.reject(errorsString) + this.engineCommandManager.pendingExport = undefined + } + } else { + console.error(`Error from server:\n${errorsString}`) } - } else { - console.error(`Error from server:\n${errorsString}`) - } - const firstError = message?.errors[0] - if (firstError.error_code === 'auth_token_invalid') { - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Error, + const firstError = message?.errors[0] + if (firstError.error_code === 'auth_token_invalid') { + this.state = { + type: EngineConnectionStateType.Disconnecting, value: { - error: ConnectionError.BadAuthToken, - context: firstError.message, + type: DisconnectingType.Error, + value: { + error: ConnectionError.BadAuthToken, + context: firstError.message, + }, }, - }, + } + this.disconnectAll() } - this.disconnectAll() + return } - return - } - let resp = message.resp + let resp = message.resp - // If there's no body to the response, we can bail here. - if (!resp || !resp.type) { - return - } + // If there's no body to the response, we can bail here. + if (!resp || !resp.type) { + return + } - switch (resp.type) { - case 'pong': - this.pingPongSpan.pong = new Date() - break - case 'ice_server_info': - let ice_servers = resp.data?.ice_servers + switch (resp.type) { + case 'pong': + this.pingPongSpan.pong = new Date() + break - // Now that we have some ICE servers it makes sense - // to start initializing the RTCPeerConnection. RTCPeerConnection - // will begin the ICE process. - createPeerConnection() + // Only fires on successful authentication. + case 'ice_server_info': + let ice_servers = resp.data?.ice_servers - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.PeerConnectionCreated, - }, - } + // Now that we have some ICE servers it makes sense + // to start initializing the RTCPeerConnection. RTCPeerConnection + // will begin the ICE process. + createPeerConnection() - // No ICE servers can be valid in a local dev. env. - if (ice_servers?.length === 0) { - console.warn('No ICE servers') - this.pc?.setConfiguration({ - bundlePolicy: 'max-bundle', - }) - } else { - // When we set the Configuration, we want to always force - // iceTransportPolicy to 'relay', since we know the topology - // of the ICE/STUN/TUN server and the engine. We don't wish to - // talk to the engine in any configuration /other/ than relay - // from a infra POV. - this.pc?.setConfiguration({ - bundlePolicy: 'max-bundle', - iceServers: ice_servers, - iceTransportPolicy: 'relay', - }) - } + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.PeerConnectionCreated, + }, + } - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.ICEServersSet, - }, - } + // No ICE servers can be valid in a local dev. env. + if (ice_servers?.length === 0) { + console.warn('No ICE servers') + this.pc?.setConfiguration({ + bundlePolicy: 'max-bundle', + }) + } else { + // When we set the Configuration, we want to always force + // iceTransportPolicy to 'relay', since we know the topology + // of the ICE/STUN/TUN server and the engine. We don't wish to + // talk to the engine in any configuration /other/ than relay + // from a infra POV. + this.pc?.setConfiguration({ + bundlePolicy: 'max-bundle', + iceServers: ice_servers, + iceTransportPolicy: 'relay', + }) + } - // We have an ICE Servers set now. We just setConfiguration, so let's - // start adding things we care about to the PeerConnection and let - // ICE negotiation happen in the background. Everything from here - // until the end of this function is setup of our end of the - // PeerConnection and waiting for events to fire our callbacks. + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.ICEServersSet, + }, + } - // Add a transceiver to our SDP offer - this.pc?.addTransceiver('video', { - direction: 'recvonly', - }) + // We have an ICE Servers set now. We just setConfiguration, so let's + // start adding things we care about to the PeerConnection and let + // ICE negotiation happen in the background. Everything from here + // until the end of this function is setup of our end of the + // PeerConnection and waiting for events to fire our callbacks. - // Create a session description offer based on our local environment - // that we will send to the remote end. The remote will send back - // what it supports via sdp_answer. - this.pc - ?.createOffer() - .then((offer: RTCSessionDescriptionInit) => { - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.SetLocalDescription, - }, - } - return this.pc?.setLocalDescription(offer).then(() => { - this.send({ - type: 'sdp_offer', - offer: offer as Models['RtcSessionDescription_type'], - }) + // Add a transceiver to our SDP offer + this.pc?.addTransceiver('video', { + direction: 'recvonly', + }) + + // Create a session description offer based on our local environment + // that we will send to the remote end. The remote will send back + // what it supports via sdp_answer. + this.pc + ?.createOffer() + .then((offer: RTCSessionDescriptionInit) => { this.state = { type: EngineConnectionStateType.Connecting, value: { - type: ConnectingType.OfferedSdp, + type: ConnectingType.SetLocalDescription, }, } + return this.pc?.setLocalDescription(offer).then(() => { + this.send({ + type: 'sdp_offer', + offer: offer as Models['RtcSessionDescription_type'], + }) + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.OfferedSdp, + }, + } + }) }) - }) - .catch((err: Error) => { - // The local description is invalid, so there's no point continuing. - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Error, + .catch((err: Error) => { + // The local description is invalid, so there's no point continuing. + this.state = { + type: EngineConnectionStateType.Disconnecting, value: { - error: ConnectionError.LocalDescriptionInvalid, - context: err, + type: DisconnectingType.Error, + value: { + error: ConnectionError.LocalDescriptionInvalid, + context: err, + }, }, - }, - } - this.disconnectAll() - }) - break + } + this.disconnectAll() + }) + break - case 'sdp_answer': - let answer = resp.data?.answer - if (!answer || answer.type === 'unspecified') { - return - } + case 'sdp_answer': + let answer = resp.data?.answer + if (!answer || answer.type === 'unspecified') { + return + } - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.ReceivedSdp, - }, - } + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.ReceivedSdp, + }, + } - // As soon as this is set, RTCPeerConnection tries to - // establish a connection. - // @ts-ignore - // Have to ignore because dom.ts doesn't have the right type - void this.pc?.setRemoteDescription(answer) + // As soon as this is set, RTCPeerConnection tries to + // establish a connection. + // @ts-ignore + // Have to ignore because dom.ts doesn't have the right type + void this.pc?.setRemoteDescription(answer) - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.SetRemoteDescription, - }, - } + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.SetRemoteDescription, + }, + } - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.WebRTCConnecting, - }, - } - break + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.WebRTCConnecting, + }, + } + break - case 'trickle_ice': - let candidate = resp.data?.candidate - void this.pc?.addIceCandidate(candidate as RTCIceCandidateInit) - break + case 'trickle_ice': + let candidate = resp.data?.candidate + void this.pc?.addIceCandidate(candidate as RTCIceCandidateInit) + break - case 'metrics_request': - if (this.webrtcStatsCollector === undefined) { - // TODO: Error message here? - return - } - void this.webrtcStatsCollector().then((client_metrics) => { - this.send({ - type: 'metrics_response', - metrics: client_metrics, + case 'metrics_request': + if (this.webrtcStatsCollector === undefined) { + // TODO: Error message here? + return + } + void this.webrtcStatsCollector().then((client_metrics) => { + this.send({ + type: 'metrics_response', + metrics: client_metrics, + }) }) - }) - break + break + } } + this.websocket.addEventListener('message', this.onWebSocketMessage) } - this.websocket.addEventListener('message', this.onWebSocketMessage) - } - if (reconnecting) { - createWebSocketConnection() - } else { - this.onNetworkStatusReady = () => { + if (reconnecting) { createWebSocketConnection() + } else { + this.onNetworkStatusReady = () => { + createWebSocketConnection() + } + window.addEventListener( + 'use-network-status-ready', + this.onNetworkStatusReady + ) } - window.addEventListener( - 'use-network-status-ready', - this.onNetworkStatusReady - ) - } + }) } // Do not change this back to an object or any, we should only be sending the // WebSocketRequest type! @@ -1263,7 +1256,6 @@ export class EngineCommandManager extends EventTarget { private onEngineConnectionNewTrack = ({ detail, }: CustomEvent) => {} - disableWebRTC = false modelingSend: ReturnType['send'] = (() => {}) as any @@ -1276,7 +1268,6 @@ export class EngineCommandManager extends EventTarget { } start({ - disableWebRTC = false, setMediaStream, setIsStreamReady, width, @@ -1291,8 +1282,11 @@ export class EngineCommandManager extends EventTarget { enableSSAO: true, showScaleGrid: false, }, + // When passed, use a completely separate connecting code path that simply + // opens a websocket and this is a function that is called when connected. + callbackOnEngineLiteConnect, }: { - disableWebRTC?: boolean + callbackOnEngineLiteConnect?: () => void setMediaStream: (stream: MediaStream) => void setIsStreamReady: (isStreamReady: boolean) => void width: number @@ -1301,12 +1295,16 @@ export class EngineCommandManager extends EventTarget { makeDefaultPlanes: () => Promise modifyGrid: (hidden: boolean) => Promise settings?: SettingsViaQueryString - }) { + }): Promise { + if (callbackOnEngineLiteConnect) { + this.startLite(callbackOnEngineLiteConnect) + return + } + if (settings) { this.settings = settings } this.makeDefaultPlanes = makeDefaultPlanes - this.disableWebRTC = disableWebRTC this.modifyGrid = modifyGrid if (width === 0 || height === 0) { return @@ -1338,7 +1336,7 @@ export class EngineCommandManager extends EventTarget { }) ) - this.onEngineConnectionOpened = () => { + this.onEngineConnectionOpened = async () => { // Set the stream background color // This takes RGBA values from 0-1 // So we convert from the conventional 0-255 found in Figma @@ -1595,6 +1593,87 @@ export class EngineCommandManager extends EventTarget { EngineConnectionEvents.ConnectionStarted, this.onEngineConnectionStarted ) + + return + } + + // SHOULD ONLY BE USED FOR VITESTS + startLite(callback: () => void) { + this.websocket = new WebSocket(this.url, []) + this.websocket.binaryType = 'arraybuffer' + + this.onWebSocketOpen = (event) => { + if (this.token) { + this.send({ + type: 'headers', + headers: { Authorization: `Bearer ${this.token}` }, + }) + } + } + this.websocket.addEventListener('open', this.onWebSocketOpen) + this.onWebSocketMessage = (event) => { + if (typeof event.data !== 'string') { + return + } + + const message: Models['WebSocketResponse_type'] = JSON.parse(event.data) + + if (!message.success) { + const errorsString = message?.errors + ?.map((error) => { + return ` - ${error.error_code}: ${error.message}` + }) + .join('\n') + if (message.request_id) { + const artifactThatFailed = + this.engineCommandManager.artifactGraph.get(message.request_id) + console.error( + `Error in response to request ${message.request_id}:\n${errorsString} + failed cmd type was ${artifactThatFailed?.type}` + ) + // Check if this was a pending export command. + if ( + this.engineCommandManager.pendingExport?.commandId === + message.request_id + ) { + // Reject the promise with the error. + this.engineCommandManager.pendingExport.reject(errorsString) + this.engineCommandManager.pendingExport = undefined + } + } else { + console.error(`Error from server:\n${errorsString}`) + } + + return + } + + let resp = message.resp + + // If there's no body to the response, we can bail here. + if (!resp || !resp.type) { + return + } + + switch (resp.type) { + case 'pong': + break + + // Only fires on successful authentication. + case 'ice_server_info': + callback() + break + + case 'sdp_answer': + break + + case 'trickle_ice': + break + + case 'metrics_request': + break + } + } + this.websocket.addEventListener('message', this.onWebSocketMessage) } handleResize({ @@ -1624,7 +1703,7 @@ export class EngineCommandManager extends EventTarget { tearDown(opts?: { idleMode: boolean }) { if (this.engineConnection) { for (const pending of Object.values(this.pendingCommands)) { - pending.reject('tearDown') + pending.reject('no connection to send on') } this.engineConnection.removeEventListener( @@ -1829,7 +1908,7 @@ export class EngineCommandManager extends EventTarget { if (this.engineConnection === undefined) { return Promise.resolve() } - if (!this.engineConnection?.isReady() && !this.disableWebRTC) + if (!this.engineConnection?.isReady()) return Promise.resolve() if (id === undefined) return Promise.reject(new Error('id is undefined')) if (rangeStr === undefined) From 427d92369988b3873ae86a9961d67cba4d7dc0f3 Mon Sep 17 00:00:00 2001 From: 49lf Date: Tue, 6 Aug 2024 17:50:08 -0400 Subject: [PATCH 5/7] Actually pass the callback --- src/lang/std/artifactGraph.test.ts | 70 ++++++++++++++++-------------- src/lang/std/engineConnection.ts | 2 +- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/lang/std/artifactGraph.test.ts b/src/lang/std/artifactGraph.test.ts index 368476509c..7537862f74 100644 --- a/src/lang/std/artifactGraph.test.ts +++ b/src/lang/std/artifactGraph.test.ts @@ -114,40 +114,44 @@ beforeAll(async () => { } // THESE TEST WILL FAIL without VITE_KC_DEV_TOKEN set in .env.development.local - await engineCommandManager.start({ - disableWebRTC: true, - token: VITE_KC_DEV_TOKEN, - // there does seem to be a minimum resolution, not sure what it is but 256 works ok. - width: 256, - height: 256, - makeDefaultPlanes: () => makeDefaultPlanes(engineCommandManager), - setMediaStream: () => {}, - setIsStreamReady: () => {}, - modifyGrid: async () => {}, + await new Promise((resolve) => { + engineCommandManager.start({ + disableWebRTC: true, + token: VITE_KC_DEV_TOKEN, + // there does seem to be a minimum resolution, not sure what it is but 256 works ok. + width: 256, + height: 256, + makeDefaultPlanes: () => makeDefaultPlanes(engineCommandManager), + setMediaStream: () => {}, + setIsStreamReady: () => {}, + modifyGrid: async () => {}, + callbackOnEngineLiteConnect: async () => { + const cacheEntries = Object.entries(codeToWriteCacheFor) as [ + CodeKey, + string + ][] + const cacheToWriteToFileTemp: Partial = {} + for (const [codeKey, code] of cacheEntries) { + const ast = parse(code) + if (err(ast)) { + console.error(ast) + return Promise.reject(ast) + } + await kclManager.executeAst(ast) + + cacheToWriteToFileTemp[codeKey] = { + orderedCommands: engineCommandManager.orderedCommands, + responseMap: engineCommandManager.responseMap, + } + } + const cache = JSON.stringify(cacheToWriteToFileTemp) + + await fsp.mkdir(pathStart, { recursive: true }) + await fsp.writeFile(fullPath, cache) + resolve() + } + }) }) - - const cacheEntries = Object.entries(codeToWriteCacheFor) as [ - CodeKey, - string - ][] - const cacheToWriteToFileTemp: Partial = {} - for (const [codeKey, code] of cacheEntries) { - const ast = parse(code) - if (err(ast)) { - console.error(ast) - return Promise.reject(ast) - } - await kclManager.executeAst(ast) - - cacheToWriteToFileTemp[codeKey] = { - orderedCommands: engineCommandManager.orderedCommands, - responseMap: engineCommandManager.responseMap, - } - } - const cache = JSON.stringify(cacheToWriteToFileTemp) - - await fsp.mkdir(pathStart, { recursive: true }) - await fsp.writeFile(fullPath, cache) }, 20_000) afterAll(() => { diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index da1fd76ba7..1f73ce65bc 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -1295,7 +1295,7 @@ export class EngineCommandManager extends EventTarget { makeDefaultPlanes: () => Promise modifyGrid: (hidden: boolean) => Promise settings?: SettingsViaQueryString - }): Promise { + }) { if (callbackOnEngineLiteConnect) { this.startLite(callbackOnEngineLiteConnect) return From eb6df023e005d05d169044d8b6e402146abf7c31 Mon Sep 17 00:00:00 2001 From: Kurt Hutten Date: Wed, 7 Aug 2024 16:03:16 +1000 Subject: [PATCH 6/7] Kurt hmmm (#3308) * kurts attempts * we're almost sane * get tests working, praise be --------- Co-authored-by: 49lf --- .../__snapshots__/artifactGraph.test.ts.snap | 387 +++++++++++++++++- src/lang/std/artifactGraph.test.ts | 13 +- src/lang/std/engineConnection.ts | 247 ++++++----- 3 files changed, 536 insertions(+), 111 deletions(-) diff --git a/src/lang/std/__snapshots__/artifactGraph.test.ts.snap b/src/lang/std/__snapshots__/artifactGraph.test.ts.snap index 874fa7b765..ad00a31fd9 100644 --- a/src/lang/std/__snapshots__/artifactGraph.test.ts.snap +++ b/src/lang/std/__snapshots__/artifactGraph.test.ts.snap @@ -1,3 +1,388 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`testing createArtifactGraph > code with an extrusion, fillet and sketch of face: > snapshot of the artifactGraph 1`] = `Map {}`; +exports[`testing createArtifactGraph > code with an extrusion, fillet and sketch of face: > snapshot of the artifactGraph 1`] = ` +Map { + "UUID-0" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 43, + 70, + ], + }, + "pathIds": [ + "UUID", + ], + "type": "plane", + }, + "UUID-1" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 43, + 70, + ], + }, + "extrusionId": "UUID", + "planeId": "UUID", + "segIds": [ + "UUID", + "UUID", + "UUID", + "UUID", + "UUID", + ], + "solid2dId": "UUID", + "type": "path", + }, + "UUID-2" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 76, + 92, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "surfaceId": "UUID", + "type": "segment", + }, + "UUID-3" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 98, + 125, + ], + }, + "edgeCutId": "UUID", + "edgeIds": [], + "pathId": "UUID", + "surfaceId": "UUID", + "type": "segment", + }, + "UUID-4" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 131, + 156, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "surfaceId": "UUID", + "type": "segment", + }, + "UUID-5" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 162, + 209, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "surfaceId": "UUID", + "type": "segment", + }, + "UUID-6" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 215, + 223, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "type": "segment", + }, + "UUID-7" => { + "pathId": "UUID", + "type": "solid2D", + }, + "UUID-8" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 243, + 266, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "surfaceIds": [ + "UUID", + "UUID", + "UUID", + "UUID", + "UUID", + "UUID", + ], + "type": "extrusion", + }, + "UUID-9" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "segId": "UUID", + "type": "wall", + }, + "UUID-10" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [ + "UUID", + ], + "segId": "UUID", + "type": "wall", + }, + "UUID-11" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "segId": "UUID", + "type": "wall", + }, + "UUID-12" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "segId": "UUID", + "type": "wall", + }, + "UUID-13" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "subType": "start", + "type": "cap", + }, + "UUID-14" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "subType": "end", + "type": "cap", + }, + "UUID-15" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 272, + 311, + ], + }, + "consumedEdgeId": "UUID", + "edgeIds": [], + "subType": "fillet", + "type": "edgeCut", + }, + "UUID-16" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 368, + 395, + ], + }, + "extrusionId": "UUID", + "planeId": "UUID", + "segIds": [ + "UUID", + "UUID", + "UUID", + "UUID", + ], + "solid2dId": "UUID", + "type": "path", + }, + "UUID-17" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 401, + 416, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "surfaceId": "UUID", + "type": "segment", + }, + "UUID-18" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 422, + 438, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "surfaceId": "UUID", + "type": "segment", + }, + "UUID-19" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 444, + 491, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "surfaceId": "UUID", + "type": "segment", + }, + "UUID-20" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 497, + 505, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "type": "segment", + }, + "UUID-21" => { + "pathId": "UUID", + "type": "solid2D", + }, + "UUID-22" => { + "codeRef": { + "pathToNode": [ + [ + "body", + "", + ], + ], + "range": [ + 525, + 546, + ], + }, + "edgeIds": [], + "pathId": "UUID", + "surfaceIds": [ + "UUID", + "UUID", + "UUID", + "UUID", + "UUID", + ], + "type": "extrusion", + }, + "UUID-23" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "segId": "UUID", + "type": "wall", + }, + "UUID-24" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "segId": "UUID", + "type": "wall", + }, + "UUID-25" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "segId": "UUID", + "type": "wall", + }, + "UUID-26" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "subType": "start", + "type": "cap", + }, + "UUID-27" => { + "edgeCutEdgeIds": [], + "extrusionId": "UUID", + "pathIds": [], + "subType": "end", + "type": "cap", + }, +} +`; diff --git a/src/lang/std/artifactGraph.test.ts b/src/lang/std/artifactGraph.test.ts index 7537862f74..337255b8d0 100644 --- a/src/lang/std/artifactGraph.test.ts +++ b/src/lang/std/artifactGraph.test.ts @@ -14,7 +14,10 @@ import { } from './artifactGraph' import { err } from 'lib/trap' import { engineCommandManager, kclManager } from 'lib/singletons' -import { EngineCommandManagerEvents, EngineConnectionEvents } from 'lang/std/engineConnection' +import { + EngineCommandManagerEvents, + EngineConnectionEvents, +} from 'lang/std/engineConnection' import { CI, VITE_KC_DEV_TOKEN } from 'env' import fsp from 'fs/promises' import fs from 'fs' @@ -116,7 +119,7 @@ beforeAll(async () => { // THESE TEST WILL FAIL without VITE_KC_DEV_TOKEN set in .env.development.local await new Promise((resolve) => { engineCommandManager.start({ - disableWebRTC: true, + // disableWebRTC: true, token: VITE_KC_DEV_TOKEN, // there does seem to be a minimum resolution, not sure what it is but 256 works ok. width: 256, @@ -137,7 +140,7 @@ beforeAll(async () => { console.error(ast) return Promise.reject(ast) } - await kclManager.executeAst(ast) + const result = await kclManager.executeAst(ast) cacheToWriteToFileTemp[codeKey] = { orderedCommands: engineCommandManager.orderedCommands, @@ -148,8 +151,8 @@ beforeAll(async () => { await fsp.mkdir(pathStart, { recursive: true }) await fsp.writeFile(fullPath, cache) - resolve() - } + resolve(true) + }, }) }) }, 20_000) diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 1f73ce65bc..d51dce3dd3 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -1,5 +1,5 @@ import { Program, SourceRange } from 'lang/wasm' -import { VITE_KC_API_WS_MODELING_URL } from 'env' +import { VITE_KC_API_WS_MODELING_URL, VITE_KC_DEV_TOKEN } from 'env' import { Models } from '@kittycad/lib' import { exportSave } from 'lib/exportSave' import { deferExecution, isOverlap, uuidv4 } from 'lib/utils' @@ -297,25 +297,34 @@ class EngineConnection extends EventTarget { private engineCommandManager: EngineCommandManager private pingPongSpan: { ping?: Date; pong?: Date } - private pingIntervalId: ReturnType + private pingIntervalId: ReturnType = setInterval(() => {}, + 60_000) + isUsingConnectionLite: boolean = false constructor({ engineCommandManager, url, token, + callbackOnEngineLiteConnect, }: { engineCommandManager: EngineCommandManager url: string token?: string + callbackOnEngineLiteConnect?: () => void }) { super() this.engineCommandManager = engineCommandManager this.url = url this.token = token - this.pingPongSpan = { ping: undefined, pong: undefined } + if (callbackOnEngineLiteConnect) { + this.connectLite(callbackOnEngineLiteConnect) + this.isUsingConnectionLite = true + return + } + // Without an interval ping, our connection will timeout. // If this.idleMode is true we skip this logic so only reconnect // happens on mouse move @@ -382,7 +391,102 @@ class EngineConnection extends EventTarget { } }, pingIntervalMs) - this.promise = this.connect() + this.connect() + } + + // SHOULD ONLY BE USED FOR VITESTS + connectLite(callback: () => void) { + const url = `${VITE_KC_API_WS_MODELING_URL}?video_res_width=${256}&video_res_height=${256}` + + this.websocket = new WebSocket(url, []) + this.websocket.binaryType = 'arraybuffer' + + this.send = (a) => { + if (!this.websocket) return + this.websocket.send(JSON.stringify(a)) + } + this.onWebSocketOpen = (event) => { + this.send({ + type: 'headers', + headers: { Authorization: `Bearer ${VITE_KC_DEV_TOKEN}` }, + }) + // } + } + this.tearDown = () => {} + this.websocket.addEventListener('open', this.onWebSocketOpen) + + this.websocket?.addEventListener('message', ((event: MessageEvent) => { + const message: Models['WebSocketResponse_type'] = JSON.parse(event.data) + const pending = + this.engineCommandManager.pendingCommands[message.request_id || ''] + if (!('resp' in message)) return + + let resp = message.resp + + // If there's no body to the response, we can bail here. + if (!resp || !resp.type) { + return + } + + switch (resp.type) { + case 'pong': + break + + // Only fires on successful authentication. + case 'ice_server_info': + callback() + return + } + + if ( + !( + pending && + message.success && + (message.resp.type === 'modeling' || + message.resp.type === 'modeling_batch') + ) + ) + return + + if ( + message.resp.type === 'modeling' && + pending.command.type === 'modeling_cmd_req' && + message.request_id + ) { + this.engineCommandManager.responseMap[message.request_id] = message.resp + } else if ( + message.resp.type === 'modeling_batch' && + pending.command.type === 'modeling_cmd_batch_req' + ) { + let individualPendingResponses: { + [key: string]: Models['WebSocketRequest_type'] + } = {} + pending.command.requests.forEach(({ cmd, cmd_id }) => { + individualPendingResponses[cmd_id] = { + type: 'modeling_cmd_req', + cmd, + cmd_id, + } + }) + Object.entries(message.resp.data.responses).forEach( + ([commandId, response]) => { + if (!('response' in response)) return + const command = individualPendingResponses[commandId] + if (!command) return + if (command.type === 'modeling_cmd_req') + this.engineCommandManager.responseMap[commandId] = { + type: 'modeling', + data: { + modeling_response: response.response, + }, + } + } + ) + } + + pending.resolve([message]) + delete this.engineCommandManager.pendingCommands[message.request_id || ''] + }) as EventListener) } isConnecting() { @@ -489,7 +593,10 @@ class EngineConnection extends EventTarget { `ICE candidate returned an error: ${event.errorCode}: ${event.errorText} for ${event.url}` ) } - this.pc?.addEventListener?.('icecandidateerror', this.onIceCandidateError) + this.pc?.addEventListener?.( + 'icecandidateerror', + this.onIceCandidateError + ) // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event // Event type: generic Event type... @@ -594,7 +701,8 @@ class EngineConnection extends EventTarget { videoTrackReport.framesPerSecond || 0 client_metrics.rtc_freeze_count = videoTrackReport.freezeCount || 0 - client_metrics.rtc_jitter_sec = videoTrackReport.jitter || 0.0 + client_metrics.rtc_jitter_sec = + videoTrackReport.jitter || 0.0 client_metrics.rtc_keyframes_decoded = videoTrackReport.keyFramesDecoded || 0 client_metrics.rtc_total_freezes_duration_sec = @@ -605,7 +713,8 @@ class EngineConnection extends EventTarget { videoTrackReport.frameWidth || 0 client_metrics.rtc_packets_lost = videoTrackReport.packetsLost || 0 - client_metrics.rtc_pli_count = videoTrackReport.pliCount || 0 + client_metrics.rtc_pli_count = + videoTrackReport.pliCount || 0 } else if (videoTrackReport.type === 'transport') { // videoTrackReport.bytesReceived, // videoTrackReport.bytesSent, @@ -647,7 +756,9 @@ class EngineConnection extends EventTarget { } // Everything is now connected. - this.state = { type: EngineConnectionStateType.ConnectionEstablished } + this.state = { + type: EngineConnectionStateType.ConnectionEstablished, + } this.engineCommandManager.inSequence = 1 @@ -707,7 +818,8 @@ class EngineConnection extends EventTarget { this.onDataChannelMessage = (event) => { const result: UnreliableResponses = JSON.parse(event.data) Object.values( - this.engineCommandManager.unreliableSubscriptions[result.type] || {} + this.engineCommandManager.unreliableSubscriptions[result.type] || + {} ).forEach( // TODO: There is only one response that uses the unreliable channel atm, // highlight_set_entity, if there are more it's likely they will all have the same @@ -774,7 +886,10 @@ class EngineConnection extends EventTarget { this.websocket?.removeEventListener('open', this.onWebSocketOpen) this.websocket?.removeEventListener('close', this.onWebSocketClose) this.websocket?.removeEventListener('error', this.onWebSocketError) - this.websocket?.removeEventListener('message', this.onWebSocketMessage) + this.websocket?.removeEventListener( + 'message', + this.onWebSocketMessage + ) window.removeEventListener( 'use-network-status-ready', @@ -813,7 +928,9 @@ class EngineConnection extends EventTarget { return } - const message: Models['WebSocketResponse_type'] = JSON.parse(event.data) + const message: Models['WebSocketResponse_type'] = JSON.parse( + event.data + ) if (!message.success) { const errorsString = message?.errors @@ -1296,11 +1413,6 @@ export class EngineCommandManager extends EventTarget { modifyGrid: (hidden: boolean) => Promise settings?: SettingsViaQueryString }) { - if (callbackOnEngineLiteConnect) { - this.startLite(callbackOnEngineLiteConnect) - return - } - if (settings) { this.settings = settings } @@ -1328,8 +1440,12 @@ export class EngineCommandManager extends EventTarget { engineCommandManager: this, url, token, + callbackOnEngineLiteConnect, }) + // Nothing more to do when using a lite engine initializiation + if (callbackOnEngineLiteConnect) return + this.dispatchEvent( new CustomEvent(EngineCommandManagerEvents.EngineAvailable, { detail: this.engineConnection, @@ -1597,85 +1713,6 @@ export class EngineCommandManager extends EventTarget { return } - // SHOULD ONLY BE USED FOR VITESTS - startLite(callback: () => void) { - this.websocket = new WebSocket(this.url, []) - this.websocket.binaryType = 'arraybuffer' - - this.onWebSocketOpen = (event) => { - if (this.token) { - this.send({ - type: 'headers', - headers: { Authorization: `Bearer ${this.token}` }, - }) - } - } - this.websocket.addEventListener('open', this.onWebSocketOpen) - this.onWebSocketMessage = (event) => { - if (typeof event.data !== 'string') { - return - } - - const message: Models['WebSocketResponse_type'] = JSON.parse(event.data) - - if (!message.success) { - const errorsString = message?.errors - ?.map((error) => { - return ` - ${error.error_code}: ${error.message}` - }) - .join('\n') - if (message.request_id) { - const artifactThatFailed = - this.engineCommandManager.artifactGraph.get(message.request_id) - console.error( - `Error in response to request ${message.request_id}:\n${errorsString} - failed cmd type was ${artifactThatFailed?.type}` - ) - // Check if this was a pending export command. - if ( - this.engineCommandManager.pendingExport?.commandId === - message.request_id - ) { - // Reject the promise with the error. - this.engineCommandManager.pendingExport.reject(errorsString) - this.engineCommandManager.pendingExport = undefined - } - } else { - console.error(`Error from server:\n${errorsString}`) - } - - return - } - - let resp = message.resp - - // If there's no body to the response, we can bail here. - if (!resp || !resp.type) { - return - } - - switch (resp.type) { - case 'pong': - break - - // Only fires on successful authentication. - case 'ice_server_info': - callback() - break - - case 'sdp_answer': - break - - case 'trickle_ice': - break - - case 'metrics_request': - break - } - } - this.websocket.addEventListener('message', this.onWebSocketMessage) - } - handleResize({ streamWidth, streamHeight, @@ -1706,19 +1743,19 @@ export class EngineCommandManager extends EventTarget { pending.reject('no connection to send on') } - this.engineConnection.removeEventListener( + this.engineConnection?.removeEventListener?.( EngineConnectionEvents.Opened, this.onEngineConnectionOpened ) - this.engineConnection.removeEventListener( + this.engineConnection.removeEventListener?.( EngineConnectionEvents.Closed, this.onEngineConnectionClosed ) - this.engineConnection.removeEventListener( + this.engineConnection.removeEventListener?.( EngineConnectionEvents.ConnectionStarted, this.onEngineConnectionStarted ) - this.engineConnection.removeEventListener( + this.engineConnection.removeEventListener?.( EngineConnectionEvents.NewTrack, this.onEngineConnectionNewTrack as EventListener ) @@ -1905,17 +1942,17 @@ export class EngineCommandManager extends EventTarget { commandStr: string, idToRangeStr: string ): Promise { - if (this.engineConnection === undefined) { - return Promise.resolve() - } - if (!this.engineConnection?.isReady()) + if (this.engineConnection === undefined) return Promise.resolve() + if ( + !this.engineConnection?.isReady() && + !this.engineConnection.isUsingConnectionLite + ) return Promise.resolve() if (id === undefined) return Promise.reject(new Error('id is undefined')) if (rangeStr === undefined) return Promise.reject(new Error('rangeStr is undefined')) - if (commandStr === undefined) { + if (commandStr === undefined) return Promise.reject(new Error('commandStr is undefined')) - } const range: SourceRange = JSON.parse(rangeStr) const command: EngineCommand = JSON.parse(commandStr) const idToRangeMap: { [key: string]: SourceRange } = From 53499dbc12bdfbe546c22ffbb5851a9bbab86f7c Mon Sep 17 00:00:00 2001 From: Kurt Hutten Irev-Dev Date: Wed, 7 Aug 2024 16:52:30 +1000 Subject: [PATCH 7/7] typo --- src/lang/std/engineConnection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index d51dce3dd3..75f92f99d0 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -1443,7 +1443,7 @@ export class EngineCommandManager extends EventTarget { callbackOnEngineLiteConnect, }) - // Nothing more to do when using a lite engine initializiation + // Nothing more to do when using a lite engine initialization if (callbackOnEngineLiteConnect) return this.dispatchEvent(