- {`${connState.error} \u{1F63F}`}
+ const afterConnectError = Boolean(connState?.error && connState.reconnecting && clientState?.is_verified)
+ useEffect(() => {
+ if (afterConnectError) {
+ const cancelKeys = (evt: KeyboardEvent | MouseEvent) => evt.stopPropagation()
+ document.body.addEventListener("keydown", cancelKeys, { capture: true })
+ document.body.addEventListener("keyup", cancelKeys, { capture: true })
+ document.body.addEventListener("click", cancelKeys, { capture: true })
+ return () => {
+ document.body.removeEventListener("keydown", cancelKeys, { capture: true })
+ document.body.removeEventListener("keyup", cancelKeys, { capture: true })
+ document.body.removeEventListener("click", cancelKeys, { capture: true })
+ }
+ }
+ }, [afterConnectError])
+ const errorOverlay = connState?.error ?
: null
+
+ if (connState?.error && !afterConnectError) {
+ return errorOverlay
+ } else if ((!connState?.connected && !afterConnectError) || !clientState) {
const msg = connState?.connected ?
"Waiting for client state..." : "Connecting to backend..."
- return
-
+ return
+
{msg}
} else if (!clientState.is_logged_in) {
@@ -61,6 +87,7 @@ function App() {
+ {errorOverlay}
}
}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index a3dab90..f64973e 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -52,16 +52,16 @@ export default class Client {
method: "POST",
signal,
})
- if (!resp.ok) {
+ if (!resp.ok && !signal.aborted) {
this.rpc.connect.emit({
connected: false,
- error: new Error(`Authentication failed: ${resp.statusText}`),
+ reconnecting: false,
+ error: `Authentication failed: ${resp.statusText}`,
})
return
}
} catch (err) {
- const error = err instanceof Error ? err : new Error(`${err}`)
- this.rpc.connect.emit({ connected: false, error })
+ this.rpc.connect.emit({ connected: false, reconnecting: false, error: `Authentication failed: ${err}` })
}
if (signal.aborted) {
return
diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts
index dd7df6f..78c4c7b 100644
--- a/web/src/api/rpc.ts
+++ b/web/src/api/rpc.ts
@@ -42,7 +42,9 @@ import type {
export interface ConnectionEvent {
connected: boolean
- error: Error | null
+ reconnecting: boolean
+ error: string | null
+ nextAttempt?: string
}
export class ErrorResponse extends Error {
diff --git a/web/src/api/wailsclient.ts b/web/src/api/wailsclient.ts
index 1d79300..a9c8a17 100644
--- a/web/src/api/wailsclient.ts
+++ b/web/src/api/wailsclient.ts
@@ -39,7 +39,7 @@ export default class WailsClient extends RPCClient {
this.event.emit(evt.data[0])
})
this.#wails.Call.ByName("main.CommandHandler.Init")
- this.connect.emit({ connected: true, error: null })
+ this.connect.emit({ connected: true, reconnecting: false, error: null })
}
async stop() {}
diff --git a/web/src/api/wsclient.ts b/web/src/api/wsclient.ts
index 5d81a74..ca8140d 100644
--- a/web/src/api/wsclient.ts
+++ b/web/src/api/wsclient.ts
@@ -25,6 +25,9 @@ export default class WSClient extends RPCClient {
#pingInterval: number | null = null
#lastReceivedEvt: number = 0
#resumeRunID: string = ""
+ #stopped = false
+ #reconnectTimeout: number | null = null
+ #connectFailures: number = 0
constructor(readonly addr: string) {
super()
@@ -32,6 +35,7 @@ export default class WSClient extends RPCClient {
start() {
try {
+ this.#stopped = false
this.#lastMessage = Date.now()
const params = new URLSearchParams({
run_id: this.#resumeRunID,
@@ -44,9 +48,8 @@ export default class WSClient extends RPCClient {
this.#conn.onopen = this.#onOpen
this.#conn.onerror = this.#onError
this.#conn.onclose = this.#onClose
- this.#pingInterval = setInterval(this.#pingLoop, PING_INTERVAL)
} catch (err) {
- this.#dispatchConnectionStatus(false, err as Error)
+ this.#dispatchConnectionStatus(false, false, `Failed to create websocket: ${err}`)
}
}
@@ -66,8 +69,10 @@ export default class WSClient extends RPCClient {
}
stop() {
+ this.#stopped = true
if (this.#pingInterval !== null) {
clearInterval(this.#pingInterval)
+ this.#pingInterval = null
}
this.#conn?.close(1000, "Client closed")
}
@@ -105,13 +110,20 @@ export default class WSClient extends RPCClient {
this.onCommand(parsed)
}
- #dispatchConnectionStatus(connected: boolean, error: Error | null) {
- this.connect.emit({ connected, error })
+ #dispatchConnectionStatus(connected: boolean, reconnecting: boolean, error: string | null, nextAttempt?: number) {
+ this.connect.emit({
+ connected,
+ reconnecting,
+ error,
+ nextAttempt: nextAttempt ? new Date(nextAttempt).toLocaleTimeString() : undefined,
+ })
}
#onOpen = () => {
console.info("Websocket opened")
- this.#dispatchConnectionStatus(true, null)
+ this.#dispatchConnectionStatus(true, false, null)
+ this.#connectFailures = 0
+ this.#pingInterval = setInterval(this.#pingLoop, PING_INTERVAL)
}
#clearPending = () => {
@@ -123,13 +135,33 @@ export default class WSClient extends RPCClient {
#onError = (ev: Event) => {
console.error("Websocket error:", ev)
- this.#dispatchConnectionStatus(false, new Error("Websocket error"))
- this.#clearPending()
}
#onClose = (ev: CloseEvent) => {
+ this.#connectFailures++
console.warn("Websocket closed:", ev)
- this.#dispatchConnectionStatus(false, new Error(`Websocket closed: ${ev.code} ${ev.reason}`))
this.#clearPending()
+ if (this.#pingInterval !== null) {
+ clearInterval(this.#pingInterval)
+ this.#pingInterval = null
+ }
+ const willReconnect = !this.#stopped && !this.#reconnectTimeout
+ const backoff = Math.min(2 ** (this.#connectFailures - 4), 10) * 1000
+ this.#dispatchConnectionStatus(
+ false,
+ willReconnect,
+ `Websocket closed: ${ev.code} ${ev.reason}`,
+ Date.now() + backoff,
+ )
+ if (willReconnect) {
+ console.log("Attempting to reconnect in", backoff, "ms")
+ this.#reconnectTimeout = setTimeout(() => {
+ console.log("Reconnecting now")
+ this.#reconnectTimeout = null
+ this.start()
+ }, backoff)
+ } else {
+ console.log(`Not reconnecting (stopped=${this.#stopped}, reconnectTimeout=${this.#reconnectTimeout})`)
+ }
}
}
diff --git a/web/src/index.css b/web/src/index.css
index d99c65a..98d8732 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -206,6 +206,37 @@ button, a.button, span.button {
}
}
+div.connection-error-wrapper {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+
+ &.post-connect {
+ background-color: var(--dimmed-overlay-background-color);
+ }
+
+ > div.connection-error-inner {
+ background-color: var(--background-color);
+ border: 2px solid var(--error-color);
+ border-radius: .5rem;
+ margin-top: 2rem;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ }
+}
+
+div.pre-connect {
+ margin-top: 2rem;
+ text-align: center;
+}
+
a {
text-decoration: none;
color: var(--link-text-color);