Propagate errors UI (#1369)

* Pass engine connection state to NetworkHealthIndicator

* Create the basis for styling and further work

* Add icons

* Update styles on network health indicator

* Cleanup styles and unused state

* Rename State to NetworkHealthState

* Update tests

* fmt

---------

Co-authored-by: 49lf <ircsurfer33@gmail.com>
This commit is contained in:
Frank Noirot
2024-02-12 16:00:31 -05:00
committed by GitHub
parent c0d4bb6c9f
commit 5430c1fa66
4 changed files with 470 additions and 152 deletions

View File

@ -58,26 +58,36 @@ type Value<T, U> = U extends undefined
type State<T, U> = Value<T, U>
enum EngineConnectionStateType {
export enum EngineConnectionStateType {
Fresh = 'fresh',
Connecting = 'connecting',
ConnectionEstablished = 'connection-established',
Disconnecting = 'disconnecting',
Disconnected = 'disconnected',
}
enum DisconnectedType {
export enum DisconnectingType {
Error = 'error',
Timeout = 'timeout',
Quit = 'quit',
}
type DisconnectedValue =
| State<DisconnectedType.Error, Error | undefined>
| State<DisconnectedType.Timeout, void>
| State<DisconnectedType.Quit, void>
export interface ErrorType {
// We may not necessary have an error to assign.
error?: Error
// We assign this in the state setter because we may have not failed at
// a Connecting state, which we check for there.
lastConnectingValue?: ConnectingValue
}
export type DisconnectingValue =
| State<DisconnectingType.Error, ErrorType>
| State<DisconnectingType.Timeout, void>
| State<DisconnectingType.Quit, void>
// These are ordered by the expected sequence.
enum ConnectingType {
export enum ConnectingType {
WebSocketConnecting = 'websocket-connecting',
WebSocketEstablished = 'websocket-established',
PeerConnectionCreated = 'peer-connection-created',
@ -94,7 +104,39 @@ enum ConnectingType {
DataChannelEstablished = 'data-channel-established',
}
type ConnectingValue =
export enum ConnectingTypeGroup {
WebSocket = 'WebSocket',
ICE = 'ICE',
WebRTC = 'WebRTC',
}
export const initialConnectingTypeGroupState: Record<
ConnectingTypeGroup,
[ConnectingType, boolean | undefined][]
> = {
[ConnectingTypeGroup.WebSocket]: [
[ConnectingType.WebSocketConnecting, undefined],
[ConnectingType.WebSocketEstablished, undefined],
],
[ConnectingTypeGroup.ICE]: [
[ConnectingType.PeerConnectionCreated, undefined],
[ConnectingType.ICEServersSet, undefined],
[ConnectingType.SetLocalDescription, undefined],
[ConnectingType.OfferedSdp, undefined],
[ConnectingType.ReceivedSdp, undefined],
[ConnectingType.SetRemoteDescription, undefined],
[ConnectingType.WebRTCConnecting, undefined],
[ConnectingType.ICECandidateReceived, undefined],
],
[ConnectingTypeGroup.WebRTC]: [
[ConnectingType.TrackReceived, undefined],
[ConnectingType.DataChannelRequested, undefined],
[ConnectingType.DataChannelConnecting, undefined],
[ConnectingType.DataChannelEstablished, undefined],
],
}
export type ConnectingValue =
| State<ConnectingType.WebSocketConnecting, void>
| State<ConnectingType.WebSocketEstablished, void>
| State<ConnectingType.PeerConnectionCreated, void>
@ -110,11 +152,12 @@ type ConnectingValue =
| State<ConnectingType.DataChannelConnecting, string>
| State<ConnectingType.DataChannelEstablished, void>
type EngineConnectionState =
export type EngineConnectionState =
| State<EngineConnectionStateType.Fresh, void>
| State<EngineConnectionStateType.Connecting, ConnectingValue>
| State<EngineConnectionStateType.ConnectionEstablished, void>
| State<EngineConnectionStateType.Disconnected, DisconnectedValue>
| State<EngineConnectionStateType.Disconnecting, DisconnectingValue>
| State<EngineConnectionStateType.Disconnected, void>
// EngineConnection encapsulates the connection(s) to the Engine
// for the EngineCommandManager; namely, the underlying WebSocket
@ -135,22 +178,38 @@ class EngineConnection {
set state(next: EngineConnectionState) {
console.log(`${JSON.stringify(this.state)}${JSON.stringify(next)}`)
if (next.type === EngineConnectionStateType.Disconnected) {
if (next.type === EngineConnectionStateType.Disconnecting) {
console.trace()
const sub = next.value
if (sub.type === DisconnectedType.Error) {
if (sub.type === DisconnectingType.Error) {
// Record the last step we failed at.
// (Check the current state that we're about to override that
// it was a Connecting state.)
console.log(sub)
if (this._state.type === EngineConnectionStateType.Connecting) {
if (!sub.value) sub.value = {}
sub.value.lastConnectingValue = this._state.value
}
console.error(sub.value)
}
}
this._state = next
this.onConnectionStateChange(this._state)
}
private failedConnTimeout: Timeout | null
readonly url: string
private readonly token?: string
private onWebsocketOpen: (engineConnection: EngineConnection) => void
private onDataChannelOpen: (engineConnection: EngineConnection) => void
// For now, this is only used by the NetworkHealthIndicator.
// We can eventually use it for more, but one step at a time.
private onConnectionStateChange: (state: EngineConnectionState) => void
// These are used for the EngineCommandManager and were created
// before onConnectionStateChange existed.
private onEngineConnectionOpen: (engineConnection: EngineConnection) => void
private onConnectionStarted: (engineConnection: EngineConnection) => void
private onClose: (engineConnection: EngineConnection) => void
@ -162,17 +221,15 @@ class EngineConnection {
constructor({
url,
token,
onWebsocketOpen = () => {},
onConnectionStateChange = () => {},
onNewTrack = () => {},
onEngineConnectionOpen = () => {},
onConnectionStarted = () => {},
onClose = () => {},
onDataChannelOpen = () => {},
}: {
url: string
token?: string
onWebsocketOpen?: (engineConnection: EngineConnection) => void
onDataChannelOpen?: (engineConnection: EngineConnection) => void
onConnectionStateChange?: (state: EngineConnectionState) => void
onEngineConnectionOpen?: (engineConnection: EngineConnection) => void
onConnectionStarted?: (engineConnection: EngineConnection) => void
onClose?: (engineConnection: EngineConnection) => void
@ -181,8 +238,7 @@ class EngineConnection {
this.url = url
this.token = token
this.failedConnTimeout = null
this.onWebsocketOpen = onWebsocketOpen
this.onDataChannelOpen = onDataChannelOpen
this.onConnectionStateChange = onConnectionStateChange
this.onEngineConnectionOpen = onEngineConnectionOpen
this.onConnectionStarted = onConnectionStarted
@ -198,6 +254,7 @@ class EngineConnection {
case EngineConnectionStateType.ConnectionEstablished:
this.send({ type: 'ping' })
break
case EngineConnectionStateType.Disconnecting:
case EngineConnectionStateType.Disconnected:
clearInterval(pingInterval)
break
@ -209,17 +266,11 @@ class EngineConnection {
const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS
let connectRetryInterval = setInterval(() => {
if (this.state.type !== EngineConnectionStateType.Disconnected) return
switch (this.state.value.type) {
case DisconnectedType.Error:
clearInterval(connectRetryInterval)
break
case DisconnectedType.Timeout:
console.log('Trying to reconnect')
this.connect()
break
default:
break
}
// Only try reconnecting when completely disconnected.
clearInterval(connectRetryInterval)
console.log('Trying to reconnect')
this.connect()
}, connectionTimeoutMs)
}
@ -234,8 +285,8 @@ class EngineConnection {
tearDown() {
this.disconnectAll()
this.state = {
type: EngineConnectionStateType.Disconnected,
value: { type: DisconnectedType.Quit },
type: EngineConnectionStateType.Disconnecting,
value: { type: DisconnectingType.Quit },
}
}
@ -352,12 +403,14 @@ class EngineConnection {
case 'failed':
this.disconnectAll()
this.state = {
type: EngineConnectionStateType.Disconnected,
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectedType.Error,
value: new Error(
'failed to negotiate ice connection; restarting'
),
type: DisconnectingType.Error,
value: {
error: new Error(
'failed to negotiate ice connection; restarting'
),
},
},
}
break
@ -473,8 +526,6 @@ class EngineConnection {
dataChannelSpan.resolve?.()
}
this.onDataChannelOpen(this)
// Everything is now connected.
this.state = { type: EngineConnectionStateType.ConnectionEstablished }
@ -482,27 +533,20 @@ class EngineConnection {
})
this.unreliableDataChannel.addEventListener('close', (event) => {
console.log(event)
console.log('unreliable data channel closed')
this.disconnectAll()
this.unreliableDataChannel = undefined
if (this.areAllConnectionsClosed()) {
this.state = {
type: EngineConnectionStateType.Disconnected,
value: { type: DisconnectedType.Quit },
}
}
this.finalizeIfAllConnectionsClosed()
})
this.unreliableDataChannel.addEventListener('error', (event) => {
this.disconnectAll()
this.state = {
type: EngineConnectionStateType.Disconnected,
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectedType.Error,
value: new Error(event.toString()),
type: DisconnectingType.Error,
value: {
error: new Error(event.toString()),
},
},
}
})
@ -527,8 +571,6 @@ class EngineConnection {
},
}
this.onWebsocketOpen(this)
// 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.
@ -560,24 +602,19 @@ class EngineConnection {
this.websocket.addEventListener('close', (event) => {
this.disconnectAll()
this.websocket = undefined
if (this.areAllConnectionsClosed()) {
this.state = {
type: EngineConnectionStateType.Disconnected,
value: { type: DisconnectedType.Quit },
}
}
this.finalizeIfAllConnectionsClosed()
})
this.websocket.addEventListener('error', (event) => {
this.disconnectAll()
this.state = {
type: EngineConnectionStateType.Disconnected,
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectedType.Error,
value: new Error(event.toString()),
type: DisconnectingType.Error,
value: {
error: new Error(event.toString()),
},
},
}
})
@ -706,10 +743,12 @@ failed cmd type was ${artifactThatFailed?.commandType}`
// The local description is invalid, so there's no point continuing.
this.disconnectAll()
this.state = {
type: EngineConnectionStateType.Disconnected,
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectedType.Error,
value: error,
type: DisconnectingType.Error,
value: {
error,
},
},
}
})
@ -787,13 +826,14 @@ failed cmd type was ${artifactThatFailed?.commandType}`
return
}
this.failedConnTimeout = null
this.disconnectAll()
this.state = {
type: EngineConnectionStateType.Disconnected,
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectedType.Timeout,
type: DisconnectingType.Timeout,
},
}
this.disconnectAll()
this.finalizeIfAllConnectionsClosed()
}, connectionTimeoutMs)
this.onConnectionStarted(this)
@ -816,11 +856,18 @@ failed cmd type was ${artifactThatFailed?.commandType}`
this.websocket?.close()
this.unreliableDataChannel?.close()
this.pc?.close()
this.webrtcStatsCollector = undefined
}
areAllConnectionsClosed() {
finalizeIfAllConnectionsClosed() {
console.log(this.websocket, this.pc, this.unreliableDataChannel)
return !this.websocket && !this.pc && !this.unreliableDataChannel
const allClosed =
this.websocket?.readyState === 3 &&
this.pc?.connectionState === 'closed' &&
this.unreliableDataChannel?.readyState === 'closed'
if (allClosed) {
this.state = { type: EngineConnectionStateType.Disconnected }
}
}
}
@ -893,6 +940,9 @@ export class EngineCommandManager {
}
} = {} as any
callbacksEngineStateConnection: ((state: EngineConnectionState) => void)[] =
[]
constructor() {
this.engineConnection = undefined
;(async () => {
@ -934,6 +984,11 @@ export class EngineCommandManager {
this.engineConnection = new EngineConnection({
url,
token,
onConnectionStateChange: (state: EngineConnectionState) => {
for (let cb of this.callbacksEngineStateConnection) {
cb(state)
}
},
onEngineConnectionOpen: () => {
this.resolveReady()
setIsStreamReady(true)
@ -1189,6 +1244,9 @@ export class EngineCommandManager {
) {
delete this.unreliableSubscriptions[event][id]
}
onConnectionStateChange(callback: (state: EngineConnectionState) => void) {
this.callbacksEngineStateConnection.push(callback)
}
endSession() {
// TODO: instead of sending a single command with `object_ids: Object.keys(this.artifactMap)`
// we need to loop over them each individually because if the engine doesn't recognise a single