diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 2501722..da3d521 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -19,6 +19,9 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] + # Guard against deadlocks in the shared-picker / watcher teardown + # path: a stuck test would otherwise consume a full 6h CI slot. + timeout-minutes: 10 steps: - uses: actions/checkout@v5 @@ -39,6 +42,58 @@ jobs: - name: Run tests run: cargo test --features zlob --workspace --exclude fff-nvim + stress-test: + name: Stress Test (Watcher + Git) + runs-on: ${{ matrix.os }} + strategy: + # Keep going after one OS fails so we can see whether a bug + # reproduces everywhere or is platform-specific. + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + # Long-running; don't let a stuck watcher thread burn a full CI + # timeout. Two scenarios should finish well under this limit. + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + + - name: Install Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.16.0 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1.15.4 + with: + cache: true + cache-on-failure: true + cache-key: "v1-rust-stress-${{ matrix.os }}" + components: rustfmt, clippy + + - name: Stress test (seeded / deterministic) + shell: bash + run: make test-stress-seeded + env: + FFF_STRESS_CASES: "3" + FFF_STRESS_MIN_OPS: "30" + FFF_STRESS_MAX_OPS: "50" + + - name: Stress test (random / fuzzy) + shell: bash + run: make test-stress-random + env: + FFF_STRESS_CASES: "5" + FFF_STRESS_MIN_OPS: "30" + FFF_STRESS_MAX_OPS: "60" + + - name: Upload proptest regressions on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: proptest-regressions-${{ matrix.os }} + path: crates/fff-core/tests/fuzz_git_watcher_stress.proptest-regressions + if-no-files-found: ignore + fmt: name: cargo fmt runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index cbdaae1..2457b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ scripts/benchmark-results/ *.dylib *.so *.dll + +# Instruments traces +*.trace/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0794162 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# To Clankers + +This repository contains **FFF.nvim (Fast File Finder)**, a high-performance file picker for Neovim inspired by blink.cmp's fuzzy matching technology. It's NOT a completion plugin, but rather a standalone file finder with advanced fuzzy search and frecency scoring. The project aims to be the drop-in replacement for telescope, fzf-lua, snacks.picker and similar plugins, focusing on speed, accuracy search and usability features. + +## Development Commands + +### Building + +- `cargo build --release` - Build the Rust fuzzy matcher and file picker + +### Testing and Development Tools + +This project does not have a traditional test suite. Testing is done through: + +- Create e2e local test file for Neovim: Load any Lua test file with `nvim -l ` +- Write inline rust unit tests for any functionality that is standalone and scoped within a single function + +### Code Quality + +- `cargo fmt` - Format Rust code (follows standard conventions) +- `make lint` - Rust linting and code analysis +- `make format` - Format all code +- `make test` - Run unit tests (limited coverage, primarily integration testing) + +When doing code make sure to REDUCE SIZE OF COMMENTS. This is very important. Every comment should be concise 1-2 liner maximum 4 lines if describes really extensive and unnatural concept. + +### Important coding rules + +- Do not add doc comments to the private structs and functions. +- Do not make public structs if something can be private + + +## Architecture + +Everything that is performance critical happens in rust world, everything that is neovim specific happens in the lua code. + +There are 3 main components: + +- Rust binary with the global file picker state containing index of all files +- Background thread with the file system watcher that updates the index in real time +- Lua UI layer that renders the picker, handles user input, and calls the rust functions via FFI + +There are 2 databases: + +- Frecency database (LMDB) that tracks file access patterns for scoring +- Query history database used to track the user's previous search queries + +### Key Files + +- `lua/fff.lua` - Entry point, delegates to main.lua +- `lua/fff/main.lua` - Public API (find_files, search, change_directory) +- `lua/fff/core.lua` - Initialization, autocmds, global state management +- `lua/fff/picker_ui.lua` - UI rendering, layout calculation, keymaps +- `lua/fff/file_picker/preview.lua` - File preview with syntax highlighting +- `lua/fff/file_picker/image.lua` - Image preview (snacks.nvim integration) +- `lua/fff/conf.lua` - Default config +- `lua/fff/rust/init.lua` - Loads compiled Rust shared library + +**Rust Side:** + +- `lua/fff/rust/lib.rs` - FFI bindings, global state (FILE_PICKER, FRECENCY) +- `lua/fff/rust/file_picker.rs` - Core FilePicker struct, indexing, background watcher +- `lua/fff/rust/frecency.rs` - Frecency database (LMDB) and scoring +- `lua/fff/rust/query_tracker.rs` - Search query history tracking +- `lua/fff/rust/score.rs` - Fuzzy match scoring with frizbee integration +- `lua/fff/rust/git.rs` - Git status caching and repository detection +- `lua/fff/rust/background_watcher.rs` - File system watcher thread + +### Scoring Algorithm + +Located at the score.rs file + +### Build System + +- `Cargo.toml` - Rust dependencies and build configuration (package name: `fff_nvim`) +- `rust-toolchain.toml` - Specifies Rust nightly toolchain with required components +- `Cross.toml` - Cross-compilation settings using Zig for Linux targets +- **CI/CD Workflows**: + - `.github/workflows/rust.yml` - Rust testing, formatting, and clippy checks + - `.github/workflows/release.yaml` - Automated multi-platform builds + - `.github/workflows/stylua.yaml` - Lua code formatting validation + - `.github/workflows/nix.yml` - Nix build validation +- **Cross-compilation Support**: Uses `cross` tool with Zig backend for efficient cross-compilation + +## Development Notes + +### Working with Rust Code + +- Prefer struct methods over functions +- If there is more than 2 impls in the file - create new file +- Smaller concise comments over giant comment blocks +- Do not add doc comments to the private functions/structs +- Be very careful around locking and better double check with the human if something is going to require potentially long lock on a mutex/rwlock + +### Working with lua code + +- Document the types of public functions in every module +- Use `vim.validate()` for validating user inputs in public functions +- Try to reuse as much of existing functions as possible +- When working on new features for the UI **IT IS EXTREMELY IMPORTANT** to keep the core functionality of navigating between files, selecting, and seeing the preview working as is. NEVER break anything from the core UI functionality, only add new features on top of the current UI. +- When making a large chunk of code make lua test that opens neovim at `~/dev/lightsource` and opens the picker to test the ui functionality across the actual code. +- When adding a new highlights or any new shortcuts and configurable UI options add them to the neovim config. AND IMPORTANT: update the README.md with the new configuration options. + +### UI rendering + +When working on the UI changeds IT IS EXTREMELY important for you to test it for both prompt_position="bottom" and prompt_position="top" as the rendering logic is different for both of them in both rust and lua world. When the prompt is positioed in the bottom everything should work the same way as the top but would be reversed in order. (though navigation is same for both) + +## Top level API that can not introduce breaking changes under any circumstance + +Top level rust, lua, C, and bun APIs can not be changed under any circumstance diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 92991dc..ddafd3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -667,14 +667,15 @@ dependencies = [ [[package]] name = "fff-notify-debouncer-full" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4594f0ef1ad0bfcd112bbe4b397ef881f50442bc710356b86858d555848e4d09" +checksum = "5c6f0c16164d10c082af931377766f5495440bee66a9a841bbcea1af5de8878c" dependencies = [ "file-id", "log", - "notify 9.0.0-rc.3", + "notify", "notify-types", + "rustc-hash 2.1.2", "walkdir", ] @@ -688,6 +689,7 @@ dependencies = [ "criterion", "ctrlc", "dirs", + "fff-notify-debouncer-full", "fff-query-parser", "fff-search", "git2", @@ -697,11 +699,11 @@ dependencies = [ "mimalloc", "mlua", "neo_frizbee", - "notify 8.2.0", - "notify-debouncer-full", + "notify", "once_cell", + "parking_lot", "pathdiff", - "rand", + "rand 0.8.5", "rayon", "serde", "smallvec", @@ -745,11 +747,12 @@ dependencies = [ "memmap2", "mimalloc", "neo_frizbee", - "notify 9.0.0-rc.3", + "notify", "once_cell", "parking_lot", "pathdiff", - "rand", + "proptest", + "rand 0.8.5", "rayon", "regex", "regex-syntax", @@ -781,6 +784,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -796,15 +805,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "futures" version = "0.3.32" @@ -1552,24 +1552,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.11.0", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - [[package]] name = "notify" version = "9.0.0-rc.3" @@ -1590,19 +1572,6 @@ dependencies = [ "xxhash-rust", ] -[[package]] -name = "notify-debouncer-full" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078" -dependencies = [ - "file-id", - "log", - "notify 8.2.0", - "notify-types", - "walkdir", -] - [[package]] name = "notify-types" version = "2.1.0" @@ -1762,7 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -1870,6 +1839,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.11.0", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -1898,8 +1890,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1909,7 +1911,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1921,6 +1933,24 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -2085,6 +2115,17 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2577,6 +2618,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2776,7 +2823,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8d600ad..680ebec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "crates/fff-query-parser", "crates/fff-grep", ] -exclude = ["crates/fff-notify-debouncer-full"] resolver = "2" [workspace.dependencies] @@ -37,7 +36,7 @@ zlob = "1.3.3" mlua = { version = "0.11.1", features = ["module", "luajit"] } neo_frizbee = { version = "0.10.1", features = ["match_end_col"] } notify = { version = "9.0.0-rc.3" } -notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.1" } +notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.3" } once_cell = "1.20.2" parking_lot = "0.12" pathdiff = "0.2.1" @@ -62,3 +61,11 @@ lto = "thin" [profile.bench] inherits = "release" + +# For Instruments / xctrace: release-level optimization but keep debuginfo +# and symbols so sampled frames resolve to real Rust names. +[profile.prof] +inherits = "release" +debug = "full" +strip = false +lto = "thin" diff --git a/Makefile b/Makefile index 99ea8dd..6ae5c61 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,11 @@ PREFIX ?= /usr/local LIBDIR ?= $(PREFIX)/lib INCLUDEDIR ?= $(PREFIX)/include -.PHONY: build build-c-lib install uninstall test test-rust test-lua test-version test-bun test-node prepare-bun prepare-node set-npm-version header +# Compile-time cfg that gates the watcher + git-status fuzz stress test. +STRESS_RUSTFLAGS := --cfg stress +FFF_STRESS_DEFAULT_SEED ?= 0xDEADBEEFCAFEBABE + +.PHONY: build build-c-lib install uninstall test test-rust test-lua test-version test-bun test-node prepare-bun prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random all: format test lint @@ -85,6 +89,26 @@ test-node: prepare-node test: test-rust test-lua test-version test-bun test-node + +test-stress-seeded: + FFF_STRESS_SEED="$${FFF_STRESS_SEED:-$(FFF_STRESS_DEFAULT_SEED)}" \ + RUSTFLAGS="$(STRESS_RUSTFLAGS)" \ + cargo test \ + -p fff-search \ + --test fuzz_git_watcher_stress \ + --features zlob \ + -- --nocapture stress_seeded + +test-stress-random: + RUSTFLAGS="$(STRESS_RUSTFLAGS)" \ + cargo test \ + -p fff-search \ + --test fuzz_git_watcher_stress \ + --features zlob \ + -- --nocapture stress_random + +test-stress: test-stress-seeded test-stress-random + # Update version in a package.json, including optionalDependencies. # Usage: make set-npm-version PKG=packages/fff-bun VERSION=1.0.0-nightly.abc1234 set-npm-version: diff --git a/_typos.toml b/_typos.toml index 91a86b3..6c280a3 100644 --- a/_typos.toml +++ b/_typos.toml @@ -6,6 +6,8 @@ noice = "noice" fo = "fo" ba = "ba" ue = "ue" +# file extensions that look like typos +thm = "thm" # some typos we use for tests comparsion = "comparsion" modfiers = "modfiers" diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index 3046df5..363dee3 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -35,7 +35,7 @@ use fff::file_picker::FilePicker; use fff::frecency::FrecencyTracker; use fff::query_tracker::QueryTracker; use fff::{DbHealthChecker, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser}; -use fff::{SharedFrecency, SharedPicker}; +use fff::{SharedFilePicker, SharedFrecency}; use ffi_types::{ FffDirItem, FffDirSearchResult, FffFileItem, FffGrepMatch, FffGrepResult, FffMixedItem, FffMixedSearchResult, FffResult, FffScanProgress, FffScore, FffSearchResult, @@ -46,7 +46,7 @@ use ffi_types::{ /// The caller receives this as `*mut c_void` and must pass it to every FFI call. /// The fff_handle is freed by `fff_destroy`. struct FffInstance { - picker: SharedPicker, + picker: SharedFilePicker, frecency: SharedFrecency, query_tracker: SharedQueryTracker, } @@ -204,7 +204,7 @@ pub unsafe extern "C" fn fff_create_instance2( let history_path = unsafe { optional_cstr(history_db_path) }.map(|s| s.to_string()); // Create shared state that background threads will write into. - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); let query_tracker = SharedQueryTracker::default(); @@ -744,18 +744,10 @@ pub unsafe extern "C" fn fff_scan_files(fff_handle: *mut c_void) -> *mut FffResu Err(e) => return e, }; - let mut guard = match inst.picker.write() { - Ok(g) => g, - Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)), - }; - - let picker = match guard.as_mut() { - Some(p) => p, - None => return FffResult::err("File picker not initialized"), - }; - - match picker.trigger_rescan(&inst.frecency) { - Ok(_) => FffResult::ok_empty(), + // Async: rescan runs on a BG thread, caller returns immediately. + // Use `fff_is_scanning` / `fff_wait_for_scan` to observe progress. + match inst.picker.trigger_full_rescan_async(&inst.frecency) { + Ok(()) => FffResult::ok_empty(), Err(e) => FffResult::err(&format!("Failed to trigger rescan: {}", e)), } } @@ -904,14 +896,17 @@ pub unsafe extern "C" fn fff_restart_index( }; let (warmup_caches, content_indexing, watch, mode) = if let Some(mut picker) = guard.take() { - let warmup = picker.need_enable_mmap_cache(); - let ci = picker.need_enable_content_indexing(); - let w = picker.need_watch(); + let warmup = picker.has_mmap_cache(); + let enable_content_indexing = picker.has_content_indexing(); + let watch = picker.has_watcher(); let mode = picker.mode(); + picker.stop_background_monitor(); - (warmup, ci, w, mode) + + (warmup, enable_content_indexing, watch, mode) } else { - (false, false, true, FFFMode::default()) + // this is error state anyway + (false, true, true, FFFMode::default()) }; drop(guard); diff --git a/crates/fff-core/Cargo.toml b/crates/fff-core/Cargo.toml index b47d8eb..66ef388 100644 --- a/crates/fff-core/Cargo.toml +++ b/crates/fff-core/Cargo.toml @@ -79,5 +79,6 @@ dunce = { workspace = true } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } +proptest = { version = "1", default-features = false, features = ["std", "fork"] } rand = { version = "0.8", features = ["small_rng"] } tempfile = "3.8" diff --git a/crates/fff-core/benches/bigram_bench.rs b/crates/fff-core/benches/bigram_bench.rs index 530209f..85c7466 100644 --- a/crates/fff-core/benches/bigram_bench.rs +++ b/crates/fff-core/benches/bigram_bench.rs @@ -90,7 +90,8 @@ fn bench_bigram_build(c: &mut Criterion) { let file_counts = [10_000, 100_000]; for &file_count in &file_counts { - // Pre-generate content so we only measure index building + // Pre-generate content so we only measure index building. + // Short content (~85 bytes/file) exercises the scalar fast path. let contents: Vec = (0..file_count) .map(|i| { format!( @@ -100,7 +101,7 @@ fn bench_bigram_build(c: &mut Criterion) { .collect(); group.bench_with_input( - BenchmarkId::new("build_and_compress", file_count), + BenchmarkId::new("short_content", file_count), &file_count, |b, &fc| { b.iter(|| { @@ -114,6 +115,40 @@ fn bench_bigram_build(c: &mut Criterion) { }); }, ); + + // Long content (~4 KB/file) exercises the SIMD pre-pass path. + // Build a realistic-looking source-like blob by repeating snippets. + let long_contents: Vec = (0..file_count) + .map(|i| { + let mut s = String::with_capacity(4096); + for j in 0..50 { + s.push_str(&format!( + "pub fn handler_{i}_{j}(ctx: &Context) -> Result {{\n" + )); + s.push_str(" let parsed = ctx.parse()?;\n"); + s.push_str(" let validated = parsed.validate()?;\n"); + s.push_str(&format!(" ctx.respond(validated, {}).await\n", j)); + s.push_str("}\n\n"); + } + s + }) + .collect(); + + group.bench_with_input( + BenchmarkId::new("long_content", file_count), + &file_count, + |b, &fc| { + b.iter(|| { + let builder = BigramIndexBuilder::new(fc); + let skip_builder = BigramIndexBuilder::new(fc); + for (i, content) in long_contents.iter().enumerate() { + builder.add_file_content(&skip_builder, i, content.as_bytes()); + } + let index = builder.compress(None); + black_box(index.columns_used()) + }); + }, + ); } group.finish(); diff --git a/crates/fff-core/build.rs b/crates/fff-core/build.rs index 6537e08..c6cf785 100644 --- a/crates/fff-core/build.rs +++ b/crates/fff-core/build.rs @@ -1,4 +1,12 @@ fn main() { + // Opt-in cfg for the long-running randomized stress tests + // used by tests/fuzz_git_watcher_stress.rs + println!("cargo::rustc-check-cfg=cfg(stress)"); + + // 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 + // MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved. if std::env::var("CARGO_FEATURE_ZLOB").is_ok() { if !zig_available() { panic!( diff --git a/crates/fff-core/src/background_watcher.rs b/crates/fff-core/src/background_watcher.rs index ef5dee2..a86dd3b 100644 --- a/crates/fff-core/src/background_watcher.rs +++ b/crates/fff-core/src/background_watcher.rs @@ -1,15 +1,15 @@ use crate::error::Error; use crate::file_picker::{FFFMode, FilePicker}; use crate::git::GitStatusCache; -use crate::shared::{SharedFrecency, SharedPicker}; +use crate::shared::{SharedFilePicker, SharedFrecency}; use crate::sort_buffer::sort_with_buffer; use git2::Repository; use notify::event::{AccessKind, AccessMode}; use notify::{Config, EventKind, EventKindMask, RecursiveMode}; use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt}; +use parking_lot::Mutex; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::time::Duration; use tracing::{Level, debug, error, info, warn}; @@ -18,24 +18,13 @@ type Debouncer = notify_debouncer_full::Debouncer, + debouncer: Arc>>, + watch_tx: Option>, owner_thread: Option>, } -const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250); +const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50); const MAX_PATHS_THRESHOLD: usize = 1024; /// On macOS, each `watch()` call creates a separate FSEventStream. When the /// number of directories exceeds this threshold we fall back to a single @@ -44,15 +33,15 @@ const MAX_MACOS_NONRECURSIVE_WATCHES: usize = 4096; /// Minimum seconds between frecency tracks of the same file in AI mode. /// Prevents score inflation from rapid burst edits by AI agents. const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60; +const MAX_OVERFLOW_FILES: usize = 1024; impl BackgroundWatcher { pub fn new( base_path: PathBuf, git_workdir: Option, - shared_picker: SharedPicker, + shared_picker: SharedFilePicker, shared_frecency: SharedFrecency, mode: FFFMode, - watch_dirs: Vec, ) -> Result { info!( "Initializing background watcher for path: {}, mode: {:?}", @@ -60,11 +49,41 @@ impl BackgroundWatcher { mode, ); - let (watch_tx, watch_rx) = mpsc::channel::(); + // Refuse to watch the filesystem root or the user's home directory. + // These are prone to high-volume event churn (editor temp files, + // browser caches, log rotations) which inflates the overflow arena + // and, on macOS, can exhaust the per-process FSEvents stream limit. + if base_path.parent().is_none() + || Some(base_path.as_os_str()) == dirs::home_dir().as_ref().map(|p| p.as_os_str()) + { + return Err(Error::FilesystemRoot(base_path)); + } - // Clone shared state for the owner thread (needed for injecting - // files that existed before a watch was registered on their directory). - let owner_picker = shared_picker.clone(); + // macOS: always use a single recursive FSEvent stream. + // + // Per-dir NonRecursive watches create one FSEvent stream per dir. + // The per-process FSEvent cap is lower than expected in practice + // (4096 per process, but FFF usually is running within code editors), + // and each failed `watch()` after the cap blocks ~40 ms on kernel retry. + // Yes we pay for filtering events on handler phase but it is usable + // + // macOS and Windows use a single recursive watch. FSEvents and + // ReadDirectoryChangesW both support true kernel-level recursion + // on one handle — per-dir NonRecursive watches burn streams/handles + // for no benefit and, on Windows, have been observed to silently + // drop Modify events for nested paths. + // + // Linux keeps the per-dir NonRecursive strategy: inotify has no + // kernel-level recursion, so Recursive here would still register + // one watch per subdir but without the ignored-dir filtering we + // get by iterating `picker.for_each_dir` ourselves. + let use_recursive = cfg!(any(target_os = "macos", target_os = "windows")); + + let (watch_tx, watch_rx) = mpsc::channel::(); + let watch_tx_for_debouncer = watch_tx.clone(); + + let owner_weak_picker = shared_picker.weaken(); + let owner_frecency = shared_frecency.clone(); let owner_git_workdir = git_workdir.clone(); let debouncer = Self::create_debouncer( @@ -73,57 +92,73 @@ impl BackgroundWatcher { shared_picker, shared_frecency, mode, - watch_dirs, - watch_tx, + use_recursive, + watch_tx_for_debouncer, )?; + info!("Background file watcher initialized successfully"); - let stop_signal = Arc::new(AtomicBool::new(false)); - let stop_clone = Arc::clone(&stop_signal); + // debouncer is shared with the owner thread, once it's dropped the thread is closed + let debouncer = Arc::new(Mutex::new(Some(debouncer))); + // Only the Linux per-dir-watch branch needs this clone; on other + // platforms the owner thread never touches the debouncer. + #[cfg(target_os = "linux")] + let owner_debouncer = Arc::clone(&debouncer); - // The owner thread keeps the debouncer alive and ensures proper - // cleanup: `Debouncer::stop()` joins its internal thread, then the - // watcher `Drop` signals its I/O thread to exit. let owner_thread = std::thread::Builder::new() - .name("fff-watcher-owner".into()) + .name("fff-watcher-own".into()) .spawn(move || { - let mut debouncer = debouncer; - while !stop_clone.load(Ordering::Acquire) { - // Process pending watch requests from the event handler - // (new directories that need to be watched). - while let Ok(dir) = watch_rx.try_recv() { - match debouncer.watch(dir.as_path(), RecursiveMode::NonRecursive) { - Ok(()) => { - debug!("Added watch for new directory: {}", dir.display()); - } - Err(e) => { - warn!("Failed to watch new directory {}: {}", dir.display(), e); - } - } + while let Ok(dir) = watch_rx.recv() { + // if the picker is dropped we do need to exit the loop + let Some(strong_picker) = owner_weak_picker.upgrade() else { + break; + }; - // Files created before the watch was registered don't - // generate events. Do a flat (non-recursive) read_dir - // to inject any files that already exist. Subdirectories - // are not descended — they get their own watches via - // future Create events from this directory's watch. - inject_existing_files(&dir, &owner_picker, &owner_git_workdir); + // Only inotify (Linux) has no kernel-level recursion, so + // it's the only platform that needs a per-subdir watch to + // be registered at runtime. macOS FSEvents and Windows + // ReadDirectoryChangesW are already watching recursively + // from the base path (see `create_debouncer`), and + // registering a second overlapping stream there produces + // duplicate/out-of-order events. + #[cfg(target_os = "linux")] + { + // Register the new directory with the debouncer, then + // drop the mutex BEFORE doing picker-side work — see + // the comment on `BackgroundWatcher::stop` for the + // lock-ordering rationale. + let mut guard = owner_debouncer.lock(); + let Some(debouncer) = guard.as_mut() else { + break; + }; + + if let Err(e) = debouncer.watch(&dir, RecursiveMode::NonRecursive) { + warn!( + ?e, + dir = %dir.display(), + "Failed to init watcher for new directory" + ); + } } - std::thread::park_timeout(Duration::from_secs(1)); + + track_files_from_new_directories( + &dir, + &strong_picker, + &owner_frecency, + &owner_git_workdir, + ); + + // Transient strong ref drops here, back + // to weak-only before the next `recv()`. } - // Debouncer::stop() joins the debouncer's event thread, then - // drops the watcher (whose Drop signals the I/O thread). - debouncer.stop(); - // On Windows the notify crate discards the ReadDirectoryChangesW - // thread's JoinHandle — we cannot join it. Its Drop signals the - // thread via semaphore so it exits almost immediately; give the - // OS a moment to fully reclaim it. - #[cfg(windows)] - std::thread::sleep(Duration::from_millis(250)); + + tracing::info!("Background watcher is stopped"); }) .expect("failed to spawn fff-watcher-owner thread"); Ok(Self { - stop_signal, + debouncer, + watch_tx: Some(watch_tx), owner_thread: Some(owner_thread), }) } @@ -131,10 +166,10 @@ impl BackgroundWatcher { fn create_debouncer( base_path: PathBuf, git_workdir: Option, - shared_picker: SharedPicker, + shared_picker: SharedFilePicker, shared_frecency: SharedFrecency, mode: FFFMode, - watch_dirs: Vec, + use_recursive: bool, watch_tx: mpsc::Sender, ) -> Result { let config = Config::default() @@ -146,33 +181,44 @@ impl BackgroundWatcher { // our own grep calls and preview window rendering .with_event_kinds(EventKindMask::CORE); - // Decide the watching strategy up-front so the event handler closure - // knows whether it needs to request dynamic directory watches. - let use_recursive = - cfg!(target_os = "macos") && watch_dirs.len() > MAX_MACOS_NONRECURSIVE_WATCHES; - + // `use_recursive` was decided by the caller from a cheap size hint, + // so the event-handler closure can capture it directly. + // + // The closure lives on the debouncer's internal event thread + // for as long as the debouncer exists — i.e. the full + // lifetime of `BackgroundWatcher`. Capturing a strong + // `SharedFilePicker` here would re-introduce the Arc cycle + // we just broke with `owner_picker`'s `downgrade()` above. + // Capture a weak handle instead and upgrade per-batch. let git_workdir_for_handler = git_workdir.clone(); + let shared_picker_for_watching = shared_picker.clone(); + let event_picker = shared_picker.weaken(); let mut debouncer = new_debouncer_opt( DEBOUNCE_TIMEOUT, Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span { move |result: DebounceEventResult| match result { Ok(events) => { + // Upgrade just long enough to drive one + // debounced batch. Failure means every + // external `SharedFilePicker` has already + // dropped and teardown is already underway. + let Some(strong_picker) = event_picker.upgrade() else { + return; + }; + let new_dirs = handle_debounced_events( events, &git_workdir_for_handler, - &shared_picker, + &strong_picker, &shared_frecency, mode, ); - // In NonRecursive mode, register watches for newly - // discovered directories so future file events in them - // are captured. In Recursive mode the single stream - // already covers new subdirectories. - if !use_recursive { - for dir in new_dirs { - let _ = watch_tx.send(dir); + // every new directory creates had to be reflected in the picker state + for dir in new_dirs { + if let Err(e) = watch_tx.send(dir) { + warn!(?e, "Failed to send directory update error"); } } } @@ -215,28 +261,70 @@ impl BackgroundWatcher { debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?; info!( "File watcher initialized with single recursive watch on {} \ - ({} directories exceeded threshold of {})", + (exceeded threshold of {})", base_path.display(), - watch_dirs.len(), MAX_MACOS_NONRECURSIVE_WATCHES, ); } else { debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?; - for dir in &watch_dirs { - match debouncer.watch(dir.as_path(), RecursiveMode::NonRecursive) { - Ok(()) => {} - Err(e) => { - // Non-fatal: directory may have been removed between discovery and watch - warn!("Failed to watch directory {}: {}", dir.display(), e); + // Stream watch-dir registration directly under the picker + // read lock. Only Linux (inotify) reaches this branch — + // macOS always takes the recursive path above. `inotify`'s + // `inotify_add_watch()` is fast-fail: on ENOSPC it returns + // immediately, no kernel retry loop, so holding the read + // lock across the stream is O(ms) even for large repos. + // + // Abort the loop after a run of failures. Once ENOSPC hits, + // further calls won't succeed until the user raises + // `fs.inotify.max_user_watches`, so there's no value in + // continuing. + const MAX_CONSECUTIVE_WATCH_FAILURES: usize = 16; + + let mut watched = 0usize; + let mut consecutive_failures = 0usize; + let mut aborted_early = false; + + if let Some(guard) = shared_picker_for_watching.read().ok() + && let Some(picker) = guard.as_ref() + { + use std::ops::ControlFlow; + picker.for_each_dir(|dir| { + match debouncer.watch(dir, RecursiveMode::NonRecursive) { + Ok(()) => { + watched += 1; + consecutive_failures = 0; + ControlFlow::Continue(()) + } + Err(e) => { + consecutive_failures += 1; + if consecutive_failures <= 4 { + warn!("Failed to watch directory {}: {}", dir.display(), e); + } + + if consecutive_failures >= MAX_CONSECUTIVE_WATCH_FAILURES { + warn!( + consecutive_failures, + watched, + "Aborting NonRecursive watch loop — per-process \ + watch cap exhausted, further dirs would just burn \ + kernel time for no coverage" + ); + aborted_early = true; + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } } - } + }); } info!( - "File watcher initialized for {} directories (NonRecursive) under {}", - watch_dirs.len(), - base_path.display() + "File watcher initialized for {} directories (NonRecursive) under {} (aborted_early={})", + watched, + base_path.display(), + aborted_early, ); } @@ -252,17 +340,50 @@ impl BackgroundWatcher { Ok(debouncer) } + /// Signal the watcher to shut down without blocking on its worker + /// threads. Safe to call from any context, including while holding + /// the [`SharedFilePicker`] write lock. + /// + /// Both the debouncer's internal event loop and our owner thread + /// may call `SharedFilePicker::write()` inside their handlers. A + /// blocking join here would deadlock against a caller that already + /// holds that lock (e.g. `stop_background_monitor` under a + /// `shared_picker.write()` guard). Instead we: + /// + /// * drop the `watch_tx` Sender — the owner thread's + /// `watch_rx.recv()` returns `Err` and the thread exits at + /// its next `recv`. + /// * call `debouncer.stop_nonblocking()` — signals the debouncer + /// event loop to exit on its next tick and drops the watcher, + /// closing the FSEvent / inotify / ReadDirectoryChangesW stream. + /// * detach both `JoinHandle`s. + /// + /// In-flight handler invocations finish on their own (at most one + /// more batch) once the caller releases any locks they hold. pub fn stop(&mut self) { - self.stop_signal.store(true, Ordering::Release); - if let Some(handle) = self.owner_thread.take() { - handle.thread().unpark(); - - if let Err(e) = handle.join() { - error!("Watcher owner thread panicked: {:?}", e); - } + self.watch_tx.take(); + if let Some(debouncer) = self.debouncer.lock().take() { + debouncer.stop_nonblocking(); } - info!("Background file watcher stopped successfully"); + self.owner_thread.take(); + + info!("Background file watcher stop signaled"); + } + + /// Queue a non-recursive watch registration on `dir`. + /// + /// The owner thread is always blocked on `watch_rx.recv()`, so + /// the `send()` here wakes it immediately via the channel's + /// condvar — no external unpark needed. + /// + /// Returns `false` once `stop()` has dropped our `Sender` — any + /// further request is silently discarded. + pub(crate) fn request_watch_dir(&self, dir: PathBuf) -> bool { + match self.watch_tx.as_ref() { + Some(tx) => tx.send(dir).is_ok(), + None => false, + } } } @@ -276,7 +397,7 @@ impl Drop for BackgroundWatcher { fn handle_debounced_events( events: Vec, git_workdir: &Option, - shared_picker: &SharedPicker, + shared_picker: &SharedFilePicker, shared_frecency: &SharedFrecency, mode: FFFMode, ) -> Vec { @@ -285,6 +406,7 @@ fn handle_debounced_events( let mut need_full_rescan = false; let mut need_full_git_rescan = false; let mut paths_to_remove = Vec::new(); + let mut dirs_to_remove: Vec = Vec::new(); let mut paths_to_add_or_modify = Vec::new(); let mut new_dirs_to_watch = Vec::new(); let mut affected_paths_count = 0usize; @@ -346,8 +468,20 @@ fn handle_debounced_events( // - Remove events are not always emitted (macOS often sends // Modify(Name(Any)) instead of Remove). let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_)); + // Directory-level remove: macOS FSEvents delivers a single + // `Remove(Folder)` event for a whole directory tree (e.g. + // after `git reset --hard` wipes a dir full of staged-but- + // uncommitted files). Individual per-file Remove events for + // the children do *not* arrive. Treat the folder removal as + // "evict every indexed descendant". + let is_folder_removal = matches!( + debounced_event.event.kind, + EventKind::Remove(notify::event::RemoveKind::Folder) + ); - if is_removal || !path.exists() { + if is_folder_removal { + dirs_to_remove.push(path.to_path_buf()); + } else if is_removal || !path.exists() { paths_to_remove.push(path.as_path()); } else if path.is_dir() { // New directory — collect it so the caller can register a @@ -382,7 +516,9 @@ fn handle_debounced_events( if need_full_rescan { info!(?affected_paths_count, "Triggering full rescan"); - trigger_full_rescan(shared_picker, shared_frecency); + if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) { + error!("Failed to trigger full rescan: {:?}", e); + } return Vec::new(); } @@ -393,59 +529,90 @@ fn handle_debounced_events( paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str())); info!( - "Event processing summary: {} to remove, {} to add/modify, {} new dirs", + "Event processing summary: {} to remove, {} dirs to remove, {} to add/modify, {} new dirs", paths_to_remove.len(), + dirs_to_remove.len(), paths_to_add_or_modify.len(), new_dirs_to_watch.len() ); // Apply file index updates (add/remove) unconditionally — these must // happen even when there is no git repository. - let files_to_update_git_status = - if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() { - debug!( - "Applying file index changes: {} to remove, {} to add/modify", - paths_to_remove.len(), - paths_to_add_or_modify.len(), + let (files_to_update_git_status, overflow_count) = if !paths_to_remove.is_empty() + || !dirs_to_remove.is_empty() + || !paths_to_add_or_modify.is_empty() + { + debug!( + "Applying file index changes: {} to remove, {} dirs to remove, {} to add/modify", + paths_to_remove.len(), + dirs_to_remove.len(), + paths_to_add_or_modify.len(), + ); + + let apply_changes = |picker: &mut FilePicker| -> (Vec, usize) { + // Remove whole directories first so any subsequent single-file + // remove event for a path that lived under them becomes a cheap + // no-op rather than a failed lookup. + for dir in &dirs_to_remove { + let count = picker.remove_all_files_in_dir(dir); + debug!("remove_all_files_in_dir({:?}) -> {} files", dir, count); + } + + for path in &paths_to_remove { + let removed = picker.remove_file_by_path(path); + debug!("remove_file_by_path({:?}) -> {}", path, removed); + } + + let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len()); + for path in &paths_to_add_or_modify { + let added = picker.on_create_or_modify(path).is_some(); + if added { + debug!("on_create_or_modify({:?}) -> Some", path); + files_to_update.push(path.to_path_buf()); + } else { + error!("on_create_or_modify({:?}) -> None (file not added!)", path); + } + } + let overflow_count = picker.get_overflow_files().len(); + info!( + "apply_changes complete: {} files to update git status, overflow={}", + files_to_update.len(), + overflow_count, ); - - let apply_changes = |picker: &mut FilePicker| -> Vec { - for path in &paths_to_remove { - let removed = picker.remove_file_by_path(path); - debug!("remove_file_by_path({:?}) -> {}", path, removed); - } - - let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len()); - for path in &paths_to_add_or_modify { - let added = picker.on_create_or_modify(path).is_some(); - if added { - debug!("on_create_or_modify({:?}) -> Some", path); - files_to_update.push(path.to_path_buf()); - } else { - error!("on_create_or_modify({:?}) -> None (file not added!)", path); - } - } - info!( - "apply_changes complete: {} files to update git status", - files_to_update.len() - ); - files_to_update - }; - - let Ok(mut guard) = shared_picker.write() else { - error!("Failed to acquire file picker write lock"); - return new_dirs_to_watch; - }; - let Some(ref mut picker) = *guard else { - error!("File picker not initialized"); - return new_dirs_to_watch; - }; - apply_changes(picker) - } else { - debug!("No file index changes to apply"); - Vec::new() + (files_to_update, overflow_count) }; + let Ok(mut guard) = shared_picker.write() else { + error!("Failed to acquire file picker write lock"); + return new_dirs_to_watch; + }; + let Some(ref mut picker) = *guard else { + error!("File picker not initialized"); + return new_dirs_to_watch; + }; + apply_changes(picker) + } else { + debug!("No file index changes to apply"); + (Vec::new(), 0) + }; + + // The overflow arena grows monotonically as new files are created — a + // file's chunks are added on creation but never reclaimed on removal. + // On directories with high churn (e.g. `$HOME` with editor temp files, + // browser caches) this inflates RSS unboundedly. Once overflow exceeds + // the threshold, fall back to a full rescan: that replaces `sync_data` + // and drops the builder arena, which is the only path that reclaims it. + if overflow_count > MAX_OVERFLOW_FILES { + warn!( + ?overflow_count, + "Overflow count exceeded the threshold, triggering full rescan.", + ); + if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) { + error!("Failed to trigger full rescan: {:?}", e); + } + return new_dirs_to_watch; + } + // AI mode: auto-track frecency for all modified/created files. // Uses a 5-minute cooldown per file to prevent score inflation from rapid // burst edits (AI agents often edit the same file many times in minutes). @@ -499,11 +666,19 @@ fn handle_debounced_events( if need_full_git_rescan { info!("Triggering full git rescan"); - let result = shared_picker.refresh_git_status(shared_frecency); - if let Err(e) = result { + if let Err(e) = shared_picker.refresh_git_status(shared_frecency) { error!("Failed to refresh git status: {:?}", e); } - return new_dirs_to_watch; + // IMPORTANT: do NOT return here. When a batch contains both + // `.git/index` events (e.g. from `git add`) AND worktree-file + // Modify events (e.g. a subsequent edit to the same file), + // `refresh_git_status` might run while libgit2 sees an + // intermediate state — lock-wait mitigates this but can't fully + // eliminate it, and refresh doesn't always observe the final + // worktree contents if the edit event landed just before the + // batch flushed. Re-running the per-path query for explicitly + // changed files overrides any stale bits from refresh with an + // authoritative per-file status read. } if !files_to_update_git_status.is_empty() { @@ -536,40 +711,14 @@ fn handle_debounced_events( new_dirs_to_watch } -fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) { - info!("Triggering full filesystem rescan"); - - // Note: no need to clear mmaps — they are backed by the kernel page cache - // and automatically reflect file changes. Old FileItems (and their mmaps) - // are dropped when the picker rebuilds its file list. - - let Ok(mut guard) = shared_picker.write() else { - error!("Failed to acquire file picker write lock for full rescan"); - return; - }; - let Some(ref mut picker) = *guard else { - error!("File picker not initialized, cannot trigger rescan"); - return; - }; - if let Err(e) = picker.trigger_rescan(shared_frecency) { - error!("Failed to trigger full rescan: {:?}", e); - return; - } - info!("Full filesystem rescan completed successfully"); - - // Spawn background warmup + bigram rebuild (mirrors the initial scan's - // post-scan phase). The write lock is still held here but the spawned - // thread re-acquires it later — safe because the guard drops at function end. - // NOTE: must NOT call shared_picker.need_complex_rebuild() here — that would - // try to read-lock the same RwLock we already hold as write, causing a deadlock. - if picker.need_enable_mmap_cache() || picker.need_enable_content_indexing() { - picker.spawn_post_rescan_rebuild(shared_picker.clone()); - } -} - /// After registering a watch on a newly created directory, list its /// immediate children and add any files to the picker. -fn inject_existing_files(dir: &Path, shared_picker: &SharedPicker, git_workdir: &Option) { +fn track_files_from_new_directories( + dir: &Path, + shared_picker: &SharedFilePicker, + shared_frecency: &SharedFrecency, + git_workdir: &Option, +) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; @@ -590,15 +739,36 @@ fn inject_existing_files(dir: &Path, shared_picker: &SharedPicker, git_workdir: return; } - let Ok(mut guard) = shared_picker.write() else { - return; - }; - let Some(ref mut picker) = *guard else { - return; - }; + // brief read lock + { + let Ok(mut guard) = shared_picker.write() else { + return; + }; - for path in &files_to_add { - picker.on_create_or_modify(path); + let Some(ref mut picker) = *guard else { + return; + }; + + for path in &files_to_add { + picker.on_create_or_modify(path); + } + } + + if let Some(repo) = repo.as_ref() { + let status = match GitStatusCache::git_status_for_paths(repo, &files_to_add) { + Ok(status) => status, + Err(e) => { + tracing::error!(?e, "inject_existing_files: git status query failed"); + return; + } + }; + + if let Ok(mut guard) = shared_picker.write() + && let Some(ref mut picker) = *guard + && let Err(e) = picker.update_git_statuses(status, shared_frecency) + { + error!("inject_existing_files: failed to update git statuses: {e:?}"); + } } debug!( @@ -638,7 +808,7 @@ fn is_path_ignored(path: &Path, repo: &Option) -> bool { } #[inline] -fn is_git_file(path: &Path) -> bool { +pub(crate) fn is_git_file(path: &Path) -> bool { path.components() .any(|component| component.as_os_str() == ".git") } diff --git a/crates/fff-core/src/bigram_filter.rs b/crates/fff-core/src/bigram_filter.rs index 561de2b..5572305 100644 --- a/crates/fff-core/src/bigram_filter.rs +++ b/crates/fff-core/src/bigram_filter.rs @@ -1,6 +1,9 @@ -use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering}; - use ahash::AHashMap; +use rayon::iter::{IndexedParallelIterator, ParallelIterator}; +use rayon::slice::ParallelSlice; +use std::cell::UnsafeCell; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU16, AtomicUsize, Ordering}; /// Maximum number of distinct bigrams tracked in the inverted index. /// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible. @@ -17,24 +20,27 @@ pub struct BigramIndexBuilder { // we use lookup as atomics only in the builder because it is filled by the rayon threads // the actual index uses pure u16 for the allocations lookup: Vec, - /// Per-column bitset data, lazily allocated via OnceLock. - col_data: Vec, + /// Flat bitset data, materialised on first use. + col_data: OnceLock>>, next_column: AtomicU16, words: usize, file_count: usize, populated: AtomicUsize, } +// SAFETY: `col_data`'s interior mutability is coordinated via disjoint +// `word_idx` ranges (word-aligned file partitioning in the driver), so +// concurrent access is safe despite the `UnsafeCell`. See builder doc. +unsafe impl Sync for BigramIndexBuilder {} + impl BigramIndexBuilder { pub fn new(file_count: usize) -> Self { let words = file_count.div_ceil(64); let mut lookup = Vec::with_capacity(65536); lookup.resize_with(65536, || AtomicU16::new(NO_COLUMN)); - let mut col_data = Vec::with_capacity(MAX_BIGRAM_COLUMNS * words); - col_data.resize_with(MAX_BIGRAM_COLUMNS * words, || AtomicU64::new(0)); Self { lookup, - col_data, + col_data: OnceLock::new(), next_column: AtomicU16::new(0), words, file_count, @@ -42,6 +48,23 @@ impl BigramIndexBuilder { } } + /// Lazily materialise the full `MAX_BIGRAM_COLUMNS * words` bitset + /// on first access. + #[inline(always)] + fn col_data_cell(&self) -> &UnsafeCell> { + self.col_data.get_or_init(|| { + let total = MAX_BIGRAM_COLUMNS * self.words; + UnsafeCell::new(vec![0u64; total].into_boxed_slice()) + }) + } + + /// Raw pointer to the start of the bitset slab. Used for in-place + /// `|=` writes under the partitioning invariant. + #[inline(always)] + fn col_data_ptr(&self) -> *mut u64 { + unsafe { (*self.col_data_cell().get()).as_mut_ptr() } + } + #[inline] fn get_or_alloc_column(&self, key: u16) -> u16 { let current = self.lookup[key as usize].load(Ordering::Relaxed); @@ -64,13 +87,36 @@ impl BigramIndexBuilder { } } - #[inline] - fn column_bitset(&self, col: u16) -> &[AtomicU64] { - let start = col as usize * self.words; - &self.col_data[start..start + self.words] + /// SAFETY: caller must not access the same `word_idx` slot from + /// another thread concurrently. Partitioning in + /// `file_picker::build_bigram_index` enforces this. + #[inline(always)] + unsafe fn column_word_ptr(&self, col: u16, word_idx: usize) -> *mut u64 { + unsafe { + self.col_data_ptr() + .add(col as usize * self.words + word_idx) + } } - pub(crate) fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) { + /// Test/bench accessor for a column's raw bitset words. Assumes the + /// caller has joined all writers (no concurrent mutation). + #[cfg(test)] + fn column_bitset(&self, col: u16) -> &[u64] { + let start = col as usize * self.words; + let slab = unsafe { &*self.col_data_cell().get() }; + &slab[start..start + self.words] + } + + // `pub` (via `#[doc(hidden)]`) only so the criterion bench can drive + // `add_file_content` directly. External consumers should use + // `build_bigram_index` instead. + /// + /// SAFETY: concurrent callers must partition `file_idx` by + /// word-aligned ranges so that `file_idx / 64` never collides across + /// threads. The `file_picker::build_bigram_index` driver enforces + /// this via `par_chunks` with a word-aligned chunk size. + #[doc(hidden)] + pub fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) { if content.len() < 2 { return; } @@ -79,68 +125,75 @@ impl BigramIndexBuilder { let word_idx = file_idx / 64; let bit_mask = 1u64 << (file_idx % 64); - // Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536 bigrams with margin - // have to fit in L1 cache + // Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536 + // bigram keys with margin. Has to fit in L1 cache. let mut seen_consec = [0u64; 1024]; let mut seen_skip = [0u64; 1024]; + // Normalise each byte as we stream and carry a 2-byte history + // across iterations so each input byte is normalised exactly once + // even though it participates in up to three bigrams (as `cur`, + // then `prev`, then `skip_prev`). Benchmarked against a NEON + // pre-pass variant — the pre-pass needs a heap scratch per call, + // which kills throughput unless content is gigantic. Inline + // normalisation is the faster choice for realistic file sizes. let bytes = content; let len = bytes.len(); - // First consecutive pair (no skip bigram possible yet). - let (a, b) = (bytes[0], bytes[1]); - if (32..=126).contains(&a) && (32..=126).contains(&b) { - let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16; - let w = key as usize >> 6; - let bit = 1u64 << (key as usize & 63); - seen_consec[w] |= bit; - let col = self.get_or_alloc_column(key); - if col != NO_COLUMN { - self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed); - } + let mut n0 = normalize_byte_scalar(bytes[0]); + let mut n1 = normalize_byte_scalar(bytes[1]); + + if n0 != u16::MAX && n1 != u16::MAX { + let key = (n0 << 8) | n1; + self.record_bigram(&mut seen_consec, key, word_idx, bit_mask); } - // Main loop: consecutive (i-1, i) and skip-1 (i-2, i) - for i in 2..len { - let cur = bytes[i]; - - // Consecutive bigram: (bytes[i-1], bytes[i]) - let prev = bytes[i - 1]; - if (32..=126).contains(&prev) && (32..=126).contains(&cur) { - let key = (prev.to_ascii_lowercase() as u16) << 8 | cur.to_ascii_lowercase() as u16; - let w = key as usize >> 6; - let bit = 1u64 << (key as usize & 63); - if seen_consec[w] & bit == 0 { - seen_consec[w] |= bit; - let col = self.get_or_alloc_column(key); - if col != NO_COLUMN { - self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed); - } - } - } - - // Skip-1 bigram: (bytes[i-2], bytes[i]) - let skip_prev = bytes[i - 2]; - if (32..=126).contains(&skip_prev) && (32..=126).contains(&cur) { - let key = - (skip_prev.to_ascii_lowercase() as u16) << 8 | cur.to_ascii_lowercase() as u16; - let w = key as usize >> 6; - let bit = 1u64 << (key as usize & 63); - if seen_skip[w] & bit == 0 { - seen_skip[w] |= bit; - let col = skip_builder.get_or_alloc_column(key); - if col != NO_COLUMN { - skip_builder.column_bitset(col)[word_idx] - .fetch_or(bit_mask, Ordering::Relaxed); - } + for &b in &bytes[2..len] { + let cur = normalize_byte_scalar(b); + if cur != u16::MAX { + if n1 != u16::MAX { + let key = (n1 << 8) | cur; + self.record_bigram(&mut seen_consec, key, word_idx, bit_mask); + } + if n0 != u16::MAX { + let key = (n0 << 8) | cur; + skip_builder.record_bigram(&mut seen_skip, key, word_idx, bit_mask); } } + n0 = n1; + n1 = cur; } self.populated.fetch_add(1, Ordering::Relaxed); skip_builder.populated.fetch_add(1, Ordering::Relaxed); } + /// Mark `key` as present for the file whose column-word is `word_idx` + /// and bit position is `bit_mask`, de-duplicating via the caller-owned + /// `seen` bitmap so we only touch the shared column slab at most once + /// per unique bigram per file. + /// + /// SAFETY: under the partitioning invariant on `add_file_content` + /// the `word_idx` slot this touches is owned exclusively by the + /// current thread, so a plain `|=` through the raw pointer is + /// race-free (no atomic RMW needed). + #[inline(always)] + fn record_bigram(&self, seen: &mut [u64; 1024], key: u16, word_idx: usize, bit_mask: u64) { + let k = key as usize; + let w = k >> 6; + let bit = 1u64 << (k & 63); + if seen[w] & bit == 0 { + seen[w] |= bit; + let col = self.get_or_alloc_column(key); + if col != NO_COLUMN { + unsafe { + let p = self.column_word_ptr(col, word_idx); + *p |= bit_mask; + } + } + } + } + pub fn is_ready(&self) -> bool { self.populated.load(Ordering::Relaxed) > 0 } @@ -157,6 +210,7 @@ impl BigramIndexBuilder { /// the default ~3.1% heuristic when `None`) and <90% of indexed files. /// Sparse columns carry too little data to justify their memory; /// ubiquitous columns (≥90%) are nearly all-ones and barely filter. + #[inline(always)] pub fn compress(self, min_density_pct: Option) -> BigramFilter { let cols = self.columns_used() as usize; let words = self.words; @@ -165,58 +219,57 @@ impl BigramIndexBuilder { let dense_bytes = words * 8; // cost of one dense column let old_lookup = self.lookup; - let col_data = self.col_data; + // If no file ever populated content, col_data was never + // materialised. Treat as empty — every column falls through. + let col_data: Option> = self.col_data.into_inner().map(UnsafeCell::into_inner); let mut lookup: Vec = vec![NO_COLUMN; 65536]; let mut dense_data: Vec = Vec::with_capacity(cols * words); let mut dense_count: usize = 0; - for key in 0..65536usize { - let old_col = old_lookup[key].load(Ordering::Relaxed); - if old_col == NO_COLUMN || old_col as usize >= cols { - continue; - } + if let Some(col_data) = col_data.as_deref() { + for key in 0..65536usize { + let old_col = old_lookup[key].load(Ordering::Relaxed); + if old_col == NO_COLUMN || old_col as usize >= cols { + continue; + } - let col_start = old_col as usize * words; - let bitset = &col_data[col_start..col_start + words]; + let col_start = old_col as usize * words; + let bitset = &col_data[col_start..col_start + words]; - // count set bits to decide if this column is worth keeping. - let mut popcount = 0u32; - for column in bitset.iter().take(words) { - popcount += column.load(Ordering::Relaxed).count_ones(); - } + // count set bits to decide if this column is worth keeping. + let mut popcount = 0u32; + for &word in bitset.iter().take(words) { + popcount += word.count_ones(); + } - // drop bigrams appearing in too few files - let not_to_rare = if let Some(min_pct) = min_density_pct { - // Percentage-based: require ≥ min_pct% of populated files. - populated > 0 && (popcount as usize) * 100 >= populated * min_pct as usize - } else { - // Default: popcount ≥ words × 2 (~3.1% of files). - (popcount as usize * 4) >= dense_bytes - }; + // drop bigrams appearing in too few files + let not_to_rare = if let Some(min_pct) = min_density_pct { + // Percentage-based: require ≥ min_pct% of populated files. + populated > 0 && (popcount as usize) * 100 >= populated * min_pct as usize + } else { + // Default: popcount ≥ words × 2 (~3.1% of files). + (popcount as usize * 4) >= dense_bytes + }; - if !not_to_rare { - continue; - } + if !not_to_rare { + continue; + } - // Drop ubiquitous bigrams — columns ≥90% ones carry almost no - // filtering power and just waste memory + AND cycles. - if populated > 0 && (popcount as usize) * 10 >= populated * 9 { - continue; - } + // Drop ubiquitous bigrams — columns ≥90% ones carry almost no + // filtering power and just waste memory + AND cycles. + if populated > 0 && (popcount as usize) * 10 >= populated * 9 { + continue; + } - let dense_idx = dense_count as u16; - lookup[key] = dense_idx; - dense_count += 1; + let dense_idx = dense_count as u16; + lookup[key] = dense_idx; + dense_count += 1; - for column in bitset.iter().take(words) { - dense_data.push(column.load(Ordering::Relaxed)); + dense_data.extend_from_slice(bitset); } } - // col_data + old_lookup dropped here — single deallocation each, - // no fragmentation. - BigramFilter { lookup, dense_data, @@ -230,7 +283,6 @@ impl BigramIndexBuilder { } unsafe impl Send for BigramIndexBuilder {} -unsafe impl Sync for BigramIndexBuilder {} /// Inverted bigram index with optional "skip-1" extension /// Copmressed into bitset for minimal usage, the layout of this struct actually matters @@ -428,6 +480,24 @@ impl BigramFilter { } } +/// Map a single input byte to its normalised form used by the bigram +/// builder: `u16::MAX` when not printable ASCII (outside `32..=126`), +/// otherwise the lowercased byte value in `0..=126`. The `u16::MAX` +/// sentinel can never collide with a printable-ASCII byte so the consumer +/// can test `!= u16::MAX` without false positives. +/// +/// Branchless and `#[inline(always)]`: LLVM lifts the ASCII-range check +/// and the conditional-lowercase OR into a handful of instructions per +/// call, so calling this inside a hot loop matches a hand-unrolled +/// equivalent. +#[inline(always)] +fn normalize_byte_scalar(b: u8) -> u16 { + let printable = b.wrapping_sub(32) <= 94; + // Branchless lowercase: OR 0x20 iff byte is in 'A'..='Z'. + let lower = b | ((b.wrapping_sub(b'A') < 26) as u8 * 0x20); + if printable { lower as u16 } else { u16::MAX } +} + pub fn extract_bigrams(content: &[u8]) -> Vec { if content.len() < 2 { return Vec::new(); @@ -525,3 +595,547 @@ impl BigramOverlay { self.modified.keys().copied().collect() } } + +pub const BIGRAM_CONTENT_CAP: usize = 64 * 1024; +const BIGRAM_CHUNK_FILES: usize = 4 * 64; + +/// Sparse-column cutoff for the skip-1 sub-index. Rare skip columns add +/// little filtering power but ~25-30% of index memory, so we drop +/// anything appearing in < 12 % of populated files. +const SKIP_INDEX_MIN_DENSITY_PCT: u32 = 12; + +thread_local! { + /// Per-rayon-worker reusable read buffer. 64 KB is too large to + /// keep on the default pthread stack (macOS ships 512 KB), so the + /// buffer lives on the heap behind a `Box<[u8; N]>`. TLS keeps the + /// allocation alive for the thread's lifetime so we pay the cost + /// once, not per file. + static READ_BUF: std::cell::RefCell> = + std::cell::RefCell::new(Box::new([0u8; BIGRAM_CONTENT_CAP])); +} + +/// Outcome of processing one file's content. +enum FileOutcome { + /// Content contained a NUL byte — mark the file as binary so future + /// greps skip it without re-reading. + Binary, + /// Read succeeded and the content was fed to the bigram builder. + Indexed, + /// File was empty or failed to open; nothing to do. + Skipped, +} + +#[tracing::instrument(skip_all, name = "Building Bigram Index", level = tracing::Level::DEBUG)] +pub(crate) fn build_bigram_index( + files: &[crate::types::FileItem], + budget: &crate::types::ContentCacheBudget, + base_path: &std::path::Path, + arena: crate::simd_path::ArenaPtr, +) -> (BigramFilter, Vec) { + let start = std::time::Instant::now(); + tracing::info!("Building bigram index for {} files...", files.len()); + + let builder = BigramIndexBuilder::new(files.len()); + let skip_builder = BigramIndexBuilder::new(files.len()); + + // this does remove a memcpy for every single file + actually reducing open time on macos + #[cfg(unix)] + let base_fd: libc::c_int = open_base_dir_fd(base_path); + #[cfg(not(unix))] + let base_fd: i32 = -1; + + // `content_binary` is only touched from the Binary branch below, so + // the mutex is cold in practice. A lock-free collector wasn't worth + // the complexity. + let content_binary: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + + crate::file_picker::BACKGROUND_THREAD_POOL.install(|| { + files + .par_chunks(BIGRAM_CHUNK_FILES) + .enumerate() + .for_each(|(chunk_idx, chunk)| { + let base_idx = chunk_idx * BIGRAM_CHUNK_FILES; + for (offset, file) in chunk.iter().enumerate() { + let file_idx = base_idx + offset; + let outcome = process_file( + file, + file_idx, + &builder, + &skip_builder, + base_fd, + base_path, + arena, + budget, + ); + if matches!(outcome, FileOutcome::Binary) { + content_binary.lock().unwrap().push(file_idx); + } + } + }); + }); + + #[cfg(unix)] + if base_fd >= 0 { + // SAFETY: we opened `base_fd` at the top of this function and + // no worker still references it once the rayon pool joined. + unsafe { libc::close(base_fd) }; + } + + let content_binary_vec = content_binary.into_inner().unwrap(); + + let cols = builder.columns_used(); + let mut index = builder.compress(None); + let skip_index = skip_builder.compress(Some(SKIP_INDEX_MIN_DENSITY_PCT)); + index.set_skip_index(skip_index); + + // Builder buffers were freed by `compress()` above (one deallocation + // each); nudge mimalloc to return them (and any transient allocs) + // to the OS. + crate::file_picker::hint_allocator_collect(); + + tracing::info!( + "Bigram index built in {:.2}s — {} dense columns for {} files", + start.elapsed().as_secs_f64(), + cols, + files.len(), + ); + if !content_binary_vec.is_empty() { + tracing::info!( + "Bigram build detected {} content-binary files (not caught by extension)", + content_binary_vec.len(), + ); + } + + (index, content_binary_vec) +} + +/// Process one file: read up to `BIGRAM_CONTENT_CAP` bytes, feed them +/// to the bigram builder (or record as binary / skipped). +/// +/// `base_fd` is the parent-directory fd for the Unix `openat` fast +/// path, or `-1` to force the portable `std::fs::File::open` fallback. +#[inline] +#[allow(clippy::too_many_arguments)] +fn process_file( + file: &crate::types::FileItem, + file_idx: usize, + builder: &BigramIndexBuilder, + skip_builder: &BigramIndexBuilder, + base_fd: i32, + base_path: &std::path::Path, + arena: crate::simd_path::ArenaPtr, + budget: &crate::types::ContentCacheBudget, +) -> FileOutcome { + if file.is_binary() || file.size == 0 || file.size > budget.max_file_size { + return FileOutcome::Skipped; + } + + // Zero-copy fast path: the warmup phase may have cached this file's + // content already. Avoid re-reading from disk. + if let Some(cached) = file.get_content(arena, base_path, budget) { + if crate::file_picker::detect_binary_content(cached) { + return FileOutcome::Binary; + } + let capped = &cached[..cached.len().min(BIGRAM_CONTENT_CAP)]; + builder.add_file_content(skip_builder, file_idx, capped); + return FileOutcome::Indexed; + } + + let want = (file.size as usize).min(BIGRAM_CONTENT_CAP); + let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; + + READ_BUF.with(|read_cell| { + let mut buf = read_cell.borrow_mut(); + let filled = read_file_content( + file, + base_fd, + base_path, + arena, + &mut path_buf, + &mut buf[..want], + ); + if filled == 0 { + return FileOutcome::Skipped; + } + let data = &buf[..filled]; + if crate::file_picker::detect_binary_content(data) { + return FileOutcome::Binary; + } + builder.add_file_content(skip_builder, file_idx, data); + FileOutcome::Indexed + }) +} + +/// Read up to `buf.len()` bytes of `file`'s content into `buf`. Returns +/// the number of bytes actually read (0 on any error, so callers treat +/// failures as "skip"). +#[inline] +fn read_file_content( + file: &crate::types::FileItem, + base_fd: i32, + base_path: &std::path::Path, + arena: crate::simd_path::ArenaPtr, + path_buf: &mut [u8; crate::simd_path::PATH_BUF_SIZE], + buf: &mut [u8], +) -> usize { + #[cfg(unix)] + { + read_file_content_unix(file, base_fd, base_path, arena, path_buf, buf) + } + #[cfg(not(unix))] + { + let _ = base_fd; + read_file_content_std(file, base_path, arena, path_buf, buf) + } +} + +#[cfg(unix)] +fn read_file_content_unix( + file: &crate::types::FileItem, + base_fd: libc::c_int, + base_path: &std::path::Path, + arena: crate::simd_path::ArenaPtr, + path_buf: &mut [u8; crate::simd_path::PATH_BUF_SIZE], + buf: &mut [u8], +) -> usize { + let fd = if base_fd >= 0 { + let rel_cstr = file.write_relative_cstr(arena, path_buf); + // SAFETY: `rel_cstr` is NUL-terminated, `base_fd` is a valid + // directory descriptor owned by the caller. + unsafe { libc::openat(base_fd, rel_cstr.as_ptr(), libc::O_RDONLY) } + } else { + use std::os::unix::io::IntoRawFd; + let abs = file.write_absolute_path(arena, base_path, path_buf); + match std::fs::File::open(abs) { + Ok(f) => f.into_raw_fd(), + Err(_) => return 0, + } + }; + if fd < 0 { + return 0; + } + + let mut filled = 0usize; + while filled < buf.len() { + // SAFETY: `fd` is an owned descriptor, `buf[filled..]` is a + // valid writable slice for `buf.len() - filled` bytes. + let n = unsafe { + libc::read( + fd, + buf[filled..].as_mut_ptr() as *mut libc::c_void, + (buf.len() - filled) as libc::size_t, + ) + }; + if n <= 0 { + break; + } + filled += n as usize; + } + // SAFETY: matching close for the owned descriptor. + unsafe { libc::close(fd) }; + filled +} + +/// Open the base directory for the `openat` fast path. Returns `-1` on +/// failure — callers interpret a negative fd as "fall back to absolute +/// paths". +#[cfg(unix)] +fn open_base_dir_fd(base_path: &std::path::Path) -> libc::c_int { + use std::os::unix::ffi::OsStrExt; + let mut cstr = [0u8; crate::simd_path::PATH_BUF_SIZE]; + let bytes = base_path.as_os_str().as_bytes(); + if bytes.len() >= cstr.len() { + return -1; + } + cstr[..bytes.len()].copy_from_slice(bytes); + // SAFETY: `cstr` is NUL-terminated by construction (zero-initialised, + // and we only filled up to `bytes.len() < cstr.len()`). + unsafe { + libc::open( + cstr.as_ptr() as *const std::os::raw::c_char, + libc::O_RDONLY | libc::O_DIRECTORY, + ) + } +} + +/// Portable fallback (Windows + non-`openat` Unix): `std::fs::File` + +/// `Read::read` into `buf`. Used on Windows unconditionally, and on +/// Unix when the base directory fd could not be opened. +#[cfg(not(unix))] +fn read_file_content_std( + file: &crate::types::FileItem, + base_path: &std::path::Path, + arena: crate::simd_path::ArenaPtr, + path_buf: &mut [u8; crate::simd_path::PATH_BUF_SIZE], + buf: &mut [u8], +) -> usize { + use std::io::Read; + let abs = file.write_absolute_path(arena, base_path, path_buf); + let Ok(mut f) = std::fs::File::open(abs) else { + return 0; + }; + let mut filled = 0usize; + while filled < buf.len() { + match f.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(_) => return 0, + } + } + filled +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a key the same way `add_file_content` does: two printable-ASCII + /// bytes, lowercased, packed as `(hi << 8) | lo`. + fn key(a: u8, b: u8) -> u16 { + ((a.to_ascii_lowercase() as u16) << 8) | b.to_ascii_lowercase() as u16 + } + + /// Return the sorted list of (consec, skip) bigram keys that should appear + /// for `content`. Used as the reference implementation. + fn expected_bigrams(content: &[u8]) -> (Vec, Vec) { + let mut consec: std::collections::BTreeSet = Default::default(); + let mut skip: std::collections::BTreeSet = Default::default(); + let printable = |b: u8| (32..=126).contains(&b); + for i in 1..content.len() { + let a = content[i - 1]; + let b = content[i]; + if printable(a) && printable(b) { + consec.insert(key(a, b)); + } + if i >= 2 { + let a = content[i - 2]; + let b = content[i]; + if printable(a) && printable(b) { + skip.insert(key(a, b)); + } + } + } + (consec.into_iter().collect(), skip.into_iter().collect()) + } + + /// Query: does the builder record file 0 as having this bigram set? + fn builder_has_key_for_file_0(b: &BigramIndexBuilder, k: u16) -> bool { + let col = b.lookup[k as usize].load(Ordering::Relaxed); + if col == NO_COLUMN { + return false; + } + b.column_bitset(col)[0] & 1 != 0 + } + + fn run_and_compare(content: &[u8]) { + let consec = BigramIndexBuilder::new(1); + let skip = BigramIndexBuilder::new(1); + consec.add_file_content(&skip, 0, content); + + let (expected_consec, expected_skip) = expected_bigrams(content); + + // Every expected bigram must be recorded. + for k in &expected_consec { + assert!( + builder_has_key_for_file_0(&consec, *k), + "consec bigram 0x{k:04x} missing for content {content:?}", + ); + } + for k in &expected_skip { + assert!( + builder_has_key_for_file_0(&skip, *k), + "skip bigram 0x{k:04x} missing for content {content:?}", + ); + } + + // No unexpected bigrams — iterate lookup for set columns. + for k in 0u32..=0xFFFF { + let recorded_consec = builder_has_key_for_file_0(&consec, k as u16); + let recorded_skip = builder_has_key_for_file_0(&skip, k as u16); + if recorded_consec { + assert!( + expected_consec.contains(&(k as u16)), + "unexpected consec bigram 0x{k:04x} in content {content:?}", + ); + } + if recorded_skip { + assert!( + expected_skip.contains(&(k as u16)), + "unexpected skip bigram 0x{k:04x} in content {content:?}", + ); + } + } + } + + #[test] + fn add_file_empty_is_noop() { + let consec = BigramIndexBuilder::new(1); + let skip = BigramIndexBuilder::new(1); + consec.add_file_content(&skip, 0, b""); + assert_eq!(consec.columns_used(), 0); + assert_eq!(skip.columns_used(), 0); + // populated counter not incremented for empty input + assert_eq!(consec.populated.load(Ordering::Relaxed), 0); + } + + #[test] + fn add_file_single_byte_is_noop() { + let consec = BigramIndexBuilder::new(1); + let skip = BigramIndexBuilder::new(1); + consec.add_file_content(&skip, 0, b"a"); + assert_eq!(consec.columns_used(), 0); + assert_eq!(skip.columns_used(), 0); + } + + #[test] + fn add_file_two_bytes_consec_only() { + // With exactly 2 bytes there's no skip bigram (needs i >= 2 in the loop). + run_and_compare(b"ab"); + } + + #[test] + fn add_file_three_bytes_has_skip() { + // "abc" -> consec {"ab", "bc"}, skip {"ac"} + run_and_compare(b"abc"); + } + + #[test] + fn add_file_ascii_words() { + run_and_compare(b"hello world"); + run_and_compare(b"the quick brown fox jumps over the lazy dog"); + run_and_compare(b"fn main() { println!(\"hi\"); }"); + } + + #[test] + fn add_file_case_is_lowered() { + // Uppercase should be lowercased before keying, so "AB" == "ab". + let upper = BigramIndexBuilder::new(1); + let upper_skip = BigramIndexBuilder::new(1); + upper.add_file_content(&upper_skip, 0, b"ABC"); + + let lower = BigramIndexBuilder::new(1); + let lower_skip = BigramIndexBuilder::new(1); + lower.add_file_content(&lower_skip, 0, b"abc"); + + // Both should have identical bigram keys. + for k in 0u32..=0xFFFF { + let u = builder_has_key_for_file_0(&upper, k as u16); + let l = builder_has_key_for_file_0(&lower, k as u16); + assert_eq!(u, l, "consec 0x{k:04x}: upper={u} lower={l}"); + let u = builder_has_key_for_file_0(&upper_skip, k as u16); + let l = builder_has_key_for_file_0(&lower_skip, k as u16); + assert_eq!(u, l, "skip 0x{k:04x}: upper={u} lower={l}"); + } + } + + #[test] + fn add_file_rejects_non_printable() { + // Bigrams where either byte is outside 32..=126 are rejected. But + // the skip-1 bigram can still connect two printable bytes across a + // non-printable one: for "\0a\0b", consec sees no valid pair but + // skip sees (a,b) at i=3. Use the reference implementation. + run_and_compare(b"\0a\0b"); + + // All-zero input: truly nothing recorded. + let consec = BigramIndexBuilder::new(1); + let skip = BigramIndexBuilder::new(1); + consec.add_file_content(&skip, 0, b"\0\0\0\0"); + assert_eq!(consec.columns_used(), 0); + assert_eq!(skip.columns_used(), 0); + } + + #[test] + fn add_file_mixed_printable_and_control() { + // "a\tb\nc d" — \t (9) and \n (10) are below 32. Consec: + // (a, \t) x, (\t, b) x, (b, \n) x, (\n, c) x, (c, ' ') ok, (' ', d) ok + // Skip (i-2, i): + // (a, b) ok, (\t, \n) x, (b, c) ok, (\n, ' ') x, (c, d) ok + run_and_compare(b"a\tb\nc d"); + } + + #[test] + fn add_file_repeats_are_deduped() { + // "ababab" has many repeats of "ab", "ba" — each unique bigram should + // be recorded exactly once (the stack-local `seen_*` dedup works). + run_and_compare(b"ababababab"); + } + + #[test] + fn add_file_tombstone_separation() { + // Two separate files share no bits; file 1's content doesn't bleed + // into file 0's row and vice-versa. + let consec = BigramIndexBuilder::new(2); + let skip = BigramIndexBuilder::new(2); + consec.add_file_content(&skip, 0, b"xy"); + consec.add_file_content(&skip, 1, b"zw"); + + let key_xy = key(b'x', b'y'); + let key_zw = key(b'z', b'w'); + + // file 0 has "xy" but not "zw" + let col_xy = consec.lookup[key_xy as usize].load(Ordering::Relaxed); + let col_zw = consec.lookup[key_zw as usize].load(Ordering::Relaxed); + let bitset_xy = consec.column_bitset(col_xy)[0]; + let bitset_zw = consec.column_bitset(col_zw)[0]; + assert_eq!(bitset_xy & 0b01, 0b01, "file 0 should have xy"); + assert_eq!(bitset_zw & 0b01, 0, "file 0 should NOT have zw"); + assert_eq!(bitset_xy & 0b10, 0, "file 1 should NOT have xy"); + assert_eq!(bitset_zw & 0b10, 0b10, "file 1 should have zw"); + } + + #[test] + fn add_file_long_content() { + // Stress test: ~8 KB of printable ASCII. Should complete without + // overflowing any stack-local bitset and produce the full set. + let mut buf = Vec::with_capacity(8192); + for i in 0..8192 { + buf.push(32u8 + ((i * 7) % 95) as u8); // cycle through printable range + } + run_and_compare(&buf); + } + + #[test] + fn add_file_simd_and_scalar_agree() { + // Cross-check: both code paths (scalar <128 bytes, SIMD ≥128) must + // produce identical bigram sets for content that straddles the + // threshold. Mix printable ASCII with some non-printable bytes and + // repeats so the non-printable branch in the SIMD path exercises. + let mut mixed = Vec::with_capacity(256); + for i in 0..256usize { + mixed.push(match i % 9 { + 0 => 0, // NUL + 1 => 0x7F, // DEL (just above 126) + 2 => b'\n', // below 32 + _ => 32 + ((i * 13) % 95) as u8, + }); + } + + run_and_compare(&mixed[..127]); // scalar path + run_and_compare(&mixed); // SIMD path (256 bytes) + run_and_compare(&mixed[..192]); // SIMD path with scalar tail + } + + #[test] + fn add_file_respects_file_count_boundary() { + // file_count=100, file_idx=63 (last bit in word 0) and file_idx=64 + // (first bit in word 1). Make sure the word_idx math is right. + let consec = BigramIndexBuilder::new(100); + let skip = BigramIndexBuilder::new(100); + consec.add_file_content(&skip, 63, b"ab"); + consec.add_file_content(&skip, 64, b"cd"); + + let kab = key(b'a', b'b'); + let kcd = key(b'c', b'd'); + let col_ab = consec.lookup[kab as usize].load(Ordering::Relaxed); + let col_cd = consec.lookup[kcd as usize].load(Ordering::Relaxed); + + let ab_bitset = consec.column_bitset(col_ab); + let cd_bitset = consec.column_bitset(col_cd); + // ab in word 0, bit 63 + assert_eq!(ab_bitset[0], 1u64 << 63); + assert_eq!(ab_bitset[1], 0); + // cd in word 1, bit 0 + assert_eq!(cd_bitset[0], 0); + assert_eq!(cd_bitset[1], 1); + } +} diff --git a/crates/fff-core/src/error.rs b/crates/fff-core/src/error.rs index bb1c248..67b285c 100644 --- a/crates/fff-core/src/error.rs +++ b/crates/fff-core/src/error.rs @@ -8,7 +8,7 @@ pub enum Error { #[error("Invalid path {0}")] InvalidPath(std::path::PathBuf), #[error( - "Can not start fff at the file system root {0} — pass a project or at least home directory instead" + "Can not run certain FFF features in a file system root or home directories. Consider smaller per-project directories." )] FilesystemRoot(std::path::PathBuf), #[error("File picker not initialized")] diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index fdc55d8..fafec34 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -31,37 +31,40 @@ //! the file index, so read-heavy search workloads rarely contend. use crate::FFFStringStorage; -use crate::background_watcher::BackgroundWatcher; -use crate::bigram_filter::{BigramFilter, BigramIndexBuilder, BigramOverlay}; +use crate::background_watcher::{BackgroundWatcher, is_git_file}; +use crate::bigram_filter::{BigramFilter, BigramOverlay}; use crate::error::Error; use crate::frecency::FrecencyTracker; use crate::git::GitStatusCache; use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search}; use crate::ignore::non_git_repo_overrides; use crate::query_tracker::QueryTracker; +use crate::scan::{ScanConfig, ScanJob, ScanSignals}; use crate::score::fuzzy_match_and_score_files; -use crate::shared::{SharedFrecency, SharedPicker}; -use crate::simd_path::ArenaPtr; +use crate::shared::{SharedFilePicker, SharedFrecency}; +use crate::simd_path::{ArenaPtr, PATH_BUF_SIZE}; use crate::types::{ ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult, PaginationArgs, Score, ScoringContext, SearchResult, }; use fff_query_parser::FFFQuery; -use git2::{Repository, Status, StatusOptions}; +use git2::{Repository, Status}; use rayon::prelude::*; use std::fmt::Debug; +use std::ops::ControlFlow; use std::path::{Path, PathBuf}; use std::sync::{ Arc, LazyLock, atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, }; +use std::thread::JoinHandle; use std::time::SystemTime; use tracing::{Level, debug, error, info, warn}; /// Dedicated thread pool for background work (scan, warmup, bigram build). /// Uses fewer threads than the global rayon pool so Neovim's event loop /// and search queries can still get CPU time. -static BACKGROUND_THREAD_POOL: LazyLock = LazyLock::new(|| { +pub(crate) static BACKGROUND_THREAD_POOL: LazyLock = LazyLock::new(|| { let total = std::thread::available_parallelism() .map(|p| p.get()) .unwrap_or(4); @@ -73,6 +76,19 @@ static BACKGROUND_THREAD_POOL: LazyLock = LazyLock::new(|| { rayon::ThreadPoolBuilder::new() .num_threads(bg_threads) .thread_name(|i| format!("fff-bg-{i}")) + .start_handler(|_| { + // Pin workers to the USER_INITIATED QoS class on macOS so the + // scheduler keeps them on P-cores. Without this the kernel is + // free to drift them to E-cores, which are ~2× slower for the + // bigram scan and per-file syscalls. + #[cfg(target_os = "macos")] + unsafe { + let _ = libc::pthread_set_qos_class_self_np( + libc::qos_class_t::QOS_CLASS_USER_INITIATED, + 0, + ); + } + }) .build() .expect("failed to create background rayon pool") }); @@ -105,24 +121,21 @@ pub struct FuzzySearchOptions<'a> { } #[derive(Debug, Clone)] -struct FileSync { - git_workdir: Option, - /// Base files sorted by (parent_dir, filename). Used for binary search and - /// bigram index. All paths are backed by `chunked_paths` arena. - /// Deletions use tombstones (`is_deleted = true`) to keep bigram indices stable. +pub(crate) struct FileSync { + pub(crate) git_workdir: Option, + /// Base files laid out in two partitions, each internally sorted by + /// (parent_dir, filename): + /// `files[..indexable_count]` - indexable + /// `files[indexable_count..base_count]` - original-unindexable + /// `files[base_count..]`— overflow (created on demand) files: Vec, - /// Number of base files (from the last full reindex). Overflow files - /// live at `files[base_count..]`, each with its own `ChunkedPathStore` - /// kept alive in `overflow_stores`. + indexable_count: usize, base_count: usize, /// Sorted directory table. Each entry is a unique parent directory of at /// least one file in `files`. Sorted by absolute path for O(log n) lookup. - /// Built during `walk_filesystem` and used for directory picker mode, - /// per-directory stats, and as a fast replacement for `extract_watch_dirs`. dirs: Vec, /// Shared builder for overflow file paths. Each overflow file's ChunkedString - /// uses `arena_override` pointing into this builder's arena. The builder - /// grows incrementally — no per-file store allocation. Dropped on rescan. + /// uses `arena_override` pointing into this builder's arena. overflow_builder: Option, /// Compressed bigram inverted index built during the post-scan phase. /// Lives here so that replacing `FileSync` on rescan automatically drops @@ -140,6 +153,7 @@ impl FileSync { fn new() -> Self { Self { files: Vec::new(), + indexable_count: 0, base_count: 0, dirs: Vec::new(), overflow_builder: None, @@ -228,30 +242,43 @@ impl FileSync { Err(_) => return Err(0), // directory not found }; - // Binary search files by (parent_dir, filename) — same order as the sort - self.files[..self.base_count].binary_search_by(|f| { + // Binary search files by (parent_dir, filename). Base files live in + // two internally-sorted partitions — indexable first, then + // unindexable — so we try each half in turn. Two O(log n) searches + // with short-circuit on the first hit. + let cmp_key = |f: &FileItem| { f.parent_dir_index().cmp(&dir_idx).then_with(|| { let fname = f.file_name(arena); fname.as_str().cmp(filename) }) - }) + }; + + if self.indexable_count > 0 + && let Ok(pos) = self.files[..self.indexable_count].binary_search_by(cmp_key) + { + return Ok(pos); + } + + if self.indexable_count < self.base_count + && let Ok(rel_pos) = + self.files[self.indexable_count..self.base_count].binary_search_by(cmp_key) + { + return Ok(self.indexable_count + rel_pos); + } + + Err(0) } /// Find a file in the overflow portion by relative path (linear scan). /// Returns the absolute index into `files` (i.e. `base_count + position`). - fn find_overflow_index(&self, rel_path: &str) -> Option { + fn find_overflow_index(&self, relative_path: &str) -> Option { let overflow_arena = self.overflow_arena_ptr(); self.files[self.base_count..] .iter() - .position(|f| f.relative_path_eq(overflow_arena, rel_path)) + .position(|f| f.relative_path_eq(overflow_arena, relative_path)) .map(|pos| self.base_count + pos) } - /// Insert a file at position. Simple - no HashMap to maintain! - fn insert_file(&mut self, position: usize, file: FileItem) { - self.files.insert(position, file); - } - fn retain_files_with_arena(&mut self, mut predicate: F) -> usize where F: FnMut(&FileItem, ArenaPtr) -> bool, @@ -259,12 +286,19 @@ impl FileSync { let base_arena = self.arena_base_ptr(); let overflow_arena = self.overflow_arena_ptr(); + let indexable_count = self.indexable_count; let base_count = self.base_count; let initial_len = self.files.len(); - let base_retained = self.files[..base_count] + + let indexable_retained = self.files[..indexable_count] .iter() .filter(|f| predicate(f, base_arena)) .count(); + let base_retained = self.files[indexable_count..base_count] + .iter() + .filter(|f| predicate(f, base_arena)) + .count() + + indexable_retained; self.files.retain(|f| { predicate( @@ -277,23 +311,10 @@ impl FileSync { ) }); + self.indexable_count = indexable_retained; self.base_count = base_retained; initial_len - self.files.len() } - - /// Insert a file in sorted order (by path). - /// Returns true if inserted, false if file already exists. - fn insert_file_sorted(&mut self, file: FileItem, base_path: &Path) -> bool { - let arena = self.arena_base_ptr(); - let abs_path = file.absolute_path(arena, base_path); - match self.find_file_index(&abs_path, base_path) { - Ok(_) => false, // File already exists - Err(position) => { - self.insert_file(position, file); - true - } - } - } } impl FileItem { @@ -425,26 +446,15 @@ impl Default for FilePickerOptions { pub struct FilePicker { pub mode: FFFMode, pub base_path: PathBuf, - pub is_scanning: Arc, sync_data: FileSync, + pub(crate) signals: ScanSignals, + pub(crate) background_watcher: Option, cache_budget: Arc, has_explicit_cache_budget: bool, - watcher_ready: Arc, scanned_files_count: Arc, - background_watcher: Option, enable_mmap_cache: bool, enable_content_indexing: bool, watch: bool, - cancelled: Arc, - // This is a soft lock that we use to prevent rescan be triggered while the - // bigram indexing is in progress. This allows to keep some of the unsafe magic - // relying on the immutabillity of the files vec after the index without worrying - // that the vec is going to be dropped before the indexing is finished - // - // In addition to that rescan is likely triggered by something unnecessary - // before the indexing is finished it means that fff is dogfooded the index either - // by the UI rendering preview or simply by walking the directory. Which is not good anyway - post_scan_busy: Arc, } impl std::fmt::Debug for FilePicker { @@ -452,7 +462,10 @@ impl std::fmt::Debug for FilePicker { f.debug_struct("FilePicker") .field("base_path", &self.base_path) .field("sync_data", &self.sync_data) - .field("is_scanning", &self.is_scanning.load(Ordering::Relaxed)) + .field( + "is_scanning", + &self.signals.scanning.load(Ordering::Relaxed), + ) .field( "scanned_files_count", &self.scanned_files_count.load(Ordering::Relaxed), @@ -483,23 +496,15 @@ impl FilePicker { &self.base_path } - /// Convert an absolute path to a relative path string (relative to base_path). - /// Returns None if the path doesn't start with base_path. - fn to_relative_path<'a>(&self, path: &'a Path) -> Option<&'a str> { - path.strip_prefix(&self.base_path) - .ok() - .and_then(|p| p.to_str()) - } - - pub fn need_enable_mmap_cache(&self) -> bool { + pub fn has_mmap_cache(&self) -> bool { self.enable_mmap_cache } - pub fn need_enable_content_indexing(&self) -> bool { + pub fn has_content_indexing(&self) -> bool { self.enable_content_indexing } - pub fn need_watch(&self) -> bool { + pub fn has_watcher(&self) -> bool { self.watch } @@ -523,15 +528,20 @@ impl FilePicker { self.sync_data.get_file_mut(index) } - pub fn set_bigram_index(&mut self, index: BigramFilter, overlay: BigramOverlay) { - self.sync_data.bigram_index = Some(Arc::new(index)); - self.sync_data.bigram_overlay = Some(Arc::new(parking_lot::RwLock::new(overlay))); - } - + /// Absolute path to the repository root if the indexed tree lives + /// inside a git working directory. `None` for non-git bases. pub fn git_root(&self) -> Option<&Path> { self.sync_data.git_workdir.as_deref() } + pub fn has_explicit_cache_budget(&self) -> bool { + self.has_explicit_cache_budget + } + + pub fn set_cache_budget(&mut self, budget: ContentCacheBudget) { + self.cache_budget = Arc::new(budget); + } + /// Get all indexed files sorted by path. /// Note: Files are stored sorted by PATH for efficient insert/remove. /// For frecency-sorted results, use search() which sorts matched results. @@ -556,51 +566,77 @@ impl FilePicker { .chunked_paths .as_ref() .map_or(0, |s| s.heap_bytes()); + (chunked, 0, 0) } - /// Extracts all unique ancestor directories from the indexed file list. - /// Uses the pre-built directory table when available (O(d) where d = unique dirs), - /// falling back to the old traversal for overflow files. - #[tracing::instrument(level = "debug", skip(self))] - pub fn extract_watch_dirs(&self) -> Vec { + #[tracing::instrument(level = "debug", skip_all)] + pub(crate) fn for_each_dir(&self, mut f: impl FnMut(&Path) -> ControlFlow<()>) { let dir_table = &self.sync_data.dirs; + let base = self.base_path.as_path(); if !dir_table.is_empty() { - // Fast path: just collect PathBufs from the dir table. - // The dir table already contains all unique parent directories. - // We also need ancestor directories (parents of parents) for the - // watcher to work. Walk up from each dir to the base. - let base = self.base_path.as_path(); let arena = self.arena_base_ptr(); - let mut all_dirs = Vec::with_capacity(dir_table.len() * 2); - let mut seen = std::collections::HashSet::with_capacity(dir_table.len() * 2); + let mut path_buf = PathBuf::with_capacity(crate::simd_path::PATH_BUF_SIZE); + let mut prev_relative_path = String::new(); + let mut scratch_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; for dir_item in dir_table { - let mut current = dir_item.absolute_path(arena, base); - while current.as_path() != base { - if !seen.insert(current.clone()) { - break; // already visited this and all its ancestors - } - all_dirs.push(current.clone()); - if !current.pop() { - break; - } - } - } + let full_relative_path = dir_item.read_relative_path(arena, &mut scratch_buf); + let relative_path = full_relative_path.trim_end_matches(std::path::is_separator); - return all_dirs; + if relative_path.is_empty() { + // Files directly under base_path + prev_relative_path.clear(); + continue; + } + + let mut i = common_dir_prefix_len(&prev_relative_path, relative_path); + // If we stopped on a separator, skip it — we want to start + // emitting at the first unseen segment, not re-emit the + // already-emitted prefix path. + if i < relative_path.len() + && std::path::is_separator(relative_path.as_bytes()[i] as char) + { + i += 1; + } + + // Walk the suffix of `relative_path` one segment at a time, emitting + // each previously unseen ancestor up to and including `relative_path`. + while i < relative_path.len() { + let next_sep = relative_path[i..] + .find(std::path::is_separator) + .map(|off| i + off) + .unwrap_or(relative_path.len()); + let ancestor_rel = &relative_path[..next_sep]; + + path_buf.clear(); + path_buf.push(base); + path_buf.push(ancestor_rel); + + // we can't really emit iterator here unfortunately + if matches!(f(path_buf.as_path()), ControlFlow::Break(())) { + return; + } + + i = next_sep + 1; + } + + prev_relative_path.clear(); + prev_relative_path.push_str(relative_path); + } + return; } - // Fallback: old traversal for cases where dir table is empty + // fallback that should never be happening, but it is possible to get the file + // path from the absolute path using components api as well: let files = self.sync_data.files(); - let base = self.base_path.as_path(); let arena = self.arena_base_ptr(); - let mut dirs = Vec::with_capacity(files.len() / 4); let mut current = self.base_path.clone(); + let mut path_buf = [0u8; PATH_BUF_SIZE]; for file in files { - let abs = file.absolute_path(arena, base); + let abs = file.write_absolute_path(arena, base, &mut path_buf); let Some(parent) = abs.parent() else { continue; }; @@ -617,11 +653,11 @@ impl FilePicker { }; for component in remainder.components() { current.push(component); - dirs.push(current.clone()); + if matches!(f(current.as_path()), ControlFlow::Break(())) { + return; + } } } - - dirs } /// Create a new FilePicker from options. @@ -638,6 +674,12 @@ impl FilePicker { return Err(Error::FilesystemRoot(path)); } + // Windows-only: canonicalize with so the base path does NOT + // have the `\\?\` UNC prefix that `std::fs::canonicalize` adds. + // libgit2's `repo.workdir()` + #[cfg(windows)] + let path = crate::path_utils::canonicalize(&path).unwrap_or(path); + let has_explicit_budget = options.cache_budget.is_some(); let initial_budget = options.cache_budget.unwrap_or_default(); @@ -645,24 +687,21 @@ impl FilePicker { background_watcher: None, base_path: path, cache_budget: Arc::new(initial_budget), - cancelled: Arc::new(AtomicBool::new(false)), has_explicit_cache_budget: has_explicit_budget, - is_scanning: Arc::new(AtomicBool::new(false)), + signals: crate::scan::ScanSignals::default(), mode: options.mode, - post_scan_busy: Arc::new(AtomicBool::new(false)), scanned_files_count: Arc::new(AtomicUsize::new(0)), sync_data: FileSync::new(), enable_mmap_cache: options.enable_mmap_cache, enable_content_indexing: options.enable_content_indexing, watch: options.watch, - watcher_ready: Arc::new(AtomicBool::new(false)), }) } /// Create a picker, place it into the shared handle, and spawn background /// indexing + file-system watcher. This is the default entry point. pub fn new_with_shared_state( - shared_picker: SharedPicker, + shared_picker: SharedFilePicker, shared_frecency: SharedFrecency, options: FilePickerOptions, ) -> Result<(), Error> { @@ -681,13 +720,8 @@ impl FilePicker { let watch = picker.watch; let mode = picker.mode; - picker.is_scanning.store(true, Ordering::Release); - - let scan_signal = Arc::clone(&picker.is_scanning); - let watcher_ready = Arc::clone(&picker.watcher_ready); - let synced_files_count = Arc::clone(&picker.scanned_files_count); - let cancelled = Arc::clone(&picker.cancelled); - let post_scan_busy = Arc::clone(&picker.post_scan_busy); + let signals = picker.scan_signals(); + let scanned_files_counter = picker.scanned_files_counter(); let path = picker.base_path.clone(); { @@ -695,20 +729,26 @@ impl FilePicker { *guard = Some(picker); } - spawn_scan_and_watcher( - path, - scan_signal, - watcher_ready, - synced_files_count, - warmup, - content_indexing, - watch, - mode, + // `ScanJob::spawn` flips `scanning=true` synchronously before handing + // off to the worker thread, so callers that invoke `wait_for_scan` + // immediately after `new_with_shared_state` are guaranteed to see + // the scan in progress. + ScanJob::new_initial( shared_picker, shared_frecency, - cancelled, - post_scan_busy, - ); + path, + mode, + signals, + scanned_files_counter, + ScanConfig { + warmup, + content_indexing, + watch, + auto_cache_budget: true, + install_watcher: true, + }, + ) + .spawn(); Ok(()) } @@ -722,18 +762,22 @@ impl FilePicker { /// // picker.get_files() is now populated /// ``` pub fn collect_files(&mut self) -> Result<(), Error> { - self.is_scanning.store(true, Ordering::Relaxed); + self.signals.scanning.store(true, Ordering::Relaxed); self.scanned_files_count.store(0, Ordering::Relaxed); + let git_workdir = FileSync::discover_git_workdir(&self.base_path); + let git_handle = git_workdir.clone().map(FileSync::spawn_git_status); + let empty_frecency = SharedFrecency::default(); - let walk = walk_filesystem( + let sync = FileSync::walk_filesystem( &self.base_path, + git_workdir, &self.scanned_files_count, &empty_frecency, self.mode, )?; - self.sync_data = walk.sync; + self.sync_data = sync; // Recalculate cache budget based on actual file count (unless // the caller provided an explicit budget via FilePickerOptions). @@ -745,7 +789,9 @@ impl FilePicker { } // Apply git status synchronously. - if let Ok(Some(git_cache)) = walk.git_handle.join() { + if let Some(handle) = git_handle + && let Ok(Some(git_cache)) = handle.join() + { let arena = self.arena_base_ptr(); for file in self.sync_data.files.iter_mut() { file.git_status = @@ -753,7 +799,7 @@ impl FilePicker { } } - self.is_scanning.store(false, Ordering::Relaxed); + self.signals.scanning.store(false, Ordering::Relaxed); Ok(()) } @@ -764,21 +810,19 @@ impl FilePicker { /// [`collect_files`](Self::collect_files) or after an initial scan. pub fn spawn_background_watcher( &mut self, - shared_picker: &SharedPicker, + shared_picker: &SharedFilePicker, shared_frecency: &SharedFrecency, ) -> Result<(), Error> { let git_workdir = self.sync_data.git_workdir.clone(); - let watch_dirs = self.extract_watch_dirs(); let watcher = BackgroundWatcher::new( self.base_path.clone(), git_workdir, shared_picker.clone(), shared_frecency.clone(), self.mode, - watch_dirs, )?; self.background_watcher = Some(watcher); - self.watcher_ready.store(true, Ordering::Release); + self.signals.watcher_ready.store(true, Ordering::Release); Ok(()) } @@ -786,9 +830,7 @@ impl FilePicker { /// /// The query should be parsed using [`FFFQuery`]::parse() before calling /// this function. If a [`QueryTracker`] is provided, the search will - /// automatically look up the last selected file for this query and apply - /// combo-boost scoring. - /// + /// automatically look up the last selected file for this query and boost it pub fn fuzzy_search<'q>( &self, query: &'q FFFQuery<'q>, @@ -1082,7 +1124,10 @@ impl FilePicker { let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read()); let arena = self.arena_base_ptr(); let overflow_arena = self.sync_data.overflow_arena_ptr(); - let cancel = options.abort_signal.as_deref().unwrap_or(&self.cancelled); + let cancel = options + .abort_signal + .as_deref() + .unwrap_or(&self.signals.cancelled); grep_search( self.get_files(), @@ -1108,7 +1153,10 @@ impl FilePicker { let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read()); let arena = self.arena_base_ptr(); let overflow_arena = self.sync_data.overflow_arena_ptr(); - let cancel = options.abort_signal.as_deref().unwrap_or(&self.cancelled); + let cancel = options + .abort_signal + .as_deref() + .unwrap_or(&self.signals.cancelled); multi_grep_search( self.get_files(), @@ -1125,15 +1173,18 @@ impl FilePicker { ) } - /// Like [`grep`](Self::grep) but ignores the bigram overlay. - pub fn grep_without_overlay( + #[doc(hidden)] + pub fn grep_original( &self, query: &FFFQuery<'_>, options: &GrepSearchOptions, ) -> GrepResult<'_> { let arena = self.arena_base_ptr(); let overflow_arena = self.sync_data.overflow_arena_ptr(); - let cancel = options.abort_signal.as_deref().unwrap_or(&self.cancelled); + let cancel = options + .abort_signal + .as_deref() + .unwrap_or(&self.signals.cancelled); grep_search( self.get_files(), @@ -1152,17 +1203,61 @@ impl FilePicker { // Returns an ongoing or finisshed scan progress pub fn get_scan_progress(&self) -> ScanProgress { let scanned_count = self.scanned_files_count.load(Ordering::Relaxed); - let is_scanning = self.is_scanning.load(Ordering::Relaxed); + let is_scanning = self.signals.scanning.load(Ordering::Relaxed); ScanProgress { scanned_files_count: scanned_count, is_scanning, - is_watcher_ready: self.watcher_ready.load(Ordering::Relaxed), + is_watcher_ready: self.signals.watcher_ready.load(Ordering::Relaxed), is_warmup_complete: self.sync_data.bigram_index.is_some(), } } + pub(crate) fn set_bigram_index(&mut self, index: BigramFilter, overlay: BigramOverlay) { + self.sync_data.bigram_index = Some(Arc::new(index)); + self.sync_data.bigram_overlay = Some(Arc::new(parking_lot::RwLock::new(overlay))); + } + + pub(crate) fn cache_budget_arc(&self) -> Arc { + Arc::clone(&self.cache_budget) + } + + /// Bundle the atomic flags the scan orchestrator needs. One method + /// instead of four separate getters so every callsite passes a + /// single `ScanSignals` value. + pub(crate) fn scan_signals(&self) -> crate::scan::ScanSignals { + crate::scan::ScanSignals { + scanning: Arc::clone(&self.signals.scanning), + watcher_ready: Arc::clone(&self.signals.watcher_ready), + cancelled: Arc::clone(&self.signals.cancelled), + post_scan_busy: Arc::clone(&self.signals.post_scan_busy), + rescan_pending: Arc::clone(&self.signals.rescan_pending), + } + } + + pub(crate) fn scanned_files_counter(&self) -> Arc { + Arc::clone(&self.scanned_files_count) + } + + pub(crate) fn sync_data_snapshot(&self) -> (&[FileItem], usize, ArenaPtr) { + ( + self.sync_data.files(), + self.sync_data.indexable_count, + self.sync_data.arena_base_ptr(), + ) + } + + pub(crate) fn commit_new_sync(&mut self, sync: FileSync) { + self.sync_data = sync; + self.cache_budget.reset(); + } + + #[inline] + pub(crate) fn arena_base_ptr(&self) -> ArenaPtr { + self.sync_data.arena_base_ptr() + } + /// Update git statuses for files, using the provided shared frecency tracker. - pub fn update_git_statuses( + pub(crate) fn update_git_statuses( &mut self, status_cache: GitStatusCache, shared_frecency: &SharedFrecency, @@ -1246,119 +1341,82 @@ impl FilePicker { index.and_then(|i| self.sync_data.get_file_mut(i)) } - /// Add a file to the picker's files in sorted order (used by background watcher) - pub fn add_file_sorted(&mut self, file: FileItem) -> Option<&FileItem> { - let arena = self.arena_base_ptr(); - let path = file.absolute_path(arena, &self.base_path); - - if self.sync_data.insert_file_sorted(file, &self.base_path) { - // File was inserted, look it up - self.sync_data - .find_file_index(&path, &self.base_path) - .ok() - .and_then(|idx| self.sync_data.get_file_mut(idx)) - .map(|file_mut| &*file_mut) // Convert &mut to & - } else { - // File already exists - warn!( - "Trying to insert a file that already exists: {}", - path.display() - ); - self.sync_data - .find_file_index(&path, &self.base_path) - .ok() - .and_then(|idx| self.sync_data.get_file_mut(idx)) - .map(|file_mut| &*file_mut) // Convert &mut to & - } - } - - #[tracing::instrument(skip(self), name = "timing_update", level = Level::DEBUG)] + #[tracing::instrument(skip(self),level = Level::DEBUG)] pub fn on_create_or_modify(&mut self, path: impl AsRef + Debug) -> Option<&FileItem> { let path = path.as_ref(); + + if let Ok(idx) = self.sync_data.find_file_index(path, &self.base_path) { + return self.handle_file_modify(path, FileSlot::Base(idx)); + } + + let relative_path = self.to_relative_path(path)?; + if let Some(idx) = self.sync_data.find_overflow_index(relative_path) { + return self.handle_file_modify(path, FileSlot::Overflow(idx)); + } + + self.add_new_file(path) + } + + #[tracing::instrument(skip_all, fields(path = ?path), level = Level::DEBUG)] + fn handle_file_modify(&mut self, path: &Path, slot: FileSlot) -> Option<&FileItem> { let overlay = self.sync_data.bigram_overlay.as_ref().map(Arc::clone); + let pos = slot.index(); + let file = self.sync_data.get_file_mut(pos)?; - if let Ok(pos) = self.sync_data.find_file_index(path, &self.base_path) { - let file = self.sync_data.get_file_mut(pos)?; + let metadata = std::fs::metadata(path) + .inspect_err(|e| { + tracing::error!( + ?e, + "File market for modification doesn't exists or not accessible" + ) + }) + .ok()?; // if we can't read metadata this file either doesn't exists or not accessible - if file.is_deleted() { - // Resurrect tombstoned file. - file.set_deleted(false); - debug!( - "on_create_or_modify: resurrected tombstoned file at index {}", - pos - ); - } + let size = metadata.len(); + let modified_time = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); - debug!( - "on_create_or_modify: file EXISTS at index {}, updating metadata", - pos - ); + if file.is_deleted() { + file.set_deleted(false); + } - let modified = match std::fs::metadata(path) { - Ok(metadata) => metadata - .modified() - .ok() - .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()), - Err(e) => { - error!("Failed to get metadata for {}: {}", path.display(), e); - None - } + file.update_metadata(&self.cache_budget, modified_time, Some(size)); + + // only base-region entries participate in the bigram overlay + if matches!(slot, FileSlot::Base(_)) + && let Some(ref overlay) = overlay + { + let in_indexable = { + let guard = overlay.read(); + pos < guard.base_file_count() }; - if let Some(modified) = modified { - let modified = modified.as_secs(); - if file.modified < modified { - file.modified = modified; - file.invalidate_mmap(&self.cache_budget); - } - } - - // Update the bigram overlay for this modified file. - if let Some(ref overlay) = overlay - && let Ok(content) = std::fs::read(path) - { + if in_indexable && let Ok(content) = std::fs::read(path) { overlay.write().modify_file(pos, &content); } - - return Some(&*file); } - // Check overflow for existing added files. - let rel_path = self.to_relative_path(path).unwrap_or(""); - if let Some(abs_idx) = self.sync_data.find_overflow_index(rel_path) { - let file = self.sync_data.get_file_mut(abs_idx)?; - let modified = std::fs::metadata(path) - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()); - if let Some(modified) = modified { - let modified = modified.as_secs(); - if file.modified < modified { - file.modified = modified; - file.invalidate_mmap(&self.cache_budget); - } - } - return Some(&*file); - } - - // New file — append to overflow (preserves base indices for bigram). - debug!( - "on_create_or_modify: file NEW, appending to overflow (base: {}, overflow: {})", - self.sync_data.base_count, - self.sync_data.overflow_files().len(), - ); + Some(&*self.sync_data.get_file_mut(pos)?) + } + /// Adds a new file from path to the picker, even if it should be ignored + #[tracing::instrument(skip(self))] + pub fn add_new_file(&mut self, path: &Path) -> Option<&FileItem> { let (mut file_item, rel_path) = FileItem::new(path.to_path_buf(), &self.base_path, None); - // Lazily create the shared overflow builder on first use. + // Lazily create the shared overflow builder if not exists yet let builder = self .sync_data .overflow_builder .get_or_insert_with(|| crate::simd_path::ChunkedPathStoreBuilder::new(64)); - let cs = builder.add_file_immediate(&rel_path, file_item.path.filename_offset); - file_item.set_path(cs); + let chunked_path = builder.add_file_immediate(&rel_path, file_item.path.filename_offset); + file_item.set_path(chunked_path); file_item.set_overflow(true); + self.sync_data.files.push(file_item); self.sync_data.files.last() } @@ -1370,6 +1428,15 @@ impl FilePicker { Ok(index) => { let file = &mut self.sync_data.files[index]; file.set_deleted(true); + // Clear any cached git status — the tombstone no longer + // corresponds to a real worktree file, so any previously + // cached status (e.g. `WT_MODIFIED` from before the + // delete) is actively misleading. All user-facing search + // paths filter `is_deleted()` so this is invisible today, + // but keeping the invariant "tombstone ⇒ git_status=None" + // means a new reader that forgets the filter can't leak + // stale data. + file.git_status = None; file.invalidate_mmap(&self.cache_budget); if let Some(ref overlay) = self.sync_data.bigram_overlay { overlay.write().delete_file(index); @@ -1408,232 +1475,47 @@ impl FilePicker { /// Use this to prevent any substantial background threads from acquiring the locks pub fn cancel(&self) { - self.cancelled.store(true, Ordering::Release); + self.signals.cancelled.store(true, Ordering::Release); } + /// Stop the background filesystem watcher. Non-blocking. pub fn stop_background_monitor(&mut self) { if let Some(mut watcher) = self.background_watcher.take() { watcher.stop(); } } - #[inline] - pub(crate) fn arena_base_ptr(&self) -> ArenaPtr { - self.sync_data.arena_base_ptr() - } - - /// Spawn a background thread to rebuild the bigram index after rescan. - pub(crate) fn spawn_post_rescan_rebuild(&self, shared_picker: SharedPicker) -> bool { - if self.cancelled.load(Ordering::Relaxed) { - return false; - } - - let post_scan_busy = Arc::clone(&self.post_scan_busy); - let cancelled = Arc::clone(&self.cancelled); - let auto_budget = !self.has_explicit_cache_budget; - let do_warmup = self.enable_mmap_cache; - let do_content_indexing = self.enable_content_indexing; - - post_scan_busy.store(true, Ordering::Release); - - std::thread::spawn(move || { - let phase_start = std::time::Instant::now(); - - // Scale cache budget if not explicitly configured. - if auto_budget - && !cancelled.load(Ordering::Acquire) - && let Ok(mut guard) = shared_picker.write() - && let Some(ref mut picker) = *guard - && !picker.has_explicit_cache_budget - { - let file_count = picker.sync_data.files().len(); - picker.cache_budget = Arc::new(ContentCacheBudget::new_for_repo(file_count)); - } - - // Take a snapshot of files + budget while holding a brief read lock. - // SAFETY: post_scan_busy blocks trigger_rescan from replacing - // sync_data, so the Vec backing this slice stays alive. - let files_snapshot = if !cancelled.load(Ordering::Acquire) { - shared_picker.read().ok().and_then(|guard| { - guard.as_ref().map(|picker| { - let files = picker.sync_data.files(); - let ptr = files.as_ptr(); - let len = files.len(); - let base_count = picker.sync_data.base_count; - let budget = Arc::clone(&picker.cache_budget); - let static_files: &[FileItem] = - unsafe { std::slice::from_raw_parts(ptr, len) }; - ( - static_files, - base_count, - budget, - picker.base_path().to_path_buf(), - picker.arena_base_ptr(), - ) - }) - }) - } else { - None - }; - - if let Some((files, base_count, budget, bp, arena)) = files_snapshot { - // Warmup mmap caches. - if do_warmup && !cancelled.load(Ordering::Acquire) { - let t = std::time::Instant::now(); - warmup_mmaps(files, &budget, &bp, arena); - info!( - "Rescan warmup completed in {:.2}s (cached {} files, {} bytes)", - t.elapsed().as_secs_f64(), - budget.cached_count.load(Ordering::Relaxed), - budget.cached_bytes.load(Ordering::Relaxed), - ); - } - - // Build bigram index (lock-free). - if do_content_indexing && !cancelled.load(Ordering::Acquire) { - let t = std::time::Instant::now(); - // Index ONLY base files — overflow files are searched - // unconditionally by the grep overflow loop, so - // `BigramFilter::file_count` must equal - // `BigramOverlay::base_file_count` for the candidate - // bitset to never carry overflow-range bits. - let base_files = &files[..base_count.min(files.len())]; - info!( - "Rescan: starting bigram index build for {} files...", - base_files.len() - ); - let (index, content_binary) = - build_bigram_index(base_files, &budget, &bp, arena); - info!( - "Rescan: bigram index ready in {:.2}s", - t.elapsed().as_secs_f64() - ); - - // Brief write lock to store the index. - if let Ok(mut guard) = shared_picker.write() - && let Some(ref mut picker) = *guard - { - for &idx in &content_binary { - if let Some(file) = picker.sync_data.get_file_mut(idx) { - file.set_binary(true); - } - } - - // Use the same `base_count` the filter was built with - // so `file_count == base_file_count` is guaranteed. - picker.sync_data.bigram_index = Some(Arc::new(index)); - picker.sync_data.bigram_overlay = Some(Arc::new(parking_lot::RwLock::new( - BigramOverlay::new(base_count), - ))); - } - } - } - - post_scan_busy.store(false, Ordering::Release); - info!( - "Rescan post-scan phase total: {:.2}s (warmup={}, content_indexing={})", - phase_start.elapsed().as_secs_f64(), - do_warmup, - do_content_indexing, - ); - }); - - true - } - - pub fn trigger_rescan(&mut self, shared_frecency: &SharedFrecency) -> Result<(), Error> { - if self.is_scanning.load(Ordering::Relaxed) { - debug!("Scan already in progress, skipping trigger_rescan"); - return Ok(()); - } - - // The post-scan warmup + bigram phase holds a raw pointer into the - // current files Vec. Replacing sync_data now would free that memory. - // Skip — the background watcher will retry on the next event. - if self.post_scan_busy.load(Ordering::Acquire) { - debug!("Post-scan bigram build in progress, skipping rescan"); - return Ok(()); - } - - self.is_scanning.store(true, Ordering::Relaxed); - self.scanned_files_count.store(0, Ordering::Relaxed); - - let walk_result = walk_filesystem( - &self.base_path, - &self.scanned_files_count, - shared_frecency, - self.mode, - ); - - match walk_result { - Ok(walk) => { - info!( - "Filesystem rescan completed: found {} files", - walk.sync.files.len() - ); - - self.sync_data = walk.sync; - self.cache_budget.reset(); - - // Apply git status synchronously for rescan (typically fast). - if let Ok(Some(git_cache)) = walk.git_handle.join() { - let frecency = shared_frecency.read().ok(); - let frecency_ref = frecency.as_ref().and_then(|f| f.as_ref()); - let mode = self.mode; - let bp = &self.base_path; - let arena = self.arena_base_ptr(); - - // Reset dir frecency before recomputation. - for dir in self.sync_data.dirs.iter() { - dir.reset_frecency(); - } - - let files = &mut self.sync_data.files; - let dirs = &self.sync_data.dirs; - BACKGROUND_THREAD_POOL.install(|| { - files.par_iter_mut().for_each(|file| { - file.git_status = - git_cache.lookup_status(&file.absolute_path(arena, bp)); - if let Some(frecency) = frecency_ref { - let _ = file.update_frecency_scores(frecency, arena, bp, mode); - } - let score = file.access_frecency_score as i32; - if score > 0 { - let dir_idx = file.parent_dir_index() as usize; - if let Some(dir) = dirs.get(dir_idx) { - dir.update_frecency_if_larger(score); - } - } - }); - }); - } - - // Warmup is deferred to the post-rescan bigram rebuild thread - // (spawned by trigger_full_rescan) which does warmup + bigram - // in one pass, matching the initial scan's post-scan phase. - } - Err(error) => error!(?error, "Failed to scan file system"), - } - - self.is_scanning.store(false, Ordering::Relaxed); - Ok(()) - } - /// Quick way to check if scan is going without acquiring a lock for [Self::get_scan_progress] pub fn is_scan_active(&self) -> bool { - self.is_scanning.load(Ordering::Relaxed) - } - - /// Return a clone of the scanning flag so callers can poll it without - /// holding a lock on the picker. - pub fn scan_signal(&self) -> Arc { - Arc::clone(&self.is_scanning) + self.signals.scanning.load(Ordering::Relaxed) } /// Return a clone of the watcher-ready flag so callers can poll it without /// holding a lock on the picker. pub fn watcher_signal(&self) -> Arc { - Arc::clone(&self.watcher_ready) + Arc::clone(&self.signals.watcher_ready) + } + + /// Convert an absolute path to a relative path string (relative to base_path). + /// Returns None if the path doesn't start with base_path. + fn to_relative_path<'a>(&self, path: &'a Path) -> Option<&'a str> { + path.strip_prefix(&self.base_path) + .ok() + .and_then(|p| p.to_str()) + } +} + +#[derive(Debug, Clone, Copy)] +enum FileSlot { + Base(usize), + Overflow(usize), +} + +impl FileSlot { + fn index(self) -> usize { + match self { + FileSlot::Base(i) | FileSlot::Overflow(i) => i, + } } } @@ -1649,226 +1531,6 @@ pub struct ScanProgress { pub is_warmup_complete: bool, } -#[allow(clippy::too_many_arguments)] -fn spawn_scan_and_watcher( - base_path: PathBuf, - scan_signal: Arc, - watcher_ready: Arc, - synced_files_count: Arc, - enable_mmap_cache: bool, - enable_content_indexing: bool, - watch: bool, - mode: FFFMode, - shared_picker: SharedPicker, - shared_frecency: SharedFrecency, - cancelled: Arc, - post_scan_busy: Arc, -) { - std::thread::spawn(move || { - // scan_signal is already `true` (set by the caller before spawning) - // so waiters see "scanning" even before this thread is scheduled. - info!("Starting initial file scan"); - - let git_workdir; - - match walk_filesystem(&base_path, &synced_files_count, &shared_frecency, mode) { - Ok(walk) => { - if cancelled.load(Ordering::Acquire) { - info!("Walk completed but picker was replaced, discarding results"); - scan_signal.store(false, Ordering::Relaxed); - return; - } - - info!( - "Initial filesystem walk completed: found {} files", - walk.sync.files.len() - ); - - git_workdir = walk.sync.git_workdir.clone(); - let git_handle = walk.git_handle; - - // Write files immediately — they are now searchable even - // before git status or warmup completes. - let write_result = shared_picker.write().ok().map(|mut guard| { - if let Some(ref mut picker) = *guard { - picker.sync_data = walk.sync; - picker.cache_budget.reset(); - } - }); - - if write_result.is_none() { - error!("Failed to write scan results into picker"); - } - - // Signal scan complete — files are searchable. - scan_signal.store(false, Ordering::Relaxed); - info!("Files indexed and searchable"); - - if !cancelled.load(Ordering::Acquire) { - apply_git_status_and_frecency( - &shared_picker, - &shared_frecency, - git_handle, - mode, - ); - } - } - Err(e) => { - error!("Initial scan failed: {:?}", e); - scan_signal.store(false, Ordering::Relaxed); - watcher_ready.store(true, Ordering::Release); - return; - } - } - - if watch && !cancelled.load(Ordering::Acquire) { - let watch_dirs = shared_picker - .read() - .ok() - .and_then(|guard| guard.as_ref().map(|picker| picker.extract_watch_dirs())) - .unwrap_or_default(); - - match BackgroundWatcher::new( - base_path.clone(), - git_workdir, - shared_picker.clone(), - shared_frecency.clone(), - mode, - watch_dirs, - ) { - Ok(watcher) => { - info!("Background file watcher initialized successfully"); - - if cancelled.load(Ordering::Acquire) { - info!("Picker was replaced, dropping orphaned watcher"); - drop(watcher); - watcher_ready.store(true, Ordering::Release); - return; - } - - let write_result = shared_picker.write().ok().map(|mut guard| { - if let Some(ref mut picker) = *guard { - picker.background_watcher = Some(watcher); - } - }); - - if write_result.is_none() { - error!("Failed to store background watcher in picker"); - } - } - Err(e) => { - error!("Failed to initialize background file watcher: {:?}", e); - } - } - } - - watcher_ready.store(true, Ordering::Release); - - let need_post_scan = - (enable_mmap_cache || enable_content_indexing) && !cancelled.load(Ordering::Acquire); - - if need_post_scan { - post_scan_busy.store(true, Ordering::Release); - let phase_start = std::time::Instant::now(); - - // Scale cache limits based on repo size (skip if caller provided an explicit budget). - if let Ok(mut guard) = shared_picker.write() - && let Some(ref mut picker) = *guard - && !picker.has_explicit_cache_budget - { - let file_count = picker.sync_data.files().len(); - picker.cache_budget = Arc::new(ContentCacheBudget::new_for_repo(file_count)); - info!( - "Cache budget configured for {} files: max_files={}, max_bytes={}", - file_count, picker.cache_budget.max_files, picker.cache_budget.max_bytes, - ); - } - - // SAFETY: The file index Vec is not resized between the initial scan - // completing and the warmup + bigram phase finishing because - // `post_scan_busy` prevents concurrent rescans from replacing - // sync_data while we hold the raw pointer. - let files_snapshot: Option<(&[FileItem], usize, Arc, ArenaPtr)> = - if !cancelled.load(Ordering::Acquire) { - let guard = shared_picker.read().ok(); - guard.and_then(|guard| { - guard.as_ref().map(|picker| { - let files = picker.sync_data.files(); - let ptr = files.as_ptr(); - let len = files.len(); - let base_count = picker.sync_data.base_count; - let budget = Arc::clone(&picker.cache_budget); - let arena = picker.arena_base_ptr(); - // SAFETY: post_scan_busy flag blocks trigger_rescan and - // background watcher rescans from replacing sync_data, - // so the Vec backing this slice stays alive. - let static_files: &[FileItem] = - unsafe { std::slice::from_raw_parts(ptr, len) }; - (static_files, base_count, budget, arena) - }) - }) - } else { - None - }; - - // both of this is using a custom soft lock not guaranteed by compiler - // this is required to keep the picker functioning if someone opened a really crazy - // e.g 10m files directory but potentially unsafe - if let Some((files, base_count, budget, arena)) = files_snapshot { - if enable_mmap_cache && !cancelled.load(Ordering::Acquire) { - let warmup_start = std::time::Instant::now(); - warmup_mmaps(files, &budget, &base_path, arena); - info!( - "Warmup completed in {:.2}s (cached {} files, {} bytes)", - warmup_start.elapsed().as_secs_f64(), - budget.cached_count.load(Ordering::Relaxed), - budget.cached_bytes.load(Ordering::Relaxed), - ); - } - - if enable_content_indexing && !cancelled.load(Ordering::Acquire) { - // Index ONLY base files. Any overflow files present in - // the snapshot (from watcher events that landed before - // this snapshot was taken) are intentionally excluded: - // grep handles them via the unconditional overflow- - // append loop, and the filter's `file_count` must match - // the overlay's `base_file_count` so the candidate - // bitset can't carry bits for overflow-range indices. - let base_files = &files[..base_count.min(files.len())]; - let (index, content_binary) = - build_bigram_index(base_files, &budget, &base_path, arena); - - if let Ok(mut guard) = shared_picker.write() - && let Some(ref mut picker) = *guard - { - for &idx in &content_binary { - if let Some(file) = picker.sync_data.get_file_mut(idx) { - file.set_binary(true); - } - } - - picker.sync_data.bigram_index = Some(Arc::new(index)); - picker.sync_data.bigram_overlay = Some(Arc::new(parking_lot::RwLock::new( - BigramOverlay::new(base_count), - ))); - } - } - } - - post_scan_busy.store(false, Ordering::Release); - - info!( - "Post-scan phase total: {:.2}s (warmup={}, content_indexing={})", - phase_start.elapsed().as_secs_f64(), - enable_mmap_cache, - enable_content_indexing, - ); - } - - // the debouncer keeps running in its own thread - }); -} - /// Pre-populate mmap caches for the most valuable files so the first grep /// search doesn't pay the mmap creation + page fault cost. /// @@ -1939,231 +1601,238 @@ pub(crate) fn warmup_mmaps( }); } -/// Max bytes of file content scanned for bigram indexing. After this many -/// bytes the ~4900 possible printable-ASCII bigrams are effectively saturated, -/// so reading further adds no new information to the index. -pub const BIGRAM_CONTENT_CAP: usize = 64 * 1024; +impl FileSync { + pub(crate) fn discover_git_workdir(base_path: &Path) -> Option { + let git_workdir = Repository::discover(base_path) + .ok() + .and_then(|repo| repo.workdir().map(Path::to_path_buf)) + .map(crate::path_utils::normalize); -#[tracing::instrument(skip_all, name = "Building Bigram Index", level = Level::DEBUG)] -pub(crate) fn build_bigram_index( - files: &[FileItem], - budget: &ContentCacheBudget, - base_path: &Path, - arena: ArenaPtr, -) -> (BigramFilter, Vec) { - let start = std::time::Instant::now(); - info!("Building bigram index for {} files...", files.len()); - let builder = BigramIndexBuilder::new(files.len()); - let skip_builder = BigramIndexBuilder::new(files.len()); - let max_file_size = budget.max_file_size; + match &git_workdir { + Some(workdir) => debug!("Git repository found at: {}", workdir.display()), + None => warn!("No git repository found for path: {}", base_path.display()), + } - // Collect indices of files that passed the extension heuristic but are - // actually binary (contain NUL bytes). These are marked `is_binary = true` - // on the real file list after the build, so grep never has to re-check. - let content_binary: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - - BACKGROUND_THREAD_POOL.install(|| { - files.par_iter().enumerate().for_each(|(i, file)| { - if file.is_binary() || file.size == 0 || file.size > max_file_size { - return; - } - // Use cached content if available (no extra memory). - // For uncached files, read from disk — heap memory is freed on drop. - let data: Option<&[u8]>; - let owned; - if let Some(cached) = file.get_content(arena, base_path, budget) { - if detect_binary_content(cached) { - content_binary.lock().unwrap().push(i); - return; - } - data = Some(cached); - owned = None; - } else if let Ok(read_data) = std::fs::read(file.absolute_path(arena, base_path)) { - if detect_binary_content(&read_data) { - content_binary.lock().unwrap().push(i); - return; - } - data = None; - owned = Some(read_data); - } else { - return; - } - - let content = data.unwrap_or_else(|| owned.as_ref().unwrap()); - let capped = &content[..content.len().min(BIGRAM_CONTENT_CAP)]; - builder.add_file_content(&skip_builder, i, capped); - }); - }); - - let cols = builder.columns_used(); - let mut index = builder.compress(None); - - // Skip bigrams are supplementary — the consecutive index does the heavy - // lifting. Rare skip columns (< 12% of files) add virtually no filtering - // on either homogeneous (kernel) or polyglot (monorepo) codebases, but - // cost ~25-30% of total index memory. Using a higher sparse cutoff for - // the skip index drops these dead-weight columns with negligible loss. - let skip_index = skip_builder.compress(Some(12)); - index.set_skip_index(skip_index); - - // The builders' flat buffers were freed by compress() above (single - // deallocation each). Hint the allocator to return pages from other - // per-thread allocations (file reads, sort buffers) during the build. - hint_allocator_collect(); - - info!( - "Bigram index built in {:.2}s — {} dense columns for {} files", - start.elapsed().as_secs_f64(), - cols, - files.len(), - ); - - let binary_indices = content_binary.into_inner().unwrap(); - if !binary_indices.is_empty() { - info!( - "Bigram build detected {} content-binary files (not caught by extension)", - binary_indices.len(), - ); + git_workdir } - (index, binary_indices) -} - -/// Result of the fast walk phase — files are searchable immediately, -/// git status arrives later via the join handle. -struct WalkResult { - sync: FileSync, - git_handle: std::thread::JoinHandle>, -} - -/// Returns files immediately (searchable) and a handle to the in-progress -/// git status computation. This avoids blocking on `git status` which can -/// take 10+ seconds on very large repos (e.g. chromium). -fn walk_filesystem( - base_path: &Path, - synced_files_count: &Arc, - shared_frecency: &SharedFrecency, - mode: FFFMode, -) -> Result { - use ignore::WalkBuilder; - - let scan_start = std::time::Instant::now(); - info!("SCAN: Starting filesystem walk and git status (async)"); - - // Discover git root (fast — just walks up looking for .git/) - let git_workdir = Repository::discover(base_path) - .ok() - .and_then(|repo| repo.workdir().map(Path::to_path_buf)); - - if let Some(ref git_dir) = git_workdir { - debug!("Git repository found at: {}", git_dir.display()); - } else { - debug!("No git repository found for path: {}", base_path.display()); - } - - // Spawn git status on a detached thread — we won't wait for it here. - let git_workdir_for_status = git_workdir.clone(); - let git_handle = std::thread::spawn(move || { - GitStatusCache::read_git_status( - git_workdir_for_status.as_deref(), - StatusOptions::new() - .include_untracked(true) - .recurse_untracked_dirs(true) - .exclude_submodules(true), - ) - }); - - // Walk files (the fast part, typically 2-3s even on huge repos). - let is_git_repo = git_workdir.is_some(); - let bg_threads = BACKGROUND_THREAD_POOL.current_num_threads(); - let mut walk_builder = WalkBuilder::new(base_path); - walk_builder - // this is a very important guard for the user opening ~/ or other root non-git dir - .hidden(!is_git_repo) - .git_ignore(true) - .git_exclude(true) - .git_global(true) - .ignore(true) - .follow_links(false) - .threads(bg_threads); - - if !is_git_repo && let Some(overrides) = non_git_repo_overrides(base_path) { - walk_builder.overrides(overrides); - } - - let walker = walk_builder.build_parallel(); - - let walker_start = std::time::Instant::now(); - debug!("SCAN: Starting file walker"); - - // Walk: collect (FileItem, rel_path) pairs. Keep the walk fast — - // no chunking, no HashMap, just Vec::push under the Mutex. - let pairs = parking_lot::Mutex::new(Vec::<(FileItem, String)>::new()); - - walker.run(|| { - let pairs = &pairs; - let counter = Arc::clone(synced_files_count); - let base_path = base_path.to_path_buf(); - - Box::new(move |result| { - let Ok(entry) = result else { - return ignore::WalkState::Continue; - }; - - if entry.file_type().is_some_and(|ft| ft.is_file()) { - let path = entry.path(); - - if is_git_file(path) { - return ignore::WalkState::Continue; - } - - if !is_git_repo && is_known_binary_extension(path) { - return ignore::WalkState::Continue; - } - - let metadata = entry.metadata().ok(); - let (file_item, rel_path) = - FileItem::new_from_walk(path, &base_path, None, metadata.as_ref()); - - pairs.lock().push((file_item, rel_path)); - counter.fetch_add(1, Ordering::Relaxed); - } - ignore::WalkState::Continue + pub(crate) fn spawn_git_status(git_workdir: PathBuf) -> JoinHandle> { + std::thread::spawn(move || { + GitStatusCache::read_git_status( + Some(git_workdir.as_path()), + &mut crate::git::default_status_options(), + ) }) - }); + } - let mut pairs = pairs.into_inner(); + /// Returns files immediately (searchable) and a handle to the in-progress + /// git status computation. This avoids blocking on `git status` which can + /// take 10+ seconds on very large repos (e.g. chromium). + pub(crate) fn walk_filesystem( + base_path: &Path, + git_workdir: Option, + synced_files_count: &Arc, + shared_frecency: &SharedFrecency, + mode: FFFMode, + ) -> Result { + use ignore::WalkBuilder; - info!( - "SCAN: File walking completed in {:?} for {} files", - walker_start.elapsed(), - pairs.len(), - ); + let scan_start = std::time::Instant::now(); + info!("SCAN: Starting filesystem walk and git status (async)"); - // Sort by full relative path. This groups files by directory naturally, - // so dir extraction becomes a simple linear scan — no HashMap. - BACKGROUND_THREAD_POOL.install(|| { - pairs.par_sort_unstable_by(|(_, a), (_, b)| a.cmp(b)); - }); + // Walk files (the fast part, typically 2-3s even on huge repos). + let is_git_repo = git_workdir.is_some(); + let bg_threads = BACKGROUND_THREAD_POOL.current_num_threads(); - // Build ChunkedPathStore + extract dirs + assign parent_dir in one pass. - // Files are sorted by relative path, so dir changes happen in order. - // add_file_immediate returns a ChunkedString with null arena_base; - // we fixup arena_base after the arena is frozen. - let mut files: Vec = Vec::with_capacity(pairs.len()); + let mut walk_builder = WalkBuilder::new(base_path); + walk_builder + // this is a very important guard for the user opening ~/ or other root non-git dir + .hidden(!is_git_repo) + .git_ignore(true) + .git_exclude(true) + .git_global(true) + .ignore(true) + .follow_links(false) + .threads(bg_threads); + + if !is_git_repo && let Some(overrides) = non_git_repo_overrides(base_path) { + walk_builder.overrides(overrides); + } + + let walker = walk_builder.build_parallel(); + let walker_start = std::time::Instant::now(); + debug!("SCAN: Starting file walker"); + + // Walk: collect (FileItem, rel_path) pairs. Keep the walk fast — + // no chunking, no HashMap, just Vec::push under the Mutex. + let pairs = parking_lot::Mutex::new(Vec::<(FileItem, String)>::new()); + + walker.run(|| { + let pairs = &pairs; + let counter = Arc::clone(synced_files_count); + let base_path = base_path.to_path_buf(); + + Box::new(move |result| { + let Ok(entry) = result else { + return ignore::WalkState::Continue; + }; + + if entry.file_type().is_some_and(|ft| ft.is_file()) { + let path = entry.path(); + + // Ignore walkers sometimes surface files inside `.git/` + // when the base is itself a git repo — skip them. + if is_git_file(path) { + return ignore::WalkState::Continue; + } + + if !is_git_repo && is_known_binary_extension(path) { + return ignore::WalkState::Continue; + } + + let metadata = entry.metadata().ok(); + let (file_item, rel_path) = + FileItem::new_from_walk(path, &base_path, None, metadata.as_ref()); + + pairs.lock().push((file_item, rel_path)); + counter.fetch_add(1, Ordering::Relaxed); + } + ignore::WalkState::Continue + }) + }); + + let mut pairs = pairs.into_inner(); + info!( + "SCAN: File walking completed in {:?} for {} files", + walker_start.elapsed(), + pairs.len(), + ); + + // Sort by (dir_part, filename). This groups files by their directory + // into contiguous runs so the linear dir-extraction pass below can + // dedupe by comparing only against the previous dir. + BACKGROUND_THREAD_POOL.install(|| { + pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| { + // SAFETY: `filename_offset` is always at a character boundary + let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize); + let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize); + a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file)) + }); + }); + + let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len()); + let dirs = populates_dirs_files_chunked_storage(&mut pairs, &mut builder); + + let mut files: Vec = pairs.into_iter().map(|(file, _)| file).collect(); + let chunked_paths = builder.finish(); + let arena = chunked_paths.as_arena_ptr(); + + // Apply frecency scores (access-based only — git status not yet available). + // DirItem.max_access_frecency is AtomicI32, so parallel threads write directly. + let frecency = shared_frecency + .read() + .map_err(|_| Error::AcquireFrecencyLock)?; + + if let Some(frecency) = frecency.as_ref() { + let dirs_ref = &dirs; + BACKGROUND_THREAD_POOL.install(|| { + files.par_iter_mut().for_each(|file| { + let _ = file.update_frecency_scores(frecency, arena, base_path, mode); + let score = file.access_frecency_score as i32; + if score > 0 { + let dir_idx = file.parent_dir_index() as usize; + if let Some(dir) = dirs_ref.get(dir_idx) { + dir.update_frecency_if_larger(score); + } + } + }); + }); + } + drop(frecency); + + // Re-sort by (indexable-first, parent_dir, filename). Indexable base + // files come first so the bigram builder can size its column bitsets to + // just the indexable subset. Within each partition files stay sorted by + // (parent_dir, filename) — `find_file_index` does two binary searches + // (one per partition) to preserve O(log n) lookups. + // + // "Indexable" = can possibly contribute bigrams: not binary-by-extension, + // non-zero size, not larger than the bigram/mmap cap. The cap matches + // `ContentCacheBudget::max_file_size` default (10 MB) — any file above + // that is skipped by `build_bigram_index` anyway. + const BIGRAM_ELIGIBLE_MAX_SIZE: u64 = 10 * 1024 * 1024; + let is_indexable = + |f: &FileItem| !f.is_binary() && f.size > 0 && f.size <= BIGRAM_ELIGIBLE_MAX_SIZE; + BACKGROUND_THREAD_POOL.install(|| { + files.par_sort_unstable_by(|a, b| { + // Sort indexables first (true < false when we invert with !). + (!is_indexable(a)) + .cmp(&!is_indexable(b)) + .then_with(|| a.parent_dir_index().cmp(&b.parent_dir_index())) + .then_with(|| a.file_name(arena).cmp(&b.file_name(arena))) + }); + }); + let indexable_count = files.partition_point(is_indexable); + + // Ask the allocator to return freed pages to the OS. + hint_allocator_collect(); + + let file_item_size = std::mem::size_of::(); + let files_vec_bytes = files.len() * file_item_size; + let dir_table_bytes = dirs.len() * std::mem::size_of::() + + dirs + .iter() + .map(|d| d.relative_path(arena).len()) + .sum::(); + + let total_time = scan_start.elapsed(); + info!( + "SCAN: Walk completed in {:?} ({} files, {} dirs, \ + chunked_store={:.2}MB, files_vec={:.2}MB, dirs={:.2}MB, FileItem={}B)", + total_time, + files.len(), + dirs.len(), + chunked_paths.heap_bytes() as f64 / 1_048_576.0, + files_vec_bytes as f64 / 1_048_576.0, + dir_table_bytes as f64 / 1_048_576.0, + file_item_size, + ); + + let base_count = files.len(); + + Ok(FileSync { + files, + indexable_count, + base_count, + dirs, + overflow_builder: None, + git_workdir, + bigram_index: None, + bigram_overlay: None, + chunked_paths: Some(chunked_paths), + }) + } +} + +/// This does both thing (yes sorry all the OOP morons) +/// in one go: populates files chunked storage and creates new directories +fn populates_dirs_files_chunked_storage<'a>( + pairs: &'a mut [(FileItem, String)], + builder: &mut crate::simd_path::ChunkedPathStoreBuilder, +) -> Vec { let mut dirs: Vec = Vec::new(); - let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len()); - // Use a sentinel that can never match any real dir_part (including "") - // so the very first file always creates its dir entry. - let mut prev_dir: Option = None; + + let mut prev_dir: &'a str = ""; + let mut prev_dir_valid = false; let mut current_dir_idx: u32 = 0; - for (mut file, rel) in pairs { - let fname_offset = file.path.filename_offset as usize; - let dir_part = &rel[..fname_offset]; + for (file, rel) in pairs.iter_mut() { + let rel: &'a str = rel; + let dir_part: &'a str = &rel[..file.path.filename_offset as usize]; + + if !prev_dir_valid || prev_dir != dir_part { + let dir_string = builder.add_dir_immediate(dir_part); - if prev_dir.as_deref() != Some(dir_part) { - let dir_cs = builder.add_dir_immediate(dir_part); // Compute last-segment offset: for "src/components/" -> 4 (points to "components/") let last_seg = if dir_part.is_empty() { 0 @@ -2174,93 +1843,25 @@ fn walk_filesystem( .map(|i| i + 1) .unwrap_or(0) as u16 }; - dirs.push(DirItem::new(dir_cs, last_seg)); + + dirs.push(DirItem::new(dir_string, last_seg)); current_dir_idx = (dirs.len() - 1) as u32; - prev_dir = Some(dir_part.to_string()); + + prev_dir = dir_part; + prev_dir_valid = true; } - let cs = builder.add_file_immediate(&rel, file.path.filename_offset); + let cs = builder.add_file_immediate(rel, file.path.filename_offset); + file.set_path(cs); file.set_parent_dir(current_dir_idx); - files.push(file); } - let chunked_paths = builder.finish(); - let arena = chunked_paths.as_arena_ptr(); - // Apply frecency scores (access-based only — git status not yet available). - // DirItem.max_access_frecency is AtomicI32, so parallel threads write directly. - let frecency = shared_frecency - .read() - .map_err(|_| Error::AcquireFrecencyLock)?; - if let Some(frecency) = frecency.as_ref() { - let dirs_ref = &dirs; - BACKGROUND_THREAD_POOL.install(|| { - files.par_iter_mut().for_each(|file| { - let _ = file.update_frecency_scores(frecency, arena, base_path, mode); - let score = file.access_frecency_score as i32; - if score > 0 { - let dir_idx = file.parent_dir_index() as usize; - if let Some(dir) = dirs_ref.get(dir_idx) { - dir.update_frecency_if_larger(score); - } - } - }); - }); - } - drop(frecency); - - // Re-sort by (parent_dir, filename) for binary search in find_file_index. - BACKGROUND_THREAD_POOL.install(|| { - files.par_sort_unstable_by(|a, b| { - a.parent_dir_index() - .cmp(&b.parent_dir_index()) - .then_with(|| a.file_name(arena).cmp(&b.file_name(arena))) - }); - }); - - // Ask the allocator to return freed pages to the OS. - hint_allocator_collect(); - - let file_item_size = std::mem::size_of::(); - let files_vec_bytes = files.len() * file_item_size; - let dir_table_bytes = dirs.len() * std::mem::size_of::() - + dirs - .iter() - .map(|d| d.relative_path(arena).len()) - .sum::(); - - let total_time = scan_start.elapsed(); - info!( - "SCAN: Walk completed in {:?} ({} files, {} dirs, \ - chunked_store={:.2}MB, files_vec={:.2}MB, dirs={:.2}MB, FileItem={}B)", - total_time, - files.len(), - dirs.len(), - chunked_paths.heap_bytes() as f64 / 1_048_576.0, - files_vec_bytes as f64 / 1_048_576.0, - dir_table_bytes as f64 / 1_048_576.0, - file_item_size, - ); - - let base_count = files.len(); - - Ok(WalkResult { - sync: FileSync { - files, - base_count, - dirs, - overflow_builder: None, - git_workdir, - bigram_index: None, - bigram_overlay: None, - chunked_paths: Some(chunked_paths), - }, - git_handle, - }) + dirs } -fn apply_git_status_and_frecency( - shared_picker: &SharedPicker, +pub(crate) fn apply_git_status_and_frecency( + shared_picker: &SharedFilePicker, shared_frecency: &SharedFrecency, git_handle: std::thread::JoinHandle>, mode: FFFMode, @@ -2277,61 +1878,85 @@ fn apply_git_status_and_frecency( let Some(git_cache) = git_cache else { return }; - if let Ok(mut guard) = shared_picker.write() - && let Some(ref mut picker) = *guard - { - let frecency = shared_frecency.read().ok(); - let frecency_ref = frecency.as_ref().and_then(|f| f.as_ref()); + // Take a snapshot of the raw pointers + metadata under a brief read + // lock, then drop the guard BEFORE running the rayon loop. Previously + // we held the picker write lock for the whole loop (multi-second: + // 500k files × LMDB read per file), which froze every FFI caller on + // the main nvim thread — searches, progress polls, BufEnter tracking. + // + // SAFETY: the scan thread is the only writer that replaces + // `sync_data` during post-scan. `post_scan_busy` blocks rescans; git + // status is applied exactly once per scan, on this thread, before + // anything else races for the files slice. The overflow watcher only + // appends to `files[base_count..]` — we only mutate `files[..]` here, + // and we deliberately do not touch or dereference the overflow tail. + #[allow(clippy::type_complexity)] + let snapshot: Option<( + *mut FileItem, + usize, + *const crate::types::DirItem, + usize, + PathBuf, + ArenaPtr, + )> = shared_picker.read().ok().and_then(|guard| { + guard.as_ref().map(|picker| { + let files = &picker.sync_data.files; + let dirs = &picker.sync_data.dirs; + ( + files.as_ptr() as *mut FileItem, + files.len(), + dirs.as_ptr(), + dirs.len(), + picker.base_path.clone(), + picker.arena_base_ptr(), + ) + }) + }); - // Destructure to split borrows: files (mut) and dirs (shared) are independent. - let bp = &picker.base_path; - let arena = picker.arena_base_ptr(); + let Some((files_ptr, files_len, dirs_ptr, dirs_len, bp, arena)) = snapshot else { + return; + }; - // Reset dir frecency before recomputation. - for dir in picker.sync_data.dirs.iter() { - dir.reset_frecency(); - } + let frecency = shared_frecency.read().ok(); + let frecency_ref = frecency.as_ref().and_then(|f| f.as_ref()); - let files = &mut picker.sync_data.files; - let dirs = &picker.sync_data.dirs; + // SAFETY: the scan thread is the sole replacer of `sync_data`; it + // won't run again until this function returns. See module-level + // comments on the post-scan phase for the full ordering argument. + let files: &mut [FileItem] = unsafe { std::slice::from_raw_parts_mut(files_ptr, files_len) }; + let dirs: &[crate::types::DirItem] = unsafe { std::slice::from_raw_parts(dirs_ptr, dirs_len) }; - BACKGROUND_THREAD_POOL.install(|| { - files.par_iter_mut().for_each(|file| { - let mut buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; - let absolute_path = file.write_absolute_path(arena, bp, &mut buf); - - file.git_status = git_cache.lookup_status(absolute_path); - if let Some(frecency) = frecency_ref { - let _ = file.update_frecency_scores(frecency, arena, bp, mode); - } - - let score = file.access_frecency_score as i32; - if score > 0 { - let dir_idx = file.parent_dir_index() as usize; - if let Some(dir) = dirs.get(dir_idx) { - dir.update_frecency_if_larger(score); - } - } - }); - }); - - info!( - "SCAN: Applied git status to {} files ({} dirty)", - picker.sync_data.files.len(), - git_cache.statuses_len(), - ); + // Reset dir frecency before recomputation. + for dir in dirs.iter() { + dir.reset_frecency(); } -} -#[inline] -fn is_git_file(path: &Path) -> bool { - path.to_str().is_some_and(|path| { - if cfg!(target_family = "windows") { - path.contains("\\.git\\") - } else { - path.contains("/.git/") - } - }) + BACKGROUND_THREAD_POOL.install(|| { + files.par_iter_mut().for_each(|file| { + let mut buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; + let absolute_path = file.write_absolute_path(arena, &bp, &mut buf); + + file.git_status = git_cache.lookup_status(absolute_path); + if let Some(frecency) = frecency_ref { + let _ = file.update_frecency_scores(frecency, arena, &bp, mode); + } + + let score = file.access_frecency_score as i32; + if score > 0 { + let dir_idx = file.parent_dir_index() as usize; + if let Some(dir) = dirs.get(dir_idx) { + dir.update_frecency_if_larger(score); + } + } + }); + }); + drop(frecency); + + info!( + "SCAN: Applied git status to {} files ({} dirty)", + files_len, + git_cache.statuses_len(), + ); } /// Fast extension-based binary detection. Avoids opening files during scan. @@ -2341,6 +1966,7 @@ fn is_known_binary_extension(path: &Path) -> bool { let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false; }; + matches!( ext, // Images @@ -2348,28 +1974,37 @@ fn is_known_binary_extension(path: &Path) -> bool { "heic" | "psd" | "icns" | "cur" | "raw" | "cr2" | "nef" | "dng" | // Video/Audio "mp4" | "avi" | "mov" | "wmv" | "mkv" | "mp3" | "wav" | "flac" | "ogg" | "m4a" | - "aac" | "webm" | "flv" | "mpg" | "mpeg" | "wma" | "opus" | + "aac" | "webm" | "flv" | "mpg" | "mpeg" | "wma" | "opus" | "pcm" | "reapeaks" | // Compressed/Archives "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" | "zst" | "lz4" | "lzma" | - "cab" | "cpio" | + "cab" | "cpio" | "jsonlz4" | // Packages/Installers "deb" | "rpm" | "apk" | "dmg" | "msi" | "iso" | "nupkg" | "whl" | "egg" | - "snap" | "appimage" | "flatpak" | + "snap" | "appimage" | "flatpak" | "crx" | "pak" | // Executables/Libraries "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib" | "bin" | "elf" | // Documents "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | // Databases "db" | "sqlite" | "sqlite3" | "mdb" | + // SQLite / LevelDB auxiliary files + "sqlite-wal" | "sqlite-shm" | "sqlite3-wal" | "sqlite3-shm" | + "db-wal" | "db-shm" | "ldb" | // Fonts "ttf" | "otf" | "woff" | "woff2" | "eot" | // Compiled/Runtime "class" | "pyc" | "pyo" | "wasm" | "dex" | "jar" | "war" | + // OCaml / Swift / Objective-C build artefacts + "cmi" | "cmt" | "cmti" | "cmx" | "cof" | "cot" | "cop" | "nib" | + "swiftdeps" | "swiftdeps~" | "swiftdoc" | "swiftmodule" | "swiftsourceinfo" | // ML/Data Science "npy" | "npz" | "pkl" | "pickle" | "h5" | "hdf5" | "pt" | "pth" | "onnx" | "safetensors" | "tfrecord" | // 3D/Game - "glb" | "fbx" | "blend" | + "glb" | "fbx" | "blend" | "blp" | "tga" | + // Game engines / Unity-Unreal side-files + "meta" | "dat" | "tfx" | "dia" | "journal" | "toc" | "thm" | "pfl" | + "shadow" | "scan" | "flm" | "bcmap" | "userinfo" | // Data/serialized "parquet" | "arrow" | "pb" | // IDE/OS metadata @@ -2385,10 +2020,46 @@ pub(crate) fn detect_binary_content(content: &[u8]) -> bool { content[..check_len].contains(&0) } +/// Length of the longest shared directory prefix of two relative dir +/// paths (without a trailing separator), measured as the number of bytes +/// up to and including the last shared separator — plus the full shorter +/// path when it is itself a directory prefix of the longer one. +/// +/// Examples: +/// `"src/components"` vs `"src/routes"` → 4 (`"src/"` emitted once) +/// `"lib/deep/nested"` vs `"lib/deep"` → 8 (`"lib/deep"` is a prefix) +/// `"lib/deep"` vs `"lib/deeper"` → 4 (only `"lib/"` is shared) +/// `"lib"` vs `"src"` → 0 +/// +/// Used by [`FilePicker::for_each_watch_dir`] to avoid re-emitting +/// ancestors that were already yielded for the previous (sorted) sibling. +fn common_dir_prefix_len(a: &str, b: &str) -> usize { + let max = a.len().min(b.len()); + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + let mut last_sep = 0; + let mut i = 0; + while i < max && a_bytes[i] == b_bytes[i] { + if std::path::is_separator(a_bytes[i] as char) { + last_sep = i + 1; + } + i += 1; + } + // If one string is a prefix of the other and the next byte in the + // longer one is a separator, the full shorter path is a shared dir. + if i == max && i > 0 { + let longer = if a.len() > b.len() { a_bytes } else { b_bytes }; + if i < longer.len() && std::path::is_separator(longer[i] as char) { + return i; + } + } + last_sep +} + /// Ask the global allocator to return freed pages to the OS. /// Enabled via the `mimalloc-collect` feature (set by fff-nvim). /// No-op when the feature is off (tests, system allocator). -fn hint_allocator_collect() { +pub(crate) fn hint_allocator_collect() { #[cfg(feature = "mimalloc-collect")] { // Collect BACKGROUND_THREAD_POOL workers — that's where the bigram @@ -2400,3 +2071,107 @@ fn hint_allocator_collect() { unsafe { libmimalloc_sys::mi_collect(true) }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The watcher must watch every ancestor directory up to `base_path`, + /// not just the immediate parents of indexed files. Intermediate dirs + /// that contain only subdirectories (no direct files) are NOT in + /// `sync_data.dirs` — yet they must still appear in `extract_watch_dirs` + /// so Create events on new subdirectories below them fire. + /// + /// Correctness regression guard for any refactor that replaces the + /// ancestor walk with a direct `sync_data.dirs` iteration. + #[test] + fn extract_watch_dirs_includes_pure_ancestor_dirs() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + // Tree: + // base/src/components/button.txt (src/components has a file) + // base/src/routes/home.txt (src/routes has a file) + // base/lib/deep/nested/util.txt (lib and lib/deep have no files) + // + // `sync_data.dirs` will only contain: + // src/components/ + // src/routes/ + // lib/deep/nested/ + // + // But the watcher also needs: + // src/ (pure ancestor — no direct files) + // lib/ (pure ancestor) + // lib/deep/ (pure ancestor) + // otherwise new siblings like `src/NewDir/x.txt` are missed. + for rel in [ + "src/components/button.txt", + "src/routes/home.txt", + "lib/deep/nested/util.txt", + ] { + let path = base.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"x").unwrap(); + } + + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_str().unwrap().into(), + watch: false, + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + + let mut watch_dirs: Vec = Vec::new(); + picker.for_each_dir(|p| { + watch_dirs.push(p.to_path_buf()); + std::ops::ControlFlow::Continue(()) + }); + let watch_set: std::collections::HashSet = watch_dirs.iter().cloned().collect(); + + // Immediate parents (in sync_data.dirs) must be present. + for rel in ["src/components", "src/routes", "lib/deep/nested"] { + assert!( + watch_set.contains(&base.join(rel)), + "expected immediate parent {rel} in watch dirs, got {watch_set:?}", + ); + } + + // Pure-ancestor dirs (NOT in sync_data.dirs) must also be present. + for rel in ["src", "lib", "lib/deep"] { + assert!( + watch_set.contains(&base.join(rel)), + "expected pure-ancestor {rel} in watch dirs, got {watch_set:?}", + ); + } + + // No duplicates — streaming dedup must not emit the same dir twice. + assert_eq!( + watch_dirs.len(), + watch_set.len(), + "duplicate watch dir emitted: {watch_dirs:?}", + ); + + // Base path itself is NOT walked into the result — the walker stops + // at `current == base`. The outer `debouncer.watch(base_path, ...)` + // call in create_debouncer covers it separately. + assert!( + !watch_set.contains(base), + "base path must not be in watch dirs (covered by the top-level watch call)", + ); + } + + #[test] + fn common_dir_prefix_len_cases() { + assert_eq!(common_dir_prefix_len("", ""), 0); + assert_eq!(common_dir_prefix_len("", "src"), 0); + assert_eq!(common_dir_prefix_len("lib", "src"), 0); + assert_eq!(common_dir_prefix_len("src/components", "src/routes"), 4); + assert_eq!(common_dir_prefix_len("lib/deep/nested", "lib/deep"), 8); + assert_eq!(common_dir_prefix_len("lib/deep", "lib/deep/nested"), 8); + assert_eq!(common_dir_prefix_len("lib/deep", "lib/deeper"), 4); + assert_eq!(common_dir_prefix_len("src", "src"), 0); + // "src" is emitted-as-dir; "src/x" extends it — full "src" is shared. + assert_eq!(common_dir_prefix_len("src", "src/x"), 3); + } +} diff --git a/crates/fff-core/src/git.rs b/crates/fff-core/src/git.rs index 90a3d0a..b92fcb5 100644 --- a/crates/fff-core/src/git.rs +++ b/crates/fff-core/src/git.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use ahash::AHashMap; use git2::{Repository, Status, StatusOptions}; use std::{ fmt::Debug, @@ -6,15 +7,21 @@ use std::{ }; use tracing::debug; -/// Represents a cache of a single git status query, if there is no -/// status aka file is clear but it was specifically requested to updated -/// the status is `None` otherwise contains only actual file statuses. -#[derive(Debug, Clone)] -pub struct GitStatusCache(Vec<(PathBuf, Status)>); +pub(crate) fn default_status_options() -> StatusOptions { + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .include_unmodified(true) + .exclude_submodules(true); + opts +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct GitStatusCache(AHashMap); impl IntoIterator for GitStatusCache { type Item = (PathBuf, Status); - type IntoIter = std::vec::IntoIter; + type IntoIter = as IntoIterator>::IntoIter; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() @@ -26,25 +33,27 @@ impl GitStatusCache { self.0.len() } + #[inline] pub fn lookup_status(&self, full_path: &Path) -> Option { - self.0 - .binary_search_by(|(path, _)| path.as_path().cmp(full_path)) - .ok() - .and_then(|idx| self.0.get(idx).map(|(_, status)| *status)) + self.0.get(full_path).copied() } #[tracing::instrument(skip(repo, status_options))] fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result { let statuses = repo.statuses(Some(status_options))?; let Some(repo_path) = repo.workdir() else { - return Ok(Self(vec![])); // repo is bare + return Ok(Self(AHashMap::new())); // repo is bare }; - let mut entries = Vec::with_capacity(statuses.len()); + let repo_path = crate::path_utils::normalize(repo_path.to_path_buf()); + + let mut entries = AHashMap::with_capacity(statuses.len()); for entry in &statuses { if let Some(entry_path) = entry.path() { - let full_path = repo_path.join(entry_path); - entries.push((full_path, entry.status())); + // libgit2 returns entry paths with forward slashes on every platform + // fff stores native paths - meaning we have forward slash issue on windows + let full_path = crate::path_utils::normalize(repo_path.join(entry_path)); + entries.insert(full_path, entry.status()); } } @@ -76,32 +85,29 @@ impl GitStatusCache { paths: &[TPath], ) -> Result { if paths.is_empty() { - return Ok(Self(vec![])); + return Ok(Self(AHashMap::new())); } let Some(workdir) = repo.workdir() else { - return Ok(Self(vec![])); + return Ok(Self(AHashMap::new())); }; + let workdir = crate::path_utils::normalize(workdir.to_path_buf()); // git pathspec is pretty slow and requires to walk the whole directory // so for a single file which is the most general use case we query directly the file if paths.len() == 1 { let full_path = paths[0].as_ref(); - let relative_path = full_path.strip_prefix(workdir)?; + let relative_path = full_path.strip_prefix(&workdir)?; let status = repo.status_file(relative_path)?; - return Ok(Self(vec![(full_path.to_path_buf(), status)])); + let mut map = AHashMap::with_capacity(1); + map.insert(full_path.to_path_buf(), status); + return Ok(Self(map)); } - let mut status_options = StatusOptions::new(); - status_options - .include_untracked(true) - .recurse_untracked_dirs(true) - // when reading partial status it's important to include all files requested - .include_unmodified(true); - + let mut status_options = default_status_options(); for path in paths { - status_options.pathspec(path.as_ref().strip_prefix(workdir)?); + status_options.pathspec(path.as_ref().strip_prefix(&workdir)?); } let git_status_cache = Self::read_status_impl(repo, &mut status_options)?; @@ -157,3 +163,81 @@ pub fn format_git_status_opt(status: Option) -> Option<&'static str> { pub fn format_git_status(status: Option) -> &'static str { format_git_status_opt(status).unwrap_or("unknown") } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::process::Command; + use tempfile::TempDir; + + fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?} failed"); + } + + /// Regression: on case-insensitive filesystems libgit2 returns + /// statuses in a case-insensitive order. Our previous sorted-`Vec` + + /// `binary_search_by(Path::cmp)` lookup silently missed entries + /// because `Path::cmp` is byte-wise. + /// + /// This test uses deliberately mixed-case filenames so the two + /// orderings disagree, then checks every lookup succeeds. + #[test] + fn lookup_is_case_exact_regardless_of_libgit2_sort_order() { + let tmp = TempDir::new().unwrap(); + let base = tmp.path().canonicalize().unwrap(); + + // Mixed-case names that sort differently under byte-wise vs + // case-insensitive comparators. + let names = [ + "README.md", + "a_lower.rs", + "Z_upper.rs", + "mixed_Case.txt", + "nested/Inner_File.rs", + ]; + for n in &names { + let p = base.join(n); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(&p, format!("// {n}\n")).unwrap(); + } + + git(&base, &["init", "-b", "main"]); + git(&base, &["add", "-A"]); + git(&base, &["commit", "-m", "seed", "--no-gpg-sign"]); + + // Modify every file so they all end up in the status output as + // WT_MODIFIED — guarantees a non-trivial map we have to look up. + for n in &names { + let p = base.join(n); + fs::write(&p, format!("// {n}\n// edit\n")).unwrap(); + } + + let repo = Repository::open(&base).unwrap(); + let paths: Vec = names.iter().map(|n| base.join(n)).collect(); + let cache = GitStatusCache::git_status_for_paths(&repo, &paths).unwrap(); + + for (n, abs) in names.iter().zip(paths.iter()) { + let status = cache.lookup_status(abs); + assert!( + status.is_some(), + "lookup for {n} returned None; cache holds {} entries", + cache.statuses_len(), + ); + assert!( + status.unwrap().contains(Status::WT_MODIFIED), + "expected WT_MODIFIED for {n}, got {:?}", + status + ); + } + } +} diff --git a/crates/fff-core/src/grep.rs b/crates/fff-core/src/grep.rs index aab263a..ec1d79e 100644 --- a/crates/fff-core/src/grep.rs +++ b/crates/fff-core/src/grep.rs @@ -1023,14 +1023,24 @@ pub(crate) fn multi_grep_search<'a>( filtered_file_count = retry_count; } - // Apply bigram prefilter to the file list + // Apply bigram prefilter to the file list. Bigram columns only cover + // the indexable region — `bigram_overlay.base_file_count()` is the + // authoritative boundary. Files past it (unindexable base, overflow) + // are always retained and searched directly. if let Some(ref candidates) = bigram_candidates { let base_ptr = files.as_ptr(); + let bigram_boundary = bigram_overlay + .map(|o| o.base_file_count()) + .unwrap_or(files.len()); + files_to_search.retain(|f| { if f.is_overflow() { return true; } let file_idx = unsafe { (*f as *const FileItem).offset_from(base_ptr) as usize }; + if file_idx >= bigram_boundary { + return true; + } BigramFilter::is_candidate(candidates, file_idx) }); } @@ -1941,7 +1951,17 @@ pub(crate) fn grep_search<'a>( } } + // Bigram columns only cover the indexable region at + // `files[..overlay.base_file_count()]` — which equals + // `indexable_count` today. Files past that boundary + // (unindexable base files, overflow) are not tracked by + // the bigram filter, so we always retain them and let + // the full text search decide. let base_ptr = files.as_ptr(); + let bigram_boundary = bigram_overlay + .map(|o| o.base_file_count()) + .unwrap_or(files.len()); + files_to_search.retain(|f| { if f.is_overflow() { return true; @@ -1950,6 +1970,10 @@ pub(crate) fn grep_search<'a>( let file_idx = unsafe { (*f as *const FileItem).offset_from(base_ptr) as usize }; + if file_idx >= bigram_boundary { + return true; + } + BigramFilter::is_candidate(&candidates, file_idx) }); } diff --git a/crates/fff-core/src/ignore.rs b/crates/fff-core/src/ignore.rs index bb61539..c3c0d36 100644 --- a/crates/fff-core/src/ignore.rs +++ b/crates/fff-core/src/ignore.rs @@ -14,8 +14,16 @@ pub(crate) const NON_GIT_IGNORED_DIRS: &[&str] = &[ ]; #[cfg(target_os = "macos")] -pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = - &["Library/Application Support", "Library/Caches"]; +pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[ + "Library/Application Support", + "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. + "Library/Group Containers", + "Library/Containers", +]; #[cfg(target_os = "windows")] pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[ diff --git a/crates/fff-core/src/lib.rs b/crates/fff-core/src/lib.rs index be9820b..703d57b 100644 --- a/crates/fff-core/src/lib.rs +++ b/crates/fff-core/src/lib.rs @@ -20,7 +20,7 @@ //! //! ## Shared State //! -//! [`SharedPicker`], [`SharedFrecency`], and [`SharedQueryTracker`] are +//! [`SharedFilePicker`], [`SharedFrecency`], and [`SharedQueryTracker`] are //! newtype wrappers around `Arc>>` for thread-safe shared //! access. They provide `read()` / `write()` methods with built-in error //! conversion and convenience helpers like `wait_for_scan()`. @@ -33,10 +33,10 @@ //! use fff_search::query_tracker::QueryTracker; //! use fff_search::{ //! FFFMode, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser, -//! SharedFrecency, SharedPicker, SharedQueryTracker, +//! SharedFrecency, SharedFilePicker, SharedQueryTracker, //! }; //! -//! let shared_picker = SharedPicker::default(); +//! let shared_picker = SharedFilePicker::default(); //! let shared_frecency = SharedFrecency::default(); //! let shared_query_tracker = SharedQueryTracker::default(); //! @@ -92,7 +92,11 @@ //! ``` mod background_watcher; -mod bigram_filter; +mod scan; +// public only for benchmarks — the inverted index is still re-exported via +// `pub use bigram_filter::*` below for external consumers. +#[doc(hidden)] +pub mod bigram_filter; pub mod bigram_query; mod constraints; mod db_healthcheck; diff --git a/crates/fff-core/src/path_utils.rs b/crates/fff-core/src/path_utils.rs index d1408d6..5047719 100644 --- a/crates/fff-core/src/path_utils.rs +++ b/crates/fff-core/src/path_utils.rs @@ -10,6 +10,20 @@ pub fn canonicalize(path: impl AsRef) -> std::io::Result { std::fs::canonicalize(path) } +/// Git requires a normalized forward-slashed paths on windows +#[cfg(windows)] +pub fn normalize(path: PathBuf) -> PathBuf { + let as_str = path.to_string_lossy(); + let with_backslashes: String = as_str.replace('/', "\\"); + let buf = PathBuf::from(with_backslashes); + dunce::canonicalize(&buf).unwrap_or(buf) +} + +#[cfg(not(windows))] +pub fn normalize(path: PathBuf) -> PathBuf { + path +} + #[cfg(windows)] pub fn expand_tilde(path: &str) -> PathBuf { return PathBuf::from(path); diff --git a/crates/fff-core/src/scan.rs b/crates/fff-core/src/scan.rs new file mode 100644 index 0000000..d97ed4d --- /dev/null +++ b/crates/fff-core/src/scan.rs @@ -0,0 +1,441 @@ +//! Unified scan-phase orchestrator. +//! +//! Every (re)index code path — initial scan, FFI-triggered rescan, +//! watcher overflow rescan — goes through [`ScanJob::run`]. The +//! orchestrator owns the *sequence* of a scan: +//! +//! 1. walk filesystem off-lock +//! 2. swap `sync_data` under a brief write +//! 3. apply git status + frecency off-lock +//! 4. (optional, initial scan only) spawn the filesystem watcher +//! 5. (optional) post-scan: auto-size cache budget, warmup, bigram +//! +//! The picker write lock is held only in step 2 and step 5's index +//! install — both O(µs-ms), never seconds. Every other FFI caller on +//! the nvim main thread keeps running. +//! +//! ## Entry points +//! +//! - [`ScanJob::spawn`] — fire-and-forget from `SharedPicker` state. +//! Used by the watcher overflow path and by FFI (`scan_files`). +//! - [`ScanJob::spawn_initial`] — same, but takes explicit config for +//! the very first scan, before the `FilePicker` struct lives inside +//! the shared handle. + +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use tracing::{error, info}; + +use crate::FileSync; +use crate::background_watcher::BackgroundWatcher; +use crate::bigram_filter::BigramOverlay; +use crate::bigram_filter::build_bigram_index; +use crate::error::Error; +use crate::file_picker::{self, FFFMode, warmup_mmaps}; +use crate::shared::{SharedFilePicker, SharedFrecency}; +use crate::types::ContentCacheBudget; + +/// Shared atomic flags surfaced by the picker for the scan worker to +/// signal its progress. Grouped so every callsite passes one value, +/// not four. +#[derive(Clone, Default)] +pub(crate) struct ScanSignals { + /// Set to `true` while any scan phase is running + pub(crate) scanning: Arc, + /// Set to `true` once the filesystem watcher has been installed + pub(crate) watcher_ready: Arc, + /// Indicates that that owning picker was requested to shut down + pub(crate) cancelled: Arc, + /// Soft lock indicating that the post scan non blocking work is active + pub(crate) post_scan_busy: Arc, + /// Used to resolve conflicts if multiple rescans were triggered in a queue + pub(crate) rescan_pending: Arc, +} + +/// Which optional phases a scan should run. +#[derive(Clone, Copy, Default)] +pub(crate) struct ScanConfig { + pub(crate) warmup: bool, + pub(crate) content_indexing: bool, + pub(crate) watch: bool, + pub(crate) auto_cache_budget: bool, + pub(crate) install_watcher: bool, +} + +/// A fully-configured scan job ready to run on a background thread. +/// +/// Build with [`ScanJob::from_picker`] (reads all state from the +/// current `FilePicker`) or [`ScanJob::initial`] (for the bootstrap +/// scan, before the picker is published to `SharedPicker`). +pub(crate) struct ScanJob { + shared_picker: SharedFilePicker, + shared_frecency: SharedFrecency, + base_path: PathBuf, + mode: FFFMode, + signals: ScanSignals, + config: ScanConfig, + /// Walker-maintained counter backing `get_scan_progress` on the UI + /// side. Reset to 0 at scan start, incremented per-file by the + /// walker. Shared `Arc` so the UI polls the same atomic. + scanned_files_counter: Arc, +} + +impl ScanJob { + pub fn new( + shared_picker: &SharedFilePicker, + shared_frecency: &SharedFrecency, + install_watcher: bool, + ) -> Result, Error> { + let guard = shared_picker.read()?; + let picker = guard.as_ref().ok_or(Error::FilePickerMissing)?; + + if picker.is_scan_active() { + return Ok(None); + } + + let signals = picker.scan_signals(); + if signals.post_scan_busy.load(Ordering::Acquire) { + return Ok(None); + } + + Ok(Some(Self { + shared_picker: shared_picker.clone(), + shared_frecency: shared_frecency.clone(), + base_path: picker.base_path().to_path_buf(), + mode: picker.mode(), + signals, + scanned_files_counter: picker.scanned_files_counter(), + config: ScanConfig { + warmup: picker.has_mmap_cache(), + content_indexing: picker.has_content_indexing(), + watch: picker.has_watcher(), + auto_cache_budget: !picker.has_explicit_cache_budget(), + install_watcher, + }, + })) + } + + /// Same as [`new`] but without reading from the picker — caller + /// supplies the base path / mode / flags directly. Used by the + /// bootstrap scan before the `FilePicker` is published to + /// `SharedPicker`. + pub fn new_initial( + shared_picker: SharedFilePicker, + shared_frecency: SharedFrecency, + base_path: PathBuf, + mode: FFFMode, + signals: ScanSignals, + scanned_files_counter: Arc, + config: ScanConfig, + ) -> Self { + Self { + shared_picker, + shared_frecency, + base_path, + mode, + signals, + scanned_files_counter, + config, + } + } + + /// Spawn the job on a dedicated OS thread. Returns immediately. + pub fn spawn(self) -> std::thread::JoinHandle<()> { + self.signals.scanning.store(true, Ordering::Release); + std::thread::Builder::new() + .name("fff-scan".into()) + .spawn(move || self.run()) + .expect("failed to spawn fff-scan thread") + } + + fn run(self) { + let Self { + shared_picker, + shared_frecency, + base_path, + mode, + signals, + scanned_files_counter, + config, + } = self; + + let _scanning = ScanningGuard::new(&signals, config.install_watcher); + + // Reset the UI-visible counter; the walker bumps it per file + // and `get_scan_progress` reads it without locks. + scanned_files_counter.store(0, Ordering::Relaxed); + + // 1. Start git discovery and walk filesystem off-lock. + let git_workdir = FileSync::discover_git_workdir(&base_path); + let status_handle = git_workdir.clone().map(FileSync::spawn_git_status); + let sync = match FileSync::walk_filesystem( + &base_path, + git_workdir, + &scanned_files_counter, + &shared_frecency, + mode, + ) { + Ok(sync) => sync, + Err(e) => { + error!(?e, "scan walk failed"); + return; + } + }; + + if signals.cancelled.load(Ordering::Acquire) { + info!("walk completed but picker was replaced, discarding results"); + return; + } + + let git_workdir = sync.git_workdir.clone(); + + // 2. Brief write to install the freshly-walked file list. + if let Ok(mut guard) = shared_picker.write() + && let Some(picker) = guard.as_mut() + { + picker.commit_new_sync(sync); + } else { + error!("failed to install scan results into picker"); + return; + } + + // Files are now searchable — flip the scan signal *early* so + // UI progress polls see the picker as "ready" while we run the + // optional post-scan steps in the background. + signals.scanning.store(false, Ordering::Relaxed); + + // in case we do a rescan, we have to resubscribe a watcher to the new set of directories + // all the already watched directories are not going to be resubscribed + if !config.install_watcher && !signals.cancelled.load(Ordering::Acquire) { + resubscribe_to_new_picker(&shared_picker); + } + + // 3. Apply git status + frecency off-lock. + if !signals.cancelled.load(Ordering::Acquire) + && let Some(status_handle) = status_handle + { + file_picker::apply_git_status_and_frecency( + &shared_picker, + &shared_frecency, + status_handle, + mode, + ); + } + + // 4. Install filesystem watcher (initial scan only). + if config.install_watcher && config.watch && !signals.cancelled.load(Ordering::Acquire) { + let shared_picker: &SharedFilePicker = &shared_picker; + let shared_frecency: &SharedFrecency = &shared_frecency; + let base_path: &std::path::Path = &base_path; + + match BackgroundWatcher::new( + base_path.to_path_buf(), + git_workdir, + shared_picker.clone(), + shared_frecency.clone(), + mode, + ) { + Ok(watcher) => { + if let Ok(mut guard) = shared_picker.write() + && let Some(picker) = guard.as_mut() + { + picker.background_watcher = Some(watcher); + } + } + Err(e) => error!(?e, "failed to initialize background watcher"), + }; + } + + // 5. Post-scan warmup + bigram build. + if (config.warmup || config.content_indexing) && !signals.cancelled.load(Ordering::Acquire) + { + run_post_scan(&shared_picker, &base_path, &signals, &config); + } + + // 6. Drain any rescan that arrived while we were busy. + // + // `trigger_full_rescan_async` sets `rescan_pending` whenever a + // caller asks for a rescan while `ScanJob::new` would have + // returned `Ok(None)` (scan active *or* post-scan busy). We + // consume the flag with `swap` so concurrent requests that land + // between the check and the follow-up spawn are still captured + // by the next invocation. + if !signals.cancelled.load(Ordering::Acquire) + && signals.rescan_pending.swap(false, Ordering::AcqRel) + { + match Self::new(&shared_picker, &shared_frecency, false) { + Ok(Some(follow_up)) => { + info!("Rescheduling deferred rescan after current scan finished"); + follow_up.spawn(); + } + Ok(None) => { + // Another scan slipped in between our post-scan exit + // and the `new()` call above. That scan will drain + // the flag we just cleared — but we re-arm it so it + // does. + signals.rescan_pending.store(true, Ordering::Release); + } + Err(e) => { + error!(?e, "Failed to reschedule deferred rescan"); + } + } + } + } +} + +/// RAII helper that flips the `scanning` signal on construction and +/// resets it on drop (so early-returns can't leave it stuck on `true`). +/// Also drives the `watcher_ready` signal on the initial-scan path. +struct ScanningGuard<'a> { + signals: &'a ScanSignals, + release_watcher_ready_on_drop: bool, +} + +impl<'a> ScanningGuard<'a> { + fn new(signals: &'a ScanSignals, release_watcher_ready_on_drop: bool) -> Self { + signals.scanning.store(true, Ordering::Relaxed); + Self { + signals, + release_watcher_ready_on_drop, + } + } +} + +impl Drop for ScanningGuard<'_> { + fn drop(&mut self) { + self.signals.scanning.store(false, Ordering::Relaxed); + if self.release_watcher_ready_on_drop { + self.signals.watcher_ready.store(true, Ordering::Release); + } + } +} + +fn run_post_scan( + shared_picker: &SharedFilePicker, + base_path: &std::path::Path, + signals: &ScanSignals, + config: &ScanConfig, +) { + let phase_start = std::time::Instant::now(); + + // Auto-scale the cache budget before we take the files snapshot — + // warmup needs the final budget. + if config.auto_cache_budget + && !signals.cancelled.load(Ordering::Acquire) + && let Ok(mut guard) = shared_picker.write() + && let Some(picker) = guard.as_mut() + && !picker.has_explicit_cache_budget() + { + let (files, _, _) = picker.sync_data_snapshot(); + picker.set_cache_budget(ContentCacheBudget::new_for_repo(files.len())); + } + + let Some((files, indexable_count, budget, arena, _busy_guard)) = shared_picker + .read() + .ok() + .and_then(|guard| guard.as_ref().map(|p| snapshot_sync_data(p, signals))) + else { + return; + }; + + if config.warmup && !signals.cancelled.load(Ordering::Acquire) { + let t = std::time::Instant::now(); + warmup_mmaps(files, &budget, base_path, arena); + info!( + "Warmup completed in {:.2}s (cached {} files, {} bytes)", + t.elapsed().as_secs_f64(), + budget.cached_count.load(Ordering::Relaxed), + budget.cached_bytes.load(Ordering::Relaxed), + ); + } + + if config.content_indexing && !signals.cancelled.load(Ordering::Acquire) { + let indexable_files = &files[..indexable_count.min(files.len())]; + let (index, content_binary) = + build_bigram_index(indexable_files, &budget, base_path, arena); + + if let Ok(mut guard) = shared_picker.write() + && let Some(picker) = guard.as_mut() + { + for &idx in &content_binary { + if let Some(file) = picker.get_file_mut(idx) { + file.set_binary(true); + } + } + picker.set_bigram_index(index, BigramOverlay::new(indexable_count)); + } + } + + info!( + "Post-scan phase total: {:.2}s (warmup={}, content_indexing={})", + phase_start.elapsed().as_secs_f64(), + config.warmup, + config.content_indexing, + ); +} + +struct PostScanBusyGuard<'a>(&'a AtomicBool); +impl Drop for PostScanBusyGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +/// Re-registers all the directories at the watcher +#[tracing::instrument(skip_all)] +fn resubscribe_to_new_picker(shared_picker: &SharedFilePicker) { + let Ok(guard) = shared_picker.read() else { + return; + }; + let Some(picker) = guard.as_ref() else { + return; + }; + let Some(watcher) = picker.background_watcher.as_ref() else { + return; + }; + + // Base path first — this is the watch that delivers `Create(Folder)` + // events for brand-new top-level subdirs. On rescan paths this + // watch is still alive (the BackgroundWatcher survives rescans), so + // the call is idempotent. Including it explicitly protects against + // any future refactor that could drop the initial base-path watch. + watcher.request_watch_dir(picker.base_path().to_path_buf()); + + picker.for_each_dir(|dir: &std::path::Path| { + watcher.request_watch_dir(dir.to_path_buf()); + std::ops::ControlFlow::Continue(()) + }); +} + +/// Take a `'static`-lifetime snapshot of `sync_data` pinned by a +/// post-scan busy guard. Concurrent rescans short-circuit while the +/// returned guard is alive, so the raw slice can't be freed from under +/// the warmup + bigram build that consumes it. +fn snapshot_sync_data<'a>( + picker: &crate::file_picker::FilePicker, + signals: &'a ScanSignals, +) -> ( + &'static [crate::types::FileItem], + usize, + Arc, + crate::simd_path::ArenaPtr, + PostScanBusyGuard<'a>, +) { + signals.post_scan_busy.store(true, Ordering::Release); + let busy = PostScanBusyGuard(&signals.post_scan_busy); + + let (files, indexable_count, arena) = picker.sync_data_snapshot(); + let ptr = files.as_ptr(); + let len = files.len(); + let static_files: &'static [crate::types::FileItem] = + unsafe { std::slice::from_raw_parts(ptr, len) }; + ( + static_files, + indexable_count, + picker.cache_budget_arc(), + arena, + busy, + ) +} diff --git a/crates/fff-core/src/shared.rs b/crates/fff-core/src/shared.rs index 26a120a..79e8b41 100644 --- a/crates/fff-core/src/shared.rs +++ b/crates/fff-core/src/shared.rs @@ -1,55 +1,125 @@ -use std::path::PathBuf; -use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; -use std::time::Duration; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}; +use std::time::{Duration, Instant}; use crate::error::Error; use crate::file_picker::FilePicker; use crate::frecency::FrecencyTracker; use crate::git::GitStatusCache; use crate::query_tracker::QueryTracker; +use crate::scan::ScanJob; + +/// Poll `.git/index.lock` until it disappears (git write completed), giving up +/// after [`GIT_LOCK_MAX_WAIT`]. Used by [`SharedPicker::refresh_git_status`] +/// to avoid reading a half-updated index when the watcher fires mid-`git add`. +/// +/// The wait is bounded and cheap: the lock file is typically cleared within +/// a few milliseconds of the git command exiting. +fn wait_for_git_index_lock_release(git_root: &Path) { + const GIT_LOCK_POLL: Duration = Duration::from_millis(10); + const GIT_LOCK_MAX_WAIT: Duration = Duration::from_millis(500); + + let lock = git_root.join(".git").join("index.lock"); + // Fast path: no lock present. + if !lock.exists() { + return; + } + let deadline = Instant::now() + GIT_LOCK_MAX_WAIT; + while lock.exists() && Instant::now() < deadline { + std::thread::sleep(GIT_LOCK_POLL); + } + if lock.exists() { + tracing::warn!( + "Proceeding with git status refresh despite lingering \ + .git/index.lock at {} — will retry once it clears", + lock.display() + ); + } +} /// Thread-safe shared handle to the [`FilePicker`] instance. +/// This accumulates only asynchronous non-blocking operations against the +/// file picker: creating, triggering various rescans and so on. /// -/// Uses `parking_lot::RwLock` which is reader-fair — new readers are not -/// blocked when a writer is waiting, preventing search query stalls during -/// background bigram builds or watcher writes. +/// For blocking access use internal picker via `.read()` or `.write()` /// -/// `Clone` gives a new handle to the same picker (Arc clone). -/// `Default` creates an empty handle suitable for `Lazy::new(SharedPicker::default)`. +/// ```ignore +/// let shared_picker = SharedFilePicker::default(); +/// +/// if let Some(picker) = shared_picker.read()?.as_ref() { +/// let files = picker.fuzzy_search(&query, options); +/// println!("Found {} files", files.len()); +/// } else { +/// println!("Picker not initialized"); +/// } +/// ``` #[derive(Clone, Default)] -pub struct SharedPicker(pub(crate) Arc>>); +pub struct SharedFilePicker(pub(crate) Arc); -impl std::fmt::Debug for SharedPicker { +pub struct SharedPickerInner { + picker: parking_lot::RwLock>, +} + +impl Default for SharedPickerInner { + fn default() -> Self { + Self { + picker: parking_lot::RwLock::new(None), + } + } +} + +/// Non-owning handle to a [`SharedPicker`]. +#[derive(Clone)] +pub(crate) struct WeakFilePicker(Weak); + +impl WeakFilePicker { + /// Try to promote the weak handle back to a strong [`SharedPicker`]. + /// + /// Returns `None` once every strong `SharedPicker` clone has been + /// dropped. Callers should treat that as "the picker is being + /// torn down" and exit their current iteration cleanly. + pub(crate) fn upgrade(&self) -> Option { + self.0.upgrade().map(SharedFilePicker) + } +} + +impl std::fmt::Debug for SharedFilePicker { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_tuple("SharedPicker").field(&"..").finish() } } -impl SharedPicker { +impl SharedFilePicker { pub fn read(&self) -> Result>, Error> { - Ok(self.0.read()) + Ok(self.0.picker.read()) } pub fn write(&self) -> Result>, Error> { - Ok(self.0.write()) + Ok(self.0.picker.write()) + } + + /// Produce a non-owning handle to the same inner picker. + /// Use it if you don't need to block internal threads from dropping while owning this ref + pub(crate) fn weaken(&self) -> WeakFilePicker { + WeakFilePicker(Arc::downgrade(&self.0)) } /// Return `true` if this is an instance of the picker that requires a complicated post-scan /// indexing/cache warmup job. The indexing is not crazy but it takes time. pub fn need_complex_rebuild(&self) -> bool { - let guard = self.0.read(); + let guard = self.0.picker.read(); guard .as_ref() - .is_some_and(|p| p.need_enable_mmap_cache() || p.need_enable_content_indexing()) + .is_some_and(|p| p.has_mmap_cache() || p.has_content_indexing()) } /// Block until the background filesystem scan finishes. /// Returns `true` if scan completed, `false` on timeout. pub fn wait_for_scan(&self, timeout: Duration) -> bool { let signal = { - let guard = self.0.read(); + let guard = self.0.picker.read(); match &*guard { - Some(picker) => picker.scan_signal(), + Some(picker) => Arc::clone(&picker.signals.scanning), None => return true, } }; @@ -67,16 +137,16 @@ impl SharedPicker { /// Block until the background file watcher is ready. /// Returns `true` if watcher ready, `false` on timeout. pub fn wait_for_watcher(&self, timeout: Duration) -> bool { - let signal = { - let guard = self.0.read(); + let watch_ready_signal = { + let guard = self.0.picker.read(); match &*guard { - Some(picker) => picker.watcher_signal(), + Some(picker) => Arc::clone(&picker.signals.watcher_ready), None => return true, } }; let start = std::time::Instant::now(); - while !signal.load(std::sync::atomic::Ordering::Acquire) { + while !watch_ready_signal.load(std::sync::atomic::Ordering::Acquire) { if start.elapsed() >= timeout { return false; } @@ -85,9 +155,37 @@ impl SharedPicker { true } + /// Trigger a full filesystem rescan without blocking the caller. + /// 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> { + match ScanJob::new(self, shared_frecency, /*install_watcher=*/ false)? { + Some(job) => { + job.spawn(); + } + None => { + // A scan is already in flight — mark a follow-up as + // needed. The running scan's `run()` drains this flag + // and reschedules itself. + if let Ok(guard) = self.read() + && let Some(picker) = guard.as_ref() + { + picker + .scan_signals() + .rescan_pending + .store(true, std::sync::atomic::Ordering::Release); + tracing::info!( + "Full rescan requested while another scan is active — \ + deferred via rescan_pending flag" + ); + } + } + } + Ok(()) + } + /// Refresh git statuses for all indexed files. pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result { - use git2::StatusOptions; use tracing::debug; let git_status = { @@ -101,13 +199,21 @@ impl SharedPicker { picker.git_root() ); + // Wait briefly for any in-progress git operation to release + // its `.git/index.lock`. libgit2 reads `.git/index` directly + // and does NOT coordinate with the filesystem lock; if a + // writer is mid-atomic-rename (lock file exists, new index + // not yet swapped in), we would observe stale status data. + // This matters most for the background watcher, which + // typically fires refresh in response to the very events + // produced by that in-flight git write. + if let Some(root) = picker.git_root() { + wait_for_git_index_lock_release(root); + } + GitStatusCache::read_git_status( picker.git_root(), - StatusOptions::new() - .include_untracked(true) - .recurse_untracked_dirs(true) - .include_unmodified(true) - .exclude_submodules(true), + &mut crate::git::default_status_options(), ) }; diff --git a/crates/fff-core/src/simd_path.rs b/crates/fff-core/src/simd_path.rs index 4da217d..2b0b0d9 100644 --- a/crates/fff-core/src/simd_path.rs +++ b/crates/fff-core/src/simd_path.rs @@ -317,15 +317,11 @@ impl ChunkedPathStoreBuilder { pub fn add_file_immediate(&mut self, rel_path: &str, filename_offset: u16) -> ChunkedString { let path_bytes = rel_path.as_bytes(); let byte_len = rel_path.len(); - let n_chunks = chunks_needed(byte_len); - let mut indices = ChunkIndices::with_capacity(n_chunks); + let mut indices = ChunkIndices::with_capacity(chunks_needed(byte_len)); - for i in 0..n_chunks { - let chunk_start = i * SIMD_CHUNK_BYTES; - let chunk_end = (chunk_start + SIMD_CHUNK_BYTES).min(byte_len); + for chunk in path_bytes.chunks(SIMD_CHUNK_BYTES) { let mut chunk_bytes = [0u8; SIMD_CHUNK_BYTES]; - chunk_bytes[..chunk_end - chunk_start] - .copy_from_slice(&path_bytes[chunk_start..chunk_end]); + chunk_bytes[..chunk.len()].copy_from_slice(chunk); let arena_idx = match self.chunk_dedup.get(&chunk_bytes) { Some(&idx) => idx, @@ -336,6 +332,7 @@ impl ChunkedPathStoreBuilder { idx } }; + indices.push(arena_idx); } diff --git a/crates/fff-core/src/types.rs b/crates/fff-core/src/types.rs index 16af60e..3082ccb 100644 --- a/crates/fff-core/src/types.rs +++ b/crates/fff-core/src/types.rs @@ -350,6 +350,9 @@ impl FileItem { path.starts_with(prefix) } + /// Write `base_path + '/' + relative_path` into `buf` and return it + /// as `&Path`. Takes a fixed-size array so the buffer can live on + /// the stack (no heap allocation, no bounds checks in the hot loop). pub(crate) fn write_absolute_path<'a>( &self, arena: ArenaPtr, @@ -359,7 +362,6 @@ impl FileItem { let base = base_path.as_os_str().as_encoded_bytes(); let base_len = base.len(); buf[..base_len].copy_from_slice(base); - // Add separator if base doesn't end with one let sep_len = if base_len > 0 && base[base_len - 1] != b'/' { buf[base_len] = b'/'; 1 @@ -367,14 +369,31 @@ impl FileItem { 0 }; let rel_start = base_len + sep_len; - let mut rel_buf = [0u8; PATH_BUF_SIZE]; - let rel = self.path.read_to_buf(arena, &mut rel_buf); - let rel_bytes = rel.as_bytes(); - buf[rel_start..rel_start + rel_bytes.len()].copy_from_slice(rel_bytes); - let total = rel_start + rel_bytes.len(); + let rel = self.path.read_to_buf(arena, &mut buf[rel_start..]); + let total = rel_start + rel.len(); Path::new(unsafe { std::str::from_utf8_unchecked(&buf[..total]) }) } + /// Write the relative path into `buf` and NUL-terminate, returning + /// a `&CStr`. Fixed-size array so the buffer is stack-allocatable. + /// + /// Paired with a parent-directory fd this eliminates the per-file + /// absolute-path memcpy: `openat(dir_fd, cstr.as_ptr(), O_RDONLY)` + /// resolves the name relative to `dir_fd`. + pub(crate) fn write_relative_cstr<'a>( + &self, + arena: ArenaPtr, + buf: &'a mut [u8; PATH_BUF_SIZE], + ) -> &'a std::ffi::CStr { + // Reserve the last byte for the NUL terminator. + let rel = self.path.read_to_buf(arena, &mut buf[..PATH_BUF_SIZE - 1]); + let n = rel.len(); + buf[n] = 0; + // SAFETY: `buf[..=n]` ends with the NUL we just wrote and + // filesystem paths never contain interior NULs. + unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(&buf[..=n]) } + } + #[inline] pub fn total_frecency_score(&self) -> i32 { self.access_frecency_score as i32 + self.modification_frecency_score as i32 @@ -439,6 +458,25 @@ impl FileItem { self.content = OnceLock::new(); } + pub fn update_metadata( + &mut self, + budget: &ContentCacheBudget, + modified_secs: Option, + new_size: Option, + ) { + if let Some(modified) = modified_secs + && self.modified < modified + { + self.modified = modified; + } + + self.invalidate_mmap(budget); + + if let Some(size) = new_size { + self.size = size; + } + } + /// Get the cached file contents or lazily load and cache them. /// /// Returns `None` if the file is too large, empty, can't be opened, **or diff --git a/crates/fff-core/tests/bigram_overlay_coherence_test.rs b/crates/fff-core/tests/bigram_overlay_coherence_test.rs index 2c8642f..8750764 100644 --- a/crates/fff-core/tests/bigram_overlay_coherence_test.rs +++ b/crates/fff-core/tests/bigram_overlay_coherence_test.rs @@ -21,7 +21,9 @@ use tempfile::TempDir; use fff_search::file_picker::{FFFMode, FilePicker, FuzzySearchOptions}; use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; -use fff_search::{FilePickerOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker}; +use fff_search::{ + FilePickerOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency, +}; /// Stress test: 200 base files, 3 rounds of edits + deletes. New files /// are tracked but NOT verified via grep (see Group 3 for that bug). @@ -502,10 +504,11 @@ fn bigram_overlay_coherence_mixed_tombstones_and_overflow() { } { + // assert let guard = shared_picker.read().unwrap(); let picker = guard.as_ref().unwrap(); - // Tombstones work: deleted tokens are gone. + // deleted tokens are gon for token in &deleted_tokens { assert_eq!( grep_count(picker, token), @@ -517,16 +520,16 @@ fn bigram_overlay_coherence_mixed_tombstones_and_overflow() { // Surviving base files still findable. for (name, token) in &repo_files[15..] { assert!( - grep_count(picker, token) >= 1, + grep_count(picker, token) == 1, "surviving {name} token should be findable" ); } - // BUG: Overflow files not findable via grep. + // New tokens needs to be findable for token in &new_tokens { assert!( - grep_count(picker, token) >= 1, - "BUG: overflow token {token} should be findable but bigram skips overflow" + grep_count(picker, token) == 1, + "BUG: overflow token {token} should be findable" ); } } @@ -754,18 +757,16 @@ fn bigram_overlay_coherence_rescan_after_git_commit() { // Phase 2: Commit and rescan. git_add_and_commit(base, "batch edit"); - - { - let mut guard = shared_picker.write().unwrap(); - let picker = guard.as_mut().unwrap(); - picker - .trigger_rescan(&shared_frecency) - .expect("trigger_rescan should succeed"); - } + shared_picker + .trigger_full_rescan_async(&shared_frecency) + .expect("rescan should succeed"); // After trigger_rescan, sync_data is replaced (and bigram_index dropped // with it). Wait for the synchronous scan to finish. - wait_for_scan(&shared_picker); + assert!( + shared_picker.wait_for_scan(Duration::from_secs(15)), + "Timed out waiting for scan to complete" + ); // Verify the file list is refreshed: all base_count + 5 files should // be present as base files (not overflow, since they're committed). @@ -890,18 +891,14 @@ fn bigram_overlay_coherence_full_lifecycle_seed_edit_commit_rescan_edit() { // -- Phase 2: Commit and rescan -- git_add_and_commit(base, "phase 1 changes"); + shared_picker + .trigger_full_rescan_async(&shared_frecency) + .expect("rescan should succeed"); - { - let mut guard = shared_picker.write().unwrap(); - let picker = guard.as_mut().unwrap(); - picker - .trigger_rescan(&shared_frecency) - .expect("rescan should succeed"); - } - - // After rescan, bigram is dropped with old FileSync. Grep falls back - // to full search, which is correct. - wait_for_scan(&shared_picker); + assert!( + shared_picker.wait_for_scan(Duration::from_secs(15)), + "Timed out waiting for scan to complete" + ); // Phase1 tokens should still be findable (now in base index). { @@ -1191,14 +1188,13 @@ fn bigram_overlay_coherence_fuzzy_search_after_rescan() { // Commit and rescan. git_add_and_commit(base, "add grpc, remove web"); - { - let mut guard = shared_picker.write().unwrap(); - let picker = guard.as_mut().unwrap(); - picker - .trigger_rescan(&shared_frecency) - .expect("rescan should succeed"); - } - wait_for_scan(&shared_picker); + shared_picker + .trigger_full_rescan_async(&shared_frecency) + .expect("rescan should succeed"); + assert!( + shared_picker.wait_for_scan(Duration::from_secs(15)), + "Timed out waiting for scan to complete" + ); // After rescan, fuzzy search should reflect the committed state. { @@ -1338,35 +1334,13 @@ fn grep_count(picker: &FilePicker, query: &str) -> usize { fn grep_without_overlay_count(picker: &FilePicker, query: &str) -> usize { let parsed = parse_grep_query(query); - picker - .grep_without_overlay(&parsed, &grep_opts()) - .matches - .len() + picker.grep_original(&parsed, &grep_opts()).matches.len() } /// Wait for scanning to finish (no bigram requirement). /// Use after `trigger_rescan` which replaces sync_data but does not /// rebuild the bigram index. -fn wait_for_scan(shared_picker: &SharedPicker) { - let deadline = std::time::Instant::now() + Duration::from_secs(15); - loop { - std::thread::sleep(Duration::from_millis(50)); - let ready = shared_picker - .read() - .ok() - .map(|guard| guard.as_ref().map_or(false, |p| !p.is_scan_active())) - .unwrap_or(false); - if ready { - break; - } - assert!( - std::time::Instant::now() < deadline, - "Timed out waiting for scan to complete" - ); - } -} - -fn wait_for_bigram(shared_picker: &SharedPicker) { +fn wait_for_bigram(shared_picker: &SharedFilePicker) { let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { std::thread::sleep(Duration::from_millis(50)); @@ -1389,7 +1363,7 @@ fn wait_for_bigram(shared_picker: &SharedPicker) { } } -fn stop_picker(shared_picker: &SharedPicker) { +fn stop_picker(shared_picker: &SharedFilePicker) { if let Ok(mut guard) = shared_picker.write() { if let Some(ref mut picker) = *guard { picker.stop_background_monitor(); @@ -1426,8 +1400,8 @@ fn git_add_and_commit(dir: &Path, msg: &str) { git_run(dir, &["commit", "-m", msg]); } -fn make_picker(base: &Path) -> (SharedPicker, SharedFrecency) { - let shared_picker = SharedPicker::default(); +fn make_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) { + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( diff --git a/crates/fff-core/tests/bigram_overlay_integration.rs b/crates/fff-core/tests/bigram_overlay_integration.rs index d6a734a..cf23a06 100644 --- a/crates/fff-core/tests/bigram_overlay_integration.rs +++ b/crates/fff-core/tests/bigram_overlay_integration.rs @@ -7,7 +7,7 @@ use tempfile::TempDir; use fff_search::file_picker::{FFFMode, FilePicker}; use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; -use fff_search::{FilePickerOptions, SharedFrecency, SharedPicker}; +use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency}; /// Create a temp directory with some initial files, run the full picker lifecycle, /// then modify a file and verify grep finds the new content. @@ -25,7 +25,7 @@ fn modified_file_findable_via_overlay() { .unwrap(); fs::write(base.join("gamma.txt"), "yet another file\nmore lines\n").unwrap(); - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( @@ -36,6 +36,7 @@ fn modified_file_findable_via_overlay() { enable_mmap_cache: true, enable_content_indexing: true, mode: FFFMode::Neovim, + watch: false, // we drive events manually ..Default::default() }, ) @@ -150,7 +151,7 @@ fn modified_file_findable_via_overlay() { let picker = guard.as_ref().unwrap(); let parsed = parse_grep_query("UNIQUE_NEEDLE"); let opts = grep_opts(); - let result = picker.grep_without_overlay(&parsed, &opts); + let result = picker.grep_original(&parsed, &opts); assert_eq!( result.matches.len(), 0, @@ -175,7 +176,7 @@ fn deleted_file_excluded_via_overlay() { fs::write(base.join("keep.txt"), "keep this content\n").unwrap(); fs::write(base.join("remove.txt"), "DELETEME_TOKEN is here\n").unwrap(); - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( @@ -186,6 +187,7 @@ fn deleted_file_excluded_via_overlay() { enable_mmap_cache: true, enable_content_indexing: true, mode: FFFMode::Neovim, + watch: false, // we drive events manually ..Default::default() }, ) @@ -244,7 +246,7 @@ fn new_file_findable_after_add() { fs::write(base.join("existing.txt"), "original content\n").unwrap(); - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( @@ -255,6 +257,7 @@ fn new_file_findable_after_add() { enable_mmap_cache: true, enable_content_indexing: true, mode: FFFMode::Neovim, + watch: false, // we drive events manually ..Default::default() }, ) @@ -314,7 +317,7 @@ fn modified_file_findable_via_regex_overlay() { ) .unwrap(); - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( @@ -325,6 +328,7 @@ fn modified_file_findable_via_regex_overlay() { enable_mmap_cache: true, enable_content_indexing: true, mode: FFFMode::Neovim, + watch: false, // we drive events manually ..Default::default() }, ) @@ -397,7 +401,7 @@ fn grep_for<'a>(picker: &'a FilePicker, query: &str) -> fff_search::grep::GrepRe picker.grep(&parsed, &grep_opts()) } -fn wait_for_bigram(shared_picker: &SharedPicker) { +fn wait_for_bigram(shared_picker: &SharedFilePicker) { let deadline = std::time::Instant::now() + Duration::from_secs(30); loop { std::thread::sleep(Duration::from_millis(50)); diff --git a/crates/fff-core/tests/fuzz_file_operations.rs b/crates/fff-core/tests/fuzz_file_operations.rs index cb41ecf..840d21a 100644 --- a/crates/fff-core/tests/fuzz_file_operations.rs +++ b/crates/fff-core/tests/fuzz_file_operations.rs @@ -20,7 +20,9 @@ use rand::{RngCore, SeedableRng}; use fff_search::file_picker::{FFFMode, FilePicker, FuzzySearchOptions}; use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; -use fff_search::{FilePickerOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker}; +use fff_search::{ + FilePickerOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency, +}; const DOMAINS: &[&str] = &[ r#" @@ -308,7 +310,7 @@ fn fuzz_file_operations_stress() { git_init_and_commit(base); t_git += t0.elapsed(); - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); FilePicker::new_with_shared_state( shared_picker.clone(), @@ -711,7 +713,7 @@ fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec { .collect() } -fn wait_for_bigram(shared_picker: &SharedPicker) { +fn wait_for_bigram(shared_picker: &SharedFilePicker) { let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { std::thread::sleep(Duration::from_millis(50)); diff --git a/crates/fff-core/tests/fuzz_git_watcher_stress.rs b/crates/fff-core/tests/fuzz_git_watcher_stress.rs new file mode 100644 index 0000000..458a1aa --- /dev/null +++ b/crates/fff-core/tests/fuzz_git_watcher_stress.rs @@ -0,0 +1,1534 @@ +//! Vibe coded stress test: randomized file + git operations driven against the *real* +//! `BackgroundWatcher`, asserting the git-status invariant after every mutation. +//! +//! ## What this test is trying to catch +//! +//! The background watcher is supposed to keep every indexed file's +//! `git_status` in sync with the actual repository state at all times. +//! There are two code paths that can drift: +//! +//! 1. **Per-file updates** — when regular files are created / modified the +//! watcher queries `git_status_for_paths(&[changed_file])`. If the git +//! state changes for files that weren't in the batch (rename detection, +//! submodule paths, index modifications applied by a sibling process), +//! those other files keep a stale `git_status` until the next full +//! rescan is triggered. +//! +//! 2. **Full rescans** — triggered by changes under `.git/` (index, HEAD, +//! MERGE_HEAD, etc.) and by `.gitignore` edits. A missed event here is +//! the most common failure mode: the watcher coalesced or dropped the +//! `.git/index` notification and the picker never learns that e.g. a +//! `git commit` cleared every `WT_MODIFIED`. +//! +//! ## How the test works +//! +//! * Spin up a real `FilePicker` with `watch: true` on a fresh temp repo. +//! * Use `proptest` to generate a sequence of 20–40 randomized ops (heavy on +//! git mutations: add / commit / reset / stash / gitignore edits). +//! * After **every** op, poll until the picker's per-file `git_status` agrees +//! with `git2::Repository::statuses()` verbatim, or bail with a rich diff. +//! * If there is ever a divergence that doesn't resolve within +//! `CONVERGE_TIMEOUT`, the test fails with the list of `(path, truth, +//! picker)` mismatches. +//! +//! The shape of the scenario is shrinkable: on failure proptest will shrink +//! the `Vec` so the reported diff corresponds to the smallest +//! surviving sequence. +//! +//! ## Runtime +//! +//! Each scenario does a real filesystem scan + watcher setup (~0.5–1 s) and +//! then runs 20–40 ops each of which waits for event propagation +//! (~100–500 ms on macOS FSEvents). With `cases = 2` the test takes +//! ~30–45 s on a typical dev machine — too slow to run as part of the +//! default `cargo test`, so it's gated behind the `stress` cfg. +//! +//! Run it explicitly with: +//! ```sh +//! RUSTFLAGS="--cfg stress" cargo test -p fff-search --test fuzz_git_watcher_stress -- --nocapture +//! ``` +//! +//! Or increase coverage via env: +//! ```sh +//! FFF_STRESS_CASES=8 FFF_STRESS_MAX_OPS=60 \ +//! RUSTFLAGS="--cfg stress" cargo test -p fff-search --test fuzz_git_watcher_stress -- --nocapture +//! ``` + +#![cfg(stress)] +use fff_search::file_picker::{FFFMode, FilePicker}; +use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; +use fff_search::{ + FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, + SharedFrecency, +}; +use git2::{Repository, Status, StatusOptions}; +use proptest::prelude::*; +use proptest::strategy::ValueTree; +use proptest::test_runner::{ + Config as ProptestConfig, FileFailurePersistence, RngAlgorithm, TestRng, TestRunner, +}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +/// Upper bound on how long we wait for the picker to converge after any op. +/// The watcher debounce is 50 ms; worst-case FSEvents propagation + queued +/// full-rescan can easily reach several seconds. 15 s is very generous. +const CONVERGE_TIMEOUT: Duration = Duration::from_secs(15); + +/// Poll interval while waiting for convergence. +const CONVERGE_POLL: Duration = Duration::from_millis(50); + +/// Small pause between back-to-back ops to simulate real user behavior +const PER_OP_SETTLE: Duration = Duration::from_millis(10); + +fn stress_cases() -> u32 { + std::env::var("FFF_STRESS_CASES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(2) +} + +fn stress_max_ops() -> usize { + std::env::var("FFF_STRESS_MAX_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(40) +} + +fn stress_min_ops() -> usize { + std::env::var("FFF_STRESS_MIN_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(20) +} + +/// A single randomized action. The runtime interprets abstract handles +/// (`idx`) by modulo against the *current* list of live files, so the same +/// abstract op sequence is always runnable regardless of which files exist. +#[derive(Debug, Clone)] +enum AbstractOp { + CreateFile { + seed: u32, + content_seed: u32, + }, + EditFile { + idx: usize, + content_seed: u32, + }, + DeleteFile { + idx: usize, + }, + RenameFile { + idx: usize, + new_seed: u32, + }, + CreateSubdirFile { + dir_seed: u16, + file_seed: u16, + content_seed: u32, + }, + GitignoreAppend { + pattern_seed: u16, + }, + GitAddAll, + GitCommit { + msg_seed: u16, + }, + GitResetHard, + GitStashThenPop, + Touch { + idx: usize, + }, // rewrite with same content; should still fire Modify + Noop, +} + +fn op_strategy() -> impl Strategy { + // Weights tuned to mirror a real editing session rather than a + // contrived git-heavy workload. In descending order: + // + // * edits dominate (~60%) — a real user bangs on Save all day + // * creates are moderate (~15%) — new files appear occasionally + // * deletes and renames are rare (~10%) — destructive ops happen + // but far less often than edits + // * explicit git operations are the rarest (~8%) — users stage / + // commit / reset in bursts between long stretches of editing + // + // The file-mutation path still exercises the per-path git-status + // update on every op via the watcher, so the git-status updater + // is *always* under load — the rare explicit `GitAddAll` / + // `GitCommit` / `GitResetHard` ops layer in coverage of the + // `.git/index` event → full-rescan path specifically. A 40-op + // scenario fires that path ~3 times, which is enough to keep the + // race between worktree writes and `.git/*` updates in rotation. + prop_oneof![ + 40 => (any::(), any::()).prop_map(|(i, c)| AbstractOp::EditFile { + idx: i, content_seed: c, + }), + 5 => any::().prop_map(|i| AbstractOp::Touch { idx: i }), + 8 => (any::(), any::()).prop_map(|(a, b)| AbstractOp::CreateFile { + seed: a, content_seed: b, + }), + 3 => (any::(), any::(), any::()).prop_map( + |(d, f, c)| AbstractOp::CreateSubdirFile { + dir_seed: d, file_seed: f, content_seed: c, + } + ), + 4 => any::().prop_map(|i| AbstractOp::DeleteFile { idx: i }), + 3 => (any::(), any::()).prop_map(|(i, s)| AbstractOp::RenameFile { + idx: i, new_seed: s, + }), + 1 => Just(AbstractOp::GitAddAll), + 1 => any::().prop_map(|m| AbstractOp::GitCommit { msg_seed: m }), + 1 => Just(AbstractOp::GitStashThenPop), + 1 => Just(AbstractOp::GitResetHard), + 1 => any::().prop_map(|p| AbstractOp::GitignoreAppend { pattern_seed: p }), + 2 => Just(AbstractOp::Noop), + ] +} + +fn ops_strategy() -> impl Strategy> { + let min = stress_min_ops(); + let max = stress_max_ops(); + prop::collection::vec(op_strategy(), min..=max) +} + +const DEFAULT_STRESS_SEED: u64 = 0xDEAD_BEEF_CAFE_BABE; + +fn proptest_config() -> ProptestConfig { + ProptestConfig { + cases: stress_cases(), + // Cap shrinking because each shrink iteration replays the whole + // scenario (seconds of real IO). Proptest's default 4096 is wild. + max_shrink_iters: 16, + // `fork: true` would isolate runs but swallows the panic payload + // and printed diff through `rusty-fork`'s wire format — we want + // the convergence report visible on stderr, so we keep in-process. + // The watcher is fully torn down between cases via `Drop`. + fork: false, + // Integration tests don't live alongside a `lib.rs`/`main.rs`, so + // proptest's default `SourceParallel` persistence strategy can't + // locate the source tree and logs a noisy warning on every run. + // Pin the regression file to an explicit path next to this test + // so failing seeds get checked in and reproduced in CI. + failure_persistence: Some(Box::new(FileFailurePersistence::Direct(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fuzz_git_watcher_stress.proptest-regressions", + )))), + ..ProptestConfig::default() + } +} + +proptest! { + #![proptest_config(proptest_config())] + + /// Random-seeded scenario. Proptest picks a fresh seed every run from + /// system entropy, so CI sees different trajectories on every build + /// while known-bad seeds remain pinned in the regressions file. + #[test] + fn stress_random(ops in ops_strategy()) { + run_stress_scenario(&ops); + } +} + +/// Deterministic scenario keyed off `FFF_STRESS_SEED` (or +/// [`DEFAULT_STRESS_SEED`] if the env var is not set). Runs the same +/// proptest `ops_strategy()` but uses a ChaCha RNG seeded by the expanded +/// u64, so the exact case sequence is reproducible across machines and CI +/// runs. +/// +/// When this test panics, the panic message includes the seed so you can +/// re-run the failing case locally via: +/// +/// ```sh +/// RUSTFLAGS="--cfg stress" FFF_STRESS_SEED=0xDEADBEEFCAFEBABE \ +/// cargo test -p fff-search --test fuzz_git_watcher_stress seeded -- --nocapture +/// ``` +#[test] +fn stress_seeded() { + let seed = parse_stress_seed(); + let seed_bytes = expand_u64_seed(seed); + + eprintln!("stress_seeded: using deterministic seed {seed:#018x}"); + + let mut config = proptest_config(); + // The seeded run should never write to the shared regressions file — + // its failures are reproducible from the env var alone, and polluting + // the shared file with the deterministic seed would mask regressions + // for the random run that actually needs persistence. + config.failure_persistence = Some(Box::new(FileFailurePersistence::Off)); + + let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &seed_bytes); + let mut runner = TestRunner::new_with_rng(config, rng); + let strategy = ops_strategy(); + + // Mimic `proptest!`'s case loop: run `Config::cases` independent draws + // from the strategy, failing the test as soon as any one scenario + // diverges. We drive this ourselves because `runner.run()` can't + // accept a non-Fn closure, and our scenario runner is side-effectful. + for case_idx in 0..runner.config().cases { + let tree = strategy + .new_tree(&mut runner) + .expect("ops_strategy::new_tree"); + let ops = tree.current(); + eprintln!( + " case {}/{}: {} ops", + case_idx + 1, + runner.config().cases, + ops.len() + ); + run_stress_scenario(&ops); + } +} + +/// Parse `FFF_STRESS_SEED` as either decimal or `0x`-prefixed hex. +fn parse_stress_seed() -> u64 { + match std::env::var("FFF_STRESS_SEED") { + Ok(raw) => { + let trimmed = raw.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16) + .unwrap_or_else(|e| panic!("FFF_STRESS_SEED={raw:?} is not valid hex: {e}")) + } else { + trimmed + .parse::() + .unwrap_or_else(|e| panic!("FFF_STRESS_SEED={raw:?} is not a valid u64: {e}")) + } + } + Err(_) => DEFAULT_STRESS_SEED, + } +} + +/// Expand a u64 into the 32-byte seed ChaCha20 wants, by repeating the +/// little-endian bytes four times. The cycle is deliberate: two different +/// u64 seeds produce completely different byte sequences, so collisions +/// across the expansion are irrelevant in practice. +fn expand_u64_seed(seed: u64) -> [u8; 32] { + let le = seed.to_le_bytes(); + let mut out = [0u8; 32]; + for i in 0..4 { + out[i * 8..(i + 1) * 8].copy_from_slice(&le); + } + out +} + +#[derive(Debug)] +struct Live { + /// Path relative to the repo root (forward slashes on all platforms). + relative: String, + abs: PathBuf, +} + +fn run_stress_scenario(ops: &[AbstractOp]) { + // Opt-in tracing so `RUST_LOG=fff_search=debug` shows the watcher / + // refresh / update trail when debugging a failing run. Uses + // `try_init` so proptest can run many scenarios in the same process + // without double-initialising the subscriber. + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + // Default to DEBUG for fff crates so CI failures on + // flaky OSes (Windows) include the watcher/event trail + // without needing to re-run with RUST_LOG set. + .unwrap_or_else(|_| { + tracing_subscriber::EnvFilter::new( + "warn,fff_search=debug,notify=debug,notify_debouncer_full=debug", + ) + }), + ) + .with_test_writer() + .try_init(); + + let tmp = TempDir::new().unwrap(); + let base = tmp.path().canonicalize().unwrap(); + + seed_repo(&base); + + let (shared_picker, _frecency) = start_watched_picker(&base); + wait_ready(&shared_picker); + + // Reconcile `live` from disk: after `git reset --hard` or similar the + // worktree can change under us, so we resync at every step instead of + // trusting our in-memory list. + let mut live: Vec = get_baseline_status_from_git(&base); + + // Sleep past the coarse (1 s) mtime tick so subsequent edits definitely + // advance mtime. Not strictly required for git status correctness but + // matches what real users experience. + std::thread::sleep(Duration::from_millis(1100)); + + for (step, op) in ops.iter().enumerate() { + apply_op(op, &base, &mut live); + + // Re-sync live from disk — ops like GitResetHard / GitStashThenPop + // may have created or removed files behind our back. + live = get_baseline_status_from_git(&base); + std::thread::sleep(PER_OP_SETTLE); + + if let Err(err) = converge_git_status(&shared_picker, &base, &live) { + panic!( + "\n──────────────────────────────────────────────────────────\n\ + ❌ Picker git_status diverged from repository truth.\n\ + ──────────────────────────────────────────────────────────\n\ + step: {step}\n\ + op: {op:?}\n\ + scenario ops:\n{trace}\n\ + ──────────────────────────────────────────────────────────\n\ + {err}\n", + trace = format_ops_trace(ops, step), + ); + } + } +} + +fn format_ops_trace(ops: &[AbstractOp], up_to_and_including: usize) -> String { + let mut s = String::new(); + for (i, op) in ops.iter().enumerate().take(up_to_and_including + 1) { + s.push_str(&format!(" [{i:>3}] {op:?}\n")); + } + s +} + +fn seed_repo(base: &Path) { + fs::create_dir_all(base.join("src")).unwrap(); + fs::write(base.join("README.md"), "# seed\n").unwrap(); + fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap(); + fs::write(base.join("src/lib.rs"), "// lib\n").unwrap(); + fs::write(base.join(".gitignore"), "*.log\ntmp/\n").unwrap(); + + git(base, &["init", "-b", "main"]); + git(base, &["config", "user.email", "fuzz@fff.test"]); + git(base, &["config", "user.name", "fuzz"]); + // Disable rename detection noise — libgit2 still computes ranks, but + // keeping the porcelain behaviour deterministic helps with diff reading. + git(base, &["config", "status.renames", "false"]); + git(base, &["add", "-A"]); + git(base, &["commit", "-m", "seed", "--no-gpg-sign"]); +} + +fn apply_op(op: &AbstractOp, base: &Path, live: &mut [Live]) { + use AbstractOp::*; + + match op { + CreateFile { seed, content_seed } => { + let rel = format!("f_{seed:08x}.rs"); + let abs = base.join(&rel); + if abs.exists() { + return; + } + fs::write(&abs, content_for(*content_seed, &rel)).unwrap(); + } + EditFile { idx, content_seed } => { + if live.is_empty() { + return; + } + let i = idx % live.len(); + let abs = &live[i].abs; + if !abs.is_file() { + return; + } + // Body must differ so git sees WT_MODIFIED (not just atime touch). + let body = format!( + "// edited seed={content_seed:08x}\n{}", + content_for(*content_seed, &live[i].relative) + ); + fs::write(abs, body).unwrap(); + } + Touch { idx } => { + // Rewrite with the same bytes we currently hold on disk. Still + // generates a Modify event; git status stays the same if the + // content matches the index, or WT_MODIFIED if it differs. + if live.is_empty() { + return; + } + let i = idx % live.len(); + let abs = &live[i].abs; + if let Ok(contents) = fs::read(abs) { + let _ = fs::write(abs, contents); + } + } + DeleteFile { idx } => { + if live.is_empty() { + return; + } + let i = idx % live.len(); + let _ = fs::remove_file(&live[i].abs); + } + RenameFile { idx, new_seed } => { + if live.is_empty() { + return; + } + let i = idx % live.len(); + let old = &live[i].abs; + if !old.is_file() { + return; + } + let new_rel = format!("r_{new_seed:08x}.rs"); + let new_abs = base.join(&new_rel); + if new_abs.exists() { + return; + } + let _ = fs::rename(old, &new_abs); + } + CreateSubdirFile { + dir_seed, + file_seed, + content_seed, + } => { + let rel = format!("d_{dir_seed:04x}/inside_{file_seed:04x}.rs"); + let abs = base.join(&rel); + if abs.exists() { + return; + } + fs::create_dir_all(abs.parent().unwrap()).unwrap(); + fs::write(&abs, content_for(*content_seed, &rel)).unwrap(); + } + GitignoreAppend { pattern_seed } => { + // Introduce NEW ignore patterns for a namespace not used by any + // other op so we never accidentally re-ignore a file under test. + let pattern = format!("__ignored_{pattern_seed:x}/\n"); + let gi = base.join(".gitignore"); + let mut cur = fs::read_to_string(&gi).unwrap_or_default(); + cur.push_str(&pattern); + fs::write(&gi, cur).unwrap(); + } + GitAddAll => { + git_allow_fail(base, &["add", "-A"]); + } + GitCommit { msg_seed } => { + // `--allow-empty` so we don't depend on there actually being + // staged changes; this still bumps HEAD and rewrites .git/index. + let _ = git_output( + base, + &[ + "commit", + "-m", + &format!("fuzz-{msg_seed:x}"), + "--allow-empty", + "--allow-empty-message", + "--no-gpg-sign", + ], + ); + } + GitResetHard => { + git_allow_fail(base, &["reset", "--hard"]); + } + GitStashThenPop => { + git_allow_fail(base, &["add", "-A"]); + let stash = git_output(base, &["stash", "push", "-u", "-m", "fuzz"]); + let had_stash = stash + .as_ref() + .map(|o| { + o.status.success() + && !String::from_utf8_lossy(&o.stdout).contains("No local changes") + }) + .unwrap_or(false); + if had_stash { + git_allow_fail(base, &["stash", "pop"]); + } + } + Noop => {} + } +} + +/// Block until the picker's git status agrees with `git2::Repository::statuses`. +/// +/// Poll until the picker's git-status view matches libgit2's truth AND +/// the real-query probe succeeds, or bail with a rich diff. +/// +/// On timeout, returns an `Err` describing every disagreement. +fn converge_git_status( + shared_picker: &SharedFilePicker, + base: &Path, + live: &[Live], +) -> Result<(), String> { + let deadline = Instant::now() + CONVERGE_TIMEOUT; + let mut last_mismatches: Vec; + let mut last_probe_err: Option = None; + + loop { + let truth = read_truth_status(base); + let picker_view = read_picker_status(shared_picker); + last_mismatches = diff_statuses(&truth, &picker_view); + + // Real-query probe: one random live file per round via fuzzy + grep. + // We only consider the probe authoritative once the main git-status + // enumeration agrees — otherwise a probe failure might just be + // the same debouncer-lag we're already waiting out. + let probe = if last_mismatches.is_empty() { + probe_real_queries(shared_picker, live) + } else { + None + }; + + match (last_mismatches.is_empty(), probe) { + (true, None) | (true, Some(Ok(()))) => return Ok(()), + (true, Some(Err(msg))) => last_probe_err = Some(msg), + _ => {} + } + + if Instant::now() >= deadline { + let mut report = if !last_mismatches.is_empty() { + format_mismatches(&last_mismatches, shared_picker) + } else { + String::from("git_status enumeration converged, but real-query probe failed:\n") + }; + if let Some(probe_msg) = last_probe_err { + report.push_str("\n── real-query probe ──\n"); + report.push_str(&probe_msg); + report.push('\n'); + } + report.push_str(&debug_dump_environment( + base, + shared_picker, + &last_mismatches, + )); + return Err(report); + } + std::thread::sleep(CONVERGE_POLL); + } +} + +/// On-failure diagnostic dump. Includes: +/// * base path (raw + OS encoding) so we can spot UNC/`\\?\` prefixes on Windows +/// * picker's own `base_path()` for comparison +/// * direct libgit2 `status_file` probes for every mismatched path +/// * full picker enumeration (first 20 rows) so we can see what keys +/// the picker is returning vs. what git2 reports +fn debug_dump_environment( + base: &Path, + shared_picker: &SharedFilePicker, + mismatches: &[Mismatch], +) -> String { + let mut s = String::new(); + s.push_str("\n── diagnostic dump ──\n"); + s.push_str(&format!( + "test base path (display) : {}\n", + base.display() + )); + s.push_str(&format!("test base path (debug) : {:?}\n", base)); + s.push_str(&format!( + "test base path (os bytes) : {:?}\n", + base.as_os_str() + )); + + if let Ok(guard) = shared_picker.read() + && let Some(picker) = guard.as_ref() + { + s.push_str(&format!( + "picker base_path (display) : {}\n", + picker.base_path().display() + )); + s.push_str(&format!( + "picker base_path (debug) : {:?}\n", + picker.base_path() + )); + } + + // Probe libgit2 directly for each mismatched path. + if let Ok(repo) = Repository::open(base) { + s.push_str("\nlibgit2 status_file probes (per mismatch path):\n"); + for m in mismatches { + let path = match m { + Mismatch::Disagree { path, .. } => path, + Mismatch::ExtraInPicker { path, .. } => path, + }; + match repo.status_file(std::path::Path::new(path)) { + Ok(st) => s.push_str(&format!(" • {path} -> {st:?}\n")), + Err(e) => { + s.push_str(&format!( + " • {path} -> ERROR {} (class={:?}, code={:?})\n", + e.message(), + e.class(), + e.code() + )); + } + } + } + } else { + s.push_str("\nlibgit2: could not open repo at base path\n"); + } + + // Dump the raw picker enumeration for comparison — up to 20 rows. + s.push_str("\npicker enumeration (first 20 entries, with byte-repr of each relative path):\n"); + if let Ok(guard) = shared_picker.read() + && let Some(picker) = guard.as_ref() + { + let parser = QueryParser::default(); + let parsed = parser.parse(""); + let result = picker.fuzzy_search( + &parsed, + None, + FuzzySearchOptions { + max_threads: 1, + pagination: PaginationArgs { + offset: 0, + limit: 100, + }, + ..Default::default() + }, + ); + for (i, f) in result.items.iter().take(20).enumerate() { + let raw = f.relative_path(picker); + let norm = normalize(raw.clone()); + s.push_str(&format!( + " [{i:>2}] raw={raw:?} norm={norm:?} status={:?}\n", + f.git_status + )); + } + s.push_str(&format!(" (total: {} items)\n", result.items.len())); + } + + s +} + +#[derive(Debug)] +enum Mismatch { + /// Git knows about this path with `truth`, picker has `picker` (or None = missing). + Disagree { + path: String, + truth: Status, + picker: Option>, + }, + /// Picker has a non-clean entry for a path git doesn't report at all. + ExtraInPicker { path: String, picker: Status }, +} + +fn read_truth_status(base: &Path) -> BTreeMap { + let repo = Repository::open(base).expect("open repo for truth"); + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .include_unmodified(true) + .exclude_submodules(true); + let statuses = repo.statuses(Some(&mut opts)).expect("read statuses"); + + let mut out = BTreeMap::new(); + for entry in statuses.iter() { + if let Some(p) = entry.path() { + // git2 returns forward-slash paths; accept as-is. + out.insert(p.to_string(), entry.status()); + } + } + out +} + +fn read_picker_status(shared: &SharedFilePicker) -> BTreeMap> { + let guard = shared.read().expect("picker read lock"); + let picker = guard.as_ref().expect("picker initialized"); + + // Use the user-facing `fuzzy_search` API with an empty query to enumerate + // every file a user could ever see in the picker. An empty fuzzy query + // falls through to the frecency-only scoring path which returns ALL + // non-deleted files (base + overflow). A very large `limit` makes sure + // we don't silently paginate anything away for scenarios with many files. + // + // This intentionally mirrors what a real Neovim user observes — soft- + // deleted tombstones, files filtered out by search-side predicates, etc. + // are all excluded from the picker's user-facing view. + let parser = QueryParser::default(); + let parsed = parser.parse(""); + let result = picker.fuzzy_search( + &parsed, + None, + FuzzySearchOptions { + max_threads: 1, + pagination: PaginationArgs { + offset: 0, + limit: 100_000, + }, + ..Default::default() + }, + ); + + let mut out = BTreeMap::new(); + for f in &result.items { + out.insert(normalize(f.relative_path(picker)), f.git_status); + } + out +} + +fn normalize(s: String) -> String { + #[cfg(windows)] + { + if s.contains('\\') { + return s.replace('\\', "/"); + } + } + s +} + +/// Probe a single file by name via the public `fuzzy_search` API and return +/// its status, or `None` if the file is not findable. Used for diagnostics +/// when the top-level enumeration reports a disagreement — confirms that +/// the mismatch reproduces via the exact user-facing lookup path. +fn probe_single_file_status(shared: &SharedFilePicker, relative: &str) -> Option> { + let guard = shared.read().ok()?; + let picker = guard.as_ref()?; + let parser = QueryParser::default(); + let parsed = parser.parse(relative); + let result = picker.fuzzy_search( + &parsed, + None, + FuzzySearchOptions { + max_threads: 1, + pagination: PaginationArgs { + offset: 0, + limit: 200, + }, + ..Default::default() + }, + ); + result + .items + .iter() + .find(|f| normalize(f.relative_path(picker)) == relative) + .map(|f| f.git_status) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Real-query probes +// ═══════════════════════════════════════════════════════════════════════════ +// +// These exercise the **user-facing** fuzzy + grep surfaces with queries that +// look like what a human actually types, on top of a live file picked at +// random from the on-disk truth. They run once per convergence round in +// addition to the primary git-status enumeration check, and add coverage +// for three paths the empty-query enumeration never touches: +// +// * fuzzy matching with a non-empty query (bigram prefilter + score) +// * path-constraint fuzzy queries ("foo src/") +// * live grep (mmap content cache + bigram overlay for overflow files) +// +// An atomic counter drives the rotation across rounds so the probe spreads +// its attention across every live file over the course of a scenario. +static PROBE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Read a file on disk and pull out the `FFF_STRESS_MARKER_` token +/// that [`content_for`] embedded in it. Returns `None` for files we never +/// wrote (seed README.md, .gitignore) — the caller skips the grep probe +/// in that case but still runs the fuzzy probe. +fn extract_marker(abs: &Path) -> Option { + let content = fs::read_to_string(abs).ok()?; + let start = content.find("FFF_STRESS_MARKER_")?; + // Marker is exactly `FFF_STRESS_MARKER_` + 8 hex chars. + const MARKER_LEN: usize = "FFF_STRESS_MARKER_".len() + 8; + if start + MARKER_LEN > content.len() { + return None; + } + let marker = &content[start..start + MARKER_LEN]; + // Sanity: the trailing 8 chars must all be hex. + if !marker.as_bytes()[MARKER_LEN - 8..] + .iter() + .all(|b| b.is_ascii_hexdigit()) + { + return None; + } + Some(marker.to_string()) +} + +/// File-stem → fuzzy search query. Strips extension + leading path +/// components so the probe feeds the picker a typical "I know roughly +/// what I'm looking for" query. +fn stem_for_query(relative: &str) -> String { + PathBuf::from(relative) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() +} + +/// Run a one-shot fuzzy_search and return the matched items' relative +/// paths paired with their `git_status`. Uses only public APIs. +fn fuzzy_search_items(shared: &SharedFilePicker, query: &str) -> Vec<(String, Option)> { + let guard = match shared.read() { + Ok(g) => g, + Err(_) => return Vec::new(), + }; + let Some(picker) = guard.as_ref() else { + return Vec::new(); + }; + let parser = QueryParser::default(); + let parsed = parser.parse(query); + let result = picker.fuzzy_search( + &parsed, + None, + FuzzySearchOptions { + max_threads: 1, + pagination: PaginationArgs { + offset: 0, + limit: 500, + }, + ..Default::default() + }, + ); + result + .items + .iter() + .map(|f| (normalize(f.relative_path(picker)), f.git_status)) + .collect() +} + +/// Run live grep (plain-text mode) and return the unique set of matched +/// file paths. A file appears in the set iff at least one line inside it +/// matches the query. Uses only public APIs. +fn grep_plain_matches(shared: &SharedFilePicker, query: &str) -> Vec { + let guard = match shared.read() { + Ok(g) => g, + Err(_) => return Vec::new(), + }; + let Some(picker) = guard.as_ref() else { + return Vec::new(); + }; + let parsed = parse_grep_query(query); + let opts = GrepSearchOptions { + max_file_size: 10 * 1024 * 1024, + max_matches_per_file: 200, + smart_case: true, + file_offset: 0, + page_limit: 500, + mode: GrepMode::PlainText, + time_budget_ms: 0, + before_context: 0, + after_context: 0, + classify_definitions: false, + trim_whitespace: false, + abort_signal: None, + }; + let result = picker.grep(&parsed, &opts); + // `GrepResult::files` is the already-deduplicated list of files that + // contained at least one match — exactly what we want. + result + .files + .iter() + .map(|f| normalize(f.relative_path(picker))) + .collect() +} + +/// Report from [`probe_real_queries`]. `None` means "nothing to probe this +/// round" (empty live set). `Some(Err)` means a probe disagreed with truth +/// — convergence should not treat this as success. +type ProbeOutcome = Option>; + +/// Real-query verification: for one rotated-live-file per round, run a +/// fuzzy search by stem and (when a marker is present) a grep for its +/// embedded token. Asserts both return the file *and* that its +/// `git_status` matches the picker's main view. +/// +/// Return values: +/// * `None` — nothing to probe (no live files). +/// * `Some(Ok(()))` — the probe agreed with truth on both surfaces. +/// * `Some(Err(s))` — diagnostic describing the disagreement. +fn probe_real_queries(shared: &SharedFilePicker, live: &[Live]) -> ProbeOutcome { + if live.is_empty() { + return None; + } + let idx = (PROBE_COUNTER.fetch_add(1, Ordering::Relaxed) as usize) % live.len(); + let target = &live[idx]; + + // --- Fuzzy probe: search by file stem --- + let stem = stem_for_query(&target.relative); + // Tiny stems (< 2 chars) are rejected by the fuzzy scorer and + // surface via the frecency fallback — skip the assertion in that + // case, nothing meaningful to verify. + if stem.len() >= 2 { + let fuzzy_hits = fuzzy_search_items(shared, &stem); + let found = fuzzy_hits.iter().find(|(p, _)| p == &target.relative); + if found.is_none() { + return Some(Err(format!( + "fuzzy_search({stem:?}) did not return expected live file {:?}\n\ + got {} results; first few: {:?}", + target.relative, + fuzzy_hits.len(), + fuzzy_hits.iter().take(5).collect::>(), + ))); + } + } + + // --- Grep probe: search for the content marker --- + if let Some(marker) = extract_marker(&target.abs) { + let matches = grep_plain_matches(shared, &marker); + if !matches.contains(&target.relative) { + return Some(Err(format!( + "grep({marker:?}) did not return expected live file {:?}\n\ + got {} matched files; first few: {:?}", + target.relative, + matches.len(), + matches.iter().take(5).collect::>(), + ))); + } + } + + Some(Ok(())) +} + +/// Returns the full list of disagreements. Empty means "in sync". +/// +/// We deliberately exclude a few categories from being considered bugs: +/// * Paths that git marks as `WT_DELETED` / `INDEX_DELETED` but the picker +/// has already dropped — this is by design (deleted files leave the +/// index immediately). +/// * Paths that git marks as `IGNORED` but the picker has already dropped. +/// * Paths under `.git/` — never tracked by the picker. +fn diff_statuses( + truth: &BTreeMap, + picker: &BTreeMap>, +) -> Vec { + let mut out = Vec::new(); + + for (path, &truth_status) in truth { + if path.starts_with(".git/") || path == ".git" { + continue; + } + match picker.get(path) { + Some(&p) => { + if !status_equivalent(truth_status, p) { + out.push(Mismatch::Disagree { + path: path.clone(), + truth: truth_status, + picker: Some(p), + }); + } + } + None => { + // Tolerate picker not having deleted-from-disk files. + let only_absence_reasons = + Status::WT_DELETED | Status::INDEX_DELETED | Status::IGNORED; + if !truth_status.intersects(only_absence_reasons) { + out.push(Mismatch::Disagree { + path: path.clone(), + truth: truth_status, + picker: None, + }); + } + } + } + } + + for (path, &p) in picker { + if path.starts_with(".git/") || path == ".git" { + continue; + } + if !truth.contains_key(path) { + // Picker thinks a file exists that git has never heard of. + // Only a bug if the picker also thinks it has a non-clean + // status for it — otherwise it's a transient during which the + // picker has indexed a file before the next truth snapshot. + let non_clean = match p { + None => false, + Some(s) => !(s.is_empty() || s == Status::CURRENT), + }; + if non_clean { + out.push(Mismatch::ExtraInPicker { + path: path.clone(), + picker: p.unwrap_or(Status::CURRENT), + }); + } + } + } + + out +} + +/// `None` and `Some(CURRENT|empty)` are both "clean"; otherwise the bitsets +/// must match exactly. +fn status_equivalent(truth: Status, picker: Option) -> bool { + let picker_bits = picker.unwrap_or(Status::CURRENT); + let truth_clean = truth.is_empty() || truth == Status::CURRENT; + let picker_clean = picker_bits.is_empty() || picker_bits == Status::CURRENT; + if truth_clean && picker_clean { + return true; + } + truth == picker_bits +} + +fn format_mismatches(mismatches: &[Mismatch], shared: &SharedFilePicker) -> String { + let mut s = String::new(); + s.push_str(&format!( + "{} mismatch(es) after {} of wait:\n", + mismatches.len(), + humantime(CONVERGE_TIMEOUT), + )); + for m in mismatches { + match m { + Mismatch::Disagree { + path, + truth, + picker, + } => { + let probe = probe_single_file_status(shared, path); + s.push_str(&format!( + " • {path}\n truth : {}\n picker(enum): {}\n picker(probe): {}\n", + format_status(Some(*truth)), + match picker { + Some(p) => format_status(*p), + None => "".into(), + }, + match probe { + Some(p) => format_status(p), + None => "".into(), + } + )); + } + Mismatch::ExtraInPicker { path, picker } => { + let probe = probe_single_file_status(shared, path); + s.push_str(&format!( + " • {path}\n truth : \n picker(enum) : {}\n picker(probe): {}\n", + format_status(Some(*picker)), + match probe { + Some(p) => format_status(p), + None => "".into(), + } + )); + } + } + } + s +} + +fn format_status(s: Option) -> String { + match s { + None => "None (= clean)".into(), + Some(st) if st.is_empty() || st == Status::CURRENT => "CURRENT (= clean)".into(), + Some(st) => format!("{st:?}"), + } +} + +fn humantime(d: Duration) -> String { + format!("{:.1}s", d.as_secs_f64()) +} + +fn get_baseline_status_from_git(base: &Path) -> Vec { + let mut out = Vec::new(); + let repo = match Repository::open(base) { + Ok(r) => r, + Err(_) => return out, + }; + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .include_unmodified(true) + .exclude_submodules(true); + let statuses = match repo.statuses(Some(&mut opts)) { + Ok(s) => s, + Err(_) => return out, + }; + for entry in statuses.iter() { + if let Some(p) = entry.path() { + let abs = base.join(p); + // Must be a real file *right now* — ignore stale WT_DELETED rows. + if abs.is_file() { + out.push(Live { + relative: p.to_string(), + abs, + }); + } + } + } + out +} + +/// Content marker embedded in every generated file body. The probing +/// layer reads this back via `grep` to exercise the live-grep path +/// (which hits the bigram index / mmap cache / overflow content store — +/// code paths the empty-query fuzzy enumeration never touches). +/// +/// Format: `FFF_STRESS_MARKER_<32-bit hex>` — namespaced so a repo +/// search for "MARKER" finds nothing from this test outside files we +/// wrote ourselves, which keeps the grep assertion unambiguous. +fn marker_for(seed: u32) -> String { + format!("FFF_STRESS_MARKER_{seed:08x}") +} + +fn content_for(seed: u32, name: &str) -> String { + let marker = marker_for(seed); + // Keep it small but distinct so adjacent edits produce different SHAs. + // The marker appears twice — once in a comment, once in a string + // literal — so a grep for it always has at least two hits per file + // and one of them is guaranteed to be on its own line for easy + // assertion. + format!( + "// file: {name}\n\ + // seed: {seed:08x}\n\ + // anchor: {marker}\n\ + pub fn anchor_{seed:x}() {{ let _ = \"{marker}\"; }}\n" + ) +} + +fn start_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) { + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::noop(); + + FilePicker::new_with_shared_state( + shared_picker.clone(), + shared_frecency.clone(), + FilePickerOptions { + base_path: base.to_string_lossy().to_string(), + enable_mmap_cache: false, + enable_content_indexing: false, + mode: FFFMode::Neovim, + watch: true, + ..Default::default() + }, + ) + .expect("FilePicker::new_with_shared_state"); + + (shared_picker, shared_frecency) +} + +fn wait_ready(p: &SharedFilePicker) { + assert!( + p.wait_for_scan(Duration::from_secs(15)), + "timed out waiting for initial scan" + ); + assert!( + p.wait_for_watcher(Duration::from_secs(15)), + "timed out waiting for watcher" + ); + // macOS FSEvents sometimes delivers a burst of "warmup" events right + // after the stream opens — let them drain before we start fuzzing. + std::thread::sleep(Duration::from_millis(200)); +} + +fn git_env() -> [(&'static str, &'static str); 4] { + [ + ("GIT_AUTHOR_NAME", "fuzz"), + ("GIT_AUTHOR_EMAIL", "fuzz@fff.test"), + ("GIT_COMMITTER_NAME", "fuzz"), + ("GIT_COMMITTER_EMAIL", "fuzz@fff.test"), + ] +} + +fn git(base: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(base) + .envs(git_env()) + .output() + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!( + out.status.success(), + "git {args:?} failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +fn git_allow_fail(base: &Path, args: &[&str]) { + let _ = Command::new("git") + .args(args) + .current_dir(base) + .envs(git_env()) + .output(); +} + +fn git_output(base: &Path, args: &[&str]) -> Option { + Command::new("git") + .args(args) + .current_dir(base) + .envs(git_env()) + .output() + .ok() +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Merge-conflict scenario +// ═══════════════════════════════════════════════════════════════════════════ +// +// The fuzz alphabet above intentionally avoids branch operations — merge +// conflicts need a very specific topology (two divergent edits to the same +// hunk) that random sampling would almost never hit. This scripted test +// fills that gap with a deterministic end-to-end conflict flow: +// +// 1. seed a single tracked file `conflict.rs` +// 2. branch to `feature`, rewrite the file's inner expression, commit +// 3. back on `main`, rewrite the same hunk differently, commit +// 4. `git merge feature` — leaves `.git/MERGE_HEAD` + conflict markers +// 5. picker must observe `Status::CONFLICTED` for `conflict.rs`, and the +// same real-query surfaces (fuzzy + grep for `<<<<<<<`) must return +// the conflicted file +// 6. resolve by writing the merged content, `git add`, `git commit` +// 7. picker must converge back to `Status::CURRENT` +// +// On top of the `--cfg stress` gate the test inherits (so it never runs +// under plain `cargo test`), this is cross-platform: it uses libgit2 +// internals + the `git` CLI, both of which work identically on macOS +// FSEvents, Linux inotify, and Windows ReadDirectoryChangesW (if we ever +// add Windows to the matrix). The convergence window is a bit larger than +// the fuzz case because `git merge` touches several `.git/*` files at +// once, producing a heftier event burst. + +/// Convergence timeout for the conflict flow. `git merge` fires more FS +/// events than a plain `git add` — `.git/ORIG_HEAD`, `.git/MERGE_HEAD`, +/// `.git/MERGE_MSG`, the worktree file itself — so the debouncer has +/// more work than a one-off refresh. 30 s is still very generous. +const CONFLICT_CONVERGE_TIMEOUT: Duration = Duration::from_secs(30); + +#[test] +fn stress_merge_conflict_convergence() { + // Opt-in tracing for parity with the other stress tests. + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + // Default to DEBUG for fff crates so CI failures on + // flaky OSes (Windows) include the watcher/event trail + // without needing to re-run with RUST_LOG set. + .unwrap_or_else(|_| { + tracing_subscriber::EnvFilter::new( + "warn,fff_search=debug,notify=debug,notify_debouncer_full=debug", + ) + }), + ) + .with_test_writer() + .try_init(); + + let tmp = TempDir::new().expect("mktemp"); + let base = tmp.path().canonicalize().expect("canonicalize tmp"); + + seed_conflict_repo(&base); + + let (shared_picker, _frecency) = start_watched_picker(&base); + wait_ready(&shared_picker); + + // ───────────────────────────────────────────────────────────────── + // Stage 1: create divergent commits on `feature` and `main` + // ───────────────────────────────────────────────────────────────── + // + // `conflict.rs` on `main` contains `BASE` text at line 2. We rewrite + // that line two different ways on two branches, so a merge can't + // pick a side automatically. + // + // Each rewrite is preceded by a 1.1 s sleep so the file's mtime + // advances past the previous write's — the watcher's mmap cache + // invalidation is mtime-triggered at 1 s granularity, and without + // the sleep a follow-up grep on the picker sees stale content from + // whichever variant was written last-but-one. + + std::thread::sleep(Duration::from_millis(1100)); + git(&base, &["checkout", "-b", "feature"]); + fs::write( + base.join("conflict.rs"), + "fn flavour() {\n \"FEATURE_VARIANT\"\n}\n", + ) + .unwrap(); + git(&base, &["add", "conflict.rs"]); + git( + &base, + &["commit", "-m", "feature: rewrite", "--no-gpg-sign"], + ); + + std::thread::sleep(Duration::from_millis(1100)); + git(&base, &["checkout", "main"]); + fs::write( + base.join("conflict.rs"), + "fn flavour() {\n \"MAIN_VARIANT\"\n}\n", + ) + .unwrap(); + git(&base, &["add", "conflict.rs"]); + git(&base, &["commit", "-m", "main: rewrite", "--no-gpg-sign"]); + + // Also sleep before the merge itself so the post-merge worktree + // write lands in a fresh second. + std::thread::sleep(Duration::from_millis(1100)); + + // Let the divergent commits settle in the picker before we merge. + expect_file_status( + &shared_picker, + &base, + "conflict.rs", + |s| { + // Post-commit: should be clean (CURRENT or None = no row). + s.is_none() || s.unwrap().is_empty() || s.unwrap().contains(Status::CURRENT) + }, + Duration::from_secs(10), + "pre-merge clean", + ) + .expect("pre-merge state should be clean"); + + // ───────────────────────────────────────────────────────────────── + // Stage 2: trigger the conflicting merge + // ───────────────────────────────────────────────────────────────── + + // `git merge feature` returns non-zero on conflict — that's fine. + // We don't use `git(..)` (asserts success) — conflict IS the happy + // path for this test. + let merge = git_output(&base, &["merge", "feature", "--no-edit", "--no-gpg-sign"]) + .expect("git merge didn't launch"); + assert!( + !merge.status.success(), + "expected `git merge feature` to conflict, but it succeeded:\nstdout:{}\nstderr:{}", + String::from_utf8_lossy(&merge.stdout), + String::from_utf8_lossy(&merge.stderr) + ); + + // libgit2 marks both sides' modifications with `CONFLICTED`. We + // wait for the picker to match. + expect_file_status( + &shared_picker, + &base, + "conflict.rs", + |s| s.is_some_and(|st| st.contains(Status::CONFLICTED)), + CONFLICT_CONVERGE_TIMEOUT, + "CONFLICTED after merge", + ) + .expect("picker must surface CONFLICTED after merge"); + + // ───────────────────────────────────────────────────────────────── + // Stage 3: real-query surfaces must still work in conflict state + // ───────────────────────────────────────────────────────────────── + + // Fuzzy search by stem returns the conflicted file. + let fuzzy_hits = fuzzy_search_items(&shared_picker, "conflict"); + assert!( + fuzzy_hits.iter().any(|(p, _)| p == "conflict.rs"), + "fuzzy_search(\"conflict\") during conflict state returned: {:?}", + fuzzy_hits + ); + + // Grep for the diff conflict marker `<<<<<<<` — it's literally in + // the worktree file right now, so live grep must find it. + // The watcher rewrites the file on-disk during merge, so the mmap + // cache needs to have been invalidated for this to succeed. + let grep_hits = grep_plain_matches(&shared_picker, "<<<<<<< "); + assert!( + grep_hits.contains(&"conflict.rs".to_string()), + "grep(\"<<<<<<< \") during conflict state returned: {:?}", + grep_hits + ); + + // ───────────────────────────────────────────────────────────────── + // Stage 4: resolve + commit, expect return to clean + // ───────────────────────────────────────────────────────────────── + + // `on_create_or_modify` invalidates the mmap cache only when `mtime` + // advances, and mtime has 1 s resolution on every filesystem we + // ship on. `git merge` in stage 2 just wrote to `conflict.rs`; if + // we re-write it within the same second the cache keeps its + // pre-resolve bytes (complete with `<<<<<<<` markers) and grep will + // keep finding them. Sleep past the mtime tick before re-writing. + // This matches the pattern in `fuzz_file_operations.rs`. + std::thread::sleep(Duration::from_millis(1100)); + + fs::write( + base.join("conflict.rs"), + "fn flavour() {\n \"RESOLVED_VARIANT\"\n}\n", + ) + .unwrap(); + git(&base, &["add", "conflict.rs"]); + git( + &base, + &[ + "commit", + "-m", + "resolve merge", + "--no-gpg-sign", + "--no-edit", + ], + ); + + expect_file_status( + &shared_picker, + &base, + "conflict.rs", + |s| s.is_none() || s.unwrap().is_empty() || s.unwrap().contains(Status::CURRENT), + CONFLICT_CONVERGE_TIMEOUT, + "CURRENT after resolve", + ) + .expect("picker must converge back to CURRENT after conflict resolution"); + + // Sanity: conflict markers are gone from both the worktree AND the + // picker's view (grep for `<<<<<<<` now returns 0 hits). + // + // The worktree check is an independent truth source — if it fails, + // the test harness itself is buggy (the resolve write didn't apply). + // The picker check can lag briefly: `expect_file_status` returned + // once `.git/index` events made git_status = CURRENT, but the + // `conflict.rs` Modify event from our resolve `fs::write` can land + // in a separate debounced batch that hasn't been processed yet. + // Poll until the mmap cache is flushed rather than asserting once. + let on_disk = fs::read_to_string(base.join("conflict.rs")).unwrap(); + assert!( + !on_disk.contains("<<<<<<<"), + "worktree still has conflict markers after resolve — test harness bug\n\ + on-disk content:\n{on_disk}" + ); + let deadline = Instant::now() + CONFLICT_CONVERGE_TIMEOUT; + loop { + let grep_hits = grep_plain_matches(&shared_picker, "<<<<<<< "); + if !grep_hits.contains(&"conflict.rs".to_string()) { + break; + } + if Instant::now() >= deadline { + panic!( + "picker grep(\"<<<<<<< \") after resolve still returns conflict.rs \ + after {} — mmap/overlay cache is stale\n\ + (worktree on disk has no conflict markers — this is a real \ + content-invalidation bug)\n\ + last grep hits: {:?}", + humantime(CONFLICT_CONVERGE_TIMEOUT), + grep_hits, + ); + } + std::thread::sleep(CONVERGE_POLL); + } +} + +/// Seed a repo with a single file that we'll later produce a conflict in. +fn seed_conflict_repo(base: &Path) { + fs::write(base.join("README.md"), "# merge conflict test\n").unwrap(); + fs::write( + base.join("conflict.rs"), + "fn flavour() {\n \"BASE\"\n}\n", + ) + .unwrap(); + git(base, &["init", "-b", "main"]); + git(base, &["config", "user.email", "fuzz@fff.test"]); + git(base, &["config", "user.name", "fuzz"]); + // Conflict behaviour must be deterministic regardless of merge.tool. + git(base, &["config", "merge.conflictstyle", "merge"]); + git(base, &["add", "-A"]); + git(base, &["commit", "-m", "seed", "--no-gpg-sign"]); +} + +/// Poll the picker for `relative` until `predicate` holds on its +/// `git_status`, or the timeout expires. On expiry returns an `Err` with +/// the last observed status + truth status for diagnostics. +fn expect_file_status( + shared: &SharedFilePicker, + base: &Path, + relative: &str, + predicate: impl Fn(Option) -> bool, + timeout: Duration, + what: &str, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; + let mut last_picker; + let mut last_truth; + loop { + let picker_status = probe_single_file_status(shared, relative).flatten(); + let truth_status = read_truth_status(base).get(relative).copied(); + last_picker = picker_status; + last_truth = truth_status; + if predicate(picker_status) { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(format!( + "timed out after {} waiting for `{relative}` to satisfy `{what}`\n\ + last picker status: {:?}\n\ + last truth status : {:?}", + humantime(timeout), + last_picker, + last_truth, + )); + } + std::thread::sleep(CONVERGE_POLL); + } +} diff --git a/crates/fff-core/tests/new_directory_watcher_test.rs b/crates/fff-core/tests/new_directory_watcher_test.rs index 4190ec2..bcddd68 100644 --- a/crates/fff-core/tests/new_directory_watcher_test.rs +++ b/crates/fff-core/tests/new_directory_watcher_test.rs @@ -24,7 +24,9 @@ use tempfile::TempDir; use fff_search::file_picker::{FFFMode, FilePicker}; use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; -use fff_search::{FilePickerOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker}; +use fff_search::{ + FilePickerOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency, +}; // ═══════════════════════════════════════════════════════════════════════ // Helpers @@ -54,8 +56,8 @@ fn git_init_and_commit(dir: &Path) { git_run(dir, &["commit", "-m", "initial"]); } -fn make_watched_picker(base: &Path) -> (SharedPicker, SharedFrecency) { - let shared_picker = SharedPicker::default(); +fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) { + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::noop(); FilePicker::new_with_shared_state( @@ -75,7 +77,7 @@ fn make_watched_picker(base: &Path) -> (SharedPicker, SharedFrecency) { } /// Wait for the initial scan + watcher to be fully ready. -fn wait_ready(shared_picker: &SharedPicker) { +fn wait_ready(shared_picker: &SharedFilePicker) { assert!( shared_picker.wait_for_scan(Duration::from_secs(10)), "Timed out waiting for initial scan" @@ -89,7 +91,7 @@ fn wait_ready(shared_picker: &SharedPicker) { /// Poll the picker until `predicate` returns true or timeout expires. /// Returns the elapsed duration if successful, panics on timeout. fn poll_until( - shared_picker: &SharedPicker, + shared_picker: &SharedFilePicker, timeout: Duration, description: &str, predicate: impl Fn(&FilePicker) -> bool, diff --git a/crates/fff-core/tests/watcher_stop_under_lock.rs b/crates/fff-core/tests/watcher_stop_under_lock.rs new file mode 100644 index 0000000..42bc9fe --- /dev/null +++ b/crates/fff-core/tests/watcher_stop_under_lock.rs @@ -0,0 +1,165 @@ +//! Regression test: stopping the background watcher while the caller +//! holds the [`SharedFilePicker`] write lock must NOT deadlock. +//! +//! There are two lock-ordering hazards the watcher has to handle: +//! +//! 1. The debouncer's event thread calls our handler, which wants +//! `shared_picker.write()` to apply events. `stop()` used to +//! `join()` that thread under the caller's write guard. +//! +//! 2. The owner thread registers new-directory watches and injects +//! their existing files. Previously it held the debouncer mutex +//! across `shared_picker.write()`, while `stop()` takes the +//! debouncer mutex under the caller's write guard — inverse +//! lock orders, classic deadlock. +//! +//! macOS FSEvents is the reliable reproducer for (1) because fresh +//! `fs::write()` calls inside a just-watched temp dir queue events +//! faster than the debounce tick can drain them. Creating new +//! subdirectories exercises (2) via the owner thread's `watch_tx`. + +use std::fs; +use std::sync::mpsc; +use std::time::Duration; +use tempfile::TempDir; + +use fff_search::file_picker::{FFFMode, FilePicker}; +use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency}; + +/// Run `f` on a worker thread, require it to finish within `timeout`, +/// panic with `msg` otherwise. The caller gets to describe what the +/// worker is doing so a hung test produces an actionable message. +fn run_with_deadlock_guard( + msg: &'static str, + timeout: Duration, + f: impl FnOnce() + Send + 'static, +) { + let (done_tx, done_rx) = mpsc::channel::<()>(); + let worker = std::thread::Builder::new() + .name("deadlock-guard-worker".into()) + .spawn(move || { + f(); + let _ = done_tx.send(()); + }) + .expect("spawn worker"); + + match done_rx.recv_timeout(timeout) { + Ok(()) => {} + Err(_) => panic!("{msg}"), + } + worker.join().expect("worker panicked"); +} + +fn make_watched_picker(base: &std::path::Path) -> (SharedFilePicker, SharedFrecency) { + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::default(); + + FilePicker::new_with_shared_state( + shared_picker.clone(), + shared_frecency.clone(), + FilePickerOptions { + base_path: base.to_string_lossy().to_string(), + enable_mmap_cache: false, + enable_content_indexing: false, + mode: FFFMode::Neovim, + watch: true, + ..Default::default() + }, + ) + .expect("Failed to create FilePicker"); + + assert!( + shared_picker.wait_for_scan(Duration::from_secs(10)), + "initial scan never completed" + ); + assert!( + shared_picker.wait_for_watcher(Duration::from_secs(10)), + "watcher never installed" + ); + + (shared_picker, shared_frecency) +} + +/// Hazard (1): debouncer event handler is waiting on `shared_picker.write()` +/// while the caller joins it from under the same guard. +#[test] +fn stop_background_monitor_under_write_lock_does_not_deadlock_file_events() { + let tmp = TempDir::new().unwrap(); + let base = tmp.path().to_path_buf(); + + for i in 0..4 { + fs::write(base.join(format!("file_{i}.txt")), format!("seed {i}\n")).unwrap(); + } + + let (shared_picker, _shared_frecency) = make_watched_picker(&base); + + // Produce enough filesystem churn that the debouncer has events + // queued and is likely mid-handler by the time we call stop. + for round in 0..8 { + for i in 0..4 { + let path = base.join(format!("file_{i}.txt")); + fs::write(&path, format!("edit {round}-{i}\n")).unwrap(); + } + } + + // Give the kernel time to deliver events into the debouncer queue + // (50 ms = default debouncer tick). + std::thread::sleep(Duration::from_millis(60)); + + let sp = shared_picker.clone(); + run_with_deadlock_guard( + "stop_background_monitor() deadlocked under shared_picker.write() — \ + the debouncer thread is likely waiting on the same write lock \ + while we join it", + Duration::from_secs(5), + move || { + let mut guard = sp.write().expect("write lock"); + if let Some(ref mut picker) = *guard { + picker.stop_background_monitor(); + } + }, + ); +} + +/// Hazard (2): owner thread holds the debouncer mutex while waiting +/// on `shared_picker.write()`, and `stop()` takes the debouncer mutex +/// under the caller's write guard. +#[test] +fn stop_background_monitor_under_write_lock_does_not_deadlock_new_dirs() { + let tmp = TempDir::new().unwrap(); + let base = tmp.path().to_path_buf(); + + fs::write(base.join("seed.txt"), "seed\n").unwrap(); + + let (shared_picker, _shared_frecency) = make_watched_picker(&base); + + // Create a burst of new subdirectories with files inside. On Linux + // the watcher event thread sends each new dir to `watch_tx`, and + // the owner thread processes them (taking the debouncer mutex + + // `shared_picker.write()`). On macOS the owner thread still runs + // `track_files_from_new_directories`, which takes the write lock. + for d in 0..8 { + let sub = base.join(format!("sub_{d}")); + fs::create_dir(&sub).unwrap(); + for f in 0..4 { + fs::write(sub.join(format!("f_{f}.txt")), format!("{d}-{f}\n")).unwrap(); + } + } + + std::thread::sleep(Duration::from_millis(120)); + + let sp = shared_picker.clone(); + run_with_deadlock_guard( + "stop_background_monitor() deadlocked under shared_picker.write() — \ + the watcher owner thread is likely holding the debouncer mutex and \ + waiting on the same write lock while we try to take the debouncer \ + mutex to tear it down", + Duration::from_secs(5), + move || { + let mut guard = sp.write().expect("write lock"); + if let Some(ref mut picker) = *guard { + picker.stop_background_monitor(); + } + }, + ); +} diff --git a/crates/fff-core/tests/watcher_thread_lifecycle_test.rs b/crates/fff-core/tests/watcher_thread_lifecycle_test.rs new file mode 100644 index 0000000..c7624f9 --- /dev/null +++ b/crates/fff-core/tests/watcher_thread_lifecycle_test.rs @@ -0,0 +1,197 @@ +#![cfg(target_os = "linux")] + +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use tempfile::TempDir; + +use fff_search::file_picker::{FFFMode, FilePicker}; +use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency}; + +/// Thread comm names Linux exposes via `/proc/self/task/*/comm` are +/// capped at `TASK_COMM_LEN - 1 = 15` bytes. Our owner thread is named +/// `"fff-watcher-owner"` (17 bytes), so what actually appears in +/// `/proc` is the 15-byte truncation below. +const WATCHER_OWNER_THREAD_NAME: &str = "fff-watcher-own"; + +/// Walk `/proc/self/task/*/comm` and return how many live threads +/// carry `name` as their `comm`. +fn count_live_threads_named(name: &str) -> usize { + let Ok(dir) = fs::read_dir("/proc/self/task") else { + return 0; + }; + let mut count = 0usize; + for entry in dir.flatten() { + let comm_path = entry.path().join("comm"); + if let Ok(content) = fs::read_to_string(&comm_path) { + if content.trim_end() == name { + count += 1; + } + } + } + count +} + +/// Poll until the thread count matches `expected` or we hit `timeout`. +fn wait_for_thread_count(name: &str, expected: usize, timeout: Duration) -> usize { + let deadline = Instant::now() + timeout; + loop { + let count = count_live_threads_named(name); + if count == expected { + return count; + } + if Instant::now() >= deadline { + return count; + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn seed_repo(base: &std::path::Path) { + fs::create_dir_all(base.join("src")).unwrap(); + fs::write(base.join("README.md"), "# seed\n").unwrap(); + fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap(); + fs::write(base.join("src/lib.rs"), "// lib\n").unwrap(); + + let _ = std::process::Command::new("git") + .args(["init", "-q", "-b", "main"]) + .current_dir(base) + .output(); +} + +fn spawn_watched_picker(base: PathBuf) -> (SharedFilePicker, SharedFrecency) { + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::default(); + + FilePicker::new_with_shared_state( + shared_picker.clone(), + shared_frecency.clone(), + FilePickerOptions { + base_path: base.to_string_lossy().to_string(), + enable_mmap_cache: false, + enable_content_indexing: false, + mode: FFFMode::Neovim, + watch: true, + ..Default::default() + }, + ) + .expect("FilePicker::new_with_shared_state"); + + assert!( + shared_picker.wait_for_scan(Duration::from_secs(10)), + "initial scan did not complete" + ); + assert!( + shared_picker.wait_for_watcher(Duration::from_secs(10)), + "watcher did not install" + ); + + (shared_picker, shared_frecency) +} + +#[test] +fn watcher_threads_do_not_leak_across_picker_lifetimes() { + // this is needed because I run this within neovim with it's own fff owner thread lmao + let baseline = count_live_threads_named(WATCHER_OWNER_THREAD_NAME); + + const PICKER_COUNT: usize = 4; + + let mut tmpdirs: Vec = (0..PICKER_COUNT) + .map(|_| TempDir::new().expect("mktemp")) + .collect(); + for td in &tmpdirs { + seed_repo(td.path()); + } + + let mut pickers: Vec<(SharedFilePicker, SharedFrecency)> = tmpdirs + .iter() + .map(|td| spawn_watched_picker(td.path().canonicalize().expect("canonicalize tmp"))) + .collect(); + + let peak = wait_for_thread_count( + WATCHER_OWNER_THREAD_NAME, + baseline + PICKER_COUNT, + Duration::from_secs(5), + ); + assert_eq!( + peak, + baseline + PICKER_COUNT, + "expected {} watcher-owner threads alive (baseline {} + {} pickers), saw {}", + baseline + PICKER_COUNT, + baseline, + PICKER_COUNT, + peak, + ); + + for i in 0..PICKER_COUNT { + let expected_remaining = baseline + PICKER_COUNT - (i + 1); + let (sp, sf) = pickers.remove(0); + drop(sp); + drop(sf); + let count = wait_for_thread_count( + WATCHER_OWNER_THREAD_NAME, + expected_remaining, + Duration::from_secs(5), + ); + assert_eq!( + count, + expected_remaining, + "after dropping picker {}/{}: expected {} owner threads, saw {}", + i + 1, + PICKER_COUNT, + expected_remaining, + count, + ); + } + tmpdirs.clear(); + + let after_stage1 = count_live_threads_named(WATCHER_OWNER_THREAD_NAME); + assert_eq!( + after_stage1, baseline, + "stage 1 leaked watcher-owner threads: baseline {}, observed {}", + baseline, after_stage1, + ); + + const ROUNDS: usize = 3; + + for round in 0..ROUNDS { + let tmp = TempDir::new().expect("mktemp"); + seed_repo(tmp.path()); + let base = tmp.path().canonicalize().expect("canonicalize tmp"); + + let (sp, sf) = spawn_watched_picker(base); + + let during = wait_for_thread_count( + WATCHER_OWNER_THREAD_NAME, + baseline + 1, + Duration::from_secs(5), + ); + assert_eq!( + during, + baseline + 1, + "round {round}: expected 1 owner thread during run, saw {during} \ + (baseline {baseline})", + ); + + drop(sp); + drop(sf); + drop(tmp); + + let after = + wait_for_thread_count(WATCHER_OWNER_THREAD_NAME, baseline, Duration::from_secs(5)); + assert_eq!( + after, baseline, + "round {round}: owner thread leaked after teardown \ + (baseline {baseline}, observed {after})", + ); + } + + let final_count = count_live_threads_named(WATCHER_OWNER_THREAD_NAME); + assert_eq!( + final_count, baseline, + "watcher-owner threads leaked past the end of the test \ + (baseline {}, final {})", + baseline, final_count, + ); +} diff --git a/crates/fff-mcp/src/main.rs b/crates/fff-mcp/src/main.rs index ae5149e..e86d500 100644 --- a/crates/fff-mcp/src/main.rs +++ b/crates/fff-mcp/src/main.rs @@ -15,7 +15,7 @@ mod update_check; use clap::Parser; use fff::file_picker::FilePicker; use fff::frecency::FrecencyTracker; -use fff::{FFFMode, SharedFrecency, SharedPicker}; +use fff::{FFFMode, SharedFilePicker, SharedFrecency}; use git2::Repository; use mimalloc::MiMalloc; use rmcp::{ServiceExt, transport::stdio}; @@ -232,7 +232,7 @@ async fn main() -> Result<(), Box> { } }; - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); if let Some(frecency_db_path) = args.frecency_db_path { match FrecencyTracker::new(&frecency_db_path, false) { diff --git a/crates/fff-mcp/src/server.rs b/crates/fff-mcp/src/server.rs index ee42928..6a1ef45 100644 --- a/crates/fff-mcp/src/server.rs +++ b/crates/fff-mcp/src/server.rs @@ -12,7 +12,7 @@ use crate::cursor::CursorStore; use crate::output::{GrepFormatter, OutputMode, file_suffix}; use fff::grep::{GrepMode, GrepSearchOptions, has_regex_metacharacters}; use fff::types::{FileItem, PaginationArgs}; -use fff::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker}; +use fff::{FuzzySearchOptions, QueryParser, SharedFilePicker, SharedFrecency}; use fff_query_parser::AiGrepConfig; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; @@ -160,7 +160,7 @@ pub struct MultiGrepParams { #[derive(Clone)] pub struct FffServer { - picker: SharedPicker, + picker: SharedFilePicker, #[allow(dead_code)] frecency: SharedFrecency, cursor_store: Arc>, @@ -169,7 +169,7 @@ pub struct FffServer { } impl FffServer { - pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self { + pub fn new(picker: SharedFilePicker, frecency: SharedFrecency) -> Self { Self { picker, frecency, diff --git a/crates/fff-nvim/Cargo.toml b/crates/fff-nvim/Cargo.toml index 6b3d8be..a026723 100644 --- a/crates/fff-nvim/Cargo.toml +++ b/crates/fff-nvim/Cargo.toml @@ -77,8 +77,9 @@ ignore = "0.4.22" mimalloc = { version = "0.1.47", features = ["local_dynamic_tls"] } mlua = { version = "0.11.1", features = ["module", "luajit"] } neo_frizbee = { workspace = true } -notify = "8.1.0" -notify-debouncer-full = "0.6" +notify = { workspace = true } +notify-debouncer-full = { workspace = true } +parking_lot = { workspace = true } once_cell = "1.20.2" pathdiff = "0.2.1" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/fff-nvim/benches/grep_bench.rs b/crates/fff-nvim/benches/grep_bench.rs index 45fadc6..7e5ebbd 100644 --- a/crates/fff-nvim/benches/grep_bench.rs +++ b/crates/fff-nvim/benches/grep_bench.rs @@ -1,13 +1,14 @@ use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; use fff::file_picker::{FFFMode, FilePicker}; use fff::{ - FilePickerOptions, GrepMode, GrepSearchOptions, SharedFrecency, SharedPicker, parse_grep_query, + FilePickerOptions, GrepMode, GrepSearchOptions, SharedFilePicker, SharedFrecency, + parse_grep_query, }; use std::sync::OnceLock; use std::time::Duration; struct TestData { - shared_picker: SharedPicker, + shared_picker: SharedFilePicker, } static SETUP: OnceLock = OnceLock::new(); @@ -32,7 +33,7 @@ fn big_repo_path() -> String { fn setup() -> &'static TestData { SETUP.get_or_init(|| { let path = big_repo_path(); - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); eprintln!("Initializing FilePicker for {:?}...", path); @@ -70,9 +71,9 @@ fn setup() -> &'static TestData { }) } -fn setup_cold() -> SharedPicker { +fn setup_cold() -> SharedFilePicker { let path = big_repo_path(); - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( diff --git a/crates/fff-nvim/benches/indexing_and_search.rs b/crates/fff-nvim/benches/indexing_and_search.rs index 27de61f..e62ad03 100644 --- a/crates/fff-nvim/benches/indexing_and_search.rs +++ b/crates/fff-nvim/benches/indexing_and_search.rs @@ -3,7 +3,7 @@ use fff::file_picker::{FFFMode, FilePicker}; use fff::types::PaginationArgs; use fff::{ FilePickerOptions, FuzzySearchOptions, GrepMode, GrepSearchOptions, QueryParser, - SharedFrecency, SharedPicker, + SharedFilePicker, SharedFrecency, }; use std::path::PathBuf; use std::time::Duration; @@ -25,7 +25,7 @@ fn init_tracing() { /// Initialize FilePicker using shared state fn init_file_picker_internal( path: &str, - shared_picker: &SharedPicker, + shared_picker: &SharedFilePicker, shared_frecency: &SharedFrecency, ) -> Result<(), String> { FilePicker::new_with_shared_state( @@ -43,7 +43,7 @@ fn init_file_picker_internal( /// Helper function to wait for scanning to complete and get file count fn wait_for_scan_completion( - shared_picker: &SharedPicker, + shared_picker: &SharedFilePicker, timeout_secs: u64, ) -> Result { let start = std::time::Instant::now(); @@ -105,7 +105,7 @@ fn wait_for_scan_completion( } /// Clean up shared state -fn cleanup_shared_state(shared_picker: &SharedPicker) { +fn cleanup_shared_state(shared_picker: &SharedFilePicker) { if let Ok(mut picker_guard) = shared_picker.write() { if let Some(mut picker) = picker_guard.take() { picker.stop_background_monitor(); @@ -114,7 +114,7 @@ fn cleanup_shared_state(shared_picker: &SharedPicker) { } /// Initialize FilePicker once and return shared state -fn setup_once() -> Result<(SharedPicker, SharedFrecency), String> { +fn setup_once() -> Result<(SharedFilePicker, SharedFrecency), String> { init_tracing(); let big_repo_path = PathBuf::from("./big-repo"); @@ -126,7 +126,7 @@ fn setup_once() -> Result<(SharedPicker, SharedFrecency), String> { .map_err(|e| format!("Failed to canonicalize path: {}", e))?; eprintln!(" Path: {:?}", canonical_path); - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); init_file_picker_internal( @@ -171,7 +171,7 @@ fn bench_indexing(c: &mut Criterion) { group.bench_function("index_big_repo", |b| { b.iter(|| { - let sp = SharedPicker::default(); + let sp = SharedFilePicker::default(); let sf = SharedFrecency::default(); let start = std::time::Instant::now(); diff --git a/crates/fff-nvim/src/bin/jemalloc_profile.rs b/crates/fff-nvim/src/bin/jemalloc_profile.rs index cda913b..8ec63bf 100644 --- a/crates/fff-nvim/src/bin/jemalloc_profile.rs +++ b/crates/fff-nvim/src/bin/jemalloc_profile.rs @@ -1,5 +1,5 @@ use fff::file_picker::{FFFMode, FilePicker}; -use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker}; +use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency}; use std::env; use std::thread; use std::time::Duration; @@ -60,7 +60,7 @@ fn format_bytes(bytes: usize) -> String { } fn test_search_memory_pattern( - shared_picker: &SharedPicker, + shared_picker: &SharedFilePicker, name: &str, iterations: usize, query_pattern: impl Fn(usize) -> String, @@ -177,7 +177,7 @@ fn main() -> Result<(), Box> { println!(); // Create shared state - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); // Initialize FilePicker diff --git a/crates/fff-nvim/src/bin/search_profiler.rs b/crates/fff-nvim/src/bin/search_profiler.rs index 09ff11f..869393a 100644 --- a/crates/fff-nvim/src/bin/search_profiler.rs +++ b/crates/fff-nvim/src/bin/search_profiler.rs @@ -1,9 +1,9 @@ use fff::file_picker::{FFFMode, FilePicker}; -use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker}; +use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency}; use std::time::{Duration, Instant}; /// Wait for background scan to complete -fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result { +fn wait_for_scan(shared_picker: &SharedFilePicker, timeout_secs: u64) -> Result { let timeout = Duration::from_secs(timeout_secs); if !shared_picker.wait_for_scan(timeout) { return Err(format!("Scan timed out after {} seconds", timeout_secs)); @@ -33,7 +33,7 @@ fn main() { fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path"); // Create shared state - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); eprintln!("Initializing FilePicker for: {:?}", canonical_path); diff --git a/crates/fff-nvim/src/bin/test_memory_leak.rs b/crates/fff-nvim/src/bin/test_memory_leak.rs index 9707dde..67e1c47 100644 --- a/crates/fff-nvim/src/bin/test_memory_leak.rs +++ b/crates/fff-nvim/src/bin/test_memory_leak.rs @@ -1,5 +1,5 @@ use fff::file_picker::{FFFMode, FilePicker}; -use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker}; +use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency}; use std::env; use std::io::{self, Write}; use std::thread; @@ -78,7 +78,7 @@ fn main() -> Result<(), Box> { println!(); // Create shared state - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); // Initialize the file picker @@ -126,14 +126,7 @@ fn main() -> Result<(), Box> { // If async scan didn't work, trigger a manual scan if !scan_completed { println!("Triggering manual rescan..."); - if let Ok(mut guard) = shared_picker.write() - && let Some(ref mut picker) = *guard - { - match picker.trigger_rescan(&shared_frecency) { - Ok(_) => println!("Manual rescan completed"), - Err(e) => println!("Manual rescan failed: {:?}", e), - } - } + let _ = shared_picker.trigger_full_rescan_async(&shared_frecency); } let initial_file_count = { diff --git a/crates/fff-nvim/src/bin/test_watcher.rs b/crates/fff-nvim/src/bin/test_watcher.rs index e4b29de..41a7482 100644 --- a/crates/fff-nvim/src/bin/test_watcher.rs +++ b/crates/fff-nvim/src/bin/test_watcher.rs @@ -4,7 +4,9 @@ use fff::file_picker::FilePicker; use fff::git::format_git_status; -use fff::{FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker}; +use fff::{ + FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency, +}; use std::env; use std::io::{self, Write}; use std::sync::Arc; @@ -25,7 +27,7 @@ fn main() -> Result<(), Box> { let r = running.clone(); // Create shared state - let shared_picker = SharedPicker::default(); + let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); // Clone for signal handler diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 33cd163..e54fc05 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -6,7 +6,7 @@ use fff::path_utils::expand_tilde; use fff::query_tracker::QueryTracker; use fff::{ DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, GrepConfig, - PaginationArgs, QueryParser, Score, SearchResult, SharedFrecency, SharedPicker, + PaginationArgs, QueryParser, Score, SearchResult, SharedFilePicker, SharedFrecency, SharedQueryTracker, }; use mimalloc::MiMalloc; @@ -27,7 +27,7 @@ static GLOBAL: MiMalloc = MiMalloc; // the global state for neovim lives here for efficiency // lua ffi is pretty bad with the overhead of converting raw pointer into tables -pub static FILE_PICKER: Lazy = Lazy::new(SharedPicker::default); +pub static FILE_PICKER: Lazy = Lazy::new(SharedFilePicker::default); pub static FRECENCY: Lazy = Lazy::new(SharedFrecency::default); pub static QUERY_TRACKER: Lazy = Lazy::new(SharedQueryTracker::default); @@ -92,19 +92,22 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult { } fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> { - // Cancel and stop the old picker under a single write lock to avoid - // a window where FILE_PICKER is None (which causes FilePickerMissing - // errors if the UI is searching concurrently). + // Cancel and stop the old picker's watcher under the write lock. + // `stop_background_monitor` is non-blocking (signals the debouncer + // to exit on its next tick without joining), so it's safe under + // the lock. In-flight watcher handlers finish naturally once we + // release the guard. { let mut guard = FILE_PICKER.write()?; if let Some(ref mut picker) = *guard { - // Signal cancellation BEFORE stopping — this tells any orphaned - // scan threads from this picker to discard their results. + // Signal cancellation BEFORE stopping the watcher so any + // orphaned scan/post-scan threads discard their results + // instead of racing with the new picker. picker.cancel(); picker.stop_background_monitor(); } - // Don't take() here — leave the old picker in place so searches - // still work until new_with_shared_state replaces it atomically. + // Don't take() the picker here — leave the old one in place so + // searches still work until new_with_shared_state replaces it. } // Create new picker — this atomically replaces the old one via write lock @@ -136,14 +139,37 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> { LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e)) })?; - if let Ok(Some(picker)) = FILE_PICKER.read().as_deref() - && picker.base_path() == canonical_path - { - return Ok(()); // same dir - } - - // Spawn a background thread to avoid blocking Lua/UI thread + // Spawn a background thread BEFORE touching the picker lock. The + // same-dir short-circuit previously called `FILE_PICKER.read()` on + // the lua/UI thread, which blocks if a reindex writer is already in + // flight — on repeated DirChanged events (e.g. LSP root switching + // right after closing the picker) that could freeze the nvim main + // loop for the entire duration of an in-progress scan. Move the + // check into the spawned worker so the main thread always returns + // instantly; the internal reinit path also re-checks and no-ops if + // the picker is already pointing at `canonical_path`. std::thread::spawn(move || { + ::tracing::info!( + ?canonical_path, + "restart_index_in_path: spawned worker running" + ); + { + let guard = match FILE_PICKER.read() { + Ok(g) => g, + Err(_) => return, + }; + if let Some(ref picker) = *guard + && picker.base_path() == canonical_path + { + ::tracing::info!(?canonical_path, "restart_index_in_path: same dir, skipping"); + return; + } + } + + ::tracing::info!( + ?canonical_path, + "restart_index_in_path: calling reinit_file_picker_internal" + ); if let Err(e) = reinit_file_picker_internal(&canonical_path) { ::tracing::error!( ?e, @@ -159,14 +185,13 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> { } pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> { - let mut file_picker = FILE_PICKER.write().into_lua_result()?; - let picker = file_picker - .as_mut() - .ok_or(Error::FilePickerMissing) + // Async: spawns a BG thread that walks off-lock, swaps sync_data + // under a brief write, then applies git + frecency off-lock. Returns + // immediately so the lua/UI thread never waits on the walk. + FILE_PICKER + .trigger_full_rescan_async(&FRECENCY) .into_lua_result()?; - - picker.trigger_rescan(&FRECENCY).into_lua_result()?; - ::tracing::info!("scan_files trigger_rescan completed"); + ::tracing::info!("scan_files rescan spawned"); Ok(()) } @@ -370,32 +395,65 @@ fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaRe } pub fn track_access(_: &Lua, file_path: String) -> LuaResult { + // Called from the nvim main thread on every BufEnter via a libuv + // async chain. The body does an LMDB write (~100-200 ms) and takes + // `FILE_PICKER.write()`, which — if it has to queue behind any + // concurrent writer (post-scan install, background watcher event + // apply) — can stall the UI for multiple seconds. Hand the work off + // to a detached OS thread and return immediately so the main loop + // never waits on us. + // + // Losing a frecency update on shutdown is acceptable: frecency is + // a best-effort signal and the next BufEnter for the same file will + // produce an equivalent update. let file_path = PathBuf::from(&file_path); + std::thread::spawn(move || { + { + let frecency_guard = match FRECENCY.read() { + Ok(g) => g, + Err(e) => { + ::tracing::debug!(?e, "track_access: frecency read lock poisoned"); + return; + } + }; + let Some(ref frecency) = *frecency_guard else { + return; + }; + if let Err(e) = frecency.track_access(file_path.as_path()) { + ::tracing::debug!(?e, ?file_path, "track_access: frecency DB write failed"); + return; + } + } - // Track access in frecency DB (expensive LMDB write, ~100-200ms) - // Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches - let frecency_guard = FRECENCY.read().into_lua_result()?; - let Some(ref frecency) = *frecency_guard else { - return Ok(false); - }; - frecency - .track_access(file_path.as_path()) - .into_lua_result()?; - drop(frecency_guard); + let mut file_picker = match FILE_PICKER.write() { + Ok(g) => g, + Err(e) => { + ::tracing::debug!(?e, "track_access: file picker write lock poisoned"); + return; + } + }; + let Some(ref mut picker) = *file_picker else { + return; + }; - // Quick lock to update single file's frecency score in picker - let mut file_picker = FILE_PICKER.write().into_lua_result()?; - let Some(ref mut picker) = *file_picker else { - return Err(error::to_lua_error(Error::FilePickerMissing)); - }; - - let frecency_guard = FRECENCY.read().into_lua_result()?; - let Some(ref frecency) = *frecency_guard else { - return Ok(false); - }; - picker - .update_single_file_frecency(&file_path, frecency) - .into_lua_result()?; + let frecency_guard = match FRECENCY.read() { + Ok(g) => g, + Err(e) => { + ::tracing::debug!(?e, "track_access: frecency read lock poisoned on update"); + return; + } + }; + let Some(ref frecency) = *frecency_guard else { + return; + }; + if let Err(e) = picker.update_single_file_frecency(&file_path, frecency) { + ::tracing::debug!( + ?e, + ?file_path, + "track_access: update_single_file_frecency failed" + ); + } + }); Ok(true) } @@ -463,13 +521,14 @@ pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult LuaResult { + // `stop_background_monitor` is non-blocking — the debouncer / + // owner threads exit on their next iteration, so it is safe to + // call under the FILE_PICKER write lock. let mut file_picker = FILE_PICKER.write().into_lua_result()?; let Some(ref mut picker) = *file_picker else { return Err(error::to_lua_error(Error::FilePickerMissing)); }; - picker.stop_background_monitor(); - Ok(true) } @@ -490,27 +549,30 @@ pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult { } pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult { - // Get the project path before spawning thread - let project_path = { - let file_picker = FILE_PICKER.read().into_lua_result()?; - let Some(ref picker) = *file_picker else { - return Ok(false); - }; - picker.base_path().to_path_buf() - }; - - // Canonicalize the file path before spawning thread - let file_path = match fff::path_utils::canonicalize(&file_path) { - Ok(path) => path, - Err(e) => { - tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking"); - return Ok(false); - } - }; - - // Spawn background thread to do the actual tracking (expensive DB write) + // Everything runs on a background thread — including the + // `FILE_PICKER.read()` used to fetch `project_path`. Doing that read + // on the main (UI) thread stalled nvim whenever a reindex writer was + // in flight: parking_lot's fair queue makes a read block behind a + // pending writer, so the main thread would freeze for the full + // duration of the scan. let query_tracker = QUERY_TRACKER.clone(); std::thread::spawn(move || { + let project_path = match FILE_PICKER.read() { + Ok(guard) => match *guard { + Some(ref picker) => picker.base_path().to_path_buf(), + None => return, + }, + Err(_) => return, + }; + + let file_path = match fff::path_utils::canonicalize(&file_path) { + Ok(path) => path, + Err(e) => { + tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking"); + return; + } + }; + if let Ok(mut guard) = query_tracker.write() && let Some(tracker) = guard.as_mut() && let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path) @@ -547,16 +609,18 @@ pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult> } pub fn track_grep_query(_: &Lua, query: String) -> LuaResult { - let project_path = { - let file_picker = FILE_PICKER.read().into_lua_result()?; - let Some(ref picker) = *file_picker else { - return Ok(false); - }; - picker.base_path().to_path_buf() - }; - + // Move the `FILE_PICKER.read()` into the spawned worker too — + // see the note on `track_query_completion` above for why. let query_tracker = QUERY_TRACKER.clone(); std::thread::spawn(move || { + let project_path = match FILE_PICKER.read() { + Ok(guard) => match *guard { + Some(ref picker) => picker.base_path().to_path_buf(), + None => return, + }, + Err(_) => return, + }; + if let Ok(mut guard) = query_tracker.write() && let Some(ref mut tracker) = *guard && let Err(e) = tracker.track_grep_query(&query, &project_path) @@ -604,39 +668,9 @@ pub fn parse_grep_query(lua: &Lua, query: String) -> LuaResult { } pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option) -> LuaResult { - // Extract the scan signal Arc WITHOUT holding the read lock, so the - // scan thread can acquire the write lock to store its results. - // Holding a read lock while polling would deadlock: the scan thread - // needs a write lock to finish, but can't acquire it while we hold the read lock. - let scan_signal = { - let file_picker = FILE_PICKER.read().into_lua_result()?; - let picker = file_picker - .as_ref() - .ok_or(Error::FilePickerMissing) - .into_lua_result()?; - picker.scan_signal() - }; // read lock released here + let scanned = FILE_PICKER.wait_for_scan(Duration::from_millis(timeout_ms.unwrap_or(500))); - let timeout_ms = timeout_ms.unwrap_or(500); - let timeout_duration = Duration::from_millis(timeout_ms); - let start_time = std::time::Instant::now(); - let mut sleep_duration = Duration::from_millis(1); - - while scan_signal.load(std::sync::atomic::Ordering::Relaxed) { - if start_time.elapsed() >= timeout_duration { - ::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms); - return Ok(false); - } - - std::thread::sleep(sleep_duration); - sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50)); - } - - ::tracing::debug!( - "wait_for_initial_scan completed in {:?}", - start_time.elapsed() - ); - Ok(true) + Ok(scanned) } pub fn init_tracing( diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index 36b6f73..b7f4097 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -1,5 +1,5 @@ *fff.nvim.txt* - For Neovim >= 0.10.0 Last change: 2026 April 30 + For Neovim >= 0.10.0 Last change: 2026 May 02 ============================================================================== Table of Contents *fff.nvim-table-of-contents* diff --git a/lua/fff/core.lua b/lua/fff/core.lua index 197c791..7a2f688 100644 --- a/lua/fff/core.lua +++ b/lua/fff/core.lua @@ -46,22 +46,50 @@ local function setup_global_autocmds(config) vim.api.nvim_create_autocmd('DirChanged', { group = group, callback = function() + -- Window-local `:lcd` / `:tcd` are per-window — they don't change the + -- effective project root for the picker, so bail before touching + -- anything else. if vim.v.event.scope == 'window' then return end + if not state.initialized then return end + local new_cwd = vim.v.event.cwd - if state.initialized and new_cwd and new_cwd ~= config.base_path then - vim.schedule(function() - -- Delay require to avoid circular dependency: core -> main -> picker_ui -> file_picker -> core - local ok, picker = pcall(require, 'fff.main') - if not ok then - vim.notify('FFF: Failed to load main module: ' .. tostring(picker), vim.log.levels.ERROR) - return - end - local change_ok, err = pcall(picker.change_indexing_directory, new_cwd) - if not change_ok then - vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR) - end - end) + if not new_cwd or new_cwd == '' then return end + + -- Canonicalize both sides before comparing. `vim.v.event.cwd` is + -- whatever the caller passed to `:cd` (often unexpanded, sometimes + -- containing `~` or symlinks), while `config.base_path` is the form + -- the picker was last re-indexed against (post-`expand`). Without + -- resolving symlinks + ensuring an absolute path, trivially + -- equivalent paths compare as different (`/private/var/x` vs + -- `/var/x` on macOS, resolved-vs-unresolved symlinks from LSP root + -- detection, etc.) and every such mismatch schedules a 450k-file + -- reindex through the Rust side. + local function canonicalize(p) + if not p or p == '' then return p end + local abs = vim.fn.fnamemodify(vim.fn.expand(p), ':p') + -- `:p` leaves a trailing slash on directories — strip for + -- comparison stability. + abs = abs:gsub('/+$', '') + local ok, resolved = pcall(vim.fn.resolve, abs) + return (ok and resolved ~= '') and resolved or abs end + + local new_canonical = canonicalize(new_cwd) + local base_canonical = canonicalize(config.base_path) + if new_canonical == base_canonical then return end + + vim.schedule(function() + -- Delay require to avoid circular dependency: core -> main -> picker_ui -> file_picker -> core + local ok, picker = pcall(require, 'fff.main') + if not ok then + vim.notify('FFF: Failed to load main module: ' .. tostring(picker), vim.log.levels.ERROR) + return + end + local change_ok, err = pcall(picker.change_indexing_directory, new_canonical) + if not change_ok then + vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR) + end + end) end, desc = 'Automatically sync FFF directory changes', })