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:
@@ -22,7 +22,7 @@ import { getSessionsForEventHelper, getModeLabelHelper, normalizeStartModeHelper
|
||||
import { renderEventWorkspaceView } from "./event_workspace_controller.js";
|
||||
import { renderEventManagerMarkup } from "./event_manager_view.js";
|
||||
import { renderEventManagerView } from "./event_manager_controller.js";
|
||||
import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announcePassingHelper, announceRaceStartedHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js";
|
||||
import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startBackgroundHydrationHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announcePassingHelper, announceRaceStartedHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js";
|
||||
import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js";
|
||||
import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildEvenLapsProgress as buildEvenLapsProgressLogic, buildStepTrainingProgress as buildStepTrainingProgressLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, buildTrainingMissions as buildTrainingMissionsLogic, buildTrainingHighlights as buildTrainingHighlightsLogic, getClubTrainingGoalProgress as getClubTrainingGoalProgressLogic, buildTrainingPaceBuckets as buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats as buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis as buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons as buildTrainingMarkerComparisonsLogic } from "./training_logic.js";
|
||||
import { renderTrainingView } from "./training_views.js";
|
||||
@@ -154,6 +154,7 @@ const checkAppVersion = () => checkAppVersionHelper({ onContact: (online) => {
|
||||
}, getBackendUrl, getBaselineAppVersion: () => baselineAppVersion, setBaselineAppVersion: (value) => { baselineAppVersion = value; }, getPendingAppVersion: () => pendingAppVersion, setPendingAppVersion: (value) => { pendingAppVersion = value; }, getLocalStateDirty: () => localStateDirty });
|
||||
const startAppVersionPolling = () => startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer: () => appVersionPollTimer, setAppVersionPollTimer: (value) => { appVersionPollTimer = value; } });
|
||||
const startOverlaySync = () => startOverlaySyncHelper({ getOverlaySyncTimer: () => overlaySyncTimer, setOverlaySyncTimer: (value) => { overlaySyncTimer = value; }, getLocalStateDirty: () => localStateDirty, hydrateFromBackend, getCurrentView: () => currentView, renderView });
|
||||
const startBackgroundHydration = () => startBackgroundHydrationHelper({ getBackgroundHydrationTimer: () => backgroundHydrationTimer, setBackgroundHydrationTimer: (value) => { backgroundHydrationTimer = value; }, getLocalStateDirty: () => localStateDirty, hydrateFromBackend, getCurrentView: () => currentView });
|
||||
const startOverlayRotation = () => startOverlayRotationHelper({ getOverlayRotationTimer: () => overlayRotationTimer, setOverlayRotationTimer: (value) => { overlayRotationTimer = value; }, getOverlayRotationIndex: () => overlayRotationIndex, setOverlayRotationIndex: (value) => { overlayRotationIndex = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderView });
|
||||
const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, refreshOverlayDynamics });
|
||||
const ensureAudioContext = () => ensureAudioContextHelper({ state, getAudioCtx: () => audioCtx, setAudioCtx: (value) => { audioCtx = value; } });
|
||||
@@ -3414,6 +3415,7 @@ let raceWizardDraft = null;
|
||||
let overlaySyncTimer = null;
|
||||
let overlayRotationTimer = null;
|
||||
let overlayLiveRefreshTimer = null;
|
||||
let backgroundHydrationTimer = null;
|
||||
let overlayRotationIndex = 0;
|
||||
let overlayEvents = [];
|
||||
let lastOverlayLeaderKeyBySession = {};
|
||||
@@ -3512,6 +3514,7 @@ async function init() {
|
||||
});
|
||||
|
||||
startOverlaySync();
|
||||
startBackgroundHydration();
|
||||
startOverlayRotation();
|
||||
startOverlayLiveRefresh();
|
||||
|
||||
|
||||
@@ -254,6 +254,22 @@ export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTime
|
||||
}, 800));
|
||||
}
|
||||
|
||||
// A browser left open on any other view never re-fetches state on its own, so the copy it
|
||||
// would push on its next save only gets staler over time - the longer it sits, the more of
|
||||
// another browser's meanwhile-made edits its next save can silently discard (see
|
||||
// syncStateToBackendHelper/the whole-document overwrite it does). This keeps that copy fresh
|
||||
// in the background without ever touching the DOM, so it can never interrupt an open, unsaved
|
||||
// edit form the way calling renderView() here could. Overlay already refreshes and renders
|
||||
// itself on its own, faster cycle above, so it's left alone.
|
||||
export function startBackgroundHydrationHelper({ getBackgroundHydrationTimer, setBackgroundHydrationTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, intervalMs = 15000 }) {
|
||||
clearInterval(getBackgroundHydrationTimer());
|
||||
setBackgroundHydrationTimer(setInterval(async () => {
|
||||
if (getCurrentView() === "overlay") return;
|
||||
if (getLocalStateDirty()) return;
|
||||
await hydrateFromBackend();
|
||||
}, intervalMs));
|
||||
}
|
||||
|
||||
export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlayRotationTimer, getOverlayRotationIndex, setOverlayRotationIndex, getCurrentView, getOverlayViewMode, renderView }) {
|
||||
clearInterval(getOverlayRotationTimer());
|
||||
setOverlayRotationTimer(setInterval(() => {
|
||||
|
||||
Reference in New Issue
Block a user