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:
101
static/quiz.js
101
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(`
|
||||
<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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user