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