Skip to content

id(web): AutoSaveManager runs concurrent saves and a stale completion marks newer edits saved #1440

Closed

Description

@dezren39

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) sets state = "saving" and awaits saveFn() 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 in onContentChange() and the retry timer in onSaveResult() both call triggerSave() directly.
  • onSaveResult() applies its transition unconditionally: a completion cannot tell whether the document changed after its save started.
  • saveFile() (main.ts:1296) captures state.doc at call time and, on success, writes the returned hash into editorContainer.dataset.hash. Two overlapping saves therefore carry two different snapshots, and whichever response completes last wins the dataset.hash update — 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 + retryAfterMs handling exists for a reason) and can interleave the dataset.hash bookkeeping 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 saved when 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 stays unsaved; retry-after-429 ⇒ single serialized retry. (The class is currently private inside main.ts; exporting it for tests, as was done for identity-refresh.ts / world-seed.ts pure helpers, is an acceptable way to make it testable.)

Notes

Activity

  1. developing-today commented on Oct 27, 2026

    @developing-today
    Contributor
  2. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    priority/normalOrdinary priority (the default)
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    effort/sSmall: expected under 2 hours of work
    on Oct 27, 2026
  3. dezren39 commented on Oct 27, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · state: filed

    • Agent: new-issue-creator-code-b
    • Session / ID: new-issue-creator-code-b cron run, 2026-10-27 ~10:34 CDT
    • Found via: filed this run
    • Task: AutoSaveManager (pkgs/id/web/src/main.ts) has no in-flight guard on the debounce/retry paths into triggerSave() and onSaveResult() applies completions unconditionally — verified with a verbatim class extract under Bun on the laptop (controllable deferred saveFn): edit during an in-flight save produces maxInFlight = 2 concurrent saves, and an older save resolving after a newer edit sets state to "saved" while that edit is still unsaved. Full probe log: goal hidden_files/probe-2026-10-27-laneB-autosave/out.log.
    • Expectation: triage (effort/s) — serialize/coalesce saves and guard completions with a dirty generation; regression tests with a deferred saveFn.
    • Links: issue id(web): AutoSaveManager runs concurrent saves and a stale completion marks newer edits saved #1440; distinct from Firefox e2e sandbox hardening: autosave save-count race + websocket-spec server stall (#229 follow-up) #1230 (e2e harness autosave race)
  4. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 27, 2026
  5. 101 remaining items

  6. added
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Oct 28, 2026
  7. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  8. added
    queue/claimedQueue worker lifecycle state
    and removed
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    on Oct 28, 2026
  9. added a commit that references this issue on Oct 28, 2026
  10. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobstatus:donetask/small-fixQueue packet kind: bounded fix with reproduction and targeted tests

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions