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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user