fix: Reduce amount of rescans in giant /Users/neogoose like folders (#751)
This commit is contained in:
committed by
GitHub
parent
3a0ce85c54
commit
031005e227
@@ -14,7 +14,7 @@ SHELL := bash
|
||||
# string rather than the literal `-o` / `pipefail` tokens.
|
||||
.SHELLFLAGS := -o pipefail -euc
|
||||
|
||||
.PHONY: build build-c-lib install uninstall test test-rust test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-regressions test-stress-repos test-node-stress sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile
|
||||
.PHONY: build build-c-lib install uninstall test test-rust test-rescan test-rescan-known-defects rescan-probe test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-regressions test-stress-repos test-node-stress sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile
|
||||
|
||||
all: format test lint
|
||||
|
||||
@@ -92,6 +92,25 @@ test-setup:
|
||||
test-rust:
|
||||
cargo test --workspace --no-default-features --features zlob --exclude fff-nvim
|
||||
|
||||
# Watcher rescan harness: asserts that editing, build output, git activity and
|
||||
# preview reads all stay on the incremental path instead of re-walking the tree.
|
||||
test-rescan:
|
||||
cargo test -p fff-search --no-default-features --features zlob \
|
||||
--lib --test rescan_regression -- rescan
|
||||
|
||||
# Live probe for watcher rescan requests and their causes.
|
||||
# Usage: make rescan-probe DIR=~/some/repo [SECONDS=120]
|
||||
rescan-probe:
|
||||
cargo run --release -p fff-nvim --bin rescan_probe \
|
||||
--no-default-features --features zlob,rescan-stats -- \
|
||||
$(or $(DIR),.) $(if $(SECONDS),--seconds $(SECONDS),)
|
||||
|
||||
# The same harness, restricted to cases that currently fail on purpose. Each
|
||||
# `#[ignore]` reason names the code that causes the unnecessary rescan.
|
||||
test-rescan-known-defects:
|
||||
cargo test --no-fail-fast -p fff-search --no-default-features --features zlob \
|
||||
--lib --test rescan_regression -- --ignored --nocapture
|
||||
|
||||
CC ?= cc
|
||||
CFLAGS ?= -O0 -g -Wall -Wextra -std=c99
|
||||
TARGET_DIR ?= target/release
|
||||
|
||||
@@ -41,6 +41,9 @@ harness = false
|
||||
default = ["ripgrep"]
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Count full rescans and their causes. Always on in debug builds; enable this
|
||||
# to keep the accounting in a release build (used by the rescan_probe binary).
|
||||
rescan-stats = []
|
||||
# Enables POC definition classification for grep result matched lines
|
||||
definitions = []
|
||||
# Pure-Rust filesystem walker + glob matcher (ignore + globset crates).
|
||||
|
||||
@@ -3,6 +3,15 @@ fn main() {
|
||||
// used by tests/fuzz_git_watcher_stress.rs
|
||||
println!("cargo::rustc-check-cfg=cfg(stress)");
|
||||
|
||||
// Full-rescan accounting. Debug builds get it for free; a release build has
|
||||
// to opt in with `--features rescan-stats` (what the rescan_probe needs).
|
||||
println!("cargo::rustc-check-cfg=cfg(rescan_stats)");
|
||||
if std::env::var("DEBUG").is_ok_and(|debug| debug != "false")
|
||||
|| std::env::var("CARGO_FEATURE_RESCAN_STATS").is_ok()
|
||||
{
|
||||
println!("cargo::rustc-cfg=rescan_stats");
|
||||
}
|
||||
|
||||
// When the `zlob` feature is enabled (Zig-compiled C library):
|
||||
// On Windows MSVC, explicitly link the C runtime libraries.
|
||||
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
|
||||
|
||||
@@ -12,16 +12,25 @@ pub const MAX_INDEXABLE_FILE_SIZE: usize = 2 * 1024 * 1024;
|
||||
pub const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Files below one page waste the remainder when mmapped, so the cache skips
|
||||
/// them and falls back to chunked reads. Unused on Windows (no content cache).
|
||||
/// them and falls back to chunked reads. Unused on Windows (no content cache)
|
||||
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
|
||||
pub const MMAP_THRESHOLD: u64 = 16 * 1024;
|
||||
#[cfg(all(not(target_os = "windows"), not(target_arch = "aarch64")))]
|
||||
pub const MMAP_THRESHOLD: u64 = 4 * 1024;
|
||||
|
||||
/// Capacity reserved for files the watcher discovers after the initial scan;
|
||||
/// exceeding it forces a full rescan.
|
||||
/// Watcher overflow capacity reserved after the initial scan
|
||||
pub const MAX_OVERFLOW_FILES: usize = 1024;
|
||||
|
||||
/// Minimum delay between watcher-initiated rescans.
|
||||
pub const RESCAN_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Rescan delay for large indexes.
|
||||
pub const RESCAN_MIN_INTERVAL_LARGE_INDEX: std::time::Duration =
|
||||
std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// Live-file count at which [`RESCAN_MIN_INTERVAL_LARGE_INDEX`] takes over.
|
||||
pub const LARGE_INDEX_FILE_COUNT: usize = 1_000_000;
|
||||
|
||||
/// Fresh-mmap threshold: files at or above this size get mmapped directly on
|
||||
/// cache miss instead of chunked reads into Vec. Empirically tuned per-platform.
|
||||
/// Only referenced on Unix; Windows uses the `std::fs::read` fallback so this
|
||||
|
||||
@@ -629,6 +629,10 @@ impl FilePicker {
|
||||
&self.base_path
|
||||
}
|
||||
|
||||
pub fn has_git_repo(&self) -> bool {
|
||||
self.sync_data.git_workdir.is_some()
|
||||
}
|
||||
|
||||
/// Ignore rules the walker assembled during the last scan (zlob backend
|
||||
/// only). The background watcher uses these to filter events without
|
||||
/// libgit2. `None` when the backend doesn't surface rules or no ignore
|
||||
|
||||
@@ -3,28 +3,53 @@ use std::path::Path;
|
||||
/// Directories excluded when walking a non-git root. Entries are `cfg`-gated
|
||||
/// so a single iteration covers standard + platform-specific overrides.
|
||||
pub(crate) const IGNORED_DIRS: &[&str] = &[
|
||||
// various dev tools that can be meet in the developer app
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
"venv",
|
||||
".venv",
|
||||
// Rust (glob-only patterns for non_git_repo_overrides; is_non_code_directory
|
||||
// matches the "target" component separately).
|
||||
"target/debug",
|
||||
"target/release",
|
||||
"target/rust-analyzer",
|
||||
"target/criterion",
|
||||
// Language package caches in non-git roots.
|
||||
"go/pkg/mod",
|
||||
".cargo/registry",
|
||||
".rustup/toolchains",
|
||||
".gradle/caches",
|
||||
".m2/repository",
|
||||
".npm/_cacache",
|
||||
".pub-cache",
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
".local/state", // this contains tons of logs which generate too much watcher noise
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Application Support",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Caches",
|
||||
// App-group sandbox storage — used by iMessage, Photos, Notes, Calendar,
|
||||
// Electron apps, etc. for SQLite-WAL, LevelDB, protobuf files. These are
|
||||
// almost entirely extension-less binary files (~80k on a typical $HOME)
|
||||
// that never need to appear in a fuzzy or grep search.
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Group Containers",
|
||||
"Library/Containers", // sandboxed apps data
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Containers",
|
||||
"Library/Group Containers", // random application data and networking
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/pnpm",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Metadata",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Developer/CoreSimulator",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Android",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Logs",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Daemon Containers",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Trial",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Preferences",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/Messages",
|
||||
#[cfg(target_os = "macos")]
|
||||
"Library/IdentityServices",
|
||||
#[cfg(target_os = "windows")]
|
||||
"bin/Debug",
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -57,6 +82,10 @@ pub(crate) fn non_git_repo_overrides(base_path: &Path) -> Option<ignore::overrid
|
||||
pub(crate) fn is_non_code_directory(path: &Path) -> bool {
|
||||
let path_str = path.as_os_str().to_str().unwrap_or("");
|
||||
IGNORED_DIRS.iter().any(|&dir| {
|
||||
// Entries are gitignore patterns for the walkers; here they are matched
|
||||
// as substrings, so a leading `*` wildcard has to come off first.
|
||||
let dir = dir.strip_prefix('*').unwrap_or(dir);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let dir = dir.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -66,3 +95,42 @@ pub(crate) fn is_non_code_directory(path: &Path) -> bool {
|
||||
path_str.contains(dir)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn home_machine_state_is_excluded_but_source_trees_are_not() {
|
||||
// Representative machine state from a home index.
|
||||
for rel in [
|
||||
"Library/pnpm/store/v3/files/00/abcdef",
|
||||
"Library/Preferences/com.apple.finder.plist",
|
||||
"Library/Messages/prewarm.db-shm",
|
||||
"Library/IdentityServices/TetraDB-identityservicesd.db-wal",
|
||||
"Library/Developer/CoreSimulator/Devices/X/data/f",
|
||||
"go/pkg/mod/github.com/x/y@v1/main.go",
|
||||
".cargo/registry/src/index.crates.io-1/serde-1.0/src/lib.rs",
|
||||
"Library/Android/sdk/platforms/android-34/data/x",
|
||||
".local/state/nvim/fff+123+456.log",
|
||||
] {
|
||||
assert!(
|
||||
is_non_code_directory(Path::new(rel)),
|
||||
"{rel} must not reach the index"
|
||||
);
|
||||
}
|
||||
|
||||
// Source trees under $HOME stay searchable.
|
||||
for rel in [
|
||||
"dev/chromium/third_party/blink/renderer/core/dom/node.cc",
|
||||
"dev/fff.nvim/crates/fff-core/src/lib.rs",
|
||||
"Documents/notes/todo.md",
|
||||
"dev/myproj/pkg/mod/thing.go",
|
||||
] {
|
||||
assert!(
|
||||
!is_non_code_directory(Path::new(rel)),
|
||||
"{rel} must stay searchable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,12 @@ pub use types::*;
|
||||
|
||||
pub mod constants;
|
||||
|
||||
/// Watcher rescan request accounting.
|
||||
pub mod rescan_stats;
|
||||
pub use rescan_stats::{RESCAN_STATS_ENABLED, RescanReason, RescanStats};
|
||||
|
||||
mod rescan_throttle;
|
||||
|
||||
// ==================================
|
||||
// these are public only for benchmarks, no backward compatibility guaranteed
|
||||
#[doc(hidden)]
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#[cfg(rescan_stats)]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Whether rescan accounting is compiled in.
|
||||
pub const RESCAN_STATS_ENABLED: bool = cfg!(rescan_stats);
|
||||
|
||||
/// Cause recorded for a filesystem rescan request.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum RescanReason {
|
||||
/// Requested through the public API (refresh, directory change).
|
||||
Explicit,
|
||||
/// The kernel dropped events and asked us to re-read the subtree.
|
||||
KernelEventLoss,
|
||||
/// A `.gitignore`/`.ignore` changed, so the cached ignore rules are stale.
|
||||
IgnoreFileChanged,
|
||||
/// A single debounce batch touched more paths than we apply incrementally.
|
||||
EventBatchOverflow,
|
||||
/// The picker refused an incremental insert/update.
|
||||
IndexUpdateRejected,
|
||||
/// The post-scan overflow region ran out of slots.
|
||||
OverflowCapacity,
|
||||
}
|
||||
|
||||
impl RescanReason {
|
||||
pub const ALL: [RescanReason; 6] = [
|
||||
RescanReason::Explicit,
|
||||
RescanReason::KernelEventLoss,
|
||||
RescanReason::IgnoreFileChanged,
|
||||
RescanReason::EventBatchOverflow,
|
||||
RescanReason::IndexUpdateRejected,
|
||||
RescanReason::OverflowCapacity,
|
||||
];
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RescanReason::Explicit => "explicit",
|
||||
RescanReason::KernelEventLoss => "kernel_event_loss",
|
||||
RescanReason::IgnoreFileChanged => "ignore_file_changed",
|
||||
RescanReason::EventBatchOverflow => "event_batch_overflow",
|
||||
RescanReason::IndexUpdateRejected => "index_update_rejected",
|
||||
RescanReason::OverflowCapacity => "overflow_capacity",
|
||||
}
|
||||
}
|
||||
|
||||
const fn slot(self) -> usize {
|
||||
match self {
|
||||
RescanReason::Explicit => 0,
|
||||
RescanReason::KernelEventLoss => 1,
|
||||
RescanReason::IgnoreFileChanged => 2,
|
||||
RescanReason::EventBatchOverflow => 3,
|
||||
RescanReason::IndexUpdateRejected => 4,
|
||||
RescanReason::OverflowCapacity => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RescanReason {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of rescan requests grouped by reason.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RescanStats {
|
||||
pub total: usize,
|
||||
/// Requests suppressed during the cooldown.
|
||||
pub throttled: usize,
|
||||
counts: [usize; RescanReason::ALL.len()],
|
||||
throttled_counts: [usize; RescanReason::ALL.len()],
|
||||
}
|
||||
|
||||
impl RescanStats {
|
||||
pub fn count(&self, reason: RescanReason) -> usize {
|
||||
self.counts[reason.slot()]
|
||||
}
|
||||
|
||||
pub fn count_throttled(&self, reason: RescanReason) -> usize {
|
||||
self.throttled_counts[reason.slot()]
|
||||
}
|
||||
|
||||
/// Admitted requests originating from watcher fallbacks.
|
||||
pub fn watcher_triggered(&self) -> usize {
|
||||
self.total - self.count(RescanReason::Explicit)
|
||||
}
|
||||
|
||||
/// Per-reason delta against an earlier snapshot.
|
||||
pub fn since(&self, earlier: &RescanStats) -> RescanStats {
|
||||
let mut counts = [0usize; RescanReason::ALL.len()];
|
||||
let mut throttled_counts = [0usize; RescanReason::ALL.len()];
|
||||
for slot in 0..RescanReason::ALL.len() {
|
||||
counts[slot] = self.counts[slot].saturating_sub(earlier.counts[slot]);
|
||||
throttled_counts[slot] =
|
||||
self.throttled_counts[slot].saturating_sub(earlier.throttled_counts[slot]);
|
||||
}
|
||||
|
||||
RescanStats {
|
||||
total: self.total.saturating_sub(earlier.total),
|
||||
throttled: self.throttled.saturating_sub(earlier.throttled),
|
||||
counts,
|
||||
throttled_counts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RescanStats {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} rescan(s)", self.total)?;
|
||||
let mut first = true;
|
||||
for reason in RescanReason::ALL {
|
||||
let count = self.count(reason);
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
f.write_str(if first { " [" } else { ", " })?;
|
||||
write!(f, "{reason}={count}")?;
|
||||
first = false;
|
||||
}
|
||||
if !first {
|
||||
f.write_str("]")?;
|
||||
}
|
||||
if self.throttled > 0 {
|
||||
write!(f, ", {} throttled", self.throttled)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(rescan_stats)]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RescanCounters {
|
||||
counters: [AtomicUsize; RescanReason::ALL.len()],
|
||||
throttled: [AtomicUsize; RescanReason::ALL.len()],
|
||||
}
|
||||
|
||||
#[cfg(rescan_stats)]
|
||||
impl RescanCounters {
|
||||
pub(crate) fn record(&self, reason: RescanReason) {
|
||||
self.counters[reason.slot()].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn record_throttled(&self, reason: RescanReason) {
|
||||
self.throttled[reason.slot()].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> RescanStats {
|
||||
let mut stats = RescanStats::default();
|
||||
for reason in RescanReason::ALL {
|
||||
let count = self.counters[reason.slot()].load(Ordering::Relaxed);
|
||||
stats.counts[reason.slot()] = count;
|
||||
stats.total += count;
|
||||
|
||||
let throttled = self.throttled[reason.slot()].load(Ordering::Relaxed);
|
||||
stats.throttled_counts[reason.slot()] = throttled;
|
||||
stats.throttled += throttled;
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&self) {
|
||||
for counter in self.counters.iter().chain(self.throttled.iter()) {
|
||||
counter.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release builds retain the API without counter storage.
|
||||
#[cfg(not(rescan_stats))]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RescanCounters;
|
||||
|
||||
#[cfg(not(rescan_stats))]
|
||||
impl RescanCounters {
|
||||
pub(crate) fn record(&self, _reason: RescanReason) {}
|
||||
|
||||
pub(crate) fn record_throttled(&self, _reason: RescanReason) {}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> RescanStats {
|
||||
RescanStats::default()
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&self) {}
|
||||
}
|
||||
|
||||
#[cfg(all(test, rescan_stats))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counters_attribute_and_diff_per_reason() {
|
||||
let counters = RescanCounters::default();
|
||||
counters.record(RescanReason::Explicit);
|
||||
let baseline = counters.snapshot();
|
||||
|
||||
counters.record(RescanReason::IgnoreFileChanged);
|
||||
counters.record(RescanReason::IgnoreFileChanged);
|
||||
counters.record(RescanReason::OverflowCapacity);
|
||||
|
||||
let stats = counters.snapshot();
|
||||
assert_eq!(stats.total, 4);
|
||||
assert_eq!(stats.watcher_triggered(), 3);
|
||||
|
||||
let delta = stats.since(&baseline);
|
||||
assert_eq!(delta.total, 3);
|
||||
assert_eq!(delta.count(RescanReason::Explicit), 0);
|
||||
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 2);
|
||||
assert_eq!(
|
||||
delta.to_string(),
|
||||
"3 rescan(s) [ignore_file_changed=2, overflow_capacity=1]"
|
||||
);
|
||||
|
||||
counters.reset();
|
||||
assert_eq!(counters.snapshot(), RescanStats::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::constants::{
|
||||
LARGE_INDEX_FILE_COUNT, RESCAN_MIN_INTERVAL, RESCAN_MIN_INTERVAL_LARGE_INDEX,
|
||||
};
|
||||
|
||||
const NEVER: u64 = u64::MAX;
|
||||
|
||||
// Drops watcher rescan requests inside the cooldown after the last scan.
|
||||
// A slightly stale index is fine: the next admitted event rescans everything.
|
||||
pub(crate) struct RescanThrottle {
|
||||
epoch: Instant,
|
||||
last_admitted: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for RescanThrottle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
epoch: Instant::now(),
|
||||
last_admitted: AtomicU64::new(NEVER),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RescanThrottle {
|
||||
/// Returns `true` if a rescan may start now and records it as the last scan
|
||||
pub(crate) fn admit(&self, live_files: usize, has_git_repo: bool) -> bool {
|
||||
let min_interval = if !has_git_repo && live_files >= LARGE_INDEX_FILE_COUNT {
|
||||
RESCAN_MIN_INTERVAL_LARGE_INDEX
|
||||
} else {
|
||||
RESCAN_MIN_INTERVAL
|
||||
};
|
||||
|
||||
let min_ms = min_interval.as_millis() as u64;
|
||||
let now = self.elapsed_ms();
|
||||
|
||||
loop {
|
||||
let last = self.last_admitted.load(Ordering::Acquire);
|
||||
if last != NEVER && now.saturating_sub(last) < min_ms {
|
||||
return false;
|
||||
}
|
||||
// CAS so two concurrent requests cannot both start a walk.
|
||||
if self
|
||||
.last_admitted
|
||||
.compare_exchange(last, now, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an explicit (unthrottled) scan so watcher requests right after
|
||||
/// it are dropped: the index is already fresh.
|
||||
pub(crate) fn note_explicit_scan(&self) {
|
||||
self.last_admitted
|
||||
.store(self.elapsed_ms(), Ordering::Release);
|
||||
}
|
||||
|
||||
fn elapsed_ms(&self) -> u64 {
|
||||
self.epoch.elapsed().as_millis() as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn throttle_at(ms_ago: u64) -> RescanThrottle {
|
||||
let now = Instant::now();
|
||||
RescanThrottle {
|
||||
epoch: now
|
||||
.checked_sub(Duration::from_millis(ms_ago))
|
||||
.expect("monotonic clock older than the rewind"),
|
||||
last_admitted: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_request_is_always_admitted() {
|
||||
let throttle = RescanThrottle::default();
|
||||
assert!(throttle.admit(100, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_inside_the_cooldown_are_dropped() {
|
||||
let throttle = throttle_at(1_000);
|
||||
assert!(!throttle.admit(100, false));
|
||||
assert!(!throttle.admit(100, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_index_outside_a_git_repo_uses_the_slower_cadence() {
|
||||
// A minute is past the normal cooldown but not the large-index one.
|
||||
let throttle = throttle_at(60_000);
|
||||
assert!(throttle.admit(100, false));
|
||||
|
||||
let throttle = throttle_at(60_000);
|
||||
assert!(!throttle.admit(LARGE_INDEX_FILE_COUNT, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_git_repo_keeps_the_normal_cadence_at_any_size() {
|
||||
let throttle = throttle_at(60_000);
|
||||
assert!(throttle.admit(LARGE_INDEX_FILE_COUNT, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_expiry_admits_again() {
|
||||
let throttle = throttle_at(RESCAN_MIN_INTERVAL.as_millis() as u64 + 1);
|
||||
assert!(throttle.admit(100, false));
|
||||
// Admission rearms the cooldown.
|
||||
assert!(!throttle.admit(100, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_scan_rearms_the_cooldown() {
|
||||
let throttle = RescanThrottle::default();
|
||||
throttle.note_explicit_scan();
|
||||
assert!(!throttle.admit(100, false));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use crate::file_picker::FilePicker;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::query_tracker::QueryTracker;
|
||||
use crate::rescan_stats::{RescanCounters, RescanReason, RescanStats};
|
||||
use crate::rescan_throttle::RescanThrottle;
|
||||
use crate::scan::ScanJob;
|
||||
use crate::watch::{WatchEvent, WatchId, WatchOptions, WatchRegistry};
|
||||
use git2::Repository;
|
||||
@@ -77,6 +79,8 @@ pub struct SharedPickerInner {
|
||||
/// Watch subscriptions live outside the picker lock so delivery and
|
||||
/// (un)subscribing never contend with searches.
|
||||
watchers: Arc<WatchRegistry>,
|
||||
rescans: RescanCounters,
|
||||
rescan_throttle: RescanThrottle,
|
||||
}
|
||||
|
||||
impl Default for SharedPickerInner {
|
||||
@@ -84,6 +88,8 @@ impl Default for SharedPickerInner {
|
||||
Self {
|
||||
picker: parking_lot::RwLock::new(None),
|
||||
watchers: Arc::new(WatchRegistry::default()),
|
||||
rescans: RescanCounters::default(),
|
||||
rescan_throttle: RescanThrottle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +205,39 @@ impl SharedFilePicker {
|
||||
/// Performs a safe async rescan. Guarantees only single active rescan per picker.
|
||||
/// If many rescans requested the last one guaranteed to be finished.
|
||||
pub fn trigger_full_rescan_async(&self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
|
||||
self.trigger_full_rescan_with_reason(shared_frecency, RescanReason::Explicit)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Returns admitted and throttled rescan requests by reason.
|
||||
/// Counters start at picker creation or the last reset.
|
||||
pub fn rescan_stats(&self) -> RescanStats {
|
||||
self.0.rescans.snapshot()
|
||||
}
|
||||
|
||||
pub fn reset_rescan_stats(&self) {
|
||||
self.0.rescans.reset();
|
||||
}
|
||||
|
||||
/// Returns `Ok(true)` when a rescan was started (or queued behind an
|
||||
/// active scan) and `Ok(false)` when the request was throttled — the
|
||||
/// caller must then fall back to incremental event processing.
|
||||
pub(crate) fn trigger_full_rescan_with_reason(
|
||||
&self,
|
||||
shared_frecency: &SharedFrecency,
|
||||
reason: RescanReason,
|
||||
) -> Result<bool, Error> {
|
||||
// for giant folders we have no other choice other than throttling rescans
|
||||
// if user is running application in millions of files with a ton of rescan events
|
||||
// we drop / throttle some of requests to avoid constant burst of IO
|
||||
if reason == RescanReason::Explicit {
|
||||
self.0.rescan_throttle.note_explicit_scan();
|
||||
} else if !self.check_rescan_throttle(reason) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.0.rescans.record(reason);
|
||||
|
||||
match ScanJob::new_rescan(self, shared_frecency)? {
|
||||
Some(job) => {
|
||||
job.spawn();
|
||||
@@ -219,7 +258,27 @@ impl SharedFilePicker {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn check_rescan_throttle(&self, reason: RescanReason) -> bool {
|
||||
let (live_files, has_git) = self
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|picker| (picker.live_file_count(), picker.has_git_repo()))
|
||||
})
|
||||
.unwrap_or((0, false));
|
||||
|
||||
if self.0.rescan_throttle.admit(live_files, has_git) {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.0.rescans.record_throttled(reason);
|
||||
tracing::debug!(%reason, live_files, "Rescan throttled, skipping");
|
||||
false
|
||||
}
|
||||
|
||||
/// Subscribe to filesystem changes matching `pattern`.
|
||||
|
||||
@@ -415,7 +415,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_full_path() {
|
||||
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
|
||||
let (store, strings, _files) = build_test_store(&[
|
||||
"src/components/Button.tsx",
|
||||
"src/components/Button.test.tsx",
|
||||
]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
@@ -423,6 +426,15 @@ mod tests {
|
||||
assert_eq!(cs.read_to_buf(arena, &mut buf), "src/components/Button.tsx");
|
||||
assert_eq!(cs.byte_len, 25);
|
||||
assert_eq!(cs.filename_offset, 15);
|
||||
|
||||
let cs = &strings[1];
|
||||
let mut buf = [0u8; 512];
|
||||
assert_eq!(
|
||||
cs.read_to_buf(arena, &mut buf),
|
||||
"src/components/Button.test.tsx"
|
||||
);
|
||||
assert_eq!(cs.byte_len, 30);
|
||||
assert_eq!(cs.filename_offset, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::constants::MAX_OVERFLOW_FILES;
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FFFMode;
|
||||
use crate::git_status_worker::GitStatusWorker;
|
||||
use crate::rescan_stats::RescanReason;
|
||||
use crate::shared::{SharedFilePicker, SharedFrecency};
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::watch::{RawWatchEvent, WatchEventKind};
|
||||
@@ -324,7 +325,7 @@ impl Drop for BackgroundWatcher {
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "fs_events", skip(events, shared_picker, shared_frecency, git_status_worker), level = Level::DEBUG)]
|
||||
fn handle_debounced_events(
|
||||
pub(crate) fn handle_debounced_events(
|
||||
mode: FFFMode,
|
||||
events: Vec<DebouncedEvent>,
|
||||
base_path: &Path,
|
||||
@@ -342,8 +343,8 @@ fn handle_debounced_events(
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().and_then(|p| p.ignore_rules()));
|
||||
let filter = IgnoreFilter::new(base_path, walker_rules, repo.as_ref());
|
||||
let mut need_full_rescan = false;
|
||||
let mut need_full_git_rescan = false;
|
||||
let mut batch_overflow_attempted = false;
|
||||
let mut paths_to_remove = Vec::new();
|
||||
let mut dirs_to_remove: Vec<PathBuf> = Vec::new();
|
||||
let mut paths_to_add_or_modify = Vec::new();
|
||||
@@ -353,6 +354,21 @@ fn handle_debounced_events(
|
||||
let watch_registry = shared_picker.watch_registry();
|
||||
let need_events_propagation = watch_registry.is_active();
|
||||
|
||||
let try_trigger_full_rescan = |reason: RescanReason| -> bool {
|
||||
match shared_picker.trigger_full_rescan_with_reason(shared_frecency, reason) {
|
||||
Ok(true) => {
|
||||
warn!(%reason, "Triggering full rescan");
|
||||
watch_registry.dispatch_rescan(base_path);
|
||||
true
|
||||
}
|
||||
Ok(false) => false,
|
||||
Err(e) => {
|
||||
error!(%reason, "Failed to trigger full rescan: {:?}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for debounced_event in &events {
|
||||
// It is very important to not react to the access errors because we inevitably
|
||||
// gonna trigger the sync by our own preview or other unnecessary noise
|
||||
@@ -370,22 +386,19 @@ fn handle_debounced_events(
|
||||
// When macOS FSEvents (or other backends) overflow their event buffer, the kernel
|
||||
// drops individual events and emits a rescan flag telling us to re-scan the subtree
|
||||
if debounced_event.event.need_rescan() {
|
||||
if debounced_event.event.paths.len() < 16 // this should be usually one event
|
||||
let small_and_known = debounced_event.event.paths.len() < 16 // this should be usually one event
|
||||
&& debounced_event
|
||||
.paths
|
||||
.iter()
|
||||
// but we are smart enough and not falling into the paths
|
||||
.all(|p| !p.is_dir() && !filter.is_ignored(p))
|
||||
{
|
||||
break;
|
||||
.all(|p| !p.is_dir() && !filter.is_ignored(p));
|
||||
|
||||
if !small_and_known && try_trigger_full_rescan(RescanReason::KernelEventLoss) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
warn!(
|
||||
"Received rescan event for paths {:?}, triggering full rescan",
|
||||
debounced_event.event.paths
|
||||
);
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
// Small batches and throttled rescans fall through: the listed
|
||||
// paths are still applied incrementally below.
|
||||
}
|
||||
|
||||
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
|
||||
@@ -394,13 +407,24 @@ fn handle_debounced_events(
|
||||
path.file_name().and_then(|f| f.to_str()),
|
||||
Some(".ignore") | Some(".gitignore")
|
||||
) {
|
||||
if path
|
||||
.parent()
|
||||
.is_some_and(|parent| filter.is_ignored(parent))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Detected change in ignore definition file: {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
if try_trigger_full_rescan(RescanReason::IgnoreFileChanged) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Throttled: fall through so the ignore file itself stays
|
||||
// indexed; the stale rules heal on the next admitted rescan.
|
||||
}
|
||||
|
||||
if is_dotgit_change_affecting_status(path, &repo) {
|
||||
@@ -462,29 +486,18 @@ fn handle_debounced_events(
|
||||
}
|
||||
|
||||
affected_paths_count += debounced_event.event.paths.len();
|
||||
if affected_paths_count > MAX_OVERFLOW_FILES {
|
||||
if !batch_overflow_attempted && affected_paths_count > MAX_OVERFLOW_FILES * 4 {
|
||||
batch_overflow_attempted = true;
|
||||
warn!(
|
||||
?affected_paths_count,
|
||||
max = MAX_OVERFLOW_FILES,
|
||||
max = MAX_OVERFLOW_FILES * 4,
|
||||
"Too many affected paths in a single batch, triggering full rescan",
|
||||
);
|
||||
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
if try_trigger_full_rescan(RescanReason::EventBatchOverflow) {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
if need_full_rescan {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if need_full_rescan {
|
||||
info!(?affected_paths_count, "Triggering full rescan");
|
||||
watch_registry.dispatch_rescan(base_path);
|
||||
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// It's important to get the allocated sort
|
||||
@@ -511,7 +524,7 @@ fn handle_debounced_events(
|
||||
}
|
||||
|
||||
let mut files_to_update_git_status = Vec::new();
|
||||
let mut need_full_rescan = false;
|
||||
let mut index_update_rejected = false;
|
||||
let mut overflow_count = 0;
|
||||
let mut removed_from_dirs = Vec::new();
|
||||
let mut watch_events = ahash::AHashMap::new();
|
||||
@@ -572,6 +585,13 @@ fn handle_debounced_events(
|
||||
|
||||
files_to_update_git_status.reserve(paths_to_add_or_modify.len());
|
||||
for path in &paths_to_add_or_modify {
|
||||
if picker.get_overflow_files().len() >= MAX_OVERFLOW_FILES
|
||||
&& picker.get_file_by_path(path).is_none()
|
||||
{
|
||||
index_update_rejected = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let existed = need_events_propagation && picker.get_file_by_path(path).is_some();
|
||||
|
||||
if picker.handle_create_or_modify(path).is_some() {
|
||||
@@ -586,7 +606,7 @@ fn handle_debounced_events(
|
||||
watch_events.insert(path.to_path_buf(), kind);
|
||||
}
|
||||
} else {
|
||||
need_full_rescan = true;
|
||||
index_update_rejected = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,13 +618,22 @@ fn handle_debounced_events(
|
||||
overflow_count, "File index changes applied",
|
||||
);
|
||||
|
||||
if need_full_rescan || overflow_count > MAX_OVERFLOW_FILES {
|
||||
info!("Watcher faced limit of index overflow. Triggering rescan");
|
||||
watch_registry.dispatch_rescan(base_path);
|
||||
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
}
|
||||
} else if need_events_propagation {
|
||||
let rescan_started = if index_update_rejected || overflow_count > MAX_OVERFLOW_FILES {
|
||||
let reason = if index_update_rejected {
|
||||
RescanReason::IndexUpdateRejected
|
||||
} else {
|
||||
RescanReason::OverflowCapacity
|
||||
};
|
||||
|
||||
info!(%reason, "Watcher faced limit of index overflow. Triggering rescan");
|
||||
try_trigger_full_rescan(reason)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// When the rescan is throttled the incrementally applied changes are
|
||||
// still the freshest state we have — propagate them to subscribers.
|
||||
if !rescan_started && need_events_propagation {
|
||||
watch_registry.dispatch(
|
||||
base_path,
|
||||
watch_events
|
||||
@@ -664,7 +693,7 @@ fn handle_debounced_events(
|
||||
|
||||
// do not try to update the paths if we anyway going to rescan everything from scratch
|
||||
// no repo => no consumer thread, so don't accumulate paths nobody will drain
|
||||
if !need_full_rescan && repo.is_some() {
|
||||
if !index_update_rejected && repo.is_some() {
|
||||
if need_full_git_rescan {
|
||||
// A full git rescan re-reads every tracked path (including ones that just
|
||||
// went clean after a commit), so it already subsumes the per-path update.
|
||||
|
||||
@@ -3,3 +3,7 @@ pub use background_watcher::*;
|
||||
|
||||
mod watch;
|
||||
pub use watch::*;
|
||||
|
||||
// The harness reads rescan counters, which release builds compile out.
|
||||
#[cfg(all(test, rescan_stats))]
|
||||
mod rescan_tests;
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use notify::Event;
|
||||
use notify::EventKind;
|
||||
use notify::event::{
|
||||
AccessKind, AccessMode, CreateKind, DataChange, Flag, ModifyKind, RemoveKind, RenameMode,
|
||||
};
|
||||
use notify_debouncer_full::DebouncedEvent;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::handle_debounced_events;
|
||||
use crate::constants::MAX_OVERFLOW_FILES;
|
||||
use crate::file_picker::{FFFMode, FilePicker, FilePickerOptions};
|
||||
use crate::git_status_worker::GitStatusWorker;
|
||||
use crate::rescan_stats::{RescanReason, RescanStats};
|
||||
use crate::shared::{SharedFilePicker, SharedFrecency};
|
||||
|
||||
#[test]
|
||||
fn saving_an_indexed_file_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write("src/main.rs", "fn main() { println!(); }");
|
||||
let delta = f.feed([modify(f.path("src/main.rs"))]);
|
||||
|
||||
f.assert_no_rescan(&delta, "saving a tracked file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_atomic_save_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// write-to-temp + rename-over-target, the way vim/VSCode/IntelliJ save.
|
||||
f.write("src/main.rs", "fn main() { println!(); }");
|
||||
let target = f.path("src/main.rs");
|
||||
let temp = f.path("src/.main.rs.swp");
|
||||
let delta = f.feed([
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Create(CreateKind::File)).add_path(temp.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::From)))
|
||||
.add_path(temp.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To)))
|
||||
.add_path(target.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Remove(RemoveKind::File)).add_path(temp),
|
||||
Instant::now(),
|
||||
),
|
||||
]);
|
||||
|
||||
f.assert_no_rescan(&delta, "an atomic editor save");
|
||||
assert!(f.is_indexed("src/main.rs"), "target must stay indexed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_and_deleting_files_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write("src/added.rs", "pub fn added() {}");
|
||||
let created = f.feed([create(f.path("src/added.rs"))]);
|
||||
f.assert_no_rescan(&created, "creating a file");
|
||||
assert!(f.is_indexed("src/added.rs"));
|
||||
|
||||
f.remove("src/added.rs");
|
||||
let removed = f.feed([remove_file(f.path("src/added.rs"))]);
|
||||
f.assert_no_rescan(&removed, "deleting a file");
|
||||
assert!(!f.is_indexed("src/added.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_directory_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.write("src/nested/a.rs", "");
|
||||
f.write("src/nested/b.rs", "");
|
||||
f.index();
|
||||
|
||||
std::fs::remove_dir_all(f.path("src/nested")).unwrap();
|
||||
let delta = f.feed([DebouncedEvent::new(
|
||||
Event::new(EventKind::Remove(RemoveKind::Folder)).add_path(f.path("src/nested")),
|
||||
Instant::now(),
|
||||
)]);
|
||||
|
||||
f.assert_no_rescan(&delta, "deleting a directory");
|
||||
assert!(!f.is_indexed("src/nested/a.rs"));
|
||||
assert!(f.is_indexed("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_access_events_are_ignored() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// fff's own preview + grep reads generate these; reacting to them would
|
||||
// make the picker rescan whenever the user scrolls the result list.
|
||||
let path = f.path("src/main.rs");
|
||||
let delta = f.feed([
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Access(AccessKind::Read)).add_path(path.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Access(AccessKind::Open(AccessMode::Read)))
|
||||
.add_path(path.clone()),
|
||||
Instant::now(),
|
||||
),
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Access(AccessKind::Close(AccessMode::Read))).add_path(path),
|
||||
Instant::now(),
|
||||
),
|
||||
]);
|
||||
|
||||
f.assert_no_rescan(&delta, "read-only access events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreating_the_same_paths_does_not_consume_overflow_capacity() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// Recreated paths must reuse their overflow slots.
|
||||
for _ in 0..8 {
|
||||
for i in 0..200 {
|
||||
let rel = format!("gen/out{i}.rs");
|
||||
f.write(&rel, "generated");
|
||||
f.feed([create(f.path(&rel))]);
|
||||
}
|
||||
for i in 0..200 {
|
||||
let rel = format!("gen/out{i}.rs");
|
||||
f.remove(&rel);
|
||||
f.feed([remove_file(f.path(&rel))]);
|
||||
}
|
||||
}
|
||||
|
||||
let delta = f.all_rescans();
|
||||
f.assert_no_rescan(&delta, "1600 create/delete cycles over 200 stable paths");
|
||||
assert!(
|
||||
f.overflow_len() <= 200,
|
||||
"each path must claim one overflow slot at most, got {}",
|
||||
f.overflow_len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_inside_a_gitignored_directory_stay_incremental() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\nnode_modules/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let mut events = Vec::new();
|
||||
for i in 0..64 {
|
||||
let rel = format!("target/debug/artifact{i}.o");
|
||||
f.write(&rel, "binary");
|
||||
events.push(create(f.path(&rel)));
|
||||
}
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "build output written into an ignored directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_event_batch_above_index_capacity_stays_incremental() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "node_modules/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let events = (0..MAX_OVERFLOW_FILES + 1)
|
||||
.map(|i| {
|
||||
let rel = format!("node_modules/pkg/file{i}.js");
|
||||
f.write(&rel, "");
|
||||
create(f.path(&rel))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "ignored events above the index capacity");
|
||||
assert_eq!(f.overflow_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_edits_above_index_capacity_stay_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let path = f.path("src/main.rs");
|
||||
let events = (0..MAX_OVERFLOW_FILES + 1)
|
||||
.map(|_| modify(path.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "repeated edits above the index capacity");
|
||||
assert_eq!(f.overflow_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_file_inside_an_ignored_directory_stays_incremental() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "node_modules/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let ignore_files =
|
||||
["left-pad", "lodash", "typescript"].map(|pkg| format!("node_modules/{pkg}/.gitignore"));
|
||||
|
||||
for rel in &ignore_files {
|
||||
f.write(rel, "dist\n");
|
||||
}
|
||||
let delta = f.feed(ignore_files.iter().map(|rel| create(f.path(rel))));
|
||||
f.assert_no_rescan(&delta, "creating ignored .gitignore files");
|
||||
|
||||
for rel in &ignore_files {
|
||||
f.write(rel, "build\n");
|
||||
}
|
||||
let delta = f.feed(ignore_files.iter().map(|rel| modify(f.path(rel))));
|
||||
f.assert_no_rescan(&delta, "modifying ignored .gitignore files");
|
||||
|
||||
for rel in &ignore_files {
|
||||
f.remove(rel);
|
||||
}
|
||||
let delta = f.feed(ignore_files.iter().map(|rel| remove_file(f.path(rel))));
|
||||
f.assert_no_rescan(&delta, "removing ignored .gitignore files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_file_inside_an_indexed_directory_triggers_a_rescan() {
|
||||
let f = Fixture::with_git();
|
||||
f.write("src/.gitignore", ".gitignore\ngenerated/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write("src/.gitignore", ".gitignore\ngenerated/\nbuild/\n");
|
||||
let delta = f.feed([modify(f.path("src/.gitignore"))]);
|
||||
|
||||
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_internal_churn_stays_incremental() {
|
||||
let f = Fixture::with_git();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let git_dir = f.path(".git");
|
||||
let delta = f.feed([
|
||||
create(git_dir.join("index.lock")),
|
||||
modify(git_dir.join("index")),
|
||||
remove_file(git_dir.join("index.lock")),
|
||||
modify(git_dir.join("HEAD")),
|
||||
modify(git_dir.join("logs/HEAD")),
|
||||
modify(git_dir.join("COMMIT_EDITMSG")),
|
||||
modify(git_dir.join("refs/heads/main")),
|
||||
]);
|
||||
|
||||
f.assert_no_rescan(&delta, "git writing its own metadata");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_root_ignore_file_triggers_a_rescan() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write(".gitignore", "target/\nsrc/\n");
|
||||
let delta = f.feed([modify(f.path(".gitignore"))]);
|
||||
|
||||
assert_eq!(
|
||||
delta.count(RescanReason::IgnoreFileChanged),
|
||||
1,
|
||||
"the indexed set depends on the root ignore rules, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_event_loss_on_a_directory_triggers_a_rescan() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let delta = f.feed([DebouncedEvent::new(
|
||||
Event::new(EventKind::Modify(ModifyKind::Any))
|
||||
.add_path(f.path("src"))
|
||||
.set_flag(Flag::Rescan),
|
||||
Instant::now(),
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
delta.count(RescanReason::KernelEventLoss),
|
||||
1,
|
||||
"a dropped-events flag over a directory means unknown subtree state, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_above_index_capacity_trigger_a_rescan() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let events = (0..MAX_OVERFLOW_FILES + 1)
|
||||
.map(|i| {
|
||||
let rel = format!("src/bulk{i}.rs");
|
||||
f.write(&rel, "");
|
||||
create(f.path(&rel))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
assert_eq!(
|
||||
delta.count(RescanReason::IndexUpdateRejected),
|
||||
1,
|
||||
"new files above the overflow region cannot be applied incrementally, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_at_the_overflow_boundary_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let events = (0..MAX_OVERFLOW_FILES)
|
||||
.map(|i| {
|
||||
let rel = format!("src/bulk{i}.rs");
|
||||
f.write(&rel, "");
|
||||
create(f.path(&rel))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "a batch exactly at the overflow limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_batch_at_four_times_index_capacity_stays_incremental() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let path = f.path("src/main.rs");
|
||||
let events = (0..MAX_OVERFLOW_FILES * 4)
|
||||
.map(|_| modify(path.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
f.assert_no_rescan(&delta, "an event batch exactly at the event limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_batch_above_four_times_index_capacity_triggers_a_rescan() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
let path = f.path("src/main.rs");
|
||||
let events = (0..MAX_OVERFLOW_FILES * 4 + 1)
|
||||
.map(|_| modify(path.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let delta = f.feed(events);
|
||||
assert_eq!(
|
||||
delta.count(RescanReason::EventBatchOverflow),
|
||||
1,
|
||||
"an event batch above four times the index capacity must rescan, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_triggers_inside_the_cooldown_collapse_to_one_rescan() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// Repeated batches during the cooldown must share one walk.
|
||||
for round in 0..50 {
|
||||
f.write(".gitignore", &format!("target/\n# round {round}\n"));
|
||||
f.feed([modify(f.path(".gitignore"))]);
|
||||
}
|
||||
|
||||
let stats = f.all_rescans();
|
||||
assert_eq!(
|
||||
stats.total, 1,
|
||||
"50 triggers inside the cooldown must collapse to a single walk, got {stats}"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.throttled, 49,
|
||||
"every suppressed request must be accounted for, got {stats}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_request_is_never_throttled() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// Burn the cooldown with a watcher trigger, then confirm a user-initiated
|
||||
// refresh still goes through.
|
||||
f.write(".gitignore", "target/\nsrc/\n");
|
||||
f.feed([modify(f.path(".gitignore"))]);
|
||||
|
||||
for _ in 0..3 {
|
||||
f.picker.trigger_full_rescan_async(&f.frecency).unwrap();
|
||||
}
|
||||
|
||||
let stats = f.all_rescans();
|
||||
assert_eq!(
|
||||
stats.count(RescanReason::Explicit),
|
||||
3,
|
||||
"explicit refreshes must bypass the throttle, got {stats}"
|
||||
);
|
||||
assert_eq!(stats.count_throttled(RescanReason::Explicit), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_after_a_suppressed_kernel_rescan_are_still_applied() {
|
||||
let f = Fixture::new();
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
f.write("src/added.rs", "pub fn added() {}");
|
||||
let delta = f.feed([
|
||||
DebouncedEvent::new(
|
||||
Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content)))
|
||||
.add_path(f.path("src/main.rs"))
|
||||
.set_flag(Flag::Rescan),
|
||||
Instant::now(),
|
||||
),
|
||||
create(f.path("src/added.rs")),
|
||||
]);
|
||||
|
||||
f.assert_no_rescan(&delta, "a dropped-events flag over a single tracked file");
|
||||
assert!(
|
||||
f.is_indexed("src/added.rs"),
|
||||
"suppressing the rescan must not drop the rest of the batch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_throttled_ignore_file_event_is_still_applied_incrementally() {
|
||||
let f = Fixture::with_git();
|
||||
f.write(".gitignore", "target/\n");
|
||||
f.write("src/main.rs", "fn main() {}");
|
||||
f.index();
|
||||
|
||||
// Burn the cooldown: deleting .gitignore admits a full rescan.
|
||||
f.remove(".gitignore");
|
||||
let delta = f.feed([remove_file(f.path(".gitignore"))]);
|
||||
assert_eq!(delta.count(RescanReason::IgnoreFileChanged), 1);
|
||||
f.picker.wait_for_indexing_complete(Duration::from_secs(10));
|
||||
|
||||
// Recreating it inside the cooldown throttles the rescan, but the file
|
||||
// itself must re-enter the index via the incremental fallback.
|
||||
f.write(".gitignore", "target/\n__ignored_x/\n");
|
||||
let delta = f.feed([create(f.path(".gitignore"))]);
|
||||
assert_eq!(delta.total, 0, "the rescan must be throttled, got {delta}");
|
||||
assert_eq!(delta.count_throttled(RescanReason::IgnoreFileChanged), 1);
|
||||
assert!(
|
||||
f.is_indexed(".gitignore"),
|
||||
"a throttled ignore-file event must still index the file itself"
|
||||
);
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
base: PathBuf,
|
||||
picker: SharedFilePicker,
|
||||
frecency: SharedFrecency,
|
||||
git_workdir: Option<PathBuf>,
|
||||
git_worker: Arc<GitStatusWorker>,
|
||||
// Dropped last so background work started by a triggered rescan still
|
||||
// sees the tree it was asked to walk.
|
||||
_tmp: TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
Self::build(false)
|
||||
}
|
||||
|
||||
fn with_git() -> Self {
|
||||
Self::build(true)
|
||||
}
|
||||
|
||||
fn build(git: bool) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = crate::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
let git_workdir = git.then(|| {
|
||||
let status = Command::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.current_dir(&base)
|
||||
.output()
|
||||
.expect("git init");
|
||||
assert!(status.status.success(), "git init failed");
|
||||
base.clone()
|
||||
});
|
||||
|
||||
Self {
|
||||
base,
|
||||
picker: SharedFilePicker::default(),
|
||||
frecency: SharedFrecency::noop(),
|
||||
git_workdir,
|
||||
git_worker: GitStatusWorker::new(),
|
||||
_tmp: tmp,
|
||||
}
|
||||
}
|
||||
|
||||
fn index(&self) {
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: self.base.to_string_lossy().into_owned(),
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
picker.collect_files().unwrap();
|
||||
self.picker.rebase_watches(&self.base);
|
||||
*self.picker.write().unwrap() = Some(picker);
|
||||
}
|
||||
|
||||
fn feed(&self, events: impl IntoIterator<Item = DebouncedEvent>) -> RescanStats {
|
||||
let before = self.picker.rescan_stats();
|
||||
handle_debounced_events(
|
||||
FFFMode::Neovim,
|
||||
events.into_iter().collect(),
|
||||
&self.base,
|
||||
&self.git_workdir,
|
||||
&self.picker,
|
||||
&self.frecency,
|
||||
&self.git_worker,
|
||||
);
|
||||
|
||||
self.picker.rescan_stats().since(&before)
|
||||
}
|
||||
|
||||
fn assert_no_rescan(&self, delta: &RescanStats, what: &str) {
|
||||
assert_eq!(delta.total, 0, "{what} must not trigger a rescan: {delta}");
|
||||
}
|
||||
|
||||
fn path(&self, rel: &str) -> PathBuf {
|
||||
self.base.join(rel)
|
||||
}
|
||||
|
||||
fn write(&self, rel: &str, contents: &str) {
|
||||
let path = self.path(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
fn remove(&self, rel: &str) {
|
||||
std::fs::remove_file(self.path(rel)).unwrap();
|
||||
}
|
||||
|
||||
fn is_indexed(&self, rel: &str) -> bool {
|
||||
let guard = self.picker.read().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.and_then(|p| p.get_file_by_path(self.path(rel)))
|
||||
.is_some_and(|file| !file.is_deleted())
|
||||
}
|
||||
|
||||
fn all_rescans(&self) -> RescanStats {
|
||||
self.picker.rescan_stats()
|
||||
}
|
||||
|
||||
fn overflow_len(&self) -> usize {
|
||||
let guard = self.picker.read().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|p| p.get_overflow_files().len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
// A test that intentionally triggers a rescan leaves a walk running on
|
||||
// the background pool; let it finish before the tree disappears.
|
||||
self.picker
|
||||
.wait_for_indexing_complete(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn event(kind: EventKind, path: PathBuf) -> DebouncedEvent {
|
||||
DebouncedEvent::new(Event::new(kind).add_path(path), Instant::now())
|
||||
}
|
||||
|
||||
fn create(path: PathBuf) -> DebouncedEvent {
|
||||
event(EventKind::Create(CreateKind::File), path)
|
||||
}
|
||||
|
||||
fn modify(path: PathBuf) -> DebouncedEvent {
|
||||
event(
|
||||
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
fn remove_file(path: PathBuf) -> DebouncedEvent {
|
||||
event(EventKind::Remove(RemoveKind::File), path)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
#![cfg(rescan_stats)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{FilePickerOptions, RescanStats, SharedFilePicker, SharedFrecency};
|
||||
use tempfile::TempDir;
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(600);
|
||||
|
||||
#[test]
|
||||
fn saving_source_files_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
for i in 0..20 {
|
||||
write(base, &format!("src/mod{i}.rs"), "pub fn f() {}");
|
||||
}
|
||||
});
|
||||
|
||||
for round in 0..10 {
|
||||
for i in 0..20 {
|
||||
repo.write(
|
||||
&format!("src/mod{i}.rs"),
|
||||
&format!("pub fn f() {{ let _ = {round}; }}"),
|
||||
);
|
||||
}
|
||||
repo.settle();
|
||||
}
|
||||
|
||||
repo.assert_quiet("200 file saves");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_output_in_ignored_directories_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\nnode_modules/\ndist/\n");
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
});
|
||||
|
||||
for round in 0..4 {
|
||||
for i in 0..150 {
|
||||
repo.write(&format!("target/debug/deps/unit-{round}-{i}.o"), "binary");
|
||||
repo.write(&format!("dist/chunk-{round}-{i}.js"), "bundled");
|
||||
}
|
||||
repo.settle();
|
||||
}
|
||||
|
||||
repo.assert_quiet("1200 build artifacts written into ignored directories");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_source_files_and_directories_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
});
|
||||
|
||||
for i in 0..40 {
|
||||
repo.write(&format!("src/feature{i}/mod.rs"), "pub mod inner;");
|
||||
repo.write(&format!("src/feature{i}/inner.rs"), "pub fn go() {}");
|
||||
}
|
||||
repo.settle();
|
||||
|
||||
assert!(
|
||||
repo.wait_indexed("src/feature39/inner.rs"),
|
||||
"watcher must index files in newly created directories"
|
||||
);
|
||||
repo.assert_quiet("40 new directories with 80 files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreating_generated_files_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
});
|
||||
|
||||
// Recreated paths must reuse their overflow slots.
|
||||
for round in 0..12 {
|
||||
for i in 0..40 {
|
||||
repo.write(&format!("src/generated/api{i}.rs"), "pub struct A;");
|
||||
}
|
||||
repo.settle();
|
||||
for i in 0..40 {
|
||||
repo.remove(&format!("src/generated/api{i}.rs"));
|
||||
}
|
||||
repo.settle();
|
||||
assert!(
|
||||
repo.overflow_len() <= 64,
|
||||
"round {round}: regenerating the same paths grew the overflow region to {}",
|
||||
repo.overflow_len()
|
||||
);
|
||||
}
|
||||
|
||||
repo.assert_quiet("12 codegen cycles over 40 stable paths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_workflow_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
write(base, "src/lib.rs", "pub mod thing;");
|
||||
git(base, &["init", "-b", "main"]);
|
||||
git(base, &["add", "-A"]);
|
||||
git(base, &["commit", "-m", "initial"]);
|
||||
});
|
||||
|
||||
repo.write("src/main.rs", "fn main() { println!(\"hi\"); }");
|
||||
repo.settle();
|
||||
repo.git(&["add", "-A"]);
|
||||
repo.settle();
|
||||
repo.git(&["commit", "-m", "second"]);
|
||||
repo.settle();
|
||||
repo.git(&["checkout", "-b", "feature"]);
|
||||
repo.settle();
|
||||
repo.write("src/feature.rs", "pub fn feature() {}");
|
||||
repo.git(&["add", "-A"]);
|
||||
repo.git(&["commit", "-m", "feature"]);
|
||||
repo.settle();
|
||||
repo.git(&["checkout", "main"]);
|
||||
repo.settle();
|
||||
repo.git(&["merge", "feature"]);
|
||||
repo.settle();
|
||||
|
||||
repo.assert_quiet("a commit / branch / merge cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_files_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "target/\n");
|
||||
for i in 0..50 {
|
||||
write(base, &format!("src/mod{i}.rs"), "pub fn f() {}");
|
||||
}
|
||||
});
|
||||
|
||||
// Preview rendering and grep open every file in the result list. Reacting
|
||||
// to those reads would make the picker rescan while the user scrolls.
|
||||
for _ in 0..5 {
|
||||
for i in 0..50 {
|
||||
let _ = std::fs::read(repo.path(&format!("src/mod{i}.rs"))).unwrap();
|
||||
}
|
||||
}
|
||||
repo.settle();
|
||||
|
||||
repo.assert_quiet("reading every indexed file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_install_style_churn_does_not_rescan() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, ".gitignore", "node_modules/\n");
|
||||
write(base, "src/index.ts", "export const a = 1;");
|
||||
});
|
||||
|
||||
for pkg in 0..100 {
|
||||
repo.write(&format!("node_modules/pkg{pkg}/package.json"), "{}");
|
||||
repo.write(
|
||||
&format!("node_modules/pkg{pkg}/index.js"),
|
||||
"module.exports={}",
|
||||
);
|
||||
repo.write(&format!("node_modules/pkg{pkg}/.gitignore"), "dist\n");
|
||||
}
|
||||
repo.settle();
|
||||
repo.settle();
|
||||
|
||||
repo.assert_quiet("an npm install into an ignored node_modules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_churning_root_is_capped_at_one_rescan_per_cooldown() {
|
||||
let repo = WatchedRepo::new(|base| {
|
||||
write(base, "src/main.rs", "fn main() {}");
|
||||
});
|
||||
|
||||
// Root ignore changes force watcher rescan requests.
|
||||
for round in 0..25 {
|
||||
repo.write(".gitignore", &format!("target/\n# round {round}\n"));
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
}
|
||||
repo.settle();
|
||||
|
||||
let stats = repo.rescans();
|
||||
assert!(
|
||||
stats.total <= 1,
|
||||
"a churning root must not exceed one walk per cooldown, got {stats}"
|
||||
);
|
||||
assert!(
|
||||
stats.throttled > 0,
|
||||
"the suppressed triggers must be recorded, got {stats}"
|
||||
);
|
||||
}
|
||||
|
||||
struct WatchedRepo {
|
||||
base: PathBuf,
|
||||
picker: SharedFilePicker,
|
||||
_frecency: SharedFrecency,
|
||||
_tmp: TempDir,
|
||||
}
|
||||
|
||||
impl WatchedRepo {
|
||||
fn new(setup: impl FnOnce(&Path)) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
setup(&base);
|
||||
|
||||
let picker = SharedFilePicker::default();
|
||||
let frecency = SharedFrecency::noop();
|
||||
FilePicker::new_with_shared_state(
|
||||
picker.clone(),
|
||||
frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().into_owned(),
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("failed to create file picker");
|
||||
|
||||
assert!(
|
||||
picker.wait_for_scan(Duration::from_secs(60)),
|
||||
"timed out waiting for the initial scan"
|
||||
);
|
||||
assert!(
|
||||
picker.wait_for_watcher(Duration::from_secs(60)),
|
||||
"timed out waiting for the watcher"
|
||||
);
|
||||
|
||||
let repo = Self {
|
||||
base,
|
||||
picker,
|
||||
_frecency: frecency,
|
||||
_tmp: tmp,
|
||||
};
|
||||
repo.settle();
|
||||
repo.picker.reset_rescan_stats();
|
||||
repo
|
||||
}
|
||||
|
||||
fn settle(&self) {
|
||||
std::thread::sleep(SETTLE);
|
||||
assert!(
|
||||
self.picker
|
||||
.wait_for_indexing_complete(Duration::from_secs(60)),
|
||||
"timed out waiting for background indexing to finish"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_quiet(&self, workload: &str) {
|
||||
let stats = self.rescans();
|
||||
assert_eq!(
|
||||
stats.watcher_triggered(),
|
||||
0,
|
||||
"{workload} must be absorbed incrementally, but the watcher fell back to {stats}"
|
||||
);
|
||||
}
|
||||
|
||||
fn rescans(&self) -> RescanStats {
|
||||
self.picker.rescan_stats()
|
||||
}
|
||||
|
||||
fn path(&self, rel: &str) -> PathBuf {
|
||||
self.base.join(rel)
|
||||
}
|
||||
|
||||
fn write(&self, rel: &str, contents: &str) {
|
||||
write(&self.base, rel, contents);
|
||||
}
|
||||
|
||||
fn remove(&self, rel: &str) {
|
||||
std::fs::remove_file(self.path(rel)).unwrap();
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) {
|
||||
git(&self.base, args);
|
||||
}
|
||||
|
||||
fn wait_indexed(&self, rel: &str) -> bool {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if self.is_indexed(rel) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_indexed(&self, rel: &str) -> bool {
|
||||
let guard = self.picker.read().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.and_then(|p| p.get_file_by_path(self.path(rel)))
|
||||
.is_some_and(|file| !file.is_deleted())
|
||||
}
|
||||
|
||||
fn overflow_len(&self) -> usize {
|
||||
let guard = self.picker.read().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|p| p.get_overflow_files().len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WatchedRepo {
|
||||
fn drop(&mut self) {
|
||||
// Stop the watcher before the tree disappears, otherwise a late batch
|
||||
// races the tempdir removal.
|
||||
if let Ok(mut guard) = self.picker.write() {
|
||||
guard.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(base: &Path, rel: &str, contents: &str) {
|
||||
let path = base.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@test.com")
|
||||
.env("GIT_COMMITTER_NAME", "test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@test.com")
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ crate-type = ["cdylib", "rlib"]
|
||||
default = ["ripgrep"]
|
||||
ripgrep = ["fff/ripgrep", "fff-query-parser/ripgrep", "dep:ignore"]
|
||||
zlob = ["fff/zlob", "fff-query-parser/zlob", "dep:zlob"]
|
||||
# Keep full-rescan accounting in a release build; required by rescan_probe.
|
||||
rescan-stats = ["fff/rescan-stats"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::{
|
||||
FFFMode, FilePickerOptions, RESCAN_STATS_ENABLED, RescanReason, RescanStats, SharedFilePicker,
|
||||
SharedFrecency,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const POLL: Duration = Duration::from_millis(250);
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (base_path, run_for) = parse_args()?;
|
||||
|
||||
if !RESCAN_STATS_ENABLED {
|
||||
return Err(
|
||||
"this build has rescan accounting compiled out; rebuild with \
|
||||
`--features rescan-stats` (or drop `--release`)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let picker = SharedFilePicker::default();
|
||||
let frecency = SharedFrecency::noop();
|
||||
|
||||
println!("indexing {base_path} ...");
|
||||
let started = Instant::now();
|
||||
FilePicker::new_with_shared_state(
|
||||
picker.clone(),
|
||||
frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::default(),
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
if !picker.wait_for_scan(Duration::from_secs(600)) {
|
||||
return Err("timed out waiting for the initial scan".into());
|
||||
}
|
||||
if !picker.wait_for_watcher(Duration::from_secs(600)) {
|
||||
return Err("timed out waiting for the watcher".into());
|
||||
}
|
||||
|
||||
println!(
|
||||
"indexed {} files in {:.2}s; watching for rescan requests.\n",
|
||||
live_files(&picker),
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
picker.reset_rescan_stats();
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let stop = Arc::clone(&running);
|
||||
ctrlc::set_handler(move || stop.store(false, Ordering::SeqCst))?;
|
||||
|
||||
let watching_since = Instant::now();
|
||||
let mut last = RescanStats::default();
|
||||
|
||||
while running.load(Ordering::SeqCst) {
|
||||
std::thread::sleep(POLL);
|
||||
|
||||
let stats = picker.rescan_stats();
|
||||
let delta = stats.since(&last);
|
||||
if delta.total > 0 || delta.throttled > 0 {
|
||||
let now = watching_since.elapsed().as_secs_f64();
|
||||
let files = live_files(&picker);
|
||||
let overflow = overflow_files(&picker);
|
||||
|
||||
for reason in RescanReason::ALL {
|
||||
for _ in 0..delta.count(reason) {
|
||||
println!(
|
||||
"[{now:>8.2}s] request {reason:<21} files={files} overflow={overflow}"
|
||||
);
|
||||
}
|
||||
let suppressed = delta.count_throttled(reason);
|
||||
if suppressed > 0 {
|
||||
println!("[{now:>8.2}s] throttled {reason:<21} x{suppressed}");
|
||||
}
|
||||
}
|
||||
last = stats;
|
||||
}
|
||||
|
||||
if run_for.is_some_and(|limit| watching_since.elapsed() >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = watching_since.elapsed();
|
||||
let stats = picker.rescan_stats();
|
||||
println!("\n{:.1}s watched", elapsed.as_secs_f64());
|
||||
println!("{stats}");
|
||||
if stats.watcher_triggered() > 0 {
|
||||
println!(
|
||||
"{:.1} watcher rescan requests/minute",
|
||||
stats.watcher_triggered() as f64 / elapsed.as_secs_f64().max(1.0) * 60.0
|
||||
);
|
||||
} else {
|
||||
println!("no full rescans: every change was applied incrementally");
|
||||
}
|
||||
if stats.throttled > 0 {
|
||||
println!(
|
||||
"{} additional request(s) were throttled; {} total requests observed",
|
||||
stats.throttled,
|
||||
stats.total + stats.throttled
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = picker.write() {
|
||||
guard.take();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<(String, Option<Duration>), Box<dyn std::error::Error>> {
|
||||
let mut base_path = None;
|
||||
let mut run_for = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--seconds" | "-s" => {
|
||||
let value = args.next().ok_or("--seconds needs a value")?;
|
||||
run_for = Some(Duration::from_secs(value.parse()?));
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: rescan_probe [path] [--seconds N]");
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => base_path = Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
let base_path = match base_path {
|
||||
Some(path) => path,
|
||||
None => std::env::current_dir()?.to_string_lossy().into_owned(),
|
||||
};
|
||||
|
||||
Ok((base_path, run_for))
|
||||
}
|
||||
|
||||
fn live_files(picker: &SharedFilePicker) -> usize {
|
||||
picker
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|p| p.live_file_count()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn overflow_files(picker: &SharedFilePicker) -> usize {
|
||||
picker
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|p| p.get_overflow_files().len()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -573,16 +573,10 @@ export interface FileFinderApi {
|
||||
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
|
||||
|
||||
/** Fuzzy directory search. */
|
||||
directorySearch(
|
||||
query: string,
|
||||
options?: DirSearchOptions,
|
||||
): Result<DirSearchResult>;
|
||||
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
|
||||
|
||||
/** Fuzzy search over files and directories interleaved by score. */
|
||||
mixedSearch(
|
||||
query: string,
|
||||
options?: SearchOptions,
|
||||
): Result<MixedSearchResult>;
|
||||
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
|
||||
|
||||
/** Content search (live grep). */
|
||||
grep(query: string, options?: GrepOptions): Result<GrepResult>;
|
||||
@@ -647,10 +641,7 @@ export interface FileFinderApi {
|
||||
* Events are debounced and submitted in batches per 100-ms window at most 128 events.
|
||||
* Gitignored and other ignored files are never triggering watcher.
|
||||
*/
|
||||
watch(
|
||||
callback: WatchBatchCallback,
|
||||
options?: WatchOptions,
|
||||
): Result<WatchUnsubscribe>;
|
||||
watch(callback: WatchBatchCallback, options?: WatchOptions): Result<WatchUnsubscribe>;
|
||||
watch(
|
||||
pattern: string,
|
||||
callback: WatchBatchCallback,
|
||||
|
||||
@@ -573,16 +573,10 @@ export interface FileFinderApi {
|
||||
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
|
||||
|
||||
/** Fuzzy directory search. */
|
||||
directorySearch(
|
||||
query: string,
|
||||
options?: DirSearchOptions,
|
||||
): Result<DirSearchResult>;
|
||||
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
|
||||
|
||||
/** Fuzzy search over files and directories interleaved by score. */
|
||||
mixedSearch(
|
||||
query: string,
|
||||
options?: SearchOptions,
|
||||
): Result<MixedSearchResult>;
|
||||
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
|
||||
|
||||
/** Content search (live grep). */
|
||||
grep(query: string, options?: GrepOptions): Result<GrepResult>;
|
||||
@@ -647,10 +641,7 @@ export interface FileFinderApi {
|
||||
* Events are debounced and submitted in batches per 100-ms window at most 128 events.
|
||||
* Gitignored and other ignored files are never triggering watcher.
|
||||
*/
|
||||
watch(
|
||||
callback: WatchBatchCallback,
|
||||
options?: WatchOptions,
|
||||
): Result<WatchUnsubscribe>;
|
||||
watch(callback: WatchBatchCallback, options?: WatchOptions): Result<WatchUnsubscribe>;
|
||||
watch(
|
||||
pattern: string,
|
||||
callback: WatchBatchCallback,
|
||||
|
||||
@@ -246,11 +246,7 @@ function readResultEnvelope(
|
||||
paramsValue: unknown[],
|
||||
): { rawPtr: JsExternal; struct: FffResultRaw } | Result<never> {
|
||||
loadLibrary();
|
||||
const { rawPtr, struct: structData } = callRaw(
|
||||
funcName,
|
||||
paramsType,
|
||||
paramsValue,
|
||||
);
|
||||
const { rawPtr, struct: structData } = callRaw(funcName, paramsType, paramsValue);
|
||||
|
||||
if (structData.success === 0) {
|
||||
const errorStr = readCString(structData.error);
|
||||
@@ -328,8 +324,7 @@ function callJsonResult<T>(
|
||||
if (isNullPointer(handlePtr)) return { ok: true, value: undefined as T };
|
||||
const jsonStr = readCString(handlePtr);
|
||||
freeString(handlePtr);
|
||||
if (jsonStr === null || jsonStr === "")
|
||||
return { ok: true, value: undefined as T };
|
||||
if (jsonStr === null || jsonStr === "") return { ok: true, value: undefined as T };
|
||||
try {
|
||||
return { ok: true, value: snakeToCamel(JSON.parse(jsonStr)) as T };
|
||||
} catch {
|
||||
@@ -849,16 +844,10 @@ function readGrepMatchFromRaw(raw: FffGrepMatchRaw): GrepMatch {
|
||||
match.fuzzyScore = raw.fuzzy_score;
|
||||
}
|
||||
if (raw.context_before_count > 0) {
|
||||
match.contextBefore = readCStringArray(
|
||||
raw.context_before,
|
||||
raw.context_before_count,
|
||||
);
|
||||
match.contextBefore = readCStringArray(raw.context_before, raw.context_before_count);
|
||||
}
|
||||
if (raw.context_after_count > 0) {
|
||||
match.contextAfter = readCStringArray(
|
||||
raw.context_after,
|
||||
raw.context_after_count,
|
||||
);
|
||||
match.contextAfter = readCStringArray(raw.context_after, raw.context_after_count);
|
||||
}
|
||||
if (raw.is_definition !== 0) {
|
||||
match.isDefinition = true;
|
||||
@@ -927,8 +916,7 @@ function parseGrepResult(rawPtr: JsExternal): Result<GrepResult> {
|
||||
totalFilesSearched: gr.total_files_searched,
|
||||
totalFiles: gr.total_files,
|
||||
filteredFileCount: gr.filtered_file_count,
|
||||
nextCursor:
|
||||
gr.next_file_offset > 0 ? createGrepCursor(gr.next_file_offset) : null,
|
||||
nextCursor: gr.next_file_offset > 0 ? createGrepCursor(gr.next_file_offset) : null,
|
||||
};
|
||||
if (regexFallbackError) {
|
||||
grepResult.regexFallbackError = regexFallbackError;
|
||||
@@ -1280,14 +1268,7 @@ export function ffiGlob(
|
||||
DataType.U32, // page_index
|
||||
DataType.U32, // page_size
|
||||
],
|
||||
paramsValue: [
|
||||
handle,
|
||||
pattern,
|
||||
currentFile,
|
||||
maxThreads,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
],
|
||||
paramsValue: [handle, pattern, currentFile, maxThreads, pageIndex, pageSize],
|
||||
freeResultMemory: false,
|
||||
}) as JsExternal;
|
||||
|
||||
@@ -1319,14 +1300,7 @@ export function ffiSearchDirectories(
|
||||
DataType.U32, // page_index
|
||||
DataType.U32, // page_size
|
||||
],
|
||||
paramsValue: [
|
||||
handle,
|
||||
query,
|
||||
currentFile ?? "",
|
||||
maxThreads,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
],
|
||||
paramsValue: [handle, query, currentFile ?? "", maxThreads, pageIndex, pageSize],
|
||||
freeResultMemory: false,
|
||||
}) as JsExternal;
|
||||
|
||||
@@ -1545,11 +1519,7 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<{
|
||||
isWarmupComplete: boolean;
|
||||
}> {
|
||||
loadLibrary();
|
||||
const res = readResultEnvelope(
|
||||
"fff_get_scan_progress",
|
||||
[DataType.External],
|
||||
[handle],
|
||||
);
|
||||
const res = readResultEnvelope("fff_get_scan_progress", [DataType.External], [handle]);
|
||||
if ("ok" in res) return res;
|
||||
|
||||
const handlePtr = res.struct.handle;
|
||||
@@ -1584,10 +1554,7 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<{
|
||||
/**
|
||||
* Wait for a tree scan to complete.
|
||||
*/
|
||||
export function ffiWaitForScan(
|
||||
handle: NativeHandle,
|
||||
timeoutMs: number,
|
||||
): Result<boolean> {
|
||||
export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<boolean> {
|
||||
return callBoolResult(
|
||||
"fff_wait_for_scan",
|
||||
[DataType.External, DataType.U64],
|
||||
@@ -1598,10 +1565,7 @@ export function ffiWaitForScan(
|
||||
/**
|
||||
* Restart index in new path.
|
||||
*/
|
||||
export function ffiRestartIndex(
|
||||
handle: NativeHandle,
|
||||
newPath: string,
|
||||
): Result<void> {
|
||||
export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<void> {
|
||||
return callVoidResult(
|
||||
"fff_restart_index",
|
||||
[DataType.External, DataType.String],
|
||||
@@ -1772,8 +1736,7 @@ function ensureWatchTrampoline(): JsExternal {
|
||||
// fff watcher uses a single cross-boundary FFI callback to deliver all events which we then manually
|
||||
// mapping to the user's javascript functions
|
||||
function ensureWatchCallbackRegistered(handle: NativeHandle): Result<void> {
|
||||
if (watchInstances.has(handle as unknown))
|
||||
return { ok: true, value: undefined };
|
||||
if (watchInstances.has(handle as unknown)) return { ok: true, value: undefined };
|
||||
const trampoline = ensureWatchTrampoline();
|
||||
const registered = callVoidResult(
|
||||
"fff_set_watch_callback",
|
||||
@@ -1785,11 +1748,7 @@ function ensureWatchCallbackRegistered(handle: NativeHandle): Result<void> {
|
||||
}
|
||||
|
||||
function releaseWatchTrampolineIfIdle(): void {
|
||||
if (
|
||||
watchHandlers.size > 0 ||
|
||||
watchInstances.size > 0 ||
|
||||
watchTrampoline === null
|
||||
)
|
||||
if (watchHandlers.size > 0 || watchInstances.size > 0 || watchTrampoline === null)
|
||||
return;
|
||||
freePointer({
|
||||
paramsType: [WATCH_TRAMPOLINE_TYPE],
|
||||
@@ -1835,10 +1794,7 @@ export function ffiWatch(
|
||||
* this returns the callback can never run again (a late native tail batch
|
||||
* misses the map lookup and is dropped).
|
||||
*/
|
||||
export function ffiUnwatch(
|
||||
handle: NativeHandle,
|
||||
watchId: number,
|
||||
): Result<boolean> {
|
||||
export function ffiUnwatch(handle: NativeHandle, watchId: number): Result<boolean> {
|
||||
const result = callBoolResult(
|
||||
"fff_unwatch",
|
||||
[DataType.External, DataType.U64],
|
||||
|
||||
@@ -84,8 +84,7 @@ export class AuxFinderPool {
|
||||
private async create(root: string): Promise<AuxPicker> {
|
||||
if (this.entries.length >= MAX_AUX) {
|
||||
let oldest = this.entries[0];
|
||||
for (const e of this.entries)
|
||||
if (e.lastUsed < oldest.lastUsed) oldest = e;
|
||||
for (const e of this.entries) if (e.lastUsed < oldest.lastUsed) oldest = e;
|
||||
if (!oldest.finder.isDestroyed) oldest.finder.destroy();
|
||||
this.entries = this.entries.filter((e) => e !== oldest);
|
||||
}
|
||||
@@ -101,9 +100,7 @@ export class AuxFinderPool {
|
||||
enableFsRootScanning: this.opts.enableFsRootScanning,
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(
|
||||
`Failed to create aux file finder for ${root}: ${result.error}`,
|
||||
);
|
||||
throw new Error(`Failed to create aux file finder for ${root}: ${result.error}`);
|
||||
|
||||
await result.value.waitForScan(SCAN_TIMEOUT_MS);
|
||||
const entry: AuxPicker = {
|
||||
@@ -125,9 +122,7 @@ export class AuxFinderPool {
|
||||
// remainder usable as a fuzzy path constraint relative to that root. Glob and
|
||||
// nonexistent segments both go into the suffix: we walk up to the nearest
|
||||
// existing ancestor so partially-wrong paths still resolve to a search root.
|
||||
export function resolveAuxRoot(
|
||||
absPath: string,
|
||||
): { root: string; suffix: string } | null {
|
||||
export function resolveAuxRoot(absPath: string): { root: string; suffix: string } | null {
|
||||
const trimmed = path.normalize(absPath.trim()).replace(/\/+$/, "") || "/";
|
||||
if (!path.isAbsolute(trimmed)) return null;
|
||||
if (trimmed === path.sep) return { root: path.sep, suffix: "" };
|
||||
@@ -187,7 +182,6 @@ export function routePathConstraint(
|
||||
return resolveAuxRoot(candidate);
|
||||
}
|
||||
|
||||
|
||||
export function rootCovers(root: string, target: string): boolean {
|
||||
if (root === target) return true;
|
||||
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
||||
|
||||
+1097
-1148
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,7 @@ export function normalizePathConstraint(
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
|
||||
if (relative === "") return null;
|
||||
if (
|
||||
relative.startsWith("../") ||
|
||||
relative === ".." ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
|
||||
throw new Error(
|
||||
`Path constraint must be relative to the workspace: ${pathConstraint}`,
|
||||
);
|
||||
|
||||
@@ -98,9 +98,7 @@ describe("routePathConstraint", () => {
|
||||
});
|
||||
|
||||
test("returns null when .. resolves back inside the workspace", () => {
|
||||
expect(
|
||||
routePathConstraint("../workspace/src", workspace),
|
||||
).toBeNull();
|
||||
expect(routePathConstraint("../workspace/src", workspace)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -567,16 +567,10 @@ export interface FileFinderApi {
|
||||
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
|
||||
|
||||
/** Fuzzy directory search. */
|
||||
directorySearch(
|
||||
query: string,
|
||||
options?: DirSearchOptions,
|
||||
): Result<DirSearchResult>;
|
||||
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
|
||||
|
||||
/** Fuzzy search over files and directories interleaved by score. */
|
||||
mixedSearch(
|
||||
query: string,
|
||||
options?: SearchOptions,
|
||||
): Result<MixedSearchResult>;
|
||||
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
|
||||
|
||||
/** Content search (live grep). */
|
||||
grep(query: string, options?: GrepOptions): Result<GrepResult>;
|
||||
@@ -641,10 +635,7 @@ export interface FileFinderApi {
|
||||
* Events are debounced and submitted in batches per 100-ms window at most 128 events.
|
||||
* Gitignored and other ignored files are never triggering watcher.
|
||||
*/
|
||||
watch(
|
||||
callback: WatchBatchCallback,
|
||||
options?: WatchOptions,
|
||||
): Result<WatchUnsubscribe>;
|
||||
watch(callback: WatchBatchCallback, options?: WatchOptions): Result<WatchUnsubscribe>;
|
||||
watch(
|
||||
pattern: string,
|
||||
callback: WatchBatchCallback,
|
||||
|
||||
Reference in New Issue
Block a user