Skip to content

Add debouncing/coalescing for filesystem watcher fs-change events #4

Description

@koraytaylan

Add Debouncing and Coalescing for Filesystem Watcher fs-change Events

Summary

The local filesystem watcher (backend/src/watcher.rs) emits a "fs-change" Tauri event for every create/modify/delete notification that the notify crate delivers.

On directories with high churn (downloads folder, build output directories, log directories, node_modules during installs, etc.), this can generate hundreds of events per second.

The frontend receives all of them over IPC and has to decide whether to ignore them. This wastes CPU, IPC bandwidth, and battery on the user's machine, and makes the code in FileContext more complicated than it needs to be.

Current Behavior (the problem)

  1. User (or external program) creates/modifies/deletes many files in a watched local directory.
  2. notify::RecommendedWatcher calls the closure in watch_directory for each OS event.
  3. The closure immediately does:
    let _ = app_clone.emit("fs-change", &payload);
  4. Every single event crosses the Tauri IPC boundary into the SolidJS frontend.
  5. FileContext listens and must implement its own "is this path relevant to me?" + staleness logic.

There is zero debouncing or coalescing on the Rust side today.

Why this matters

  • Performance: A recursive npm install or a git checkout of a medium repo can easily produce 500–2000+ filesystem events in a few seconds. Each one becomes a full IPC round-trip.
  • Battery & CPU waste on laptops.
  • Frontend complexity: The smart auto-refresh logic in FileContext has to be defensive against event storms.
  • Correctness risk: Under very high event rates, we could theoretically overwhelm the event channel or cause UI jank.
  • Inconsistent with the rest of the architecture: The job system and search indexer are both very careful about not doing unnecessary work. The watcher should be too.

Goal of this issue

Move the rate-limiting / coalescing logic into the backend (where it belongs), so the frontend receives at most one meaningful "something changed in this watched directory" signal per debounce window.

How to implement (junior developer friendly)

Recommended Approach: Per-Watch-ID Debounced Emitter

We will keep one small debouncer state per active watch (keyed by the watch_id we already generate).

When any event arrives for a watch:

  1. Record that "this watch has pending changes".
  2. If a debounce timer is not already running for this watch, start one (e.g., 150–300 ms).
  3. When the timer fires, emit one "fs-change" event with the watch information, then clear the pending flag.

This is classic "debounce" + "coalesce" (multiple events → single notification).

Concrete Steps

  1. Study the existing watcher code

    • Read the entire backend/src/watcher.rs
    • Understand WatcherRegistry, FsChangeEvent, and how the closure captures disk_id_clone + path_clone.
    • Look at how the frontend consumes the event (search fs-change in frontend/src).
  2. Add a debounce mechanism

    You have two good options in Rust/Tokio:

    Option A (simpler for junior): Use tokio::time::sleep + a DashMap or Mutex<HashMap> of (watch_id → JoinHandle).

    Option B (more elegant): Use the tokio-util crate's Debounce or implement a tiny manual version with tokio::sync::mpsc + select!.

    For the first implementation, Option A with a small helper struct is perfectly acceptable and easier to review.

  3. Design the state you need

    You will probably need something like:

    struct PendingWatch {
        disk_id: String,
        path: String,
        timer: Option<tokio::task::JoinHandle<()>>,
    }
    
    // stored inside WatcherRegistry or a new small struct next to it
    pending: RwLock<HashMap<String, PendingWatch>>,

    Or even simpler: just a RwLock<HashMap<String, Instant>> of "last emitted time per watch" and skip emission if within the window.

  4. Choose a debounce duration

    Suggested starting value: 200 milliseconds.
    This is long enough to coalesce bursts (file saves, build steps) but short enough that the UI still feels live.

  5. Modify the event closure

    Instead of immediately emitting, call a new method on the registry or a helper:

    watchers.note_change(&watch_id, &disk_id, &path, &app).await;
  6. Emit exactly one event when the timer fires

    The emitted payload can stay exactly the same (FsChangeEvent). The frontend does not need to change at all for this improvement.

  7. Handle watcher removal cleanly

    When unwatch_directory is called, you must cancel any pending timer task for that watch ID so we don't leak tasks.

  8. Add a test

    • You can test this with the in-memory behavior or by using tempfile + notify in a controlled way.
    • At minimum, write a unit test that shows that N rapid simulated changes only produce 1 (or a bounded number) of actual emissions after the debounce window.

Acceptance Criteria

  • High-frequency filesystem events for the same watched directory are coalesced into at most one "fs-change" event per debounce window (200 ms recommended)
  • The public event shape (FsChangeEvent) and the frontend listener do not need to change
  • When a watcher is stopped (unwatch_directory), any pending debounce timer is cancelled (no leaked tasks)
  • cargo clippy -- -D warnings is still clean
  • You add a test (even a simple one with a counter + tokio::time::sleep) that proves multiple rapid changes result in fewer emitted events
  • The debounce duration is defined as a constant (easy to tune later)
  • PR description includes before/after mental model ("event storm → single notification")

Learning Goals

  • Event-driven architecture and rate limiting
  • Managing background timer tasks in async Rust (JoinHandle, cancellation)
  • Understanding the difference between "debounce" and "throttle"
  • Making performance improvements that are invisible to calling code (good API design)
  • Writing tests for timing-sensitive asynchronous behavior

Files You Will Modify

  • backend/src/watcher.rs (primary)
  • Possibly backend/src/state.rs if you decide to store pending state in AppState

Out of Scope (future improvements)

  • Different debounce windows per directory type (e.g., longer for Downloads)
  • Sending the set of changed paths instead of just "something happened here"
  • Making the debounce duration configurable by the user

This is a classic "make the system robust under load" issue. It looks small but teaches real production engineering thinking. A junior who ships this will have made the app noticeably lighter when users have busy directories watched.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions