fix(save): let one write per tab be in flight at a time - #438
Merged
Conversation
#436 closed one direction of the save race: `saveContent` now disarms the auto-save debounce itself, so an *armed* timer can no longer fire during an explicit save. The other direction stayed open, and #436's own "Not covered" named it. The auto-save timer callback deletes itself from `autoSaveTimers` as its first statement and only then calls `saveContent`. Once the timer has fired there is nothing left for `cancelPendingAutoSave` to cancel, and `saveContent` had no in-flight guard — so a Cmd+S arriving while the auto-save write is still awaiting `invoke('save_file_content')` started a second concurrent write of the same file. `atomic_write` (also #436) makes that safe: neither writer can corrupt the file or fail the other. But safety is not ordering, and the older snapshot could still rename last. The severity is narrower than #436's, and that is worth saying plainly. Measured against the real `documentSession`, the losing continuation also runs last and sets `originalContent` to the older snapshot, so `isDirty` goes true: the buffer is 'B', the disk is 'A', and the dirty dot honestly reports the disagreement. The flag is accurate rather than spurious, and the auto-save effect re-arms off that same flag and rewrites 'B' about 1.5s later. No silent loss. With one exception, which is the reason to fix this at all. On the close path there is no tab left to carry the flag: with auto-save on and confirm off, `canCloseTab` saves, observes `isDirty` false, and closes; the older write then renames on top of it and nothing survives to say so. The user's last edits are gone and the close looked clean. `writeExclusively` chains a tab's writes so the second caller waits for the first and takes its snapshot on the far side of that wait. Waiting rather than returning the in-flight promise, because the second caller pressed Cmd+S *after* typing more: handing back the running write would report success for a file that does not contain those keystrokes. Keyed by tab rather than by path — the corrupted state is the tab's own, the two racers are by construction one tab's, and an untitled tab has no path to key on until its dialog closes. A chain rather than a bare `await inFlight`, because several callers awaiting one promise all wake together and then all write at once. `saveContentAs` shares the guard: its target is usually another file, but both continuations write the same tab's bookkeeping, and picking the tab's own file in the dialog is an allowed overwrite. Serialising also repairs `markSelfWrite`, whose failure mode under overlap was the more interesting consequence. Overlapping successes only push the suppression deadline further out, so the watcher stays correctly quiet. But `clearSelfWrite` in the catch deletes the entry unconditionally, so one write failing while the other succeeded erased the guard the successful write had just installed — measured: our own write then read as an external change and `resolveExternalChange` returned `reload`. Ordered, the failing write's catch can no longer reach a later write's guard. Five tests, all red without the guard and green with it, including the close-path case. Hoisting the snapshot back outside the wait fails only the test that pins snapshot placement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#436 fixed one direction of the save race and, in its own "Not covered", named the other and left it:
That was the right call on the evidence available. This picks it up, having measured what the residual actually does — which is less bad than #436 assumed on every path but one, and worse than it assumed on that one.
1. The mechanism, and why
cancelPendingAutoSavecannot reach itThe auto-save timer callback (
MarkdownViewer.svelte) deletes itself fromautoSaveTimersas its first statement, then callssaveContent:So #436's fix covers exactly the window before the timer fires. Once it has fired,
options.cancelPendingAutoSave(tab.id)finds nothing to cancel, andsaveContenthad no in-flight guard of its own. A Cmd+S landing while the auto-save write is still awaitinginvoke('save_file_content')started a second concurrent write to the same path.atomic_write(#436 part 1) makes that safe — neither writer can corrupt the file or fail the other. It is a safety property, not an ordering one. The older snapshot can still rename last.The trigger window is one disk write. The auto-save debounce fires 1.5s after the last keystroke, so reaching this by hand means going idle for 1.5s and then pressing Cmd+S inside the few hundred microseconds the write takes. On a local SSD with a small file that is effectively unreachable. It widens with the write: a large document, a network volume, a busy or encrypted filesystem.
2. Severity: narrower than #436's, with one exception that is not
#436's bug was silent data loss — a clean-looking buffer over an older file. This one is not, on almost every path, and that is measured, not reasoned.
Driving the real
documentSessionandTabManagerwith a stubbed backend: auto-save takes snapshotA, the user typesB, Cmd+S takes snapshotB, and the first (older) write is made to rename last:await invoke(...)resolves after Rust's rename, so resolution order tracks rename order — which means the continuation that lands last is the one whose snapshot is on disk:The end state is self-consistent.
isDirtyis not a spurious flag; it correctly says "the buffer and the disk disagree", and the dirty dot tells the user. Reading the auto-save effect, it also self-heals:isDirtyflipping true with no timer armed re-arms the debounce, so the buffer is rewritten ~1.5s later without the user touching anything. #436's residual note said "heals on the next keystroke" — it is better than that; no keystroke is needed.The exception is the close path, and it is silent loss. With auto-save on and confirm-before-save off,
canCloseTabsaves and closes if the save reports success and the tab reads clean. Measured:The user closes the tab, everything looks clean, and the older snapshot then renames on top. There is no tab left to raise the dirty flag, and no effect left to re-arm. The user's last edits are gone with no indication. That single path is the reason this is worth fixing; without it I would have argued for closing this as not worth the code, in the spirit of #430 and #431.
3.
markSelfWriteunder overlap — the more interesting consequencemarkSelfWrite(path)is called both before and after the invoke, to stop the file watcher treating our own write as an external change. Two questions, both measured.Both writes succeed: the four
markSelfWritecalls only ever push the deadline further out, so the suppression window is extended, not broken. The mechanism degrades safely.One fails, the other succeeds:
clearSelfWrite(path)in thecatchdeletes the entry unconditionally — including the one the successful write had just installed.The app re-reads the file it just wrote. With a clean tab that is a spurious reload; with a dirty tab
resolveExternalChangereturnsconflict, i.e. the "this file changed on disk" bar raised about our own write. #436'satomic_writemeans concurrent writers no longer fail each other, so this now needs an independent failure (ENOSPC, permissions, a disconnected volume) landing on one of the two — narrow, but real.Serialising removes it structurally rather than by special-casing: ordered, the failing write's
catchruns before the next write'smarkSelfWrite, so a clear can never reach a later write's guard.4. The fix
writeExclusively(tabId, write)indocumentSession.svelte.ts. Three choices, each of which could have gone the other way:Wait and then write, rather than return the in-flight promise. They are different answers, not two spellings of one. The second caller pressed Cmd+S after typing more, so handing back the running write would report success for a file that does not contain those keystrokes — and would leave the tab dirty while telling the caller it saved. Waiting costs one disk write and ends with the text the user asked to save actually on disk. The snapshot is therefore taken inside the queued closure, on the far side of the wait; taking it before would serialise the writes and still publish stale text.
Keyed by tab, not by path. The state these two writes corrupt is the tab's own —
originalContent,isDirty, its path — and the two racers are by construction one tab's, since both come from the same tab's debounce and the same tab's Cmd+S. A path key also could not be taken where it is needed: an untitled tab has no path until its Save dialog closes, which is anawait. Two tabs pointing at one file still write concurrently; that is last-writer-wins between two documents, which the app permits by allowing the second tab at all, and is a different question. This is deliberately not a general per-path write queue — the window is one disk write, and the fix is sized to it.A chain, not a bare
await inFlight. Several callers awaiting one promise all wake in the same microtask and then all write at once — the race again, with extra steps. The chain is ~10 lines and self-clearing: only the tail deletes the map entry, so a link finishing cannot drop a queue others are still behind.saveContentAsshares the same guard. Its target is usually a different file, so the renames need not collide — but both continuations write the same tab'soriginalContent,isDirtyand path, and picking the tab's own file in the dialog is an allowed overwrite (as its existing comment notes) that collides outright.The guard sits after
cancelPendingAutoSaveand after the Save dialog, so #436's two contracts are untouched: the cancel still precedes the write, and nothing is disarmed before a modal the user can still back out of.Tests
scripts/oneWritePerTabInFlight.test.ts, five cases against the realTabManagerand the realdocumentSession. The stub gives the first write the longer duration, which is what makes the race observable rather than merely possible.saveContentAsshares the guardCounter-proofs:
const result = write())two writes to one file were in flight at onceThe second is there to show the tests are not all pinning one thing.
All four of #436's
explicitSaveCancelsAutoSave.test.tscases still pass and still mean what they meant — including its two source-text assertions, which requirecancelPendingAutoSaveto precedeinvoke('save_file_content')insidesaveContentand forbid a call site taking the duty back. Both remain true.Verification
On
upstream/master(b63ff2e):cargo testnot run: no Rust changed. #436'satomic_writehardening is what this rests on and is not touched.Not covered
isDirty, and the guard is per-session state that does not reach across them — the same limit fix(save): stop two writers on one file from breaking each other #436 recorded. This is exactly whyatomic_writemust stay the safety layer; the frontend can only supply ordering within one window.isDirtygoing true re-arms the debounce comes from reading the auto-save effect inMarkdownViewer.svelte; the tests stub$effectout, so no test exercises it. It affects the severity argument, not the fix.saveContentat all, so no reverted buffer can be queued. No guard was added for it; noting it as the one ordering consequence a reviewer might want to push back on.documentSessionwith a stubbed Tauri backend, not by hand in the running app on a slow volume.🤖 Generated with Claude Code