fix(tags): stop two windows from erasing each other's pinned tags - #424
Merged
Conversation
`save_pinned_tag` and `remove_pinned_tag` each read all of `pinned-tags.json`, edit the list in memory, and write the whole thing back. Nothing serialised that cycle. Tauri dispatches commands on a thread pool and every window can call these, so two windows can both read the same list and both write back a full copy - and the second write silently drops whatever the first one recorded. `atomic_write` does not cover this, which is the easy assumption to make. Its temp-file-fsync-rename ruled out a *torn* file: no reader ever sees half a JSON document. A lost update produces two whole, valid files in sequence; the second is simply built from a snapshot taken before the first one landed. The realistic trigger is not exotic. Each window saves its pinned tag from its own close handler (`appExit`, `destroyWindowAfterTabsClosed`, and the close-requested path), so quitting two tagged windows with Cmd-Q runs both cycles at once. `TitleBar.togglePinnedTag` and `clearTag` also fire their invoke without awaiting it. This is the same defect class as #405, which fixed recent-files being clobbered by re-reading live storage instead of an in-memory snapshot. A re-read alone was sufficient there because `localStorage` is per-document and single-threaded, so an RMW cycle is atomic by construction. Rust commands have no such property, so the cycle needs an explicit lock. The cycle now runs inside `update_pinned_tags`, holding a new `AppState.pinned_tags: Mutex<()>` - the same shape and the same `lock_recover` poison handling as the existing `window_registry`. Recovering from poisoning is right here too: `atomic_write` publishes by rename, so a panic inside the cycle leaves the previous file intact rather than a half-applied one, and propagating the poison would instead disable pinning for the rest of the session. `list_pinned_tags` deliberately does not lock. A plain read of a file that is only ever replaced by rename returns either the whole old list or the whole new one, both of which Markpad wrote. The cycle is what is unsafe, not the read. Measured with the lock removed, 8 writers x 4 rounds: 1-4 of 8 pins survived and 3-7 of 8 unpins came back from under a stale snapshot. The unlocked runs also failed outright with `File exists` and `No such file or directory` - concurrent `atomic_write` calls on one target can pick the same temp name (target name + pid + nanosecond clock), and the loser's cleanup deletes the file the winner was about to rename. Serialising removes that exposure for this file as well. Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
force-pushed
the
fix/pinned-tag-lost-update
branch
from
August 3, 2026 07:13
ad1957b to
34fdc23
Compare
PathGao
pushed a commit
that referenced
this pull request
Aug 3, 2026
`in_code_region` is a `binary_search_by`, so `code_region_ranges` must return its regions in document order. It did — by calling `sort_unstable()` on the last line, after a second pass had appended every inline code span behind the fenced regions. Deleting that one line left `cargo test` at 144 passed, while markers inside a fenced block (`![[embed]]`, `[[wikilink]]`, `==highlight==`, `^[footnote]`, `$x$`) were reported as prose and rewritten. The order is now produced by construction: the scan records each plain segment's inline spans at the moment it closes that segment, immediately before the fence that ended it, so every push is at a higher offset than the last. The sort is gone, the `plain_segments` vector is gone, and a `debug_assert!` names the invariant at its one construction site. Four tests cover the consequence — one per consumer of `code_region_ranges`. Also in this change: - `convert_markdown` captures its parameter as `raw_buffer` before any preprocessing runs, and hands that to `annotate_task_checkboxes`. The fail-safe only works while its second argument is the unpreprocessed buffer, and the natural way to add a step — `let content = ...` near the top — silently retargeted it. A source-level test pins the three properties the capture depends on; provenance is not a type, so a source check is what is available. - `annotate_task_checkboxes`'s doc comment claimed the frontend "writes a `- [x]` marker into whatever happens to sit on that line". That describes the pre-#352 frontend. Rewritten to the current behaviour and to the two cases that still corrupt. - `read_file_content` is deleted: no call site since #379, and its defining property is that it hides the lossy-decode verdict. Its frontend guard was a hard-coded three-file allowlist; it is now a whole-tree scan plus an assertion that the command stays deleted. - `update_pinned_tags`'s comment said `localStorage` makes an RMW cycle atomic by construction. It does not — that claim came from #424, this project's own recent work — and the passage now states the real asymmetry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
added a commit
that referenced
this pull request
Aug 3, 2026
…ort (#434) `in_code_region` is a `binary_search_by`, so `code_region_ranges` must return its regions in document order. It did — by calling `sort_unstable()` on the last line, after a second pass had appended every inline code span behind the fenced regions. Deleting that one line left `cargo test` at 144 passed, while markers inside a fenced block (`![[embed]]`, `[[wikilink]]`, `==highlight==`, `^[footnote]`, `$x$`) were reported as prose and rewritten. The order is now produced by construction: the scan records each plain segment's inline spans at the moment it closes that segment, immediately before the fence that ended it, so every push is at a higher offset than the last. The sort is gone, the `plain_segments` vector is gone, and a `debug_assert!` names the invariant at its one construction site. Four tests cover the consequence — one per consumer of `code_region_ranges`. Also in this change: - `convert_markdown` captures its parameter as `raw_buffer` before any preprocessing runs, and hands that to `annotate_task_checkboxes`. The fail-safe only works while its second argument is the unpreprocessed buffer, and the natural way to add a step — `let content = ...` near the top — silently retargeted it. A source-level test pins the three properties the capture depends on; provenance is not a type, so a source check is what is available. - `annotate_task_checkboxes`'s doc comment claimed the frontend "writes a `- [x]` marker into whatever happens to sit on that line". That describes the pre-#352 frontend. Rewritten to the current behaviour and to the two cases that still corrupt. - `read_file_content` is deleted: no call site since #379, and its defining property is that it hides the lossy-decode verdict. Its frontend guard was a hard-coded three-file allowlist; it is now a whole-tree scan plus an assertion that the command stays deleted. - `update_pinned_tags`'s comment said `localStorage` makes an RMW cycle atomic by construction. It does not — that claim came from #424, this project's own recent work — and the passage now states the real asymmetry. Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PathGao
pushed a commit
to PathGao/Markpad
that referenced
this pull request
Aug 3, 2026
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>
PathGao
added a commit
that referenced
this pull request
Aug 3, 2026
* fix(save): make atomic_write's temp name collision-proof 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 #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> * fix(save): let saveContent disarm the debounce that races it 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> --------- 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.
The defect
save_pinned_tagandremove_pinned_tagare unsynchronised read-modify-write cycles over one shared file:Tauri dispatches commands on a thread pool and every window can call these. Two windows both read the same list, both write back a full copy, and the second write silently drops what the first recorded.
Why
atomic_writedoes not already cover thisThis is the natural assumption, and it is the reason the bug is easy to walk past.
atomic_writerules out a torn file — temp file, fsync, rename, so no reader ever sees half a JSON document. A lost update is a different failure: both writers produce a whole, valid file, and the second one is simply built from a snapshot taken before the first one landed.Atomicity is a property of each individual write. It says nothing about the interval between a read and the write derived from it.
The trigger is not theoretical
savePinnedTagIfNeededruns from each window's own close handler —appExit,destroyWindowAfterTabsClosed, and the close-requested path inMarkdownViewer.svelte. Quitting two tagged windows with ⌘Q (or a system shutdown) runs both cycles at once.TitleBar.togglePinnedTagandclearTagalso fire theirinvokewithout awaiting it.Same defect class as #405, different fix, and the difference matters
#405 fixed recent-files being clobbered by re-reading live storage instead of serialising an in-memory snapshot. A re-read alone was sufficient there because
localStorageis per-document and single-threaded — an RMW cycle is atomic by construction, so there is no interval to protect. Rust commands have no such property. The same shape here needs an explicit lock, not a second re-read.The fix
The cycle moves into
update_pinned_tags, which holds a newAppState.pinned_tags: Mutex<()>across read → edit → write. Same shape and the samelock_recoveras the existingwindow_registry: Mutex<HashMap<…>>— no new concurrency primitive and no async runtime.save_pinned_tag/remove_pinned_tagkeep their signatures; they resolve the path and the state and hand the edit to the guarded cycle.save_pinned_tag_at/remove_pinned_tag_attake the lock and path directly, which is what makes the race testable without a liveAppHandle.Three decisions worth stating
The lock does not cover plain reads.
list_pinned_tagsstays unlocked. The file is only ever replaced byatomic_write's rename, so a reader racing a writer opens either the whole previous list or the whole next one — both lists Markpad actually wrote. The read is safe; the cycle is not.Poisoning is recovered, matching the rest of the file.
lock_recoveralready does this forwindow_registry,startup_filesand the watcher map. The argument extends here even though this mutex guards a file:atomic_writepublishes by rename, so a panic inside the cycle leaves the pre-existingpinned-tags.jsonintact — there is no half-applied state for the next holder to inherit. Propagating the poison would instead disable pinning for the rest of the session.No other Rust command has this shape. Grepped every
read_to_string/fs::write/atomic_writeinlib.rs,setup.rs,tab_transfer.rs.save_theme,save_file,save_file_binary,save_window_stateand the VSIX theme install all write a whole value handed down from the frontend — no read step in Rust, so no cycle.save_window_stateshares a file across windows butwindowSession.persistStategates onisMainWindow, so only one window writes it. The broker intab_transfer.rsis in-memory and alreadyMutex-guarded.setup.rsruns once at install time.pinned-tags.jsonis the only one.Tests
concurrent_edits_do_not_overwrite_one_anotherruns 8 savers and 8 removers × 4 rounds against one file in a private temp directory (never the realapp_config_dir) and asserts the property: the final file contains exactly thekeep-*pins the writers asked for and none of thedoomed-*ones they removed.It asserts the surviving set rather than any mechanism on purpose. This repo has been bitten twice by tests that were green on macOS for the wrong reason — a race test that passed because the interleaving did not occur, and an
is_err()assertion on a branch macOS never executes. The set of surviving tag names is what the user loses when this breaks, and it is checked identically on every platform.Counter-proof, with the lock removed and nothing else changed (5 runs, macOS/APFS):
The unlocked runs also failed outright with
File exists (os error 17)andNo such file or directory (os error 2).atomic_writenames its temp file from the target name, the pid and a nanosecond clock reading; two threads of one process that land on the same reading collide oncreate_new, and the loser's cleanup then deletes the temp file the winner was about to rename. Serialising the cycle removes that exposure for this file too. Recorded in the doc comment.Two supporting tests: re-pinning a tag updates it in place rather than appending a duplicate, and a writer that panics mid-cycle does not lock out the next one.
Verification
cargo fmt --checkis clean forwindow_runtime.rs; the repo's 52 pre-existing diffs are all inlib.rs/setup.rsand were left alone.Not covered
tauri-plugin-single-instanceis what makes that not the normal case, and no file lock was added.atomic_writecall sites. The temp-name collision above is a property ofatomic_write, not of this file. It needs two threads writing the same target at the same nanosecond, which for documents means two tabs on one file — narrowed by fix(tabs): one tab per file path #413 and fix(tabs): ask the filesystem whether two paths name the same file #416 but not proven impossible. Reported here rather than fixed; a fix belongs withatomic_write.read_pinned_tags_at's silent fallback. An unreadable or unparseable file still yields an empty list, which the next write then persists. Under the lock this is only reachable through damage from outside Markpad, so the behaviour is unchanged and documented rather than altered.🤖 Generated with Claude Code