feat(student): auto-reconnect with backoff + WS-open retry

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.
This commit is contained in:
ameer
2026-05-03 15:05:41 +08:00
parent 55ecb1b396
commit ec8d83aea8
2 changed files with 109 additions and 12 deletions

View File

@@ -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(`
<div class="card narrow center">
<h1>Disconnected</h1>
<p class="muted">Your connection dropped.</p>
<button class="btn primary block" onclick="window.location.reload()">Reconnect</button>
</div>
`);
// 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(`
<div class="card narrow center">
<h1>Disconnected</h1>
<p class="muted">We couldn't reconnect after several tries. Reload to try again.</p>
<button class="btn primary block" onclick="window.location.reload()">Reload</button>
</div>
`);
}
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;

View File

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