@@ -82,7 +82,7 @@ impl BackgroundWatcher {
8282 let ( watch_tx, watch_rx) = mpsc:: channel :: < PathBuf > ( ) ;
8383 let watch_tx_for_debouncer = watch_tx. clone ( ) ;
8484
85- let owner_weak_picker = shared_picker. downgrade ( ) ;
85+ let owner_weak_picker = shared_picker. weaken ( ) ;
8686 let owner_frecency = shared_frecency. clone ( ) ;
8787 let owner_git_workdir = git_workdir. clone ( ) ;
8888
@@ -100,6 +100,9 @@ impl BackgroundWatcher {
100100
101101 // debouncer is shared with the owner thread, once it's dropped the thread is closed
102102 let debouncer = Arc :: new ( Mutex :: new ( Some ( debouncer) ) ) ;
103+ // Only the Linux per-dir-watch branch needs this clone; on other
104+ // platforms the owner thread never touches the debouncer.
105+ #[ cfg( target_os = "linux" ) ]
103106 let owner_debouncer = Arc :: clone ( & debouncer) ;
104107
105108 let owner_thread = std:: thread:: Builder :: new ( )
@@ -111,16 +114,40 @@ impl BackgroundWatcher {
111114 break ;
112115 } ;
113116
114- if let Some ( debouncer) = owner_debouncer. lock ( ) . as_mut ( ) {
115- process_watch_request (
116- debouncer,
117- & dir,
118- & strong_picker,
119- & owner_frecency,
120- & owner_git_workdir,
121- ) ;
117+ // Only inotify (Linux) has no kernel-level recursion, so
118+ // it's the only platform that needs a per-subdir watch to
119+ // be registered at runtime. macOS FSEvents and Windows
120+ // ReadDirectoryChangesW are already watching recursively
121+ // from the base path (see `create_debouncer`), and
122+ // registering a second overlapping stream there produces
123+ // duplicate/out-of-order events.
124+ #[ cfg( target_os = "linux" ) ]
125+ {
126+ // Register the new directory with the debouncer, then
127+ // drop the mutex BEFORE doing picker-side work — see
128+ // the comment on `BackgroundWatcher::stop` for the
129+ // lock-ordering rationale.
130+ let mut guard = owner_debouncer. lock ( ) ;
131+ let Some ( debouncer) = guard. as_mut ( ) else {
132+ break ;
133+ } ;
134+
135+ if let Err ( e) = debouncer. watch ( & dir, RecursiveMode :: NonRecursive ) {
136+ warn ! (
137+ ?e,
138+ dir = %dir. display( ) ,
139+ "Failed to init watcher for new directory"
140+ ) ;
141+ }
122142 }
123143
144+ track_files_from_new_directories (
145+ & dir,
146+ & strong_picker,
147+ & owner_frecency,
148+ & owner_git_workdir,
149+ ) ;
150+
124151 // Transient strong ref drops here, back
125152 // to weak-only before the next `recv()`.
126153 }
@@ -165,7 +192,7 @@ impl BackgroundWatcher {
165192 // Capture a weak handle instead and upgrade per-batch.
166193 let git_workdir_for_handler = git_workdir. clone ( ) ;
167194 let shared_picker_for_watching = shared_picker. clone ( ) ;
168- let event_picker = shared_picker. downgrade ( ) ;
195+ let event_picker = shared_picker. weaken ( ) ;
169196 let mut debouncer = new_debouncer_opt (
170197 DEBOUNCE_TIMEOUT ,
171198 Some ( DEBOUNCE_TIMEOUT / 2 ) , // tick rate for the event span
@@ -313,10 +340,30 @@ impl BackgroundWatcher {
313340 Ok ( debouncer)
314341 }
315342
343+ /// Signal the watcher to shut down without blocking on its worker
344+ /// threads. Safe to call from any context, including while holding
345+ /// the [`SharedFilePicker`] write lock.
346+ ///
347+ /// Both the debouncer's internal event loop and our owner thread
348+ /// may call `SharedFilePicker::write()` inside their handlers. A
349+ /// blocking join here would deadlock against a caller that already
350+ /// holds that lock (e.g. `stop_background_monitor` under a
351+ /// `shared_picker.write()` guard). Instead we:
352+ ///
353+ /// * drop the `watch_tx` Sender — the owner thread's
354+ /// `watch_rx.recv()` returns `Err` and the thread exits at
355+ /// its next `recv`.
356+ /// * call `debouncer.stop_nonblocking()` — signals the debouncer
357+ /// event loop to exit on its next tick and drops the watcher,
358+ /// closing the FSEvent / inotify / ReadDirectoryChangesW stream.
359+ /// * detach both `JoinHandle`s.
360+ ///
361+ /// In-flight handler invocations finish on their own (at most one
362+ /// more batch) once the caller releases any locks they hold.
316363 pub fn stop ( & mut self ) {
317364 self . watch_tx . take ( ) ;
318365 if let Some ( debouncer) = self . debouncer . lock ( ) . take ( ) {
319- debouncer. stop ( ) ;
366+ debouncer. stop_nonblocking ( ) ;
320367 }
321368
322369 self . owner_thread . take ( ) ;
@@ -346,29 +393,6 @@ impl Drop for BackgroundWatcher {
346393 }
347394}
348395
349- /// Handle a single `watch_rx` entry: register a non-recursive watch on
350- /// the directory and inject any files that already exist inside it.
351- ///
352- /// Extracted so both the `recv_timeout` wakeup branch and the inner
353- /// `try_recv` drain loop in the owner thread share one implementation.
354- fn process_watch_request (
355- debouncer : & mut Debouncer ,
356- dir : & Path ,
357- shared_picker : & SharedFilePicker ,
358- shared_frecency : & SharedFrecency ,
359- git_workdir : & Option < PathBuf > ,
360- ) {
361- // macos uses a single watcher per file directory which is
362- if !cfg ! ( target_os = "macos" ) {
363- match debouncer. watch ( dir, RecursiveMode :: NonRecursive ) {
364- Ok ( ( ) ) => debug ! ( "Added watch for new directory: {}" , dir. display( ) ) ,
365- Err ( e) => warn ! ( "Failed to watch new directory {}: {}" , dir. display( ) , e) ,
366- }
367- }
368-
369- track_files_from_new_directories ( dir, shared_picker, shared_frecency, git_workdir) ;
370- }
371-
372396#[ tracing:: instrument( name = "fs_events" , skip( events, shared_picker, shared_frecency) , level = Level :: DEBUG ) ]
373397fn handle_debounced_events (
374398 events : Vec < DebouncedEvent > ,
0 commit comments