decoder reconnect

This commit is contained in:
larssand
2026-09-09 08:08:56 +02:00
parent 09524944dd
commit d18cd7fb5e
5 changed files with 74 additions and 2 deletions

View File

@@ -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. - 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 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 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 ### Fixed

View File

@@ -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] 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] 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. - [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. - [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 ## 3. Clearer practice follow-up

View File

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

View File

@@ -21,7 +21,9 @@ export async function startFakeDecoder() {
// plain server.close() would wait forever for them to end; track and destroy them // plain server.close() would wait forever for them to end; track and destroy them
// explicitly instead. closeAllConnections() does not reach upgraded sockets. // explicitly instead. closeAllConnections() does not reach upgraded sockets.
const sockets = new Set(); const sockets = new Set();
let totalConnections = 0;
server.on("upgrade", (req, socket) => { server.on("upgrade", (req, socket) => {
totalConnections += 1;
sockets.add(socket); sockets.add(socket);
socket.on("close", () => sockets.delete(socket)); socket.on("close", () => sockets.delete(socket));
const key = req.headers["sec-websocket-key"]; 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); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); });
return { return {
port, 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 => { close: () => new Promise(resolve => {
for (const socket of sockets) socket.destroy(); for (const socket of sockets) socket.destroy();
server.close(() => resolve()); server.close(() => resolve());

View File

@@ -2,6 +2,7 @@ import { runOverlaySmoke } from "./browser_overlays.js";
import { runRaceSetupSmoke } from "./browser_race_setup.js"; import { runRaceSetupSmoke } from "./browser_race_setup.js";
import { runRaceDaySmoke } from "./browser_race_day.js"; import { runRaceDaySmoke } from "./browser_race_day.js";
import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js";
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { existsSync, mkdtempSync, rmSync } from "node:fs";
@@ -134,6 +135,7 @@ try {
await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause }); await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause });
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs }); await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick }); 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 navigate("classes", "classForm");
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await fill("classForm", { name: "Offline Draft" }); await fill("classForm", { name: "Offline Draft" });