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

@@ -196,6 +196,35 @@ app.post("/api/ammc/stop", async (_req, res) => {
}
});
// Two admin browsers can be open at once, and each pushes its own full in-memory copy of
// state on every save - whichever save lands last silently overwrites everything else,
// including any other browser's edits since its own last page load. That risk is most
// severe for resultsBySession: a browser that's been idle for a while (so its copy is
// stale) can revert live race results computed moments ago by a different, active browser.
// /api/passings is the only route driven by live decoder data, so a recent write through it
// is a reliable signal that a session's results are currently "hot" and worth protecting.
const RESULT_PROTECTION_WINDOW_MS = Number(process.env.RC_RESULT_PROTECTION_WINDOW_MS || 15000);
const recentSessionResultWrites = new Map();
function protectRecentSessionResults(incomingState) {
const stored = readStoredAppState();
if (!stored?.state?.resultsBySession || typeof stored.state.resultsBySession !== "object") return incomingState;
const now = Date.now();
for (const [sessionId, writtenAt] of recentSessionResultWrites) {
if (now - writtenAt > RESULT_PROTECTION_WINDOW_MS) {
recentSessionResultWrites.delete(sessionId);
continue;
}
const storedResult = stored.state.resultsBySession[sessionId];
if (storedResult === undefined) continue;
const incomingResult = incomingState.resultsBySession?.[sessionId];
if (JSON.stringify(incomingResult) === JSON.stringify(storedResult)) continue;
if (!incomingState.resultsBySession || typeof incomingState.resultsBySession !== "object") incomingState.resultsBySession = {};
incomingState.resultsBySession[sessionId] = storedResult;
}
return incomingState;
}
app.get("/api/state", (_req, res) => {
const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get();
if (!row) {
@@ -216,7 +245,7 @@ app.post("/api/state", (req, res) => {
return;
}
const stateJson = JSON.stringify(req.body);
const stateJson = JSON.stringify(protectRecentSessionResults(req.body));
const nowIso = new Date().toISOString();
db.prepare(
@@ -285,6 +314,7 @@ app.post("/api/passings", (req, res) => {
parsed.resultsBySession = {};
}
parsed.resultsBySession[String(sessionId)] = sessionResult;
recentSessionResultWrites.set(String(sessionId), Date.now());
db.prepare(
`
INSERT INTO app_state (id, state_json, updated_at)