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:
@@ -21,6 +21,17 @@ const store = {
|
|||||||
deadlineMs: null,
|
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;
|
let countdownTimer = null;
|
||||||
|
|
||||||
function fmtScore(value) {
|
function fmtScore(value) {
|
||||||
@@ -134,23 +145,83 @@ function connect() {
|
|||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
|
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
|
||||||
store.ws = ws;
|
store.ws = ws;
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearReconnectState();
|
||||||
|
});
|
||||||
ws.addEventListener("message", (event) => {
|
ws.addEventListener("message", (event) => {
|
||||||
try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
|
try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
|
||||||
});
|
});
|
||||||
ws.addEventListener("close", () => {
|
ws.addEventListener("close", (event) => {
|
||||||
// session_reset already drove a re-boot; suppress the generic
|
// session_reset already drove a re-boot; suppress the reconnect path
|
||||||
// "disconnected" screen so it doesn't briefly flash on top of the
|
// so it doesn't fight with the "Re-joining…" interstitial.
|
||||||
// "Re-joining…" interstitial.
|
|
||||||
if (store.resetting) return;
|
if (store.resetting) return;
|
||||||
stopCountdown();
|
stopCountdown();
|
||||||
|
// 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(`
|
setView(`
|
||||||
<div class="card narrow center">
|
<div class="card narrow center">
|
||||||
<h1>Disconnected</h1>
|
<h1>Disconnected</h1>
|
||||||
<p class="muted">Your connection dropped.</p>
|
<p class="muted">We couldn't reconnect after several tries. Reload to try again.</p>
|
||||||
<button class="btn primary block" onclick="window.location.reload()">Reconnect</button>
|
<button class="btn primary block" onclick="window.location.reload()">Reload</button>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(message) {
|
function handleMessage(message) {
|
||||||
@@ -172,6 +243,7 @@ function handleSessionReset() {
|
|||||||
// server) and we'll land cleanly on the join form.
|
// server) and we'll land cleanly on the join form.
|
||||||
store.resetting = true;
|
store.resetting = true;
|
||||||
stopCountdown();
|
stopCountdown();
|
||||||
|
clearReconnectState();
|
||||||
store.me = null;
|
store.me = null;
|
||||||
store.currentQuestion = null;
|
store.currentQuestion = null;
|
||||||
store.submitted = null;
|
store.submitted = null;
|
||||||
@@ -243,7 +315,12 @@ function renderQuestion(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submitAnswer(answer) {
|
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;
|
store.pickedAnswer = answer;
|
||||||
document.querySelectorAll("[data-answer]").forEach((btn) => {
|
document.querySelectorAll("[data-answer]").forEach((btn) => {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|||||||
@@ -332,6 +332,26 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.alert.error { margin: 0; }
|
.alert.error { margin: 0; }
|
||||||
.alert.info { border-left-color: var(--primary); color: var(--primary); background: color-mix(in srgb, var(--primary) 5%, transparent); }
|
.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 topbar ---------- */
|
||||||
|
|
||||||
.admin-body { padding-bottom: 32px; }
|
.admin-body { padding-bottom: 32px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user