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:
32
server.js
32
server.js
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user