web/wsclient: reconnect automatically if disconnected

This commit is contained in:
Tulir Asokan 2024-12-07 00:41:22 +02:00
parent 7d6bbe77b9
commit 894fcb3fa0
6 changed files with 112 additions and 20 deletions

View file

@ -41,15 +41,41 @@ function App() {
}, [client])
useEffect(() => client.start(), [client])
if (connState?.error) {
return <div>
{`${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 ? <div
className={`connection-error-wrapper ${afterConnectError ? "post-connect" : ""}`}
tabIndex={-1}
>
<div className="connection-error-inner">
<div>{connState.error} &#x1F63F;</div>
{connState.reconnecting && <div>
<ScaleLoader width="2rem" height="2rem" color="var(--primary-color)"/>
Reconnecting to backend...
{connState.nextAttempt ? <div><small>(next attempt at {connState.nextAttempt})</small></div> : null}
</div>}
</div>
} else if (!connState?.connected || !clientState) {
</div> : 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 <div>
<ScaleLoader/>
return <div className="pre-connect">
<ScaleLoader width="2rem" height="2rem" color="var(--primary-color)"/>
{msg}
</div>
} else if (!clientState.is_logged_in) {
@ -61,6 +87,7 @@ function App() {
<LightboxWrapper>
<MainScreen/>
</LightboxWrapper>
{errorOverlay}
</ClientContext>
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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() {}

View file

@ -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})`)
}
}
}

View file

@ -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);