Repository navigation
Description
Summary
AutoSaveManager in pkgs/id/web/src/main.ts does not serialize saves. The debounce and rate-limit-retry paths call triggerSave() with no in-flight guard — the state === "saving" check exists only in saveNow() — so an edit made while a save is in flight starts a second concurrent /api/save POST once its debounce fires. A stale save completion also overwrites the state of newer edits: when the older save resolves after the user has typed more, onSaveResult sets the state to saved and the button reads "saved ✓" while the newer edit is still unsaved (its debounce still pending).
Evidence (verified on main 95cd41f0, 2026-10-27)
Verbatim extract of the manager from main.ts (SaveResult type :15, AUTOSAVE_DEBOUNCE_MS :22, SaveState :24, class :26–200; sha-verbatim lines driven under Bun on the laptop with a controllable saveFn whose promises resolve only when the probe says so; log in the goal's hidden_files/probe-2026-10-27-laneB-autosave/out.log):
after edit1 state: unsaved
after debounce: calls = 1 state: saving (save #1 in flight, unresolved)
after edit2 (during flight) state: unsaved
after edit2 debounce: calls = 2 maxInFlight = 2 state: saving
after resolving save#2: state: saved
after resolving stale save#1: state: saved calls: 2
scenario B: state after older save resolves with newer edit pending: saved (debounce still pending, calls: 1)
Code facts:
triggerSave()(main.ts, class body) setsstate = "saving"and awaitssaveFn()unconditionally — no re-entry guard, no generation/dirty counter.saveNow()is the only guarded entry point (if (this.state === "saving" || this.state === "rate-limited") return;); the debounce timer inonContentChange()and the retry timer inonSaveResult()both calltriggerSave()directly.onSaveResult()applies its transition unconditionally: a completion cannot tell whether the document changed after its save started.saveFile()(main.ts:1296) capturesstate.docat call time and, on success, writes the returned hash intoeditorContainer.dataset.hash. Two overlapping saves therefore carry two different snapshots, and whichever response completes last wins thedataset.hashupdate — completion order, not capture order.
Why it matters
- The save indicator can say "saved ✓" for content that has not been saved yet (scenario B). A user who trusts it and closes or navigates away in that window loses the pending edit — the debounce only saves it if the page stays open.
- Overlapping saves waste the server's save budget (the endpoint rate-limits, 429 +
retryAfterMshandling exists for a reason) and can interleave thedataset.hashbookkeeping with the wrong save's result. - Slow saves make this easy to hit: the debounce is 2s, so any save slower than ~2s (large document, slow network, server archiving on save) plus continued typing overlaps.
Acceptance criteria
- At most one
saveFn()invocation is ever in flight: an edit during a save schedules exactly one follow-up save after the current one settles (coalescing, not dropping, and not a second concurrent call). - A save completion never moves the indicator to
savedwhen an edit arrived after that save started (track a dirty generation/epoch, or equivalent); the indicator keeps showing the pending edit as unsaved until a save that covers it completes. - The rate-limit retry path obeys the same serialization (a retry does not overlap a newer save, and a newer save does not silently cancel a pending retry without rescheduling).
-
saveNow()behaviour is unchanged for the idle/unsaved cases (immediate save, no-op while saving). - Regression tests drive the manager with a controllable
saveFn(deferred promises) covering: edit-during-flight ⇒ exactly one follow-up save, max one in flight; stale completion ⇒ state staysunsaved; retry-after-429 ⇒ single serialized retry. (The class is currently private insidemain.ts; exporting it for tests, as was done foridentity-refresh.ts/world-seed.tspure helpers, is an acceptable way to make it testable.)
Notes
- Distinct from Firefox e2e sandbox hardening: autosave save-count race + websocket-spec server stall (#229 follow-up) #1230 (e2e test-harness autosave save-count race in Firefox CI) — this is the production manager in
main.ts, not the test setup. - Verification was done with the verbatim class extract on the laptop, per the fleet's no-builds-on-the-sandbox rule; no production code was changed.
Activity
developing-today commentedon Oct 27, 2026
CODE-523
dezren39 commentedon Oct 27, 2026
🤖 Agent note · state: filed
101 remaining items
dezren39 commentedon Oct 28, 2026
🤖 Agent note · picked up
Merge pull request #1545 from developing-today/queue/1499-1440-autosa…
dezren39 commentedon Oct 28, 2026
🤖 Agent note · done