From ec8d83aea8ee6130d3ce3dc948af724cc031c68d Mon Sep 17 00:00:00 2001 From: ameer Date: Sun, 3 May 2026 15:05:41 +0800 Subject: [PATCH] feat(student): auto-reconnect with backoff + WS-open retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the manual "Disconnected → Reconnect button" screen with a small top-banner that retries the WS up to 8 times (500ms → 5s, ~27s budget). On open, snapshot replay restores the question card and countdown so a brief network blip is invisible to the student. After the retry budget exhausts, fall back to a manual "Reload" card. Same path covers initial WS-open failures (transient TLS hiccups on the Aliyun edge), since the first connect() and subsequent reconnects share the schedule. Auth-related closes (1008) still hard-reload immediately so an invalidated cookie lands on the join form, not in a retry loop. Submits are now also gated on ws.readyState === OPEN; clicks during a reconnect are silent no-ops, and the question re-renders fresh once the server replays state. --- static/quiz.js | 101 +++++++++++++++++++++++++++++++++++++++++------ static/style.css | 20 ++++++++++ 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/static/quiz.js b/static/quiz.js index e64a380..4b26ee4 100644 --- a/static/quiz.js +++ b/static/quiz.js @@ -21,6 +21,17 @@ const store = { deadlineMs: null, }; +// WS reconnect with exponential backoff. Total budget is ~27s across 8 +// attempts (500ms, 1s, 2s, 4s, then 5s × 4), which covers typical mobile +// hand-off and Aliyun-edge TLS hiccups without giving up too quickly. +const RECONNECT = { + attempt: 0, + maxAttempts: 8, + baseDelayMs: 500, + maxDelayMs: 5000, + timer: null, +}; + let countdownTimer = null; function fmtScore(value) { @@ -134,23 +145,83 @@ function connect() { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`); store.ws = ws; + ws.addEventListener("open", () => { + clearReconnectState(); + }); ws.addEventListener("message", (event) => { try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); } }); - ws.addEventListener("close", () => { - // session_reset already drove a re-boot; suppress the generic - // "disconnected" screen so it doesn't briefly flash on top of the - // "Re-joining…" interstitial. + ws.addEventListener("close", (event) => { + // session_reset already drove a re-boot; suppress the reconnect path + // so it doesn't fight with the "Re-joining…" interstitial. if (store.resetting) return; stopCountdown(); - setView(` -
-

Disconnected

-

Your connection dropped.

- -
- `); + // 1008 = policy violation (server rejected the cookie / session). + // Retrying won't help; reload so /me re-checks auth and we land on + // the join form (or "ask your instructor") cleanly. + if (event.code === 1008) { + window.location.reload(); + return; + } + scheduleReconnect(); }); + ws.addEventListener("error", () => { + // The "close" event will fire next; reconnect handling lives there. + }); +} + +function scheduleReconnect() { + if (store.resetting) return; + if (RECONNECT.attempt >= RECONNECT.maxAttempts) { + showReconnectFailed(); + return; + } + RECONNECT.attempt += 1; + const delay = Math.min( + RECONNECT.baseDelayMs * Math.pow(2, RECONNECT.attempt - 1), + RECONNECT.maxDelayMs + ); + showReconnectingBanner(`Reconnecting… (${RECONNECT.attempt}/${RECONNECT.maxAttempts})`); + RECONNECT.timer = setTimeout(connect, delay); +} + +function clearReconnectState() { + if (RECONNECT.timer) { + clearTimeout(RECONNECT.timer); + RECONNECT.timer = null; + } + RECONNECT.attempt = 0; + hideReconnectingBanner(); +} + +function showReconnectingBanner(text) { + let el = document.querySelector("#reconnect-banner"); + if (!el) { + el = document.createElement("div"); + el.id = "reconnect-banner"; + el.className = "reconnect-banner"; + el.setAttribute("role", "status"); + el.setAttribute("aria-live", "polite"); + document.body.appendChild(el); + } + el.textContent = text; + el.hidden = false; +} + +function hideReconnectingBanner() { + const el = document.querySelector("#reconnect-banner"); + if (el) el.hidden = true; +} + +function showReconnectFailed() { + hideReconnectingBanner(); + setView(` +
+

Disconnected

+

We couldn't reconnect after several tries. Reload to try again.

+ +
+ `); } function handleMessage(message) { @@ -172,6 +243,7 @@ function handleSessionReset() { // server) and we'll land cleanly on the join form. store.resetting = true; stopCountdown(); + clearReconnectState(); store.me = null; store.currentQuestion = null; store.submitted = null; @@ -243,7 +315,12 @@ function renderQuestion(message) { } function submitAnswer(answer) { - if (!store.ws || !store.currentQuestion || store.submitted || store.pickedAnswer) return; + if (!store.currentQuestion || store.submitted || store.pickedAnswer) return; + // Drop the click silently if the WS isn't open right now (mid-reconnect + // or already torn down). On reconnect the server replays question_open + // for the same qidx, which re-renders the card with buttons re-enabled, + // so the student just clicks again. + if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return; store.pickedAnswer = answer; document.querySelectorAll("[data-answer]").forEach((btn) => { btn.disabled = true; diff --git a/static/style.css b/static/style.css index ad6370c..59adab3 100644 --- a/static/style.css +++ b/static/style.css @@ -332,6 +332,26 @@ input:focus, textarea:focus, select:focus { .alert.error { margin: 0; } .alert.info { border-left-color: var(--primary); color: var(--primary); background: color-mix(in srgb, var(--primary) 5%, transparent); } +/* ---------- Reconnect banner ---------- */ + +.reconnect-banner { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 50; + padding: 8px 16px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 500; + color: var(--warn-text); + background: var(--warn); + box-shadow: var(--shadow); + letter-spacing: 0.01em; + pointer-events: none; +} +.reconnect-banner[hidden] { display: none; } + /* ---------- Admin topbar ---------- */ .admin-body { padding-bottom: 32px; }