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