Skip to content

perf: avoid debouncer file-ID cache walk on watcher creation; incremental watcher registry - #122

Merged
mattenarle10 merged 1 commit into
mattenarle10:mainfrom
sashamerzliakov:perf/non-scanning-folder-watchers
Jul 27, 2026
Merged

perf: avoid debouncer file-ID cache walk on watcher creation; incremental watcher registry#122
mattenarle10 merged 1 commit into
mattenarle10:mainfrom
sashamerzliakov:perf/non-scanning-folder-watchers

Conversation

@sashamerzliakov

Copy link
Copy Markdown
Contributor

Fixes #121.

Transparency up front: I hit this bug as a user; the root-cause tracing, fix, and tests were done with AI assistance (Claude — see the commit trailer), with the mechanism claims verified against the vendored crate sources cited below. I've verified the behaviour on my own machine (macOS, Apple Silicon) and I'm happy to answer questions — same tooling in the loop.

What changed

use-folder-watcher.ts + tests. Three behavior changes, stated explicitly:

  1. watchImmediate instead of debounced watch() — with delayMs, tauri-plugin-fs routes through notify-debouncer-full, whose file-ID cache walks the entire tree (a stat/handle-open per entry) at debouncer.watch() time on macOS and Windows, inside a synchronous Tauri command on the main thread. watchImmediate skips the debouncer entirely. Event schemas are identical (both branches serialize the inner notify::Event), so isDirectoryChangeEvent filters both the same way — refresh timing changes, per point 2.
  2. Coalescing moves to JS with different semantics — previously the Rust debouncer aged each event ~350 ms per path; now the first qualifying event schedules a refresh 350 ms later on a fixed window shared across roots, and further events ride that window instead of resetting it (a naive trailing-edge debounce would starve under sustained churn — there's a test pinning this). Under a responsive event loop that approximately bounds refreshes to ~3/sec during continuous writes while keeping the tree from going stale.
  3. Incremental watcher registry — the watcher lifecycle is extracted into a small dependency-injectable controller; adding/removing a folder now touches only that folder's watcher instead of tearing down and re-creating all of them. A failed registration is evicted so a later reconciliation retries it (previously the recreate-all behavior provided implicit retry; the registry keeps that property explicitly).

Testing

  • Automated: 8 new unit tests over the controller (incremental reconciliation, burst coalescing with schedule/cancel-count assertions, stale-watcher suppression incl. remove/re-add identity, failure retry, dispose lifecycle, unwatch of late-resolving registrations) — bun test 55 pass. The watcher lifecycle previously had no unit coverage and no seam for injecting fakes; the extracted controller provides both.
  • Manual (macOS, Apple Silicon): with a ~10k-file root, startup and add-folder no longer beachball; external create/rename/delete still refresh the tree. I have not measured Windows/Linux — on Linux the removed cost doesn't exist (NoCache), so this change mainly benefits macOS/Windows; the incremental registry benefits all platforms.

Tradeoff / limitation

Raw events now cross IPC uncoalesced during bulk writes; the JS window bounds refresh work but not message volume. If that ever shows up in practice, the durable fix is upstream in tauri-plugin-fs (async command / opt-out of the cache).

watchableFolderPaths / isDirectoryChangeEvent exports are unchanged.

…ntal watcher registry

With delayMs set, tauri-plugin-fs routes watch() through notify-debouncer-full,
whose file-ID cache walks the entire tree (stat/handle-open per entry) at
watcher creation on macOS and Windows, inside a synchronous Tauri command on
the main thread — freezing the UI at startup and on folder add for large roots.

- use watchImmediate (no debouncer, no cache walk; identical event shapes)
- coalesce JS-side with a fixed 350ms window (bounded refresh under sustained
  churn — a naive trailing-edge debounce would starve)
- incremental watcher registry: adding a folder creates only that watcher;
  failed registrations are evicted so reconciliation retries them
- extract a dependency-injectable controller + 7 unit tests over the lifecycle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mattenarle10
mattenarle10 merged commit fa80c71 into mattenarle10:main Jul 27, 2026
2 checks passed
sashamerzliakov added a commit to sashamerzliakov/markamd that referenced this pull request Jul 27, 2026
sashamerzliakov added a commit to sashamerzliakov/markamd that referenced this pull request Jul 29, 2026
- pr-plan.md: fork track marked merged (#1 / d312b49), the three-way split
  replaced with what actually landed and why it landed as one PR, order of
  operations rewritten around what's done and what's next.
- CUSTOM.md: files-touched list was three features out of date — it predates
  the watcher rewrite, the minimal-diff dispatch, the tsv work, the tests and
  the checklist.
- Removed docs/upstream-submission-drafts.md and docs/upstream-zoom-pr-draft.md.
  Both described PRs that merged upstream as mattenarle10#122 and mattenarle10#123 in July; leaving
  them reads as pending work.

The release gap is now stated in the plan rather than implied: with no fork
release, this repo's install link and badges point at upstream's builds, so a
visitor downloading from the front page gets an app without any of the
features listed above the link.

Co-Authored-By: Claude Opus 5 (1M context) <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.

UI freezes at startup and on folder add — debounced watcher builds a file-ID cache over the whole tree (macOS/Windows)

2 participants