From d18cd7fb5e7f492f20a87d7fa260f912dea26c2c Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 9 Sep 2026 08:08:56 +0200 Subject: [PATCH] decoder reconnect --- CHANGELOG.md | 1 + ROADMAP.md | 6 ++- tests/browser_decoder_reconnect.js | 59 ++++++++++++++++++++++++++++++ tests/browser_fake_decoder.js | 8 ++++ tests/browser_smoke.js | 2 + 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/browser_decoder_reconnect.js diff --git a/CHANGELOG.md b/CHANGELOG.md index adc0847..5b1ada1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The application version is maintained in `package.json` and displayed in the adm - Personal mobile profiles show last successful data retrieval, stale-data and failed-update warnings, and a Retry button without replacing loaded statistics or active forms. Requests time out after 15 seconds and cannot overlap. - Browser coverage for Race Day start/pause/resume/finish and switching to the next queued heat, against a simulated decoder socket. - Browser coverage for opening the OBS overlay as a real popup window (not a direct URL load): compact layout, and live clock updates that freeze on pause, polling independently of the admin page. +- Browser coverage for decoder auto-reconnect after a dropped socket (simulated network interruption): reconnects onto exactly one connection, never a duplicate, and stops retrying once auto-reconnect is turned off. ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 3e0e502..fac2bd2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,10 +56,12 @@ Acceptance: each configured channel distinguishes an open socket from actual rec - [x] Add a bounded live message sample/log with pause, clear and copy controls. - [x] Show why an incoming passing was ignored or rejected without changing lap validation. - [x] Add simulated-socket tests for receiving, parsing, disconnecting and open status. -- [ ] Verify automatic reconnect against a real decoder/network interruption. +- [x] Verify automatic reconnect against a simulated decoder/network interruption (raw socket drop, not a clean close), checking for duplicate connections and correct reconnect-timer behavior. - [x] Document IP/port/channel setup and the difference between decoder connection and incoming data. -Verified 2026-09-08: 128 logic/API tests and browser smoke tests passed, including mobile profile retention during network loss and recovery via Retry. Real decoder/network reconnect remains a hardware check. +Verified 2026-09-08: 128 logic/API tests and browser smoke tests passed, including mobile profile retention during network loss and recovery via Retry. + +Verified 2026-09-09: a browser test (`tests/browser_decoder_reconnect.js`) drops a real TCP connection to a simulated decoder socket and confirms the frontend detects it, waits for the ~2s reconnect timer in `src/decoder_runtime.js`, and settles on exactly one live connection afterward - never two at once. Disabling auto-reconnect through the real Settings form (not a raw state patch, which never reaches an already-loaded page) correctly stops further retries after a drop. Reconnecting against physically real AMMC hardware remains a separate hardware-in-the-loop check this suite cannot cover. ## 3. Clearer practice follow-up diff --git a/tests/browser_decoder_reconnect.js b/tests/browser_decoder_reconnect.js new file mode 100644 index 0000000..0ee0c2b --- /dev/null +++ b/tests/browser_decoder_reconnect.js @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { startFakeDecoder } from "./browser_fake_decoder.js"; + +export async function runDecoderReconnectSmoke({ evaluate, navigate, savedState, waitFor, pause, base }) { + const patchState = async patch => { + const current = await savedState(); + Object.assign(current, patch); + const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) }); + assert.equal(response.ok, true); + }; + const badgeOnline = () => evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'); + + const decoder = await startFakeDecoder(); + try { + const seeded = await savedState(); + await patchState({ settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}` } }); + + await navigate("events", "eventForm"); + await waitFor(async () => await badgeOnline(), "decoder connects on page load"); + assert.equal(decoder.activeConnections(), 1, "exactly one live connection after the initial connect"); + assert.equal(decoder.totalConnections(), 1, "a clean initial connect makes exactly one connection attempt"); + + // Simulate a real network/decoder interruption: drop the TCP connection with a raw + // reset rather than a clean WS close handshake, the way a lost cable or a decoder + // power-cycle actually would - see src/decoder_runtime.js's wsClient.onclose. + decoder.dropConnections(); + await waitFor(async () => (await badgeOnline()) === false, "badge shows offline once the drop is detected"); + assert.equal(decoder.activeConnections(), 0, "no lingering connection right after the drop"); + + // decoder_runtime.js schedules exactly one reconnect attempt 2000ms after onclose. + await waitFor(async () => decoder.totalConnections() >= 2, "a reconnect attempt reaches the decoder socket"); + await waitFor(async () => await badgeOnline(), "badge shows online again after auto-reconnect"); + assert.equal(decoder.totalConnections(), 2, "auto-reconnect made exactly one new connection attempt, not several"); + assert.equal(decoder.activeConnections(), 1, "auto-reconnect settles on exactly one live connection, not a duplicate"); + console.log("PASS decoder auto-reconnects after a dropped socket onto exactly one connection"); + + // A second drop must not leave a stray reconnect timer running once autoReconnect is + // turned off - the retry loop should stop, not silently keep firing in the background. + // Toggling it through the real Settings form (not a raw state patch) matters here: + // a raw backend patch never reaches this already-loaded page's in-memory state, only + // a fresh navigation would, and that would also drop and reopen the live connection. + await navigate("settings", "settingsForm"); + await waitFor(async () => await badgeOnline(), "decoder is connected on the settings page"); + await evaluate('document.querySelector("#settingsForm [name=autoReconnect]").checked = false'); + await evaluate('document.getElementById("settingsForm").requestSubmit()'); + const connectionsBeforeDrop = decoder.totalConnections(); + decoder.dropConnections(); + await waitFor(async () => (await badgeOnline()) === false, "badge shows offline after the second drop"); + await pause(3000); + assert.equal(decoder.totalConnections(), connectionsBeforeDrop, "no reconnect attempt is made once autoReconnect is disabled"); + assert.equal(await badgeOnline(), false, "stays offline with autoReconnect disabled"); + assert.equal(decoder.activeConnections(), 0, "no duplicate or stray connection appears while disabled"); + console.log("PASS disabling auto-reconnect stops further reconnect attempts after a drop"); + } finally { + const cleanup = await savedState(); + await patchState({ settings: { ...cleanup.settings, autoReconnect: false } }); + await decoder.close(); + } +} diff --git a/tests/browser_fake_decoder.js b/tests/browser_fake_decoder.js index f0b84c6..5262610 100644 --- a/tests/browser_fake_decoder.js +++ b/tests/browser_fake_decoder.js @@ -21,7 +21,9 @@ export async function startFakeDecoder() { // plain server.close() would wait forever for them to end; track and destroy them // explicitly instead. closeAllConnections() does not reach upgraded sockets. const sockets = new Set(); + let totalConnections = 0; server.on("upgrade", (req, socket) => { + totalConnections += 1; sockets.add(socket); socket.on("close", () => sockets.delete(socket)); const key = req.headers["sec-websocket-key"]; @@ -32,6 +34,12 @@ export async function startFakeDecoder() { await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); }); return { port, + activeConnections: () => sockets.size, + totalConnections: () => totalConnections, + // Drops every currently-open socket with a raw TCP reset (not a clean WS close + // handshake) to simulate a decoder/network interruption, while the server keeps + // listening so the frontend's own reconnect logic has somewhere to reconnect to. + dropConnections: () => { for (const socket of sockets) socket.destroy(); }, close: () => new Promise(resolve => { for (const socket of sockets) socket.destroy(); server.close(() => resolve()); diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 556b8b0..e7ec638 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -2,6 +2,7 @@ import { runOverlaySmoke } from "./browser_overlays.js"; import { runRaceSetupSmoke } from "./browser_race_setup.js"; import { runRaceDaySmoke } from "./browser_race_day.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js"; +import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; @@ -134,6 +135,7 @@ try { await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause }); await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs }); await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick }); + await runDecoderReconnectSmoke({ evaluate, navigate, savedState, waitFor, pause, base }); await navigate("classes", "classForm"); await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); await fill("classForm", { name: "Offline Draft" });