diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 10:42:00 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 10:42:00 +0900 |
| commit | 68afd41be364acd03520837bdf456ba13efd45e4 (patch) | |
| tree | 90e16e0ddb12a74361a640d3bfce8d8f784e396f | |
| parent | ecce93d9dfecd0c61c0f8c3fa8024f60359e1f17 (diff) | |
| download | dispatch-68afd41be364acd03520837bdf456ba13efd45e4.tar.gz dispatch-68afd41be364acd03520837bdf456ba13efd45e4.zip | |
fix(frontend): ClaudeReset — global snapshot sequencer fixes cross-hour race
Replaces the per-hour inFlightSeq with a single shared SnapshotSequencer
used by both loadFromServer() and postToggle() (Gemini #2, High; nit #4).
The bug: applySnapshot replaces the *whole* schedule object. The old
per-hour counter could not stop request A for hour 9 (knows only about
hour 9) from clobbering request B for hour 10 (knows about both) when B
returned first and A straggled in — hour 10 would visually vanish.
Same race existed between the initial-mount loadFromServer and a quick
user toggle: whichever lost the race won the UI.
Fix: every request to /models/wake-schedule (GET and POST) bumps a single
monotonic seq. On response, sequencer.accept(seq) returns false if any
newer request has already won; we drop the snapshot.
Also drops the inFlightSeq mechanism entirely — it was redundant with
pendingHours for user clicks AND insufficient for the cross-hour and
initial-load races, so two mechanisms became one.
| -rw-r--r-- | packages/frontend/src/lib/components/ClaudeReset.svelte | 29 |
1 files changed, 19 insertions, 10 deletions
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte index 14ffa9e..1bd0f55 100644 --- a/packages/frontend/src/lib/components/ClaudeReset.svelte +++ b/packages/frontend/src/lib/components/ClaudeReset.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { onDestroy } from "svelte"; +import { SnapshotSequencer } from "../snapshot-sequencer.js"; const { apiBase = "" }: { apiBase?: string } = $props(); @@ -49,11 +50,18 @@ let pendingRetry = $state<PendingRetry | null>(null); let pendingHours = $state<Set<number>>(new Set()); /** - * Per-hour sequence numbers. Each toggle bumps the hour's counter; when a - * response comes back we only apply it if it matches the latest counter, - * so rapid double-clicks can't let an older response overwrite a newer one. + * Single global sequencer for ALL /models/wake-schedule responses (initial + * GET + every toggle POST). Each response is dropped if a newer request has + * already won. This protects against three races: + * 1. Two toggles on different hours land out of order — older snapshot + * blindly overwrites the newer one, and the most-recent click vanishes + * from the UI. + * 2. The initial loadFromServer is still in flight when the user clicks. + * 3. Any future fan-out (e.g. polling) racing a user action. + * A per-hour counter was insufficient because applySnapshot replaces the + * whole `schedule` object, not just one hour's slot. See snapshot-sequencer.ts. */ -const inFlightSeq: Record<number, number> = {}; +const sequencer = new SnapshotSequencer(); /** Live "now" used for the current-hour ring + relative timestamps. */ let nowMs = $state<number>(Date.now()); @@ -105,10 +113,12 @@ function applySnapshot(data: ScheduleSnapshot): void { } async function loadFromServer(): Promise<void> { + const mySeq = sequencer.begin(); try { const res = await fetch(`${apiBase}/models/wake-schedule`); if (!res.ok) return; const data = (await res.json()) as ScheduleSnapshot; + if (!sequencer.accept(mySeq)) return; // a newer response already won applySnapshot(data); } catch { // Network error — leave existing state @@ -123,8 +133,7 @@ function markPending(hour: number, isPending: boolean): void { } async function postToggle(hour: number, timestamps?: Record<number, number>): Promise<void> { - const mySeq = (inFlightSeq[hour] ?? 0) + 1; - inFlightSeq[hour] = mySeq; + const mySeq = sequencer.begin(); markPending(hour, true); try { @@ -139,16 +148,16 @@ async function postToggle(hour: number, timestamps?: Record<number, number>): Pr headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (inFlightSeq[hour] !== mySeq) return; if (!res.ok) return; const data = (await res.json()) as ScheduleSnapshot; + // Drop stale snapshots — only the most-recent request wins for ALL + // shared state (schedule, lastWake, pendingRetry). + if (!sequencer.accept(mySeq)) return; applySnapshot(data); } catch { // Network error — leave local state alone; user can re-toggle. } finally { - if (inFlightSeq[hour] === mySeq) { - markPending(hour, false); - } + markPending(hour, false); } } |
