Skip to content

fix(app): stop the registry watcher clobbering a fresher in-memory write (SOU-329) - #624

Open
tsouth89 wants to merge 2 commits into
mainfrom
sou-329-watcher-clobber
Open

fix(app): stop the registry watcher clobbering a fresher in-memory write (SOU-329)#624
tsouth89 wants to merge 2 commits into
mainfrom
sou-329-watcher-clobber

Conversation

@tsouth89

@tsouth89 tsouth89 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

The race

watch_registry_for_app loads the registry file outside the RegistryState mutex and assigns the result under it, while write_registry holds the mutex only for its own update. So:

  1. Watcher sees the mtime change and reads disk state A.
  2. A command writes B to both disk and cache, under the mutex.
  3. Watcher takes the mutex and assigns its now-stale A, then emits registry-changed with A.

The UI shows the reverted state, and the cache disagrees with the file until something else touches it. The disk read-modify-write is already serialized (SOU-23); it is the app's in-memory cache that had no generation guard.

The fix

A process-wide REGISTRY_GENERATION counter, bumped by every in-memory replacement while the RegistryState mutex is held. The watcher samples it before reading the file and, under the mutex, applies the load only if it is unchanged.

A mismatch means the cache is now fresher, so the read is dropped and nothing is emitted. Nothing is lost: the winning write persisted to disk too, so its own mtime change brings the watcher back with the newer content on the next tick. last_json is deliberately left alone on a skip so the dropped content is not remembered as applied.

Two design notes:

  • Why not load under the mutex? It also closes the race, but it holds the registry lock across file IO on every change, which is the pattern SOU-95 removed from the dispatch path. The guard keeps the read unlocked and makes only the publish conditional.
  • Why a module-level counter? The registry is already a process-wide singleton, and threading a second piece of managed state through ~70 command call sites is a lot of churn for one comparison. It is only ever read or written under the RegistryState mutex, which is what makes the comparison meaningful; the atomic is for interior mutability, not lock-free access.

Testing

3 new tests against the real mutex plus counter, serialized so the shared counter cannot make them interfere:

  • a_racing_write_beats_a_stale_watcher_load — the SOU-329 sequence in order; the newer write must survive.
  • an_uncontended_watcher_load_is_applied — the ordinary case (gateway or team sync changed the file, nothing touched the cache) must still work, so the guard cannot be trivially satisfied by refusing everything.
  • applying_advances_the_generation — publishing bumps the counter, so a second load sampled at the same moment cannot overwrite what was just published.

Mutation-verified. With the guard removed, the first and third fail and the second still passes, so the tests discriminate the fix rather than the code path.

Full suites green: 692 lib + 219 gateway bin Rust tests.

Closes SOU-329.

…ite (SOU-329)

watch_registry_for_app loads the registry file outside the RegistryState mutex
and assigns the result under it, while write_registry holds the mutex only for
its own update. That leaves a window: the watcher samples disk state A, a
command writes B to both disk and cache, and the watcher then assigns its stale
A over B and emits registry-changed with A. The UI shows the reverted state and
the cache disagrees with the file until something else touches it.

The disk read-modify-write is already serialized (SOU-23); it is the app's
in-memory cache that had no generation guard.

Adds a process-wide REGISTRY_GENERATION counter, bumped by every in-memory
replacement while the RegistryState mutex is held. The watcher samples it before
reading the file and, under the mutex, applies the load only if it is unchanged.
A mismatch means the cache is now fresher, so the read is dropped and nothing is
emitted; the winning write persisted to disk too, so its own mtime change brings
the watcher back with the newer content on the next tick.

Loading under the mutex would also have closed the race, but it holds the
registry lock across file IO on every change, which is the pattern SOU-95 removed
from the dispatch path. A module-level counter rather than more managed state
because the registry is already a process-wide singleton and threading a second
handle through ~70 command call sites is a lot of churn for one comparison.

Mutation-verified: removing the guard fails a_racing_write_beats_a_stale_watcher_load
and applying_advances_the_generation while an_uncontended_watcher_load_is_applied
still passes, so the tests discriminate the fix rather than the code path.

Signed-off-by: Tyler <258147599+tsouth89@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 5, 2026 08:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a race where the desktop app’s registry file watcher could overwrite a newer in-memory registry update with an older on-disk read, causing the UI to briefly revert and the cache to diverge from disk state.

Changes:

  • Introduces a process-wide REGISTRY_GENERATION counter to guard publishing watcher-loaded registry data against concurrent in-memory writes.
  • Updates registry write/refresh paths to bump the generation counter on in-memory cache replacement.
  • Adds unit tests covering the race scenario and validating generation behavior, and documents the user-visible fix in the changelog.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src-tauri/src/desktop.rs Adds a generation guard to prevent the registry watcher from clobbering fresher in-memory state; adds targeted unit tests.
CHANGELOG.md Documents the SOU-329 user-facing fix for the transient registry revert.
Suppressed comments (1)

src-tauri/src/desktop.rs:2031

  • Ordering::SeqCst is stronger than needed for this generation counter. Since the registry data itself is protected by the RegistryState mutex and the atomic is only used as a numeric generation marker for equality checks, Relaxed ordering is sufficient and avoids unnecessary global synchronization.
    REGISTRY_GENERATION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src-tauri/src/desktop.rs Outdated
Comment on lines +2021 to +2023
/// Only ever read or written under the `RegistryState` mutex, which is what makes
/// the comparison meaningful; the atomic is for interior mutability, not for
/// lock-free access.
Comment thread src-tauri/src/desktop.rs
static REGISTRY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

fn registry_generation() -> u64 {
REGISTRY_GENERATION.load(std::sync::atomic::Ordering::SeqCst)
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The registry now uses a process-wide generation counter. Writes and reloads advance the counter. The disk watcher discards snapshots sampled before an in-memory change. Tests cover racing writes, external updates, and duplicate stale loads.

Changes

Registry consistency

Layer / File(s) Summary
Generation tracking and guarded publication
src-tauri/src/desktop.rs
Registry writes and reloads advance the generation counter. Snapshot publication applies a loaded state only when the sampled generation remains current.
Watcher integration and concurrency validation
src-tauri/src/desktop.rs
The watcher avoids cached JSON updates and events for stale snapshots. Serialized tests cover concurrent writes, external changes, and generation advancement.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RegistryWatcher
  participant DiskRegistry
  participant RegistryCache
  participant RegistryEvent
  RegistryWatcher->>RegistryCache: sample generation
  RegistryWatcher->>DiskRegistry: load registry snapshot
  RegistryWatcher->>RegistryCache: guarded snapshot publication
  RegistryCache-->>RegistryWatcher: applied or stale
  RegistryWatcher->>RegistryEvent: emit update when applied
Loading

Possibly related PRs

  • tsouth89/toolport#307: Both changes modify registry write and reload behavior to prevent stale state from overwriting concurrent changes.

Suggested reviewers: copilot, rohankumardubey

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the registry watcher race and the fix to prevent stale in-memory overwrites.
Description check ✅ Passed The description directly explains the race, generation-counter fix, tests, and verification results.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sou-329-watcher-clobber

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/desktop.rs`:
- Around line 2056-2058: Update reload_into_state to snapshot the registry
generation before loading from disk, then verify it is unchanged immediately
before publishing fresh into the locked state. If the generation changed during
the load, reject or retry instead of replacing the newer cache; ensure
refresh_from_disk and nudge_gateway follow the same stale-load protection.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 88358457-339e-445c-973a-42a4d42ff2a0

📥 Commits

Reviewing files that changed from the base of the PR and between a871441 and 36ee419.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !**/*.md
📒 Files selected for processing (1)
  • src-tauri/src/desktop.rs

Comment thread src-tauri/src/desktop.rs Outdated
Comment on lines +2056 to +2058
let mut guard = state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = fresh.clone();
bump_registry_generation();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Guard reload_into_state before it replaces the cache.

The disk load at Line 2055 completes before this mutex acquisition. A concurrent write_registry can persist and cache registry B, then advance the generation. This function can then replace B with its earlier registry A.

Sample the generation before the load. Reject or retry the load if the generation changed before publication. Otherwise, refresh_from_disk and nudge_gateway can still restore stale cached state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/desktop.rs` around lines 2056 - 2058, Update reload_into_state
to snapshot the registry generation before loading from disk, then verify it is
unchanged immediately before publishing fresh into the locked state. If the
generation changed during the load, reject or retry instead of replacing the
newer cache; ensure refresh_from_disk and nudge_gateway follow the same
stale-load protection.

From the review on #624.

reload_into_state has the same shape as the watcher - load disk, then take the
mutex and assign - so it had the identical race, reachable through
refresh_from_disk and the team-sync paths rather than through the file watcher.
Fixing only the watcher left the defect live behind a second door.

Rather than a second copy of the guard, both callers now go through
publish_if_unchanged. The first attempt at this DID duplicate the guard inline,
and the test written for it passed with that guard mutated away, because the test
exercised the shared helper while reload_into_state ran its own copy - coverage
that proved nothing. One implementation removes the possibility. Mutation-verified
now: removing the guard fails three of the four tests, and the uncontended case
still passes.

On a lost race the caller gets the cached registry, which is the newer of the
two, so the return value is still the authoritative current state.

Also corrects the generation counter's doc comment, which claimed reads were
always under the mutex; the pre-load sample is deliberately outside it, since
locking there would reintroduce the hold-across-IO this design avoids. SeqCst is
kept over Relaxed, with the reasoning written down: a stale unlocked sample can
only cause a false mismatch, which drops a load rather than clobbering one, but
that is a subtlety a later edit could invalidate and the ordering costs nothing
at one bump per write and one read per 1500 ms tick.

Signed-off-by: Tyler <258147599+tsouth89@users.noreply.github.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.

2 participants