Skip to content

Commit dac9bd0

Browse files
diegorvclaude
andcommitted
fix(watcher): flush debounce on real-event deadline so a hidden-dir stream cannot starve index updates
Context: The native vault watcher (`run_debounce_loop`) accumulates real file changes into a buffer and emits a single `vault-files-changed` event per debounced burst. The notify callback forwards EVERY raw path into the channel; the hidden-dir filter (`is_inside_hidden_dir`) is applied inside the loop. The 500 ms debounce was implemented as `recv_timeout(debounce)` with the buffer flush living only in the `Timeout` arm. Problem: `recv_timeout` restarts a fresh window on every call and returns `Ok` the instant ANY event arrives, including hidden-dir events that are dropped without touching the buffer or `last_event`. So a hidden-dir stream arriving faster than 500 ms kept the loop in the `Ok` arm indefinitely, the `Timeout` arm never ran, and a buffered real edit was never emitted until the stream paused for a full window (or the sender disconnected on vault switch/teardown). Realistic triggers, some self-inflicted: a nested `.git` working copy doing background churn (documented in this file's own audit comments), a sync client touching `.dropbox.cache`/`.stfolder`, or our own `{vault}/.kokobrain/kokobrain.db` WAL writes during a semantic indexing run. Effect: user saves a note but backlinks/tags/tasks/ properties indexes (all driven off `vault-files-changed`) stay stale until the noise quiets. No panic, no data loss, self-heals. The existing test `debounce_hidden_events_do_not_delay_real_files` only sent a bounded 20-event burst then dropped the sender, so it passed via the `Disconnected` final flush and never modeled a sustained stream that outlives the assertion. False confidence. Solution: Shrink the recv timeout to the remaining deadline measured from the LAST REAL event (`debounce.saturating_sub(last_event.elapsed())`), and run the flush check after EVERY wakeup instead of only in the `Timeout` arm. With an empty buffer the loop blocks a full window (nothing pending, and this paces the stop check) which also guards against a busy-spin: flushing empties the buffer, so the next iteration blocks again. A real event resets `last_event` and so extends the window, matching the prior TS debounce shape. The `Timeout` arm is now a no-op; `Disconnected` keeps its final flush. Behavior: A buffered real change now emits ~500 ms after the last real edit regardless of how many hidden-dir events keep waking the loop. Hidden-dir events never extend the deadline and never delay the emit. Index updates no longer stall behind background hidden-dir churn. Files: - src-tauri/src/vault/watcher.rs:138-207 — rewrote `run_debounce_loop`: per-iteration `wait` deadline, flush check moved out of the `Timeout` arm to after the match, empty-buffer full-window block (busy-spin guard), `Timeout` arm now empty. - src-tauri/src/vault/watcher.rs:611-680 — added regression test `debounce_sustained_hidden_stream_does_not_starve_real_file`: a pump thread streams hidden-dir events every 100 ms and keeps the sender alive past the assertion, so the emit cannot come from `Disconnected`. Fails against the pre-fix loop (times out), passes after. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de53fe9 commit dac9bd0

1 file changed

Lines changed: 102 additions & 14 deletions

File tree

src-tauri/src/vault/watcher.rs

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -146,26 +146,36 @@ pub fn run_debounce_loop<F>(
146146
return;
147147
}
148148

149-
match event_rx.recv_timeout(debounce) {
149+
// Wait only until the debounce window measured from the LAST REAL
150+
// event elapses. With an empty buffer there is nothing pending, so
151+
// block a full window (this also paces the stop check above).
152+
//
153+
// Audit 2026-06-01 (P1): `recv_timeout(debounce)` restarts a fresh
154+
// window on every call and returns `Ok` the instant ANY event
155+
// arrives — including hidden-dir events that are dropped below
156+
// without touching the buffer. The flush used to live only in the
157+
// `Timeout` arm, so a hidden-dir stream arriving faster than the
158+
// debounce window (a nested `.git`, a sync client, or our own
159+
// `.kokobrain/` DB-WAL writes during indexing) kept the loop in the
160+
// `Ok` arm forever, the `Timeout` arm never ran, and a buffered
161+
// real edit was never emitted until the stream paused for a full
162+
// window. Shrinking the wait to the remaining deadline (and
163+
// flushing after EVERY wakeup, below) makes a buffered change emit
164+
// on schedule regardless of ongoing hidden noise.
165+
let wait = if buffer.is_empty() {
166+
debounce
167+
} else {
168+
debounce.saturating_sub(last_event.elapsed())
169+
};
170+
171+
match event_rx.recv_timeout(wait) {
150172
Ok(path) => {
151173
if !is_inside_hidden_dir(&path, &vault_prefix) {
152174
buffer.insert(path);
153175
last_event = Instant::now();
154176
}
155177
}
156-
Err(mpsc::RecvTimeoutError::Timeout) => {
157-
// No event in `DEBOUNCE_MS`. Emit if we have a non-empty
158-
// buffer AND the last event is at least one debounce
159-
// window old (defends against a single late event
160-
// extending the burst forever — match TS shape).
161-
if !buffer.is_empty() && last_event.elapsed() >= debounce {
162-
let raw: Vec<String> = buffer.drain().collect();
163-
let filtered = filter_ancestor_paths(&raw);
164-
if !filtered.is_empty() {
165-
on_emit(filtered);
166-
}
167-
}
168-
}
178+
Err(mpsc::RecvTimeoutError::Timeout) => {}
169179
Err(mpsc::RecvTimeoutError::Disconnected) => {
170180
// Watcher dropped. Final flush, then exit.
171181
if !buffer.is_empty() {
@@ -178,6 +188,22 @@ pub fn run_debounce_loop<F>(
178188
return;
179189
}
180190
}
191+
192+
// Emit once the debounce window since the last real event has
193+
// elapsed. Checked after EVERY wakeup (real event, dropped hidden
194+
// event, or timeout) so a buffered change flushes on schedule even
195+
// while filtered events keep waking the loop early. A real event
196+
// resets `last_event` and so extends the window (matches the TS
197+
// shape — a single late real edit folds into the same burst).
198+
// Flushing empties the buffer, so the next iteration blocks a full
199+
// window again — no busy-spin.
200+
if !buffer.is_empty() && last_event.elapsed() >= debounce {
201+
let raw: Vec<String> = buffer.drain().collect();
202+
let filtered = filter_ancestor_paths(&raw);
203+
if !filtered.is_empty() {
204+
on_emit(filtered);
205+
}
206+
}
181207
}
182208
}
183209

@@ -639,6 +665,68 @@ mod tests {
639665
handle.join().unwrap();
640666
}
641667

668+
#[test]
669+
fn debounce_sustained_hidden_stream_does_not_starve_real_file() {
670+
// Regression (Audit 2026-06-01, P1): a hidden-dir event stream
671+
// arriving faster than the debounce window used to keep
672+
// `recv_timeout` returning `Ok` before it ever timed out, so the
673+
// only flush path (the old `Timeout` arm) never ran and a buffered
674+
// real edit was never emitted until the stream stopped. A nested
675+
// `.git`, a sync client, or our own `.kokobrain/` DB-WAL writes
676+
// during indexing reproduce this. The loop must now flush on a
677+
// deadline measured from the last REAL event, regardless of the
678+
// ongoing hidden noise.
679+
//
680+
// Unlike `debounce_hidden_events_do_not_delay_real_files`, the
681+
// sender here is kept ALIVE past the assertion (a background pump
682+
// thread keeps streaming), so the emit cannot come from the
683+
// `Disconnected` final flush — it must come from the debounce
684+
// deadline. Against the pre-fix loop this test times out and fails.
685+
let (event_tx, event_rx) = mpsc::channel::<String>();
686+
let (stop_tx, stop_rx) = mpsc::channel::<()>();
687+
let (emit_tx, emit_rx) = mpsc::channel::<Vec<String>>();
688+
689+
let handle = thread::spawn(move || {
690+
run_debounce_loop(event_rx, stop_rx, "/v/".to_string(), move |paths| {
691+
let _ = emit_tx.send(paths);
692+
});
693+
});
694+
695+
// Real edit enters the buffer first.
696+
event_tx.send("/v/note.md".to_string()).unwrap();
697+
698+
// Pump hidden-dir events every 100 ms (< 500 ms debounce) WITHOUT
699+
// ever stopping or dropping the sender — models a `.git`/sync/
700+
// DB-WAL churn loop. Stops when the loop's receiver goes away.
701+
let pump_tx = event_tx.clone();
702+
let pump = thread::spawn(move || {
703+
for i in 0..30 {
704+
if pump_tx
705+
.send(format!("/v/.kokobrain/kokobrain.db-wal-{}", i))
706+
.is_err()
707+
{
708+
break;
709+
}
710+
thread::sleep(Duration::from_millis(100));
711+
}
712+
});
713+
714+
// With the bug this times out (the real file is starved). Fixed:
715+
// `note.md` flushes ~500 ms after it entered while hidden events
716+
// are still streaming.
717+
let emitted = emit_rx
718+
.recv_timeout(Duration::from_secs(2))
719+
.expect("real file must emit while a hidden stream is still active");
720+
assert!(emitted.contains(&"/v/note.md".to_string()));
721+
722+
// Cleanup: stop the loop, drop our sender; the pump exits once the
723+
// loop's receiver is gone (its sends start failing).
724+
let _ = stop_tx.send(());
725+
drop(event_tx);
726+
pump.join().unwrap();
727+
handle.join().unwrap();
728+
}
729+
642730
// ---------- payload serialization ----------
643731

644732
#[test]

0 commit comments

Comments
 (0)