From 894fcb3fa091b4399a23b7a75953b1a2d3a8d1cd Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 7 Dec 2024 00:41:22 +0200 Subject: [PATCH] web/wsclient: reconnect automatically if disconnected --- web/src/App.tsx | 39 ++++++++++++++++++++++++++----- web/src/api/client.ts | 8 +++---- web/src/api/rpc.ts | 4 +++- web/src/api/wailsclient.ts | 2 +- web/src/api/wsclient.ts | 48 +++++++++++++++++++++++++++++++------- web/src/index.css | 31 ++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 20 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 919346c..748115c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -41,15 +41,41 @@ function App() { }, [client]) useEffect(() => client.start(), [client]) - if (connState?.error) { - return
- {`${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 ?
+
+
{connState.error} 😿
+ {connState.reconnecting &&
+ + Reconnecting to backend... + {connState.nextAttempt ?
(next attempt at {connState.nextAttempt})
: null} +
}
- } else if (!connState?.connected || !clientState) { +
: 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);