Skip to main content

fff_search/
shared.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
3use std::time::{Duration, Instant};
4
5use crate::dbs::lmdb::{LmdbStore, spawn_lmdb_gc};
6use crate::error::Error;
7use crate::file_picker::FilePicker;
8use crate::frecency::FrecencyTracker;
9use crate::git::GitStatusCache;
10use crate::git_recency;
11use crate::query_tracker::QueryTracker;
12use crate::rescan_stats::{RescanCounters, RescanReason, RescanStats};
13use crate::rescan_throttle::RescanThrottle;
14use crate::scan::ScanJob;
15use crate::watch::{WatchEvent, WatchId, WatchOptions, WatchRegistry};
16use git2::Repository;
17
18/// Poll `.git/index.lock` until it disappears (git write completed), giving up
19/// after [`GIT_LOCK_MAX_WAIT`]. Used by [`SharedPicker::refresh_git_status`]
20/// to avoid reading a half-updated index when the watcher fires mid-`git add`.
21///
22/// The wait is bounded and cheap: the lock file is typically cleared within
23/// a few milliseconds of the git command exiting.
24fn wait_for_git_index_lock_release(git_root: &Path) {
25    const GIT_LOCK_POLL: Duration = Duration::from_millis(10);
26    const GIT_LOCK_MAX_WAIT: Duration = Duration::from_millis(500);
27
28    let lock = git_root.join(".git").join("index.lock");
29    // Fast path: no lock present.
30    if !lock.exists() {
31        return;
32    }
33    let deadline = Instant::now() + GIT_LOCK_MAX_WAIT;
34    while lock.exists() && Instant::now() < deadline {
35        std::thread::sleep(GIT_LOCK_POLL);
36    }
37    if lock.exists() {
38        tracing::warn!(
39            "Proceeding with git status refresh despite lingering \
40             .git/index.lock at {} — will retry once it clears",
41            lock.display()
42        );
43    }
44}
45
46/// Poll `done` every 10ms until it returns `true`, or until `timeout` elapses.
47/// Returns `true` if the condition was met, `false` on timeout.
48fn poll_until(timeout: Duration, mut done: impl FnMut() -> bool) -> bool {
49    let start = Instant::now();
50    while !done() {
51        if start.elapsed() >= timeout {
52            return false;
53        }
54        std::thread::sleep(Duration::from_millis(10));
55    }
56    true
57}
58
59/// Thread-safe shared handle to the [`FilePicker`] instance.
60/// This accumulates only asynchronous non-blocking operations against the
61/// file picker: creating, triggering various rescans and so on.
62///
63/// For blocking access use internal picker via `.read()` or `.write()`
64///
65/// ```ignore
66/// let shared_picker = SharedFilePicker::default();
67///
68/// if let Some(picker) = shared_picker.read()?.as_ref() {
69///     let files = picker.fuzzy_search(&query, options);
70///     println!("Found {} files", files.len());
71/// } else {
72///     println!("Picker not initialized");
73/// }
74/// ```
75#[derive(Clone, Default)]
76pub struct SharedFilePicker(pub(crate) Arc<SharedPickerInner>);
77
78pub struct SharedPickerInner {
79    picker: parking_lot::RwLock<Option<FilePicker>>,
80    /// Watch subscriptions live outside the picker lock so delivery and
81    /// (un)subscribing never contend with searches.
82    watchers: Arc<WatchRegistry>,
83    rescans: RescanCounters,
84    rescan_throttle: RescanThrottle,
85}
86
87impl Default for SharedPickerInner {
88    fn default() -> Self {
89        Self {
90            picker: parking_lot::RwLock::new(None),
91            watchers: Arc::new(WatchRegistry::default()),
92            rescans: RescanCounters::default(),
93            rescan_throttle: RescanThrottle::default(),
94        }
95    }
96}
97
98/// Non-owning handle to a [`SharedPicker`].
99#[derive(Clone)]
100pub(crate) struct WeakFilePicker(Weak<SharedPickerInner>);
101
102impl WeakFilePicker {
103    /// Try to promote the weak handle back to a strong [`SharedPicker`].
104    ///
105    /// Returns `None` once every strong `SharedPicker` clone has been
106    /// dropped. Callers should treat that as "the picker is being
107    /// torn down" and exit their current iteration cleanly.
108    pub(crate) fn upgrade(&self) -> Option<SharedFilePicker> {
109        self.0.upgrade().map(SharedFilePicker)
110    }
111}
112
113impl std::fmt::Debug for SharedFilePicker {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_tuple("SharedPicker").field(&"..").finish()
116    }
117}
118
119impl SharedFilePicker {
120    pub fn read(&self) -> Result<parking_lot::RwLockReadGuard<'_, Option<FilePicker>>, Error> {
121        Ok(self.0.picker.read())
122    }
123
124    pub fn write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, Option<FilePicker>>, Error> {
125        Ok(self.0.picker.write())
126    }
127
128    /// Signal the background scan to cancel. Non-blocking: post-scan
129    /// threads check this flag and bail out at their next cancellation point.
130    pub fn cancel(&self) {
131        if let Ok(guard) = self.read()
132            && let Some(picker) = guard.as_ref()
133        {
134            picker.cancel();
135        }
136    }
137
138    /// Produce a non-owning handle to the same inner picker.
139    /// Use it if you don't need to block internal threads from dropping while owning this ref
140    pub(crate) fn weaken(&self) -> WeakFilePicker {
141        WeakFilePicker(Arc::downgrade(&self.0))
142    }
143
144    /// Return `true` if this is an instance of the picker that requires a complicated post-scan
145    /// indexing/cache warmup job. The indexing is not crazy but it takes time.
146    pub fn need_complex_rebuild(&self) -> bool {
147        let guard = self.0.picker.read();
148        guard
149            .as_ref()
150            .is_some_and(|p| p.has_mmap_cache() || p.has_content_indexing())
151    }
152
153    /// Block until the background filesystem scan finishes.
154    /// Returns `true` if scan completed, `false` on timeout.
155    pub fn wait_for_scan(&self, timeout: Duration) -> bool {
156        let signal = {
157            let guard = self.0.picker.read();
158            match &*guard {
159                Some(picker) => Arc::clone(&picker.signals.scanning),
160                None => return true,
161            }
162        };
163
164        poll_until(timeout, || {
165            !signal.load(std::sync::atomic::Ordering::Acquire)
166        })
167    }
168
169    /// Block until the background file watcher is ready.
170    /// Returns `true` if watcher ready, `false` on timeout.
171    pub fn wait_for_watcher(&self, timeout: Duration) -> bool {
172        let watch_ready_signal = {
173            let guard = self.0.picker.read();
174            match &*guard {
175                Some(picker) => Arc::clone(&picker.signals.watcher_ready),
176                None => return true,
177            }
178        };
179
180        poll_until(timeout, || {
181            watch_ready_signal.load(std::sync::atomic::Ordering::Acquire)
182        })
183    }
184
185    /// Blocks until both the filesystem walk and post-scan indexing are done.
186    /// Returns true once scanning=false AND post_scan_indexing_active=false.
187    pub fn wait_for_indexing_complete(&self, timeout: Duration) -> bool {
188        let (scanning, post_scan_active) = {
189            let guard = self.0.picker.read();
190            match &*guard {
191                Some(picker) => (
192                    Arc::clone(&picker.signals.scanning),
193                    Arc::clone(&picker.signals.post_scan_indexing_active),
194                ),
195                None => return true,
196            }
197        };
198
199        poll_until(timeout, || {
200            !scanning.load(std::sync::atomic::Ordering::Acquire)
201                && !post_scan_active.load(std::sync::atomic::Ordering::Acquire)
202        })
203    }
204
205    /// Trigger a full filesystem rescan without blocking the caller.
206    /// Performs a safe async rescan. Guarantees only single active rescan per picker.
207    /// If many rescans requested the last one guaranteed to be finished.
208    pub fn trigger_full_rescan_async(&self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
209        self.trigger_full_rescan_with_reason(shared_frecency, RescanReason::Explicit)
210            .map(|_| ())
211    }
212
213    /// Returns admitted and throttled rescan requests by reason.
214    /// Counters start at picker creation or the last reset.
215    pub fn rescan_stats(&self) -> RescanStats {
216        self.0.rescans.snapshot()
217    }
218
219    pub fn reset_rescan_stats(&self) {
220        self.0.rescans.reset();
221    }
222
223    /// Returns `Ok(true)` when a rescan was started (or queued behind an
224    /// active scan) and `Ok(false)` when the request was throttled — the
225    /// caller must then fall back to incremental event processing.
226    pub(crate) fn trigger_full_rescan_with_reason(
227        &self,
228        shared_frecency: &SharedFrecency,
229        reason: RescanReason,
230    ) -> Result<bool, Error> {
231        // for giant folders we have no other choice other than throttling rescans
232        // if user is running application in millions of files with a ton of rescan events
233        // we drop / throttle some of requests to avoid constant burst of IO
234        if reason == RescanReason::Explicit {
235            self.0.rescan_throttle.note_explicit_scan();
236        } else if !self.check_rescan_throttle(reason) {
237            return Ok(false);
238        }
239
240        self.0.rescans.record(reason);
241
242        match ScanJob::new_rescan(self, shared_frecency)? {
243            Some(job) => {
244                job.spawn();
245            }
246            None => {
247                // we can not abort the ongoing sync, but if the events
248                if let Ok(guard) = self.read()
249                    && let Some(picker) = guard.as_ref()
250                {
251                    picker
252                        .scan_signals()
253                        .rescan_pending
254                        .store(true, std::sync::atomic::Ordering::Release);
255                    tracing::info!(
256                        "Full rescan requested while another scan is active — \
257                         deferred via rescan_pending flag"
258                    );
259                }
260            }
261        }
262        Ok(true)
263    }
264
265    fn check_rescan_throttle(&self, reason: RescanReason) -> bool {
266        let (live_files, has_git) = self
267            .read()
268            .ok()
269            .and_then(|guard| {
270                guard
271                    .as_ref()
272                    .map(|picker| (picker.live_file_count(), picker.has_git_repo()))
273            })
274            .unwrap_or((0, false));
275
276        if self.0.rescan_throttle.admit(live_files, has_git) {
277            return true;
278        }
279
280        self.0.rescans.record_throttled(reason);
281        tracing::debug!(%reason, live_files, "Rescan throttled, skipping");
282        false
283    }
284
285    /// Subscribe to filesystem changes matching `pattern`.
286    ///
287    /// Patterns may be base-relative globs (./ works), exact paths inside the indexed
288    /// tree, or existing directories. An empty pattern watches the whole tree.
289    ///
290    /// Events are debounced over a 50-ms window and submitted in batches of at most 128 events.
291    /// Gitignored and other ignored files are never triggering watcher.
292    pub fn watch(
293        &self,
294        pattern: &str,
295        options: WatchOptions,
296        callback: impl Fn(WatchId, &[WatchEvent]) + Send + Sync + 'static,
297    ) -> Result<WatchId, Error> {
298        let (base_path, has_watcher, watcher_ready) = {
299            let guard = self.read()?;
300            let picker = guard.as_ref().ok_or(Error::FilePickerMissing)?;
301
302            (
303                picker.base_path().to_path_buf(),
304                picker.has_watcher(),
305                picker.is_watcher_ready(),
306            )
307        };
308
309        if !has_watcher {
310            return Err(Error::WatcherDisabled);
311        }
312        if !watcher_ready {
313            return Err(Error::WatcherNotReady);
314        }
315
316        self.0
317            .watchers
318            .subscribe(&base_path, pattern, options, Box::new(callback))
319    }
320
321    /// Remove a watch subscription. Returns `true` if the id was active.
322    pub fn unwatch(&self, id: WatchId) -> bool {
323        self.0.watchers.unsubscribe(id)
324    }
325
326    /// Return whether a watch subscription is active.
327    pub fn is_watch_active(&self, id: WatchId) -> bool {
328        self.0.watchers.contains(id)
329    }
330
331    /// Remove every subscription without waiting for an executing callback.
332    pub fn shutdown_watches(&self) {
333        self.0.watchers.shutdown();
334    }
335
336    /// Remove every subscription and wait for an executing callback.
337    /// When called by that callback, it does not wait on itself.
338    pub fn shutdown_watches_and_wait(&self) {
339        self.0.watchers.shutdown_and_wait();
340    }
341
342    pub(crate) fn rebase_watches(&self, base_path: &Path) {
343        self.0.watchers.rebase(base_path);
344    }
345
346    pub(crate) fn watch_registry(&self) -> &Arc<WatchRegistry> {
347        &self.0.watchers
348    }
349
350    /// Refresh git statuses for all indexed files
351    #[tracing::instrument(level = "info", skip_all)]
352    pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result<usize, Error> {
353        let (git_root, recency_config, base_path, picker_id) = {
354            // we do the libgit2 off lock cause it might take quite some time on very large repos
355            let guard = self.read()?;
356            let Some(ref picker) = *guard else {
357                return Err(Error::FilePickerMissing);
358            };
359            (
360                picker.git_root().map(|p| p.to_path_buf()),
361                picker.git_recency_config(),
362                picker.base_path().to_path_buf(),
363                picker.trace_id().to_owned(),
364            )
365        };
366
367        let repo = git_root.as_deref().and_then(|root| {
368            wait_for_git_index_lock_release(root);
369            Repository::open(root)
370                .inspect_err(|e| tracing::error!(?e, "Failed to open repo for git refresh"))
371                .ok()
372        });
373
374        let git_status = repo.as_ref().and_then(|repo| {
375            GitStatusCache::read_status(repo, &mut crate::git::default_status_options())
376                .inspect_err(|e| tracing::error!(?e, "Failed to read git status"))
377                .ok()
378        });
379
380        let recency = repo
381            .as_ref()
382            .and_then(|repo| git_recency::compute_git_recency(repo, &recency_config, &base_path));
383
384        let mut guard = self.write()?;
385        let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
386
387        // ensure consistency
388        if picker.trace_id() != picker_id {
389            return Ok(0);
390        }
391
392        let statuses_count = if let Some(git_status) = git_status {
393            let count = git_status.statuses_len();
394            picker.update_git_statuses(git_status, shared_frecency)?;
395            count
396        } else {
397            0
398        };
399
400        picker.apply_git_recency(recency.as_ref());
401
402        Ok(statuses_count)
403    }
404
405    /// Recompute and apply git status for a specific set of paths.
406    pub fn update_git_status_for_paths(
407        &self,
408        paths: &[PathBuf],
409        shared_frecency: &SharedFrecency,
410    ) -> Result<(), Error> {
411        if paths.is_empty() {
412            return Ok(());
413        }
414
415        let git_root = {
416            let guard = self.read()?;
417            let Some(ref picker) = *guard else {
418                return Err(Error::FilePickerMissing);
419            };
420            picker.git_root().map(|p| p.to_path_buf())
421        };
422        let Some(git_root) = git_root else {
423            return Ok(());
424        };
425
426        wait_for_git_index_lock_release(&git_root);
427
428        let repo = Repository::open(&git_root)?;
429        let status = GitStatusCache::git_status_for_paths(&repo, paths)?;
430
431        let mut guard = self.write()?;
432        let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
433        picker.update_git_statuses(status, shared_frecency)
434    }
435}
436
437/// Thread-safe shared handle to an LMDB-backed store. A disabled (`noop`)
438/// instance silently ignores writes. See the [`SharedFrecency`] and
439/// [`SharedQueryTracker`] aliases.
440///
441/// `LmdbStore` is intentionally crate-private, so the store type is sealed:
442/// only `FrecencyTracker` / `QueryTracker` can ever instantiate this.
443#[allow(private_bounds)]
444pub struct SharedDb<T: LmdbStore> {
445    inner: Arc<RwLock<Option<T>>>,
446    enabled: bool,
447}
448
449// Hand-written to avoid a spurious `T: Clone` bound — `Arc` is always `Clone`.
450impl<T: LmdbStore> Clone for SharedDb<T> {
451    fn clone(&self) -> Self {
452        Self {
453            inner: self.inner.clone(),
454            enabled: self.enabled,
455        }
456    }
457}
458
459impl<T: LmdbStore> Default for SharedDb<T> {
460    fn default() -> Self {
461        Self {
462            inner: Arc::new(RwLock::new(None)),
463            enabled: true,
464        }
465    }
466}
467
468impl<T: LmdbStore> std::fmt::Debug for SharedDb<T> {
469    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470        f.debug_tuple("SharedDb").field(&T::LABEL).finish()
471    }
472}
473
474#[allow(private_bounds)]
475impl<T: LmdbStore> SharedDb<T> {
476    /// Creates a disabled instance that silently ignores all writes.
477    pub fn noop() -> Self {
478        Self {
479            inner: Arc::new(RwLock::new(None)),
480            enabled: false,
481        }
482    }
483
484    pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<T>>, Error> {
485        self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
486    }
487
488    pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<T>>, Error> {
489        self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
490    }
491
492    /// Initialize the store + spawn GC in the background. No-op when disabled.
493    pub fn init(&self, tracker: T) -> Result<(), Error> {
494        if !self.enabled {
495            return Ok(());
496        }
497
498        {
499            let mut guard = self.write()?;
500            *guard = Some(tracker);
501        }
502
503        // GC holds a read guard on this lock, so destroy / re-init wait won't race
504        spawn_lmdb_gc(self.inner.clone());
505        Ok(())
506    }
507
508    /// Drop the in-memory tracker and delete the on-disk database directory.
509    ///
510    /// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no tracker was initialized.
511    pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
512        let mut guard = self.write()?;
513        let Some(tracker) = guard.take() else {
514            return Ok(None);
515        };
516
517        let closing_event = match tracker.shared_env().destroy() {
518            Ok(closing) => closing,
519            Err(e) => {
520                *guard = Some(tracker);
521                return Err(e);
522            }
523        };
524
525        let db_path = tracker.env().path().to_path_buf();
526        // Drop closes the LMDB env and unmaps the files
527        drop(tracker);
528        drop(guard);
529
530        // Deleting before mdb_env_close finishes would race the unmap.
531        if let Some(event) = closing_event {
532            event.wait_timeout(Duration::from_secs(5));
533        }
534
535        std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
536            path: db_path.clone(),
537            source,
538        })?;
539        Ok(Some(db_path))
540    }
541}
542
543/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
544pub type SharedFrecency = SharedDb<FrecencyTracker>;
545
546/// Thread-safe shared handle to the [`QueryTracker`] instance.
547pub type SharedQueryTracker = SharedDb<QueryTracker>;