Protect live race results and shrink cross-browser staleness

Investigated whether two admin browsers open at once can overwrite each
other's changes, per an explicit request to report the risk before
making bigger changes. Found that POST /api/state always replaces the
entire stored document with a client's in-memory copy, with no version
check and no per-field merge - and a browser only ever pushes, never
re-fetching server state after page load except on the Overlay view.
An idle browser's next save, even an unrelated edit, could silently
revert another browser's more recent changes, worst case a live race's
in-progress results.

Two scoped fixes instead of the bigger optimistic-locking rework (that
needs real UX decisions about conflicting saves, deferred separately):

1. server.js now remembers which session results were just written by
   a real live decoder passing (/api/passings) and protects that key
   for 15 seconds against being reverted by a stale full-state save,
   while still applying the rest of that save normally.
2. Every non-Overlay view now quietly refreshes its data in the
   background every 15 seconds (skipped while a local edit is
   pending), without ever calling renderView(), so it can never
   discard an open, unsaved edit form - shrinking how stale a
   browser's next save can be from hours to seconds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 12:56:58 +02:00
parent be9a2de31a
commit 3373f8dddd
8 changed files with 160 additions and 6 deletions

View File

@@ -287,3 +287,93 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
rmSync(dataDir, { recursive: true, force: true });
}
});
test("a stale full-state save from a second browser cannot revert a session's just-arrived live results", { timeout: 20000 }, async () => {
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-concurrent-"));
const port = await reservePort();
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, [join(projectRoot, "server.js")], {
cwd: projectRoot,
env: {
...process.env,
HOST: "127.0.0.1",
PORT: String(port),
RC_TIMING_DATA_DIR: dataDir,
RC_TIMING_PID_FILE: "",
RC_RESULT_PROTECTION_WINDOW_MS: "300",
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForServer(child);
const staleSessionResult = { competitors: {}, passings: [], adjustments: [] };
const baseState = {
events: [{ id: "event_1", name: "Club race" }],
classes: [{ id: "class_1", name: "Buggy" }],
drivers: [{ id: "driver_1", name: "Driver One", classId: "class_1" }],
sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }],
resultsBySession: { session_1: staleSessionResult },
settings: {},
training: {},
};
await fetch(`${baseUrl}/api/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(baseState),
});
// Browser A is actively timing: a live passing arrives and updates session_1's result.
const liveSessionResult = {
competitors: { driver_1: { key: "driver_1", driverName: "Driver One", laps: 1, bestLapMs: 16000 } },
passings: [{ competitorKey: "driver_1", timestamp: 5000, lapMs: 16000, validLap: true }],
adjustments: [],
};
await fetch(`${baseUrl}/api/passings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "session_1",
passing: { timestamp: 5000, transponder: "1234567", driverId: "driver_1" },
sessionResult: liveSessionResult,
}),
});
// Browser B has been idle since before the race and still has driver_1's old name and the
// stale (empty) session_1 result in memory. It now saves an unrelated edit (renaming the
// driver) and pushes its entire stale snapshot along with it.
const staleBrowserState = {
...baseState,
drivers: [{ id: "driver_1", name: "Driver One (renamed)", classId: "class_1" }],
};
const staleSaveResponse = await fetch(`${baseUrl}/api/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(staleBrowserState),
});
assert.equal(staleSaveResponse.status, 200);
const afterStaleSave = await (await fetch(`${baseUrl}/api/state`)).json();
// The unrelated edit went through...
assert.equal(afterStaleSave.state.drivers[0].name, "Driver One (renamed)");
// ...but the live race result survived the stale overwrite instead of reverting to empty.
assert.deepEqual(afterStaleSave.state.resultsBySession.session_1, liveSessionResult);
// The protection is time-bounded, not permanent: once the window passes, a save is trusted
// again, so a legitimate later change to that same session's results (e.g. an admin
// deleting the session) still takes effect.
await new Promise((resolve) => setTimeout(resolve, 400));
const afterWindowState = { ...staleBrowserState, resultsBySession: {} };
await fetch(`${baseUrl}/api/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(afterWindowState),
});
const afterWindow = await (await fetch(`${baseUrl}/api/state`)).json();
assert.deepEqual(afterWindow.state.resultsBySession, {});
} finally {
await stopServer(child);
rmSync(dataDir, { recursive: true, force: true });
}
});