fix(save): stop two writers on one file from breaking each other - #436
Merged
PathGao merged 2 commits intoAug 3, 2026
Merged
Conversation
PathGao
force-pushed
the
PathGao-claude/peaceful-edison-e717c9
branch
from
August 3, 2026 09:49
2af816b to
677724d
Compare
The temp file was named from the target name, the pid and a nanosecond clock reading. macOS ticks coarser than a nanosecond, so two threads of one process writing the same target routinely derive the same name — and the collision took down both writers, not just the loser: the loser of `create_new` ran `fs::remove_file(&temp_path)` on a path it had never created, deleting the file the winner was about to rename, so the winner then failed with ENOENT. Uniqueness now comes from a process-wide atomic counter, which no two callers can be handed the same value from, rather than from a clock that cannot supply it. `create_new` still arbitrates across processes (a stale temp left by a dead process whose pid we inherited), so an AlreadyExists retries with a fresh name. The file handle is acquired before `temp_path` exists as a binding, so no cleanup can reach a file this call did not create. Reported in sftwrdotdev#424, which fixed the pinned-tags exposure by serialising that file's read-modify-write cycle and left the underlying weakness to `atomic_write`. Every other caller was still exposed: save_file, save_file_binary, save_theme, the VSIX theme install, save_window_state and the image-drop path. The new test runs 8 threads against one target and asserts every call returns Ok and the surviving file is one of the values written. Against the old naming it fails 20 times in 40 runs; against this, 0 in 40. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An explicit save and the 1.5s auto-save timer can be aimed at one tab. The timer is armed on the last keystroke and disarmed by the auto-save effect only once `isDirty` goes false, which happens after the write resolves — so a timer expiring while an explicit save is in flight starts a second write of the same file, and the two race to the rename. `atomic_write` was hardened separately so concurrent writers cannot corrupt the file or fail each other, but that is a safety property, not an ordering one. If the older snapshot lands last, the disk holds the earlier text while the tab records the newer one as saved: the buffer reads clean and stays a revision behind until the next keystroke. `cancelPendingAutoSave` already existed for exactly this, but was a call-site duty, and three of the six explicit-save entry points did not discharge it — Ctrl+S, the toolbar, and the preview task checkbox. It moves into `saveContent`, past the Save dialog so it never disarms a tab on a path the user can still cancel, and the four now-redundant call-site copies are removed. The discard branch of `canCloseTab` keeps its own, but as scope rather than as necessity: no `saveContent` is on that path to do it, and while the auto-save effect would drop the timer anyway once `isDirty` goes false three lines later, that route rests on effect flush ordering and on the effect running at all during teardown. A synchronous cancel rests on neither. Left alone because this change is about the save paths. Tests assert the ordering (cancel before write, since cancelling after would leave the timer free to fire during the very await it protects) and that no call site takes the duty back. Removing the cancel fails 3 of the 4; re-adding a call-site cancel fails the guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
force-pushed
the
PathGao-claude/peaceful-edison-e717c9
branch
from
August 3, 2026 09:52
677724d to
e87c373
Compare
PathGao
added a commit
that referenced
this pull request
Aug 3, 2026
#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: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PathGao
pushed a commit
that referenced
this pull request
Aug 3, 2026
…er one #425 gave each document its own heading-fold state by moving the set from the window onto the tab. A tab is not a document, though: `navigate` (following a Markdown link), `goBack` and `goForward` keep the tab and swap the file under it, and `collapsedHeaders` travelled with it. A fold key is a heading slug, unique only within a document, so the incoming file was rendered with any section whose slug happened to match already shut. `loadMarkdown` reads the set on the line after the `navigate` call, so nothing is deferred — the HTML that first appears carries `is-collapsed`, and the outline hides the same section's children on the same render, with nothing on screen to explain either. Measured over 202 real Markdown documents (this repo and its dependencies' READMEs and changelogs): for 27.9% of ordered document pairs, at least one heading slug of the first names a heading in the second. `installation` occurs in 29.7% of them, `usage` in 27.7%. #447 established that the same three routes must clear the reading position, and left folds for a separate change. Rather than have each route remember two resets — the shape #436 and #439 were both about — those routes now call one `forgetPreviousDocument(tab)`, which calls #447's `clearReadingPosition` and a new `clearCollapsedHeaders`. The two helpers stay separate: a stale position moves the viewport, a stale fold hides text, and each needs its own explanation. What they shared was the trigger, which had no name until now. Save As (`updateTabPath`) and rename (`renameTab`) change the path while the text on screen stays put, so they do not get there. Tests guard both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
added a commit
that referenced
this pull request
Aug 3, 2026
…er one (#448) #425 gave each document its own heading-fold state by moving the set from the window onto the tab. A tab is not a document, though: `navigate` (following a Markdown link), `goBack` and `goForward` keep the tab and swap the file under it, and `collapsedHeaders` travelled with it. A fold key is a heading slug, unique only within a document, so the incoming file was rendered with any section whose slug happened to match already shut. `loadMarkdown` reads the set on the line after the `navigate` call, so nothing is deferred — the HTML that first appears carries `is-collapsed`, and the outline hides the same section's children on the same render, with nothing on screen to explain either. Measured over 202 real Markdown documents (this repo and its dependencies' READMEs and changelogs): for 27.9% of ordered document pairs, at least one heading slug of the first names a heading in the second. `installation` occurs in 29.7% of them, `usage` in 27.7%. #447 established that the same three routes must clear the reading position, and left folds for a separate change. Rather than have each route remember two resets — the shape #436 and #439 were both about — those routes now call one `forgetPreviousDocument(tab)`, which calls #447's `clearReadingPosition` and a new `clearCollapsedHeaders`. The two helpers stay separate: a stale position moves the viewport, a stale fold hides text, and each needs its own explanation. What they shared was the trigger, which had no name until now. Save As (`updateTabPath`) and rename (`renameTab`) change the path while the text on screen stays put, so they do not get there. Tests guard both directions. Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
Follow-up to #424, which reported this and deliberately did not fix it:
Two commits, one focused problem: two writers on one file used to break each other. The first fixes the primitive so concurrent writers are safe; the second removes the in-app source of the concurrency. They touch disjoint files and are independently reviewable — the split is by layer, and either can be dropped without the other failing to compile or test.
1.
atomic_write's temp name was a collision waiting on the clockThe temp file was named from the target name, the pid, and a nanosecond clock reading:
macOS's timer granularity is coarser than a nanosecond, so two threads calling
SystemTime::now()back to back read the same value and derive the same name.The collision took down both writers, not just the loser
This is the part that makes it a defect rather than a retry.
create_newpicking one winner is correct and intended. The damage is in the loser's cleanup:The loser ran
fs::remove_file(&temp_path)on a path it had computed but never successfully created. At that moment the path names the winner's file. So one collision produced theFile exists (os error 17)/No such file or directory (os error 2)pair reported in #424.The window is small, which does not make it rare
The overlap needed is not "two writes overlapping" — it is "two
now()calls in one clock tick":The predicate's width is the clock tick, not the write duration. Eight threads starting together land in one or two ticks.
The fix
Uniqueness comes from a process-wide
AtomicU64instead of a clock that cannot supply it —fetch_addis an atomic read-modify-write, so no two callers can be handed the same value. pid and nanos stay in the name so it is still unique across processes and readable in a directory listing.create_newstill arbitrates across processes — a stale temp left by a dead process whose pid we inherited — soAlreadyExistsretries with a fresh name, bounded at 16. Any other error returns immediately; retrying will not help.The cleanup path is fixed independently of the naming: the file handle is acquired before
temp_pathexists as a binding, so every subsequentremove_fileprovably refers to a file this call created. Even if the naming were broken again, a collision would degrade to an ordinary error affecting only its own caller.The fsync / rename / permission-restore / symlink semantics in the doc comment are untouched.
Who was exposed
#424 fixed
pinned-tags.jsonby serialising that file's read-modify-write cycle, which incidentally removed its exposure. The weakness itself was untouched, so every other caller still had it:save_file,save_file_binary,save_theme, the VSIX theme install,save_window_state, and the image-drop path.Test
concurrent_atomic_writes_to_one_target_all_succeedruns 8 threads against one target and asserts every call returnsOk, that the surviving file is byte-identical to one of the values written (a torn file would mean a rename published someone else's half-filled temp), and that no temp files remain.Counter-proof, old naming and cleanup restored, nothing else changed (macOS/APFS, 40 runs each):
2. An explicit save and its own debounce could both be in flight
atomic_writenow makes concurrent writers safe. It does not make them ordered, and it cannot — atomicity is per-write. So the second half is about not starting the second write.The auto-save timer is armed on the last keystroke. It is disarmed by the auto-save effect only once
isDirtygoes false, and that happens afterawait invoke('save_file_content', …)resolves. A timer expiring inside that window issues a second write of the same file:If the user typed during the flight, the two writes carry different snapshots and race to the rename. If the older one lands last, the disk holds the earlier text while the tab has recorded the newer one as saved —
isDirtyis false, the buffer reads clean, and it stays a revision behind until the next keystroke.The window is milliseconds on a local SSD. On a network or removable volume it is seconds —
save_file_content's own doc comment is why (atomic_writefsyncs twice, which is what made the command async in the first place). The defect scales with the storage the user can least afford to lose work on.The duty was at the call sites, and half of them missed it
cancelPendingAutoSavealready existed for exactly this, with a documented contract: never disarm on a path the user can still cancel. But calling it was each caller's job, and of the six explicit-save entry points, three did not:toggleTaskCheckbox)flushBeforeLeavingEditableMode)canCloseTab)The fix
The cancel moves into
saveContent, placed after the Save dialog so it still cannot disarm a tab on a path the user can back out of, and before the write it protects. The four now-redundant call-site copies are removed.One call site keeps its own: the discard branch of
canCloseTab. This is kept as scope, not as a proven necessity, and it is the one thing worth a reviewer's eye. NosaveContentis on that path to do it — but the three statements after it are synchronous, so no timer can fire between them, and the auto-save effect drops the timer on its own onceisDirtygoes false. Removing the call would therefore probably still be correct; correctness would just rest on the effect flushing ahead of a pending macrotask, and on the effect running at all while a window tears down on the quit path. A synchronous cancel rests on neither, and rewriting it is a separate judgement from the one this change makes about the save paths. Happy to drop it if you would rather it went.Moving it also fixes the class rather than the three instances — a seventh entry point cannot reintroduce this by forgetting.
Tests
scripts/explicitSaveCancelsAutoSave.test.ts, four cases against the realTabManagerand the realdocumentSession:saveContent(), which is what Ctrl+S and the toolbar do) disarms the active tab too;Counter-proofs:
saveContentsaveContentThe dialog-cancel case is an invariant rather than a regression test for this change, and correctly stays green under both.
Verification
Run on the rebased branch (current
upstream/master,fab137c):Not covered
isDirty. No frontend coordination reaches across them — this is precisely why part 1 belongs inatomic_writeand part 2 cannot replace it. Both writes now succeed, the later one wins, and the other window's file watcher raises the existing external-change bar. That is the pre-existing product semantic and is unchanged here.atomic_write. It would not have fixed fix(tags): stop two windows from erasing each other's pinned tags #424: a read-modify-write cycle needs the lock above the read, which is why fix(tags): stop two windows from erasing each other's pinned tags #424's mutex sits inwindow_runtime.rs. For whole-value writers, part 1 already guarantees safety and wholeness, and last-write-wins is the semantic the callers hand down.documentSessionagainst a stubbed backend. Neither was reproduced by hand in the running app.🤖 Generated with Claude Code