Skip to content

fix(save): stop two writers on one file from breaking each other - #436

Merged
PathGao merged 2 commits into
sftwrdotdev:masterfrom
PathGao:PathGao-claude/peaceful-edison-e717c9
Aug 3, 2026
Merged

fix(save): stop two writers on one file from breaking each other#436
PathGao merged 2 commits into
sftwrdotdev:masterfrom
PathGao:PathGao-claude/peaceful-edison-e717c9

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #424, which reported this and deliberately did not fix it:

The temp-name collision above is a property of atomic_write, not of this file. It needs two threads writing the same target at the same nanosecond […] Reported here rather than fixed; a fix belongs with atomic_write.

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 clock

The temp file was named from the target name, the pid, and a nanosecond clock reading:

let nanos = SystemTime::now().duration_since(UNIX_EPOCH)...as_nanos();
let temp_name = format!(".{}.markpad-tmp-{}-{}", file_name, pid, nanos);

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_new picking one winner is correct and intended. The damage is in the loser's cleanup:

  thread A                              thread B
 ①  now() → 1754_331_000                now() → 1754_331_000    ← same reading
 ②  .a.md.markpad-tmp-9182-1754_331_000 (byte-identical name)
 ③  create_new ✓ wins                   create_new ✗ EEXIST(17)
 ④  write_all + fsync                   remove_file(temp_path)
                                             ↑ deletes A's file
                                        return Err(17)          ← B fails
 ⑤  rename(temp → a.md)
    ✗ ENOENT(2)                                                 ← A fails too

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 the File 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":

  ├─ now() ─ format ─ create_new ─┤─ write_all ─ fsync ─ rename ─ fsync(dir) ─┤
  └──── the vulnerable window ────┘└──── irrelevant to whether names collide ──┘

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 AtomicU64 instead of a clock that cannot supply it — fetch_add is 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_new still arbitrates across processes — a stale temp left by a dead process whose pid we inherited — so AlreadyExists retries 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_path exists as a binding, so every subsequent remove_file provably 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.json by 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_succeed runs 8 threads against one target and asserts every call returns Ok, 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):

failures
clock-derived name + unconditional cleanup 20 / 40
this branch 0 / 40

2. An explicit save and its own debounce could both be in flight

atomic_write now 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 isDirty goes false, and that happens after await invoke('save_file_content', …) resolves. A timer expiring inside that window issues a second write of the same file:

  last keystroke
  ├────────────────── 1500ms debounce ──────────────────┤
  │                                                     ▼ timer fires
  │                        ┌──── Ctrl+S ────┐
  │                        │ invoke in flight│
  │                        │ isDirty still true → timer still armed
  │                        └────────────────┘
  │                                     └── overlap = one invoke's duration

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 savedisDirty is 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_write fsyncs 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

cancelPendingAutoSave already 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:

entry point cancelled before?
Ctrl+S
toolbar / menu Save
preview task checkbox (toggleTaskCheckbox)
leaving edit mode / closing split (flushBeforeLeavingEditableMode)
closing a dirty tab (canCloseTab)
quit-time bulk save

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. No saveContent is 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 once isDirty goes 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 real TabManager and the real documentSession:

  • the cancel happens before the write — cancelling after would leave the timer free to fire during the very await this protects, so ordering is the assertion, not presence;
  • a save reached without naming a tab (saveContent(), which is what Ctrl+S and the toolbar do) disarms the active tab too;
  • a Save dialog the user cancels leaves the timer alone — the standing contract, pinned so the placement cannot drift back before the modal;
  • no call site takes the duty back, which is the actual regression being guarded.

Counter-proofs:

change result
remove the cancel from saveContent 3 of 4 fail
re-add a call-site cancel before a saveContent the call-site guard fails

The 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):

npm run check   0 errors, 0 warnings   (645 files — #432's scripts/ checking covers the new test)
npm test        566 / 566
cargo test      150 / 150
cargo clippy    3 warnings — the pre-existing baseline, none from this change

Not covered

  • Cross-window writers to one file. Tab de-duplication (fix(tabs): ask the filesystem whether two paths name the same file #416) is per-window: two windows can each hold the same document, with independent timers and independent isDirty. No frontend coordination reaches across them — this is precisely why part 1 belongs in atomic_write and 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.
  • A write slower than the 1.5s debounce. The timer can still be re-armed by typing and fire during a very long explicit save. Part 1 makes that safe; the residual is the transient stale-on-disk described above, which heals on the next keystroke. Serialising writes per path would close it, at the cost of a promise-chain and snapshot relocation — not taken, as the remaining exposure is narrow and no longer unsafe.
  • No per-path lock in 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 in window_runtime.rs. For whole-value writers, part 1 already guarantees safety and wholeness, and last-write-wins is the semantic the callers hand down.
  • No live multi-window or slow-volume run. Part 1 is verified by threads against the real function; part 2 by the real documentSession against a stubbed backend. Neither was reproduced by hand in the running app.

🤖 Generated with Claude Code

@PathGao
PathGao force-pushed the PathGao-claude/peaceful-edison-e717c9 branch from 2af816b to 677724d Compare August 3, 2026 09:49
PathGao and others added 2 commits August 3, 2026 17:52
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
PathGao force-pushed the PathGao-claude/peaceful-edison-e717c9 branch from 677724d to e87c373 Compare August 3, 2026 09:52
@PathGao
PathGao merged commit b63ff2e into sftwrdotdev:master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the PathGao-claude/peaceful-edison-e717c9 branch August 3, 2026 10:14
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant