add proper error and disconnect handling

error handling shows you the error and offers the ability to reload;
disconnect handling shows you that the page will reload in a few seconds.
it uses exponential backoff with some random sprinkled into it to prevent overwhelming the server once people's clients decide to reconnect.
This commit is contained in:
liquidex 2024-08-25 12:53:53 +02:00
parent 84abba3e0b
commit 0d831698e2
8 changed files with 150 additions and 20 deletions

View file

@ -4,12 +4,17 @@ use axum::{routing::get, Router};
use tokio::time::sleep;
pub fn router<S>() -> Router<S> {
Router::new()
.route("/stall", get(stall))
.route("/back-up", get(back_up))
.with_state(())
let router = Router::new().route("/back-up", get(back_up));
// The endpoint for immediate reload is only enabled on debug builds.
// Release builds use the exponential backoff system that detects is the WebSocket is closed.
#[cfg(debug_assertions)]
let router = router.route("/stall", get(stall));
router.with_state(())
}
#[cfg(debug_assertions)]
async fn stall() -> String {
loop {
// Sleep for a day, I guess. Just to uphold the connection forever without really using any

View file

@ -18,7 +18,6 @@ mod api;
mod config;
mod haku;
mod id;
#[cfg(debug_assertions)]
mod live_reload;
mod login;
pub mod schema;
@ -102,8 +101,7 @@ async fn fallible_main() -> eyre::Result<()> {
.nest_service("/static", ServeDir::new(paths.target_dir.join("static")))
.nest("/api", api::router(api));
#[cfg(debug_assertions)]
let app = app.nest("/dev/live-reload", live_reload::router());
let app = app.nest("/auto-reload", live_reload::router());
let port: u16 = std::env::var("RKGK_PORT")
.unwrap_or("8080".into())

View file

@ -5,6 +5,25 @@ export class ConnectionStatus extends HTMLElement {
// This is a progress dialog and shouldn't be closed.
this.loggingInDialog.addEventListener("cancel", (event) => event.preventDefault());
this.errorDialog = this.querySelector("dialog[name='error-dialog']");
this.errorText = this.errorDialog.querySelector("[name='error-text']");
this.errorRefresh = this.errorDialog.querySelector("button[name='refresh']");
// If this appears then something broke, and therefore the app can't continue normally.
this.errorDialog.addEventListener("cancel", (event) => event.preventDefault());
this.errorRefresh.addEventListener("click", () => {
window.location.reload();
});
this.disconnectedDialog = this.querySelector("dialog[name='disconnected-dialog']");
this.reconnectDuration = this.disconnectedDialog.querySelector(
"[name='reconnect-duration']",
);
// If this appears then we can't let the user use the app, because we're disconnected.
this.disconnectedDialog.addEventListener("cancel", (event) => event.preventDefault());
}
showLoggingIn() {
@ -14,6 +33,35 @@ export class ConnectionStatus extends HTMLElement {
hideLoggingIn() {
this.loggingInDialog.close();
}
showError(error) {
this.errorDialog.showModal();
if (error instanceof Error) {
if (error.stack != null && error.stack != "") {
this.errorText.textContent = `${error.toString()}\n\n${error.stack}`;
} else {
this.errorText.textContent = error.toString();
}
}
}
async showDisconnected(duration) {
this.disconnectedDialog.showModal();
let updateDuration = (remaining) => {
let seconds = Math.floor(remaining / 1000);
this.reconnectDuration.textContent = `${seconds} ${seconds == 1 ? "second" : "seconds"}`;
};
let remaining = duration;
updateDuration(remaining);
while (remaining > 0) {
let delay = Math.min(1000, remaining);
remaining -= delay;
updateDuration(remaining);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
customElements.define("rkgk-connection-status", ConnectionStatus);

View file

@ -36,6 +36,15 @@ body {
font-weight: 400;
}
@font-face {
font-family: "Fira Sans";
src:
local("Fira Sans Italic"),
url("font/FiraSans-Italic.ttf");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "Fira Sans";
src:
@ -131,8 +140,7 @@ input {
border-bottom: 1px solid var(--color-panel-border);
}
*[contenteditable]:focus, input:focus, textarea:focus {
border-radius: 2px;
*:focus {
outline: 1px solid #40b1f4;
outline-offset: 4px;
}
@ -149,6 +157,12 @@ dialog::backdrop {
backdrop-filter: blur(8px);
}
/* Details */
details>summary {
cursor: pointer;
}
/* Throbbers */
@keyframes rkgk-throbber-loading {
@ -295,7 +309,7 @@ rkgk-welcome {
/* Connection status dialogs */
rkgk-connection-status {
&>dialog[name='logging-in-dialog'][open] {
&>dialog[name='logging-in-dialog'][open], &>dialog[name='disconnected-dialog'][open] {
border: none;
outline: none;
background: none;
@ -304,4 +318,14 @@ rkgk-connection-status {
gap: 8px;
align-items: center;
}
&>dialog[name='error-dialog'][open] {
& textarea[name='error-text'] {
box-sizing: border-box;
width: 100%;
resize: none;
border: 1px solid var(--color-panel-border);
padding: 4px;
}
}
}

View file

@ -55,12 +55,38 @@
<rkgk-connection-status>
<dialog name="logging-in-dialog">
<rkgk-throbber class="loading"></rkgk-throbber><p>Logging in...</p>
<rkgk-throbber class="loading"></rkgk-throbber><p>Logging in…</p>
</dialog>
<dialog name="error-dialog" class="rkgk-panel">
<h1>owie! >_<</h1>
<p><i>Uh oh. Seems like the pipe cracked again… There's water everywhere.<br>The basement's half full already. God dammit.</i></p>
<p>Super sorry about this! But rakugaki encountered an error and has to restart.</p>
<p><b>Rest assured your drawings are safe and sound.</b></p>
<p>Either way… try refreshing the page and see if it helps. If not, please report a bug with the following details.</p>
<details>
<summary>Show error details</summary>
<textarea name="error-text" rows="10" readonly></textarea>
</details>
<p>Thank you from the mountain!</p>
<div style="display: flex; flex-direction: row; align-items: center; justify-content: end;">
<button name="refresh">Refresh</button>
</div>
</dialog>
<dialog name="disconnected-dialog">
<rkgk-throbber class="loading"></rkgk-throbber>
<p>Connection lost. Attempting to reconnect in <span name="reconnect-duration"></span></p>
</dialog>
</rkgk-connection-status>
<div class="fullscreen" id="js-loading">
<rkgk-throbber class="loading"></rkgk-throbber>
<noscript>
<style>
#js-loading>rkgk-throbber { display: none; }
@ -68,6 +94,7 @@
<p>
rakugaki is a web app and does not work without JavaScript :(<br>
but I swear it's a very lightweight and delightful web app!
You won't regret trying it out.
</p>
</noscript>
</div>

View file

@ -77,14 +77,35 @@ function readUrl() {
canvasRenderer.viewport.panY = urlData.viewport.y;
canvasRenderer.viewport.zoomLevel = urlData.viewport.zoom;
let session = await newSession(
getUserId(),
getLoginSecret(),
urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
{
let session = await newSession({
userId: getUserId(),
secret: getLoginSecret(),
wallId: urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
userInit: {
brush: brushEditor.code,
},
);
onError(error) {
connectionStatus.showError(error.source);
},
async onDisconnect() {
let duration = 5000 + Math.random() * 1000;
while (true) {
console.info("waiting a bit for the server to come back up", duration);
await connectionStatus.showDisconnected(duration);
try {
console.info("trying to reconnect");
let response = await fetch("/auto-reload/back-up");
if (response.status == 200) {
window.location.reload();
break;
}
} catch (e) {}
duration = duration * 1.618033989 + Math.random() * 1000;
}
},
});
localStorage.setItem("rkgk.mostRecentWallId", session.wallId);
connectionStatus.hideLoggingIn();

View file

@ -1,10 +1,10 @@
// NOTE: The server never fulfills this request, it stalls forever.
// Once the connection is closed, we try to connect with the server until we establish a successful
// connection. Then we reload the page.
await fetch("/dev/live-reload/stall").catch(async () => {
await fetch("/auto-reload/stall").catch(async () => {
while (true) {
try {
let response = await fetch("/dev/live-reload/back-up");
let response = await fetch("/auto-reload/back-up");
if (response.status == 200) {
window.location.reload();
break;

View file

@ -134,6 +134,11 @@ class Session extends EventTarget {
}
});
this.ws.addEventListener("close", (_) => {
console.info("connection closed");
this.dispatchEvent(new Event("disconnect"));
});
try {
await listen([this.ws, "open"]);
await this.joinInner(wallId, userInit);
@ -269,8 +274,10 @@ class Session extends EventTarget {
}
}
export async function newSession(userId, secret, wallId, userInit) {
export async function newSession({ userId, secret, wallId, userInit, onError, onDisconnect }) {
let session = new Session(userId, secret);
session.addEventListener("error", onError);
session.addEventListener("disconnect", onDisconnect);
await session.join(wallId, userInit);
return session;
}