Skip to content

fix(tags): stop two windows from erasing each other's pinned tags - #424

Merged
PathGao merged 1 commit into
masterfrom
fix/pinned-tag-lost-update
Aug 3, 2026
Merged

fix(tags): stop two windows from erasing each other's pinned tags#424
PathGao merged 1 commit into
masterfrom
fix/pinned-tag-lost-update

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

The defect

save_pinned_tag and remove_pinned_tag are unsynchronised read-modify-write cycles over one shared file:

let mut tags = read_pinned_tags(&app);   // read the whole file
...                                       // edit the list
crate::atomic_write(&pinned_tags_path(&app)?, json.as_bytes())   // write the whole 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_write does not already cover this

This is the natural assumption, and it is the reason the bug is easy to walk past. atomic_write rules 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.

window A: read [x] ─── add "a" ─────────── write [x, a]
window B:      read [x] ─── add "b" ─────────────────── write [x, b]
on disk:  [x]                             [x, a]        [x, b]   ← "a" gone

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

savePinnedTagIfNeeded runs from each window's own close handlerappExit, destroyWindowAfterTabsClosed, and the close-requested path in MarkdownViewer.svelte. Quitting two tagged windows with ⌘Q (or a system shutdown) runs both cycles at once. TitleBar.togglePinnedTag and clearTag also fire their invoke without 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 localStorage is 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 new AppState.pinned_tags: Mutex<()> across read → edit → write. Same shape and the same lock_recover as the existing window_registry: Mutex<HashMap<…>> — no new concurrency primitive and no async runtime.

save_pinned_tag / remove_pinned_tag keep their signatures; they resolve the path and the state and hand the edit to the guarded cycle. save_pinned_tag_at / remove_pinned_tag_at take the lock and path directly, which is what makes the race testable without a live AppHandle.

Three decisions worth stating

The lock does not cover plain reads. list_pinned_tags stays unlocked. The file is only ever replaced by atomic_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_recover already does this for window_registry, startup_files and the watcher map. The argument extends here even though this mutex guards a file: atomic_write publishes by rename, so a panic inside the cycle leaves the pre-existing pinned-tags.json intact — 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_write in lib.rs, setup.rs, tab_transfer.rs. save_theme, save_file, save_file_binary, save_window_state and the VSIX theme install all write a whole value handed down from the frontend — no read step in Rust, so no cycle. save_window_state shares a file across windows but windowSession.persistState gates on isMainWindow, so only one window writes it. The broker in tab_transfer.rs is in-memory and already Mutex-guarded. setup.rs runs once at install time. pinned-tags.json is the only one.

Tests

concurrent_edits_do_not_overwrite_one_another runs 8 savers and 8 removers × 4 rounds against one file in a private temp directory (never the real app_config_dir) and asserts the property: the final file contains exactly the keep-* pins the writers asked for and none of the doomed-* 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):

pins that survived unpins that stuck
unlocked (master's shape) 1–4 of 8 1–5 of 8
locked 8 of 8 8 of 8

The unlocked runs also failed outright with File exists (os error 17) and No such file or directory (os error 2). atomic_write names 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 on create_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 test    139 / 139   (136 before, +3)
cargo clippy  3 warnings  — the pre-existing baseline, none from this change
npm test      540 / 540
npm run check 0 errors, 0 warnings

cargo fmt --check is clean for window_runtime.rs; the repo's 52 pre-existing diffs are all in lib.rs / setup.rs and were left alone.

Not covered

  • Cross-process. The mutex is per-process. Two Markpad processes editing the file would still race; tauri-plugin-single-instance is what makes that not the normal case, and no file lock was added.
  • The other atomic_write call sites. 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, 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 with atomic_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.
  • No live multi-window run. Verified by threads against the real functions, not by quitting two tagged windows by hand.

🤖 Generated with Claude Code

`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
PathGao force-pushed the fix/pinned-tag-lost-update branch from ad1957b to 34fdc23 Compare August 3, 2026 07:13
@PathGao
PathGao merged commit a31af8e into master Aug 3, 2026
4 checks passed
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
PathGao deleted the fix/pinned-tag-lost-update branch August 3, 2026 09:25
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>
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