From a51f0f4272ac93c2ae75b1b7d58f9eb5c63b4349 Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:28:44 -0700 Subject: [PATCH 1/5] fix: enforce grep time budget on zero-match searches (#826) The plain/regex deadline check was gated on `all_matches.len() > 1`, so a search that matched nothing never aborted and scanned every candidate file. Removing the gate alone is not enough: both accounting paths overwrote `files_consumed` with the full slice length whenever no file matched, which zeroed out `next_file_offset` and lost the resume cursor. Workers now latch the abort in their `map_init` state and record the lowest index they skipped, so the resume cursor is the exact first unsearched file. Matches past that index are dropped and re-found on the next page, keeping paging free of both gaps and duplicates. Closes #826 --- crates/fff-core/src/grep/fuzzy_grep.rs | 18 +-- crates/fff-core/src/grep/grep.rs | 59 ++++---- crates/fff-core/src/grep/types.rs | 22 ++- .../tests/grep_time_budget_zero_match.rs | 129 ++++++++++++++++++ 4 files changed, 190 insertions(+), 38 deletions(-) create mode 100644 crates/fff-core/tests/grep_time_budget_zero_match.rs diff --git a/crates/fff-core/src/grep/fuzzy_grep.rs b/crates/fff-core/src/grep/fuzzy_grep.rs index 6f6becbeb..f2ced19a8 100644 --- a/crates/fff-core/src/grep/fuzzy_grep.rs +++ b/crates/fff-core/src/grep/fuzzy_grep.rs @@ -3,7 +3,7 @@ use crate::types::{ContentCacheBudget, FileItem, MmapSlot}; use fff_grep::lines::LineStep; use rayon::prelude::*; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use super::sink::{ char_indices_to_byte_offsets, classify_definition, strip_line_terminators, @@ -108,6 +108,8 @@ pub(super) fn fuzzy_grep_search<'a>( }; let search_start = std::time::Instant::now(); let budget_exceeded = AtomicBool::new(false); + // Lowest index in files_to_search an abort skipped: the exact resume point. + let first_skipped = AtomicUsize::new(usize::MAX); let max_matches_per_file = options.max_matches_per_file; // for fuzzy match we need a bit smarter chunking as the amount of work we have to perform is @@ -153,15 +155,11 @@ pub(super) fn fuzzy_grep_search<'a>( ) }, |(matcher, buf, mmap_slot), (local_idx, file)| { - if abort_signal.load(Ordering::Relaxed) { - budget_exceeded.store(true, Ordering::Relaxed); - return None; - } - - if let Some(budget) = time_budget - && search_start.elapsed() > budget + if abort_signal.load(Ordering::Relaxed) + || time_budget.is_some_and(|b| search_start.elapsed() > b) { budget_exceeded.store(true, Ordering::Relaxed); + first_skipped.fetch_min(chunk_offset + local_idx, Ordering::Relaxed); return None; } @@ -353,6 +351,8 @@ pub(super) fn fuzzy_grep_search<'a>( options, total_files, filtered_file_count, - budget_exceeded.load(Ordering::Relaxed), + budget_exceeded + .load(Ordering::Relaxed) + .then(|| first_skipped.load(Ordering::Relaxed)), ) } diff --git a/crates/fff-core/src/grep/grep.rs b/crates/fff-core/src/grep/grep.rs index 5a4af83f8..0209773fc 100644 --- a/crates/fff-core/src/grep/grep.rs +++ b/crates/fff-core/src/grep/grep.rs @@ -16,7 +16,7 @@ use fff_query_parser::{FFFQuery, GrepConfig, QueryParser}; use rayon::prelude::*; use smallvec::SmallVec; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use tracing::Level; #[allow(clippy::large_enum_variant)] @@ -562,6 +562,9 @@ where let search_start = std::time::Instant::now(); let page_limit = options.page_limit; let budget_exceeded = AtomicBool::new(false); + // Lowest index in files_to_search an abort skipped. Everything below it was + // searched, so it is the exact resume point for the next page. + let first_skipped = AtomicUsize::new(usize::MAX); let mut result_files: Vec<&'a FileItem> = Vec::new(); let mut all_matches: Vec = Vec::new(); @@ -600,23 +603,19 @@ where // tested it out a few times, this is just fine for rayon worker in this specific // case it doesn't reallocate this many times and it is actually faster than using // scoped threads with a predefined local scratch buffers because of spawn cost - || (Vec::with_capacity(64 * 1024), MmapSlot::default()), - |(buf, mmap_slot), (local_idx, file)| { - // perform all the atomic machinery on every 8th - if local_idx % 8 == 0 { - let mut need_abort = ctx.abort_signal.load(Ordering::Relaxed); - if !need_abort - && let Some(budget) = time_budget - && all_matches.len() > 1 - && search_start.elapsed() > budget - { - need_abort = true; - } - - if need_abort { - budget_exceeded.store(true, Ordering::Relaxed); - return None; - } + || (Vec::with_capacity(64 * 1024), MmapSlot::default(), false), + |(buf, mmap_slot, aborted), (local_idx, file)| { + // perform all the atomic machinery on every 8th; `aborted` + // latches so the rest of this worker's range bails out for free + if !*aborted && local_idx % 8 == 0 { + *aborted = ctx.abort_signal.load(Ordering::Relaxed) + || time_budget.is_some_and(|b| search_start.elapsed() > b); + } + + if *aborted { + budget_exceeded.store(true, Ordering::Relaxed); + first_skipped.fetch_min(chunk_offset + local_idx, Ordering::Relaxed); + return None; } let content = file.get_content_for_search( @@ -648,11 +647,23 @@ where .flatten() .collect(); - // Every file in the chunk was visited by rayon (matched or not). + // Every file in the chunk was visited by rayon (matched or not), unless an + // abort cut it short — then we only consumed up to the first skipped file. + let abort_resume = budget_exceeded + .load(Ordering::Relaxed) + .then(|| first_skipped.load(Ordering::Relaxed)); files_consumed = chunk_offset + chunk.len(); + if let Some(resume_at) = abort_resume { + files_consumed = files_consumed.min(resume_at); + } // Flatten this chunk's results into the accumulator. for (batch_idx, file, file_matches) in chunk_results { + // Matches past the abort point are dropped; the cursor re-searches them. + if abort_resume.is_some_and(|resume_at| batch_idx >= resume_at) { + continue; + } + let file_result_idx = result_files.len(); result_files.push(file); @@ -678,13 +689,15 @@ where } } - // If no file had any match, we searched the entire slice. - if result_files.is_empty() { + let aborted = budget_exceeded.load(Ordering::Relaxed); + + // If no file had any match, we searched the entire slice. An abort is the + // exception: files_consumed already holds how far we actually got. + if result_files.is_empty() && !aborted { files_consumed = files_to_search.len(); } - let has_more = budget_exceeded.load(Ordering::Relaxed) - || (page_filled && files_consumed < files_to_search.len()); + let has_more = (aborted || page_filled) && files_consumed < files_to_search.len(); let next_file_offset = if has_more { options.file_offset + files_consumed diff --git a/crates/fff-core/src/grep/types.rs b/crates/fff-core/src/grep/types.rs index 08f354fa8..cef58f3a6 100644 --- a/crates/fff-core/src/grep/types.rs +++ b/crates/fff-core/src/grep/types.rs @@ -173,7 +173,9 @@ impl<'a> GrepResult<'a> { options: &GrepSearchOptions, total_files: usize, filtered_file_count: usize, - budget_exceeded: bool, + // Set when an abort stopped the search: the lowest index in files_to_search + // that was not searched, i.e. the exact resume point for the next page. + abort_resume: Option, ) -> Self { let page_limit = options.page_limit; @@ -191,6 +193,11 @@ impl<'a> GrepResult<'a> { let mut files_consumed: usize = 0; for (batch_idx, file, file_matches) in per_file_results { + // Matches past the abort point are dropped; the cursor re-searches them. + if abort_resume.is_some_and(|resume_at| batch_idx >= resume_at) { + continue; + } + // batch_idx is the 0-based position in files_to_search. // Advance files_consumed to include this file and all no-match files before it. files_consumed = batch_idx + 1; @@ -214,13 +221,16 @@ impl<'a> GrepResult<'a> { } } - // If no file had any match, we searched the entire slice. - if result_files.is_empty() { - files_consumed = files_to_search_len; + // An abort stopped us at a known index. Otherwise, zero matches means the + // whole slice was searched. + match abort_resume { + Some(resume_at) => files_consumed = resume_at.min(files_to_search_len), + None if result_files.is_empty() => files_consumed = files_to_search_len, + None => {} } - let has_more = budget_exceeded - || (all_matches.len() >= page_limit && files_consumed < files_to_search_len); + let has_more = files_consumed < files_to_search_len + && (abort_resume.is_some() || all_matches.len() >= page_limit); let next_file_offset = if has_more { options.file_offset + files_consumed diff --git a/crates/fff-core/tests/grep_time_budget_zero_match.rs b/crates/fff-core/tests/grep_time_budget_zero_match.rs new file mode 100644 index 000000000..97ee1558c --- /dev/null +++ b/crates/fff-core/tests/grep_time_budget_zero_match.rs @@ -0,0 +1,129 @@ +use std::fs; +use std::path::Path; +use tempfile::TempDir; + +use fff_search::FilePickerOptions; +use fff_search::file_picker::FilePicker; +use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; + +const FILE_COUNT: usize = 10_000; +const NEEDLE: &str = "needle-in-a-haystack"; + +/// 10k files of 4KiB filler. `needle_at` gets NEEDLE appended so paging can be +/// checked for skipped files. +fn create_picker(base: &Path, needle_at: Option) -> FilePicker { + let filler = format!("{}\n", "x".repeat(4 * 1024)); + for i in 0..FILE_COUNT { + let path = base.join(format!("file-{i}.txt")); + if needle_at == Some(i) { + fs::write(&path, format!("{filler}{NEEDLE}\n")).unwrap(); + } else { + fs::write(&path, &filler).unwrap(); + } + } + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().to_string(), + enable_mmap_cache: false, + watch: false, + ..Default::default() + }) + .expect("Failed to create FilePicker"); + picker.collect_files().expect("Failed to collect files"); + picker +} + +fn budget_opts(mode: GrepMode) -> GrepSearchOptions { + GrepSearchOptions { + max_file_size: 1024 * 1024, + max_matches_per_file: 200, + smart_case: true, + file_offset: 0, + page_limit: 500, + mode, + time_budget_ms: 5, + before_context: 0, + after_context: 0, + classify_definitions: false, + trim_whitespace: false, + abort_signal: None, + } +} + +/// A zero-match search must stop at the time budget and hand back a resume +/// cursor instead of scanning every candidate file. See issue #826. +#[test] +fn zero_match_search_stops_at_time_budget() { + let tmp = TempDir::new().unwrap(); + let picker = create_picker(tmp.path(), None); + + let parsed = parse_grep_query("zzz-absent-literal"); + let result = picker.grep(&parsed, &budget_opts(GrepMode::PlainText)); + + assert_eq!(result.matches.len(), 0, "sanity: query must not match"); + assert_eq!(result.filtered_file_count, FILE_COUNT); + assert!( + result.total_files_searched < result.filtered_file_count, + "budget expired but all {} candidate files were searched", + result.filtered_file_count + ); + assert!( + result.next_file_offset > 0, + "budget expired with no resume cursor" + ); +} + +#[test] +fn zero_match_fuzzy_search_stops_at_time_budget() { + let tmp = TempDir::new().unwrap(); + let picker = create_picker(tmp.path(), None); + + let parsed = parse_grep_query("zzzabsentfuzzy"); + let result = picker.grep(&parsed, &budget_opts(GrepMode::Fuzzy)); + + assert_eq!(result.matches.len(), 0, "sanity: query must not match"); + assert!( + result.total_files_searched < result.filtered_file_count, + "budget expired but all {} candidate files were searched", + result.filtered_file_count + ); + assert!( + result.next_file_offset > 0, + "budget expired with no resume cursor" + ); +} + +/// The resume cursor must not skip files: paging a budget-limited search to +/// exhaustion has to find a needle that lives past the first page. +#[test] +fn budget_resume_cursor_does_not_skip_files() { + let tmp = TempDir::new().unwrap(); + let picker = create_picker(tmp.path(), Some(FILE_COUNT - 1)); + + let parsed = parse_grep_query(NEEDLE); + let mut opts = budget_opts(GrepMode::PlainText); + let mut found = 0usize; + let mut pages = 0usize; + + loop { + let result = picker.grep(&parsed, &opts); + found += result.matches.len(); + pages += 1; + + assert!(pages < 5_000, "paging did not terminate"); + if result.next_file_offset == 0 { + break; + } + assert!( + result.next_file_offset > opts.file_offset, + "cursor did not advance: {} -> {}", + opts.file_offset, + result.next_file_offset + ); + opts.file_offset = result.next_file_offset; + } + + assert_eq!( + found, 1, + "resume cursor skipped the file holding the needle" + ); +} From 8bf40312cc178569d8f29e12918e674c6d8064fa Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:38:34 -0700 Subject: [PATCH 2/5] fix(grep): gate zero-match time budget behind enforce_time_budget (#826) Per review: enforcing the budget on zero-match searches unconditionally changes behaviour for existing callers, who relied on a full scan when nothing matched. Make it opt-in instead. - GrepSearchOptions.enforce_time_budget, default false. Off keeps the historical plain/regex rule (budget dormant until matches exist). - Exposed as enforceTimeBudget (node/bun), enforce_time_budget (lua config + python kwarg), and new fff_live_grep_ex / fff_multi_grep_ex C symbols. The existing C symbols keep their exact signature and forward with false. - Fuzzy grep already enforced the budget unconditionally; unchanged. - Cursor accounting fix stays unconditional: an aborted page now reports how far it actually got instead of claiming the whole slice. - Never skip file 0, so an abort cannot emit next_file_offset == 0, which callers read as "done". --- README.md | 2 + crates/fff-c/include/fff.h | 47 +++++++++- crates/fff-c/src/lib.rs | 85 +++++++++++++++++++ crates/fff-core/src/grep/fuzzy_grep.rs | 7 +- crates/fff-core/src/grep/grep.rs | 12 ++- crates/fff-core/src/grep/grep_tests.rs | 2 + crates/fff-core/src/grep/types.rs | 5 ++ .../tests/bigram_overlay_coherence_test.rs | 1 + .../tests/bigram_overlay_integration.rs | 1 + crates/fff-core/tests/fuzz_file_operations.rs | 1 + .../fff-core/tests/fuzz_git_watcher_stress.rs | 3 + crates/fff-core/tests/fuzz_real_repos.rs | 1 + crates/fff-core/tests/grep_integration.rs | 3 + .../tests/grep_time_budget_zero_match.rs | 30 +++++-- .../tests/new_directory_watcher_test.rs | 1 + .../tests/path_separator_constraint_test.rs | 1 + crates/fff-core/tests/real_binary_fixtures.rs | 1 + crates/fff-mcp/src/server.rs | 1 + crates/fff-nvim/benches/fuzzy_search_bench.rs | 1 + crates/fff-nvim/benches/grep_bench.rs | 1 + crates/fff-nvim/src/bin/bench_grep_query.rs | 1 + crates/fff-nvim/src/bin/fuzzy_grep_test.rs | 1 + crates/fff-nvim/src/bin/grep_profiler.rs | 2 + crates/fff-nvim/src/bin/grep_vs_rg.rs | 2 + crates/fff-nvim/src/lib.rs | 3 + crates/fff-python/src/finder.rs | 8 ++ lua/fff/conf.lua | 4 + lua/fff/main.lua | 2 + lua/fff/picker_ui/grep_renderer.lua | 3 +- packages/fff-bun/src/fff-api.ts | 14 +++ packages/fff-bun/src/ffi.ts | 14 ++- packages/fff-bun/src/finder.ts | 2 + packages/fff-node/src/fff-api.ts | 14 +++ packages/fff-node/src/ffi.ts | 10 ++- packages/fff-node/src/finder.ts | 2 + packages/shared/fff-api.ts | 14 +++ 36 files changed, 282 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 581b38896..aeaa6dc4d 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ local r = require('fff').content_search('TODO', { page_size = 50, file_offset = 0, time_budget_ms = 0, + enforce_time_budget = false, -- also bound zero-match searches trim_whitespace = false, cwd = nil, -- switch indexed root if different wait_for_index_ms = nil, -- override the default scan wait timeout @@ -425,6 +426,7 @@ require('fff').setup({ max_matches_per_file = 100, smart_case = true, time_budget_ms = 150, + enforce_time_budget = false, -- apply time_budget_ms even before anything matched; off = a zero-match query scans every candidate file modes = { 'plain', 'regex', 'fuzzy' }, trim_whitespace = false, enable_filename_constraint = false, -- treat filename-like tokens (e.g. `score.rs`) in a grep query as a file-path filter scoping the search; off = searched as literal text diff --git a/crates/fff-c/include/fff.h b/crates/fff-c/include/fff.h index 83df6c38c..eb40f0e14 100644 --- a/crates/fff-c/include/fff.h +++ b/crates/fff-c/include/fff.h @@ -636,6 +636,31 @@ struct FffResult *fff_live_grep(void *fff_handle, uint32_t after_context, bool classify_definitions); +/** + * [`fff_live_grep`] plus `enforce_time_budget`. + * + * When false (what [`fff_live_grep`] passes) plain/regex grep only starts + * counting `time_budget_ms` once matches exist, so a zero-match query scans + * every candidate file. When true the budget is a hard bound and + * `next_file_offset` resumes at the first unsearched file. + * + * ## Safety + * Same as [`fff_live_grep`]. + */ +struct FffResult *fff_live_grep_ex(void *fff_handle, + const char *query, + uint8_t mode, + uint64_t max_file_size, + uint32_t max_matches_per_file, + bool smart_case, + uint32_t file_offset, + uint32_t page_limit, + uint64_t time_budget_ms, + bool enforce_time_budget, + uint32_t before_context, + uint32_t after_context, + bool classify_definitions); + /** * Multi-pattern OR search (SIMD Aho-Corasick): lines matching ANY pattern. * @@ -660,6 +685,26 @@ struct FffResult *fff_multi_grep(void *fff_handle, uint32_t after_context, bool classify_definitions); +/** + * [`fff_multi_grep`] plus `enforce_time_budget`, as in [`fff_live_grep_ex`]. + * + * ## Safety + * Same as [`fff_multi_grep`]. + */ +struct FffResult *fff_multi_grep_ex(void *fff_handle, + const char *patterns_joined, + const char *constraints, + uint64_t max_file_size, + uint32_t max_matches_per_file, + bool smart_case, + uint32_t file_offset, + uint32_t page_limit, + uint64_t time_budget_ms, + bool enforce_time_budget, + uint32_t before_context, + uint32_t after_context, + bool classify_definitions); + /** * Trigger a rescan of the file index. * @@ -1321,7 +1366,7 @@ struct FffResult *fff_watch_args(void *fff_handle, struct FffResult *fff_unwatch(void *fff_handle, uint64_t watch_id); /** - * Number of events in a batch; 0 if `batch` is null. + * Number of events in a batch, 0 if `batch` is null. * * ## Safety * `batch` must be a valid `FffWatchEventBatch` pointer or null. diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index 00280ccb2..b85dc4a58 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -626,6 +626,50 @@ pub unsafe extern "C" fn fff_live_grep( before_context: u32, after_context: u32, classify_definitions: bool, +) -> *mut FffResult { + unsafe { + fff_live_grep_ex( + fff_handle, + query, + mode, + max_file_size, + max_matches_per_file, + smart_case, + file_offset, + page_limit, + time_budget_ms, + false, + before_context, + after_context, + classify_definitions, + ) + } +} + +/// [`fff_live_grep`] plus `enforce_time_budget`. +/// +/// When false (what [`fff_live_grep`] passes) plain/regex grep only starts +/// counting `time_budget_ms` once matches exist, so a zero-match query scans +/// every candidate file. When true the budget is a hard bound and +/// `next_file_offset` resumes at the first unsearched file. +/// +/// ## Safety +/// Same as [`fff_live_grep`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_live_grep_ex( + fff_handle: *mut c_void, + query: *const c_char, + mode: u8, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + file_offset: u32, + page_limit: u32, + time_budget_ms: u64, + enforce_time_budget: bool, + before_context: u32, + after_context: u32, + classify_definitions: bool, ) -> *mut FffResult { let inst = match unsafe { instance_ref(fff_handle) } { Ok(i) => i, @@ -664,6 +708,7 @@ pub unsafe extern "C" fn fff_live_grep( page_limit: default_u32(page_limit, 50) as usize, mode: grep_mode_from_u8(mode), time_budget_ms, + enforce_time_budget, before_context: before_context as usize, after_context: after_context as usize, classify_definitions, @@ -699,6 +744,45 @@ pub unsafe extern "C" fn fff_multi_grep( before_context: u32, after_context: u32, classify_definitions: bool, +) -> *mut FffResult { + unsafe { + fff_multi_grep_ex( + fff_handle, + patterns_joined, + constraints, + max_file_size, + max_matches_per_file, + smart_case, + file_offset, + page_limit, + time_budget_ms, + false, + before_context, + after_context, + classify_definitions, + ) + } +} + +/// [`fff_multi_grep`] plus `enforce_time_budget`, as in [`fff_live_grep_ex`]. +/// +/// ## Safety +/// Same as [`fff_multi_grep`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn fff_multi_grep_ex( + fff_handle: *mut c_void, + patterns_joined: *const c_char, + constraints: *const c_char, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + file_offset: u32, + page_limit: u32, + time_budget_ms: u64, + enforce_time_budget: bool, + before_context: u32, + after_context: u32, + classify_definitions: bool, ) -> *mut FffResult { let inst = match unsafe { instance_ref(fff_handle) } { Ok(i) => i, @@ -746,6 +830,7 @@ pub unsafe extern "C" fn fff_multi_grep( page_limit: default_u32(page_limit, 50) as usize, mode: fff::GrepMode::PlainText, // ignored by multi_grep_search time_budget_ms, + enforce_time_budget, before_context: before_context as usize, after_context: after_context as usize, classify_definitions, diff --git a/crates/fff-core/src/grep/fuzzy_grep.rs b/crates/fff-core/src/grep/fuzzy_grep.rs index f2ced19a8..73a9e082c 100644 --- a/crates/fff-core/src/grep/fuzzy_grep.rs +++ b/crates/fff-core/src/grep/fuzzy_grep.rs @@ -155,8 +155,11 @@ pub(super) fn fuzzy_grep_search<'a>( ) }, |(matcher, buf, mmap_slot), (local_idx, file)| { - if abort_signal.load(Ordering::Relaxed) - || time_budget.is_some_and(|b| search_start.elapsed() > b) + // File 0 is never skipped: a cursor of 0 reads as "done", so + // the page must always consume at least one file. + if chunk_offset + local_idx > 0 + && (abort_signal.load(Ordering::Relaxed) + || time_budget.is_some_and(|b| search_start.elapsed() > b)) { budget_exceeded.store(true, Ordering::Relaxed); first_skipped.fetch_min(chunk_offset + local_idx, Ordering::Relaxed); diff --git a/crates/fff-core/src/grep/grep.rs b/crates/fff-core/src/grep/grep.rs index 0209773fc..51f1f18fa 100644 --- a/crates/fff-core/src/grep/grep.rs +++ b/crates/fff-core/src/grep/grep.rs @@ -596,6 +596,10 @@ where chunk_size = (chunk_size * growth).min(max_chunk); let chunk_offset = files_consumed; + // Unless enforcement is requested the budget stays dormant until matches + // exist, so a zero-match query keeps its historical full-scan behaviour. + let budget = time_budget.filter(|_| options.enforce_time_budget || all_matches.len() > 1); + let chunk_results: Vec<(usize, &'a FileItem, Vec)> = chunk .par_iter() .enumerate() @@ -606,10 +610,12 @@ where || (Vec::with_capacity(64 * 1024), MmapSlot::default(), false), |(buf, mmap_slot, aborted), (local_idx, file)| { // perform all the atomic machinery on every 8th; `aborted` - // latches so the rest of this worker's range bails out for free - if !*aborted && local_idx % 8 == 0 { + // latches so the rest of this worker's range bails out for free. + // File 0 is never skipped: a cursor of 0 reads as "done", so + // the page must always consume at least one file. + if !*aborted && local_idx % 8 == 0 && chunk_offset + local_idx > 0 { *aborted = ctx.abort_signal.load(Ordering::Relaxed) - || time_budget.is_some_and(|b| search_start.elapsed() > b); + || budget.is_some_and(|b| search_start.elapsed() > b); } if *aborted { diff --git a/crates/fff-core/src/grep/grep_tests.rs b/crates/fff-core/src/grep/grep_tests.rs index 149bde977..a7c42da34 100644 --- a/crates/fff-core/src/grep/grep_tests.rs +++ b/crates/fff-core/src/grep/grep_tests.rs @@ -150,6 +150,7 @@ fn test_multi_grep_search() { page_limit: 100, mode: super::GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -422,6 +423,7 @@ fn test_grep_no_duplicates_with_overflow_trailing_bits() { page_limit: 100, mode: super::GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/src/grep/types.rs b/crates/fff-core/src/grep/types.rs index cef58f3a6..686507ab0 100644 --- a/crates/fff-core/src/grep/types.rs +++ b/crates/fff-core/src/grep/types.rs @@ -94,6 +94,10 @@ pub struct GrepSearchOptions { /// Maximum time in milliseconds to spend searching before returning partial /// results. Prevents UI freezes on pathological queries. 0 = no limit. pub time_budget_ms: u64, + /// Apply `time_budget_ms` even when nothing matched yet. Off by default: + /// plain/regex grep historically only started counting after the first + /// matches, so a zero-match query always scanned every candidate file. + pub enforce_time_budget: bool, /// Number of context lines to include before each match. 0 = disabled. pub before_context: usize, /// Number of context lines to include after each match. 0 = disabled. @@ -122,6 +126,7 @@ impl Default for GrepSearchOptions { page_limit: 50, mode: GrepMode::default(), time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/bigram_overlay_coherence_test.rs b/crates/fff-core/tests/bigram_overlay_coherence_test.rs index 6cee64e4f..fdf81ec76 100644 --- a/crates/fff-core/tests/bigram_overlay_coherence_test.rs +++ b/crates/fff-core/tests/bigram_overlay_coherence_test.rs @@ -1314,6 +1314,7 @@ fn grep_opts() -> GrepSearchOptions { page_limit: 500, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/bigram_overlay_integration.rs b/crates/fff-core/tests/bigram_overlay_integration.rs index c663acf8f..96948ed89 100644 --- a/crates/fff-core/tests/bigram_overlay_integration.rs +++ b/crates/fff-core/tests/bigram_overlay_integration.rs @@ -373,6 +373,7 @@ fn grep_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/fuzz_file_operations.rs b/crates/fff-core/tests/fuzz_file_operations.rs index 846849396..822b87d9e 100644 --- a/crates/fff-core/tests/fuzz_file_operations.rs +++ b/crates/fff-core/tests/fuzz_file_operations.rs @@ -620,6 +620,7 @@ fn grep_plain_opts() -> GrepSearchOptions { page_limit: 500, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/fuzz_git_watcher_stress.rs b/crates/fff-core/tests/fuzz_git_watcher_stress.rs index 8fdaff8ed..79969d59e 100644 --- a/crates/fff-core/tests/fuzz_git_watcher_stress.rs +++ b/crates/fff-core/tests/fuzz_git_watcher_stress.rs @@ -957,6 +957,7 @@ fn grep_plain_matches(shared: &SharedFilePicker, query: &str) -> Vec { page_limit: 500, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -994,6 +995,7 @@ fn grep_fuzzy_matches(shared: &SharedFilePicker, query: &str) -> Vec { page_limit: 500, mode: GrepMode::Fuzzy, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -1026,6 +1028,7 @@ fn grep_regex_matches(shared: &SharedFilePicker, query: &str) -> Vec { page_limit: 500, mode: GrepMode::Regex, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/fuzz_real_repos.rs b/crates/fff-core/tests/fuzz_real_repos.rs index b06851a36..6a349902e 100644 --- a/crates/fff-core/tests/fuzz_real_repos.rs +++ b/crates/fff-core/tests/fuzz_real_repos.rs @@ -216,6 +216,7 @@ fn grep_opts(mode: GrepMode) -> GrepSearchOptions { page_limit: 500, mode, time_budget_ms: 5000, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/grep_integration.rs b/crates/fff-core/tests/grep_integration.rs index d579370ac..ae2f2a233 100644 --- a/crates/fff-core/tests/grep_integration.rs +++ b/crates/fff-core/tests/grep_integration.rs @@ -36,6 +36,7 @@ fn plain_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -54,6 +55,7 @@ fn regex_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::Regex, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -72,6 +74,7 @@ fn fuzzy_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::Fuzzy, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/grep_time_budget_zero_match.rs b/crates/fff-core/tests/grep_time_budget_zero_match.rs index 97ee1558c..7b8974112 100644 --- a/crates/fff-core/tests/grep_time_budget_zero_match.rs +++ b/crates/fff-core/tests/grep_time_budget_zero_match.rs @@ -32,7 +32,7 @@ fn create_picker(base: &Path, needle_at: Option) -> FilePicker { picker } -fn budget_opts(mode: GrepMode) -> GrepSearchOptions { +fn budget_opts(mode: GrepMode, enforce_time_budget: bool) -> GrepSearchOptions { GrepSearchOptions { max_file_size: 1024 * 1024, max_matches_per_file: 200, @@ -41,6 +41,7 @@ fn budget_opts(mode: GrepMode) -> GrepSearchOptions { page_limit: 500, mode, time_budget_ms: 5, + enforce_time_budget, before_context: 0, after_context: 0, classify_definitions: false, @@ -49,15 +50,15 @@ fn budget_opts(mode: GrepMode) -> GrepSearchOptions { } } -/// A zero-match search must stop at the time budget and hand back a resume -/// cursor instead of scanning every candidate file. See issue #826. +/// With `enforce_time_budget` a zero-match search must stop at the budget and +/// hand back a resume cursor instead of scanning every candidate. Issue #826. #[test] -fn zero_match_search_stops_at_time_budget() { +fn zero_match_search_stops_at_enforced_time_budget() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), None); let parsed = parse_grep_query("zzz-absent-literal"); - let result = picker.grep(&parsed, &budget_opts(GrepMode::PlainText)); + let result = picker.grep(&parsed, &budget_opts(GrepMode::PlainText, true)); assert_eq!(result.matches.len(), 0, "sanity: query must not match"); assert_eq!(result.filtered_file_count, FILE_COUNT); @@ -72,13 +73,28 @@ fn zero_match_search_stops_at_time_budget() { ); } +/// Default stays as it always was: plain/regex ignores the budget until matches +/// exist, so a zero-match query still scans everything and reports no cursor. +#[test] +fn zero_match_search_ignores_unenforced_time_budget() { + let tmp = TempDir::new().unwrap(); + let picker = create_picker(tmp.path(), None); + + let parsed = parse_grep_query("zzz-absent-literal"); + let result = picker.grep(&parsed, &budget_opts(GrepMode::PlainText, false)); + + assert_eq!(result.matches.len(), 0, "sanity: query must not match"); + assert_eq!(result.total_files_searched, result.filtered_file_count); + assert_eq!(result.next_file_offset, 0); +} + #[test] fn zero_match_fuzzy_search_stops_at_time_budget() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), None); let parsed = parse_grep_query("zzzabsentfuzzy"); - let result = picker.grep(&parsed, &budget_opts(GrepMode::Fuzzy)); + let result = picker.grep(&parsed, &budget_opts(GrepMode::Fuzzy, false)); assert_eq!(result.matches.len(), 0, "sanity: query must not match"); assert!( @@ -100,7 +116,7 @@ fn budget_resume_cursor_does_not_skip_files() { let picker = create_picker(tmp.path(), Some(FILE_COUNT - 1)); let parsed = parse_grep_query(NEEDLE); - let mut opts = budget_opts(GrepMode::PlainText); + let mut opts = budget_opts(GrepMode::PlainText, true); let mut found = 0usize; let mut pages = 0usize; diff --git a/crates/fff-core/tests/new_directory_watcher_test.rs b/crates/fff-core/tests/new_directory_watcher_test.rs index e76b71b59..e22608888 100644 --- a/crates/fff-core/tests/new_directory_watcher_test.rs +++ b/crates/fff-core/tests/new_directory_watcher_test.rs @@ -138,6 +138,7 @@ fn grep_plain_count(picker: &FilePicker, query: &str) -> usize { page_limit: 500, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/path_separator_constraint_test.rs b/crates/fff-core/tests/path_separator_constraint_test.rs index bc614a158..3637e93c7 100644 --- a/crates/fff-core/tests/path_separator_constraint_test.rs +++ b/crates/fff-core/tests/path_separator_constraint_test.rs @@ -41,6 +41,7 @@ fn plain_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-core/tests/real_binary_fixtures.rs b/crates/fff-core/tests/real_binary_fixtures.rs index e4da06915..22c4c4f63 100644 --- a/crates/fff-core/tests/real_binary_fixtures.rs +++ b/crates/fff-core/tests/real_binary_fixtures.rs @@ -35,6 +35,7 @@ fn plain_opts() -> GrepSearchOptions { page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-mcp/src/server.rs b/crates/fff-mcp/src/server.rs index fed78683a..1633e7826 100644 --- a/crates/fff-mcp/src/server.rs +++ b/crates/fff-mcp/src/server.rs @@ -60,6 +60,7 @@ fn make_grep_options( page_limit: 50, mode, time_budget_ms: 0, + enforce_time_budget: false, before_context: ctx_lines, after_context: after_ctx, classify_definitions: true, diff --git a/crates/fff-nvim/benches/fuzzy_search_bench.rs b/crates/fff-nvim/benches/fuzzy_search_bench.rs index e0ccfee18..c199af085 100644 --- a/crates/fff-nvim/benches/fuzzy_search_bench.rs +++ b/crates/fff-nvim/benches/fuzzy_search_bench.rs @@ -618,6 +618,7 @@ fn bench_grep_search(c: &mut Criterion) { page_limit: 100, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/benches/grep_bench.rs b/crates/fff-nvim/benches/grep_bench.rs index 64939946d..21ff6f42f 100644 --- a/crates/fff-nvim/benches/grep_bench.rs +++ b/crates/fff-nvim/benches/grep_bench.rs @@ -115,6 +115,7 @@ fn plain_options() -> GrepSearchOptions { page_limit: 50, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/bin/bench_grep_query.rs b/crates/fff-nvim/src/bin/bench_grep_query.rs index 063d08160..9209d4b8d 100644 --- a/crates/fff-nvim/src/bin/bench_grep_query.rs +++ b/crates/fff-nvim/src/bin/bench_grep_query.rs @@ -27,6 +27,7 @@ fn run_grep(picker: &FilePicker, query: &str, iters: usize) { page_limit: usize::MAX, mode: GrepMode::PlainText, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/bin/fuzzy_grep_test.rs b/crates/fff-nvim/src/bin/fuzzy_grep_test.rs index e3bceac81..d209ecfcf 100644 --- a/crates/fff-nvim/src/bin/fuzzy_grep_test.rs +++ b/crates/fff-nvim/src/bin/fuzzy_grep_test.rs @@ -32,6 +32,7 @@ fn run_fuzzy_query(picker: &FilePicker, query: &str, label: &str) { page_limit: 100, mode: GrepMode::Fuzzy, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/bin/grep_profiler.rs b/crates/fff-nvim/src/bin/grep_profiler.rs index 4c0386b2b..0b6713a9d 100644 --- a/crates/fff-nvim/src/bin/grep_profiler.rs +++ b/crates/fff-nvim/src/bin/grep_profiler.rs @@ -93,6 +93,7 @@ impl<'a> GrepBench<'a> { page_limit: 50, mode, time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -414,6 +415,7 @@ fn main() { page_limit: 50, mode: Default::default(), time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/bin/grep_vs_rg.rs b/crates/fff-nvim/src/bin/grep_vs_rg.rs index f202fc1f9..033353025 100644 --- a/crates/fff-nvim/src/bin/grep_vs_rg.rs +++ b/crates/fff-nvim/src/bin/grep_vs_rg.rs @@ -167,6 +167,7 @@ fn run_fff_full(picker: &FilePicker, query: &str) -> (usize, Duration) { page_limit: usize::MAX, mode: Default::default(), time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, @@ -190,6 +191,7 @@ fn run_fff_page(picker: &FilePicker, query: &str) -> (usize, Duration) { page_limit: 50, mode: Default::default(), time_budget_ms: 0, + enforce_time_budget: false, before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 4ea4208af..7f9c90932 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -437,6 +437,7 @@ pub fn live_grep( grep_mode, time_budget_ms, trim_whitespace, + enforce_time_budget, ): ( String, Option, @@ -447,6 +448,7 @@ pub fn live_grep( Option, Option, Option, + Option, ), ) -> LuaResult { let file_picker_guard = FILE_PICKER.read().into_lua_result()?; @@ -469,6 +471,7 @@ pub fn live_grep( page_limit: page_size.unwrap_or(50), mode, time_budget_ms: time_budget_ms.unwrap_or(0), + enforce_time_budget: enforce_time_budget.unwrap_or(false), before_context: 0, after_context: 0, classify_definitions: false, diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index 7d7857f19..a8ecab233 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -86,6 +86,7 @@ fn grep_options( smart_case: bool, page_limit: u32, time_budget_ms: u64, + enforce_time_budget: bool, before_context: u32, after_context: u32, classify_definitions: bool, @@ -99,6 +100,7 @@ fn grep_options( page_limit: defaulted_usize(page_limit, defaults.page_limit), mode, time_budget_ms, + enforce_time_budget, before_context: before_context as usize, after_context: after_context as usize, classify_definitions, @@ -594,6 +596,7 @@ impl FileFinder { cursor=None, page_limit=0, time_budget_ms=0, + enforce_time_budget=false, before_context=0, after_context=0, classify_definitions=false, @@ -609,6 +612,7 @@ impl FileFinder { cursor: Option<&GrepCursor>, page_limit: u32, time_budget_ms: u64, + enforce_time_budget: bool, before_context: u32, after_context: u32, classify_definitions: bool, @@ -637,6 +641,7 @@ impl FileFinder { smart_case, page_limit, time_budget_ms, + enforce_time_budget, before_context, after_context, classify_definitions, @@ -658,6 +663,7 @@ impl FileFinder { cursor=None, page_limit=0, time_budget_ms=0, + enforce_time_budget=false, before_context=0, after_context=0, classify_definitions=false, @@ -674,6 +680,7 @@ impl FileFinder { cursor: Option<&GrepCursor>, page_limit: u32, time_budget_ms: u64, + enforce_time_budget: bool, before_context: u32, after_context: u32, classify_definitions: bool, @@ -708,6 +715,7 @@ impl FileFinder { smart_case, page_limit, time_budget_ms, + enforce_time_budget, before_context, after_context, classify_definitions, diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 54bfebc0e..7a1fae302 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -62,6 +62,7 @@ local M = {} --- @field max_matches_per_file number --- @field smart_case boolean --- @field time_budget_ms number +--- @field enforce_time_budget boolean --- @field modes string[] --- @field trim_whitespace boolean --- @field location_format string @@ -460,6 +461,9 @@ local function init() max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited) smart_case = true, -- Case-insensitive unless query has uppercase time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit) + -- Apply time_budget_ms even before anything matched. Off by default: plain/regex grep + -- only starts counting once matches exist, so a zero-match query scans every candidate. + enforce_time_budget = false, modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display) -- Treat filename-like tokens (e.g. `score.rs`, `src/main.rs`) in a grep query as a diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 01cf38bbf..2197dd79f 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -332,6 +332,7 @@ end --- @field page_size? number Max matches returned (default: 50). --- @field file_offset? number File-based pagination offset (default: 0). --- @field time_budget_ms? number Max wall-clock time, 0 = unlimited (default: config.grep.time_budget_ms). +--- @field enforce_time_budget? boolean Apply the budget even with zero matches (default: config.grep.enforce_time_budget). --- @field trim_whitespace? boolean Strip leading whitespace from matched lines (default: config.grep.trim_whitespace). --- @field cwd? string Switch indexed root before grepping (same semantics as `file_search`). --- @field wait_for_index_ms? number Block up to this many ms for the index to be ready. @@ -376,6 +377,7 @@ function M.content_search(query, opts) max_matches_per_file = opts.max_matches_per_file or grep_cfg.max_matches_per_file, smart_case = opts.smart_case == nil and grep_cfg.smart_case or opts.smart_case, time_budget_ms = opts.time_budget_ms or grep_cfg.time_budget_ms, + enforce_time_budget = opts.enforce_time_budget == nil and grep_cfg.enforce_time_budget or opts.enforce_time_budget, trim_whitespace = opts.trim_whitespace == nil and grep_cfg.trim_whitespace or opts.trim_whitespace, } diff --git a/lua/fff/picker_ui/grep_renderer.lua b/lua/fff/picker_ui/grep_renderer.lua index 7bebe9bee..b96088fb7 100644 --- a/lua/fff/picker_ui/grep_renderer.lua +++ b/lua/fff/picker_ui/grep_renderer.lua @@ -38,7 +38,8 @@ function M.search(query, file_offset, page_size, config, grep_mode) conf.smart_case, grep_mode or 'plain', conf.time_budget_ms, - conf.trim_whitespace + conf.trim_whitespace, + conf.enforce_time_budget ) return last_result end diff --git a/packages/fff-bun/src/fff-api.ts b/packages/fff-bun/src/fff-api.ts index 2d0038aab..02371d097 100644 --- a/packages/fff-bun/src/fff-api.ts +++ b/packages/fff-bun/src/fff-api.ts @@ -432,6 +432,13 @@ export interface GrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default: plain and + * regex grep only start counting once matches exist, so a zero-match query + * scans every candidate file. Turn on for a hard bound; `nextCursor` then + * resumes at the first unsearched file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ @@ -538,6 +545,13 @@ export interface MultiGrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default: plain and + * regex grep only start counting once matches exist, so a zero-match query + * scans every candidate file. Turn on for a hard bound; `nextCursor` then + * resumes at the first unsearched file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ diff --git a/packages/fff-bun/src/ffi.ts b/packages/fff-bun/src/ffi.ts index 0a9f3b075..4cfe65264 100644 --- a/packages/fff-bun/src/ffi.ts +++ b/packages/fff-bun/src/ffi.ts @@ -139,7 +139,7 @@ const ffiDefinition = { }, // Live grep (content search) - fff_live_grep: { + fff_live_grep_ex: { args: [ FFIType.ptr, // handle FFIType.cstring, // query @@ -150,6 +150,7 @@ const ffiDefinition = { FFIType.u32, // file_offset FFIType.u32, // page_limit FFIType.u64, // time_budget_ms + FFIType.bool, // enforce_time_budget FFIType.u32, // before_context FFIType.u32, // after_context FFIType.bool, // classify_definitions @@ -158,7 +159,7 @@ const ffiDefinition = { }, // Multi-pattern grep (Aho-Corasick) - fff_multi_grep: { + fff_multi_grep_ex: { args: [ FFIType.ptr, // handle FFIType.cstring, // patterns_joined (\n-separated) @@ -169,6 +170,7 @@ const ffiDefinition = { FFIType.u32, // file_offset FFIType.u32, // page_limit FFIType.u64, // time_budget_ms + FFIType.bool, // enforce_time_budget FFIType.u32, // before_context FFIType.u32, // after_context FFIType.bool, // classify_definitions @@ -1244,12 +1246,13 @@ export function ffiLiveGrep( fileOffset: number, pageLimit: number, timeBudgetMs: number, + enforceTimeBudget: boolean, beforeContext: number, afterContext: number, classifyDefinitions: boolean, ): Result { const library = loadLibrary(); - const resultPtr = library.symbols.fff_live_grep( + const resultPtr = library.symbols.fff_live_grep_ex( handle, ptr(encodeString(query)), grepModeToU8(mode), @@ -1259,6 +1262,7 @@ export function ffiLiveGrep( fileOffset, pageLimit, BigInt(timeBudgetMs), + enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, @@ -1279,12 +1283,13 @@ export function ffiMultiGrep( fileOffset: number, pageLimit: number, timeBudgetMs: number, + enforceTimeBudget: boolean, beforeContext: number, afterContext: number, classifyDefinitions: boolean, ): Result { const library = loadLibrary(); - const resultPtr = library.symbols.fff_multi_grep( + const resultPtr = library.symbols.fff_multi_grep_ex( handle, ptr(encodeString(patternsJoined)), ptr(encodeString(constraints)), @@ -1294,6 +1299,7 @@ export function ffiMultiGrep( fileOffset, pageLimit, BigInt(timeBudgetMs), + enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, diff --git a/packages/fff-bun/src/finder.ts b/packages/fff-bun/src/finder.ts index d8210b43f..6f5b23aea 100644 --- a/packages/fff-bun/src/finder.ts +++ b/packages/fff-bun/src/finder.ts @@ -373,6 +373,7 @@ export class FileFinder implements FileFinderApi { options?.cursor?._offset ?? 0, options?.pageSize ?? 0, options?.timeBudgetMs ?? 0, + options?.enforceTimeBudget ?? false, options?.beforeContext ?? 0, options?.afterContext ?? 0, options?.classifyDefinitions ?? false, @@ -422,6 +423,7 @@ export class FileFinder implements FileFinderApi { options.cursor?._offset ?? 0, options.pageSize ?? 0, options.timeBudgetMs ?? 0, + options.enforceTimeBudget ?? false, options.beforeContext ?? 0, options.afterContext ?? 0, options.classifyDefinitions ?? false, diff --git a/packages/fff-node/src/fff-api.ts b/packages/fff-node/src/fff-api.ts index 2d0038aab..02371d097 100644 --- a/packages/fff-node/src/fff-api.ts +++ b/packages/fff-node/src/fff-api.ts @@ -432,6 +432,13 @@ export interface GrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default: plain and + * regex grep only start counting once matches exist, so a zero-match query + * scans every candidate file. Turn on for a hard bound; `nextCursor` then + * resumes at the first unsearched file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ @@ -538,6 +545,13 @@ export interface MultiGrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default: plain and + * regex grep only start counting once matches exist, so a zero-match query + * scans every candidate file. Turn on for a hard bound; `nextCursor` then + * resumes at the first unsearched file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ diff --git a/packages/fff-node/src/ffi.ts b/packages/fff-node/src/ffi.ts index 61080f421..ccfee81dd 100644 --- a/packages/fff-node/src/ffi.ts +++ b/packages/fff-node/src/ffi.ts @@ -1365,6 +1365,7 @@ export function ffiLiveGrep( fileOffset: number, pageLimit: number, timeBudgetMs: number, + enforceTimeBudget: boolean, beforeContext: number, afterContext: number, classifyDefinitions: boolean, @@ -1373,7 +1374,7 @@ export function ffiLiveGrep( const rawPtr = load({ library: LIBRARY_KEY, - funcName: "fff_live_grep", + funcName: "fff_live_grep_ex", retType: DataType.External, paramsType: [ DataType.External, // handle @@ -1385,6 +1386,7 @@ export function ffiLiveGrep( DataType.U32, // file_offset DataType.U32, // page_limit DataType.U64, // time_budget_ms + DataType.Boolean, // enforce_time_budget DataType.U32, // before_context DataType.U32, // after_context DataType.Boolean, // classify_definitions @@ -1399,6 +1401,7 @@ export function ffiLiveGrep( fileOffset, pageLimit, timeBudgetMs, + enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, @@ -1422,6 +1425,7 @@ export function ffiMultiGrep( fileOffset: number, pageLimit: number, timeBudgetMs: number, + enforceTimeBudget: boolean, beforeContext: number, afterContext: number, classifyDefinitions: boolean, @@ -1430,7 +1434,7 @@ export function ffiMultiGrep( const rawPtr = load({ library: LIBRARY_KEY, - funcName: "fff_multi_grep", + funcName: "fff_multi_grep_ex", retType: DataType.External, paramsType: [ DataType.External, // handle @@ -1442,6 +1446,7 @@ export function ffiMultiGrep( DataType.U32, // file_offset DataType.U32, // page_limit DataType.U64, // time_budget_ms + DataType.Boolean, // enforce_time_budget DataType.U32, // before_context DataType.U32, // after_context DataType.Boolean, // classify_definitions @@ -1456,6 +1461,7 @@ export function ffiMultiGrep( fileOffset, pageLimit, timeBudgetMs, + enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, diff --git a/packages/fff-node/src/finder.ts b/packages/fff-node/src/finder.ts index e80b4ef66..d49baf363 100644 --- a/packages/fff-node/src/finder.ts +++ b/packages/fff-node/src/finder.ts @@ -379,6 +379,7 @@ export class FileFinder implements FileFinderApi { options?.cursor?._offset ?? 0, options?.pageSize ?? 0, options?.timeBudgetMs ?? 0, + options?.enforceTimeBudget ?? false, options?.beforeContext ?? 0, options?.afterContext ?? 0, options?.classifyDefinitions ?? false, @@ -428,6 +429,7 @@ export class FileFinder implements FileFinderApi { options.cursor?._offset ?? 0, options.pageSize ?? 0, options.timeBudgetMs ?? 0, + options.enforceTimeBudget ?? false, options.beforeContext ?? 0, options.afterContext ?? 0, options.classifyDefinitions ?? false, diff --git a/packages/shared/fff-api.ts b/packages/shared/fff-api.ts index dcd9f2ac2..d451164c4 100644 --- a/packages/shared/fff-api.ts +++ b/packages/shared/fff-api.ts @@ -426,6 +426,13 @@ export interface GrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default: plain and + * regex grep only start counting once matches exist, so a zero-match query + * scans every candidate file. Turn on for a hard bound; `nextCursor` then + * resumes at the first unsearched file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ @@ -532,6 +539,13 @@ export interface MultiGrepOptions { * partial results. 0 = unlimited. (default: 0) */ timeBudgetMs?: number; + /** + * Apply `timeBudgetMs` even before anything matched. Off by default: plain and + * regex grep only start counting once matches exist, so a zero-match query + * scans every candidate file. Turn on for a hard bound; `nextCursor` then + * resumes at the first unsearched file. (default: false) + */ + enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ beforeContext?: number; /** Number of context lines to include after each match (default: 0) */ From be0c61c2d52d1359bffdf950cbb1584b87dc6516 Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:52:56 -0700 Subject: [PATCH 3/5] test(grep): move zero-match budget helpers to end of file Repo style: private helpers live at the bottom, plain comments not doc comments. --- .../tests/grep_time_budget_zero_match.rs | 94 +++++++++---------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/crates/fff-core/tests/grep_time_budget_zero_match.rs b/crates/fff-core/tests/grep_time_budget_zero_match.rs index 7b8974112..d3fe0a177 100644 --- a/crates/fff-core/tests/grep_time_budget_zero_match.rs +++ b/crates/fff-core/tests/grep_time_budget_zero_match.rs @@ -9,49 +9,8 @@ use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; const FILE_COUNT: usize = 10_000; const NEEDLE: &str = "needle-in-a-haystack"; -/// 10k files of 4KiB filler. `needle_at` gets NEEDLE appended so paging can be -/// checked for skipped files. -fn create_picker(base: &Path, needle_at: Option) -> FilePicker { - let filler = format!("{}\n", "x".repeat(4 * 1024)); - for i in 0..FILE_COUNT { - let path = base.join(format!("file-{i}.txt")); - if needle_at == Some(i) { - fs::write(&path, format!("{filler}{NEEDLE}\n")).unwrap(); - } else { - fs::write(&path, &filler).unwrap(); - } - } - let mut picker = FilePicker::new(FilePickerOptions { - base_path: base.to_string_lossy().to_string(), - enable_mmap_cache: false, - watch: false, - ..Default::default() - }) - .expect("Failed to create FilePicker"); - picker.collect_files().expect("Failed to collect files"); - picker -} - -fn budget_opts(mode: GrepMode, enforce_time_budget: bool) -> GrepSearchOptions { - GrepSearchOptions { - max_file_size: 1024 * 1024, - max_matches_per_file: 200, - smart_case: true, - file_offset: 0, - page_limit: 500, - mode, - time_budget_ms: 5, - enforce_time_budget, - before_context: 0, - after_context: 0, - classify_definitions: false, - trim_whitespace: false, - abort_signal: None, - } -} - -/// With `enforce_time_budget` a zero-match search must stop at the budget and -/// hand back a resume cursor instead of scanning every candidate. Issue #826. +// With `enforce_time_budget` a zero-match search must stop at the budget and +// hand back a resume cursor instead of scanning every candidate. Issue #826. #[test] fn zero_match_search_stops_at_enforced_time_budget() { let tmp = TempDir::new().unwrap(); @@ -73,8 +32,8 @@ fn zero_match_search_stops_at_enforced_time_budget() { ); } -/// Default stays as it always was: plain/regex ignores the budget until matches -/// exist, so a zero-match query still scans everything and reports no cursor. +// Default stays as it always was: plain/regex ignores the budget until matches +// exist, so a zero-match query still scans everything and reports no cursor. #[test] fn zero_match_search_ignores_unenforced_time_budget() { let tmp = TempDir::new().unwrap(); @@ -108,8 +67,8 @@ fn zero_match_fuzzy_search_stops_at_time_budget() { ); } -/// The resume cursor must not skip files: paging a budget-limited search to -/// exhaustion has to find a needle that lives past the first page. +// The resume cursor must not skip files: paging a budget-limited search to +// exhaustion has to find a needle that lives past the first page. #[test] fn budget_resume_cursor_does_not_skip_files() { let tmp = TempDir::new().unwrap(); @@ -143,3 +102,44 @@ fn budget_resume_cursor_does_not_skip_files() { "resume cursor skipped the file holding the needle" ); } + +// 10k files of 4KiB filler. `needle_at` gets NEEDLE appended so paging can be +// checked for skipped files. +fn create_picker(base: &Path, needle_at: Option) -> FilePicker { + let filler = format!("{}\n", "x".repeat(4 * 1024)); + for i in 0..FILE_COUNT { + let path = base.join(format!("file-{i}.txt")); + if needle_at == Some(i) { + fs::write(&path, format!("{filler}{NEEDLE}\n")).unwrap(); + } else { + fs::write(&path, &filler).unwrap(); + } + } + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().to_string(), + enable_mmap_cache: false, + watch: false, + ..Default::default() + }) + .expect("Failed to create FilePicker"); + picker.collect_files().expect("Failed to collect files"); + picker +} + +fn budget_opts(mode: GrepMode, enforce_time_budget: bool) -> GrepSearchOptions { + GrepSearchOptions { + max_file_size: 1024 * 1024, + max_matches_per_file: 200, + smart_case: true, + file_offset: 0, + page_limit: 500, + mode, + time_budget_ms: 5, + enforce_time_budget, + before_context: 0, + after_context: 0, + classify_definitions: false, + trim_whitespace: false, + abort_signal: None, + } +} From 7fb5848fbaa76e9eb70d38cb794ecb68bc11bbd5 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 1 Sep 2026 21:52:35 -0700 Subject: [PATCH 4/5] fix(python): declare enforce_time_budget in grep type stubs --- packages/fff-python/src/fff/__init__.pyi | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi index 44cee2f1d..4b841f4d9 100644 --- a/packages/fff-python/src/fff/__init__.pyi +++ b/packages/fff-python/src/fff/__init__.pyi @@ -241,6 +241,7 @@ class FileFinder: cursor: GrepCursor | None = None, page_limit: int = 0, time_budget_ms: int = 0, + enforce_time_budget: bool = False, before_context: int = 0, after_context: int = 0, classify_definitions: bool = False, @@ -257,6 +258,7 @@ class FileFinder: cursor: GrepCursor | None = None, page_limit: int = 0, time_budget_ms: int = 0, + enforce_time_budget: bool = False, before_context: int = 0, after_context: int = 0, classify_definitions: bool = False, From cab93bbbb5423c8a511152321c7ac6ae0b4d6b59 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 1 Sep 2026 22:03:05 -0700 Subject: [PATCH 5/5] refactor(grep): drop redundant budget_exceeded flag and tighten comments --- README.md | 4 +-- crates/fff-c/include/fff.h | 8 ++--- crates/fff-c/src/lib.rs | 8 ++--- crates/fff-core/src/grep/fuzzy_grep.rs | 22 ++++++------ crates/fff-core/src/grep/grep.rs | 50 ++++++++++---------------- crates/fff-core/src/grep/types.rs | 15 ++++---- lua/fff/conf.lua | 4 +-- packages/fff-bun/src/fff-api.ts | 12 +++---- packages/fff-node/src/fff-api.ts | 12 +++---- packages/shared/fff-api.ts | 12 +++---- 10 files changed, 54 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 0e9d2b178..60a2e969c 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ local r = require('fff').content_search('TODO', { page_size = 50, file_offset = 0, time_budget_ms = 0, - enforce_time_budget = false, -- also bound zero-match searches + enforce_time_budget = false, -- also bound zero-match searches trim_whitespace = false, cwd = nil, -- switch indexed root if different wait_for_index_ms = nil, -- override the default scan wait timeout @@ -432,7 +432,7 @@ require('fff').setup({ max_matches_per_file = 100, smart_case = true, time_budget_ms = 150, - enforce_time_budget = false, -- apply time_budget_ms even before anything matched; off = a zero-match query scans every candidate file + enforce_time_budget = false, -- apply time_budget_ms even before anything matched (off = zero-match queries scan everything) modes = { 'plain', 'regex', 'fuzzy' }, trim_whitespace = false, enable_filename_constraint = false, -- treat filename-like tokens (e.g. `score.rs`) in a grep query as a file-path filter scoping the search; off = searched as literal text diff --git a/crates/fff-c/include/fff.h b/crates/fff-c/include/fff.h index eb40f0e14..10b295d25 100644 --- a/crates/fff-c/include/fff.h +++ b/crates/fff-c/include/fff.h @@ -637,12 +637,8 @@ struct FffResult *fff_live_grep(void *fff_handle, bool classify_definitions); /** - * [`fff_live_grep`] plus `enforce_time_budget`. - * - * When false (what [`fff_live_grep`] passes) plain/regex grep only starts - * counting `time_budget_ms` once matches exist, so a zero-match query scans - * every candidate file. When true the budget is a hard bound and - * `next_file_offset` resumes at the first unsearched file. + * [`fff_live_grep`] plus `enforce_time_budget`: when true the budget also bounds + * zero-match searches and `next_file_offset` resumes at the first unsearched file. * * ## Safety * Same as [`fff_live_grep`]. diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index b85dc4a58..265b3492d 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -646,12 +646,8 @@ pub unsafe extern "C" fn fff_live_grep( } } -/// [`fff_live_grep`] plus `enforce_time_budget`. -/// -/// When false (what [`fff_live_grep`] passes) plain/regex grep only starts -/// counting `time_budget_ms` once matches exist, so a zero-match query scans -/// every candidate file. When true the budget is a hard bound and -/// `next_file_offset` resumes at the first unsearched file. +/// [`fff_live_grep`] plus `enforce_time_budget`: when true the budget also bounds +/// zero-match searches and `next_file_offset` resumes at the first unsearched file. /// /// ## Safety /// Same as [`fff_live_grep`]. diff --git a/crates/fff-core/src/grep/fuzzy_grep.rs b/crates/fff-core/src/grep/fuzzy_grep.rs index 73a9e082c..fb4143b43 100644 --- a/crates/fff-core/src/grep/fuzzy_grep.rs +++ b/crates/fff-core/src/grep/fuzzy_grep.rs @@ -107,8 +107,7 @@ pub(super) fn fuzzy_grep_search<'a>( None }; let search_start = std::time::Instant::now(); - let budget_exceeded = AtomicBool::new(false); - // Lowest index in files_to_search an abort skipped: the exact resume point. + // Lowest index an abort skipped, i.e. the resume point; usize::MAX means no abort. let first_skipped = AtomicUsize::new(usize::MAX); let max_matches_per_file = options.max_matches_per_file; @@ -155,14 +154,14 @@ pub(super) fn fuzzy_grep_search<'a>( ) }, |(matcher, buf, mmap_slot), (local_idx, file)| { - // File 0 is never skipped: a cursor of 0 reads as "done", so - // the page must always consume at least one file. - if chunk_offset + local_idx > 0 + // File 0 is never skipped so a page always consumes at least one + // file (cursor 0 means "done"). + let idx = chunk_offset + local_idx; + if idx > 0 && (abort_signal.load(Ordering::Relaxed) || time_budget.is_some_and(|b| search_start.elapsed() > b)) { - budget_exceeded.store(true, Ordering::Relaxed); - first_skipped.fetch_min(chunk_offset + local_idx, Ordering::Relaxed); + first_skipped.fetch_min(idx, Ordering::Relaxed); return None; } @@ -332,7 +331,7 @@ pub(super) fn fuzzy_grep_search<'a>( return None; } - Some((chunk_offset + local_idx, *file, file_matches)) + Some((idx, *file, file_matches)) }, ) .flatten() @@ -343,19 +342,18 @@ pub(super) fn fuzzy_grep_search<'a>( per_file_results.push(result); } - if running_matches >= page_limit || budget_exceeded.load(Ordering::Relaxed) { + if running_matches >= page_limit || first_skipped.load(Ordering::Relaxed) != usize::MAX { break; } } + let abort_resume = Some(first_skipped.load(Ordering::Relaxed)).filter(|&i| i != usize::MAX); GrepResult::collect( per_file_results, files_to_search.len(), options, total_files, filtered_file_count, - budget_exceeded - .load(Ordering::Relaxed) - .then(|| first_skipped.load(Ordering::Relaxed)), + abort_resume, ) } diff --git a/crates/fff-core/src/grep/grep.rs b/crates/fff-core/src/grep/grep.rs index 51f1f18fa..20fb8fa28 100644 --- a/crates/fff-core/src/grep/grep.rs +++ b/crates/fff-core/src/grep/grep.rs @@ -561,15 +561,15 @@ where let search_start = std::time::Instant::now(); let page_limit = options.page_limit; - let budget_exceeded = AtomicBool::new(false); - // Lowest index in files_to_search an abort skipped. Everything below it was - // searched, so it is the exact resume point for the next page. + // Lowest index an abort skipped; everything below it was searched, so it is + // the resume point for the next page. usize::MAX means no abort happened. let first_skipped = AtomicUsize::new(usize::MAX); let mut result_files: Vec<&'a FileItem> = Vec::new(); let mut all_matches: Vec = Vec::new(); let mut files_consumed: usize = 0; let mut page_filled = false; + let mut aborted = false; // Each chunk is a rayon barrier. A flat small chunk over 500k files = ~7800 // barriers; x2 growth makes it logarithmic. But a too-aggressive growth @@ -596,8 +596,7 @@ where chunk_size = (chunk_size * growth).min(max_chunk); let chunk_offset = files_consumed; - // Unless enforcement is requested the budget stays dormant until matches - // exist, so a zero-match query keeps its historical full-scan behaviour. + // Unless enforced, the budget stays dormant until something matched. let budget = time_budget.filter(|_| options.enforce_time_budget || all_matches.len() > 1); let chunk_results: Vec<(usize, &'a FileItem, Vec)> = chunk @@ -609,18 +608,16 @@ where // scoped threads with a predefined local scratch buffers because of spawn cost || (Vec::with_capacity(64 * 1024), MmapSlot::default(), false), |(buf, mmap_slot, aborted), (local_idx, file)| { - // perform all the atomic machinery on every 8th; `aborted` - // latches so the rest of this worker's range bails out for free. - // File 0 is never skipped: a cursor of 0 reads as "done", so - // the page must always consume at least one file. - if !*aborted && local_idx % 8 == 0 && chunk_offset + local_idx > 0 { + // check the clock on every 8th file only; `aborted` latches for the + // rest of this worker's range. File 0 is never skipped so a page + // always consumes at least one file (cursor 0 means "done"). + let idx = chunk_offset + local_idx; + if !*aborted && local_idx % 8 == 0 && idx > 0 { *aborted = ctx.abort_signal.load(Ordering::Relaxed) || budget.is_some_and(|b| search_start.elapsed() > b); } - if *aborted { - budget_exceeded.store(true, Ordering::Relaxed); - first_skipped.fetch_min(chunk_offset + local_idx, Ordering::Relaxed); + first_skipped.fetch_min(idx, Ordering::Relaxed); return None; } @@ -647,26 +644,20 @@ where return None; } - Some((chunk_offset + local_idx, *file, file_matches)) + Some((idx, *file, file_matches)) }, ) .flatten() .collect(); - // Every file in the chunk was visited by rayon (matched or not), unless an - // abort cut it short — then we only consumed up to the first skipped file. - let abort_resume = budget_exceeded - .load(Ordering::Relaxed) - .then(|| first_skipped.load(Ordering::Relaxed)); - files_consumed = chunk_offset + chunk.len(); - if let Some(resume_at) = abort_resume { - files_consumed = files_consumed.min(resume_at); - } + // Every file in the chunk was visited unless an abort cut it short. + let resume_at = first_skipped.load(Ordering::Relaxed); + aborted = resume_at != usize::MAX; + files_consumed = resume_at.min(chunk_offset + chunk.len()); - // Flatten this chunk's results into the accumulator. for (batch_idx, file, file_matches) in chunk_results { - // Matches past the abort point are dropped; the cursor re-searches them. - if abort_resume.is_some_and(|resume_at| batch_idx >= resume_at) { + // Matches past the abort point are dropped; the next page re-searches them. + if batch_idx >= resume_at { continue; } @@ -690,15 +681,12 @@ where } } - if page_filled || budget_exceeded.load(Ordering::Relaxed) { + if page_filled || aborted { break; } } - let aborted = budget_exceeded.load(Ordering::Relaxed); - - // If no file had any match, we searched the entire slice. An abort is the - // exception: files_consumed already holds how far we actually got. + // No match and no abort means the whole slice was searched. if result_files.is_empty() && !aborted { files_consumed = files_to_search.len(); } diff --git a/crates/fff-core/src/grep/types.rs b/crates/fff-core/src/grep/types.rs index 686507ab0..7c3888564 100644 --- a/crates/fff-core/src/grep/types.rs +++ b/crates/fff-core/src/grep/types.rs @@ -94,9 +94,8 @@ pub struct GrepSearchOptions { /// Maximum time in milliseconds to spend searching before returning partial /// results. Prevents UI freezes on pathological queries. 0 = no limit. pub time_budget_ms: u64, - /// Apply `time_budget_ms` even when nothing matched yet. Off by default: - /// plain/regex grep historically only started counting after the first - /// matches, so a zero-match query always scanned every candidate file. + /// Apply `time_budget_ms` even before anything matched. Off by default: plain/regex + /// grep only starts counting once matches exist, so a zero-match query scans everything. pub enforce_time_budget: bool, /// Number of context lines to include before each match. 0 = disabled. pub before_context: usize, @@ -178,8 +177,7 @@ impl<'a> GrepResult<'a> { options: &GrepSearchOptions, total_files: usize, filtered_file_count: usize, - // Set when an abort stopped the search: the lowest index in files_to_search - // that was not searched, i.e. the exact resume point for the next page. + // Lowest unsearched index when an abort stopped the search. abort_resume: Option, ) -> Self { let page_limit = options.page_limit; @@ -198,8 +196,8 @@ impl<'a> GrepResult<'a> { let mut files_consumed: usize = 0; for (batch_idx, file, file_matches) in per_file_results { - // Matches past the abort point are dropped; the cursor re-searches them. - if abort_resume.is_some_and(|resume_at| batch_idx >= resume_at) { + // Matches past the abort point are dropped; the next page re-searches them. + if abort_resume.is_some_and(|at| batch_idx >= at) { continue; } @@ -226,8 +224,7 @@ impl<'a> GrepResult<'a> { } } - // An abort stopped us at a known index. Otherwise, zero matches means the - // whole slice was searched. + // Abort stops at a known index; otherwise zero matches means a full scan. match abort_resume { Some(resume_at) => files_consumed = resume_at.min(files_to_search_len), None if result_files.is_empty() => files_consumed = files_to_search_len, diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index fc80dc715..7231e7c2e 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -464,9 +464,7 @@ local function init() max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited) smart_case = true, -- Case-insensitive unless query has uppercase time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit) - -- Apply time_budget_ms even before anything matched. Off by default: plain/regex grep - -- only starts counting once matches exist, so a zero-match query scans every candidate. - enforce_time_budget = false, + enforce_time_budget = false, -- Apply time_budget_ms even before anything matched (off = zero-match queries scan everything) modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display) -- Treat filename-like tokens (e.g. `score.rs`, `src/main.rs`) in a grep query as a diff --git a/packages/fff-bun/src/fff-api.ts b/packages/fff-bun/src/fff-api.ts index 02371d097..4573b8d22 100644 --- a/packages/fff-bun/src/fff-api.ts +++ b/packages/fff-bun/src/fff-api.ts @@ -433,10 +433,8 @@ export interface GrepOptions { */ timeBudgetMs?: number; /** - * Apply `timeBudgetMs` even before anything matched. Off by default: plain and - * regex grep only start counting once matches exist, so a zero-match query - * scans every candidate file. Turn on for a hard bound; `nextCursor` then - * resumes at the first unsearched file. (default: false) + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) */ enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ @@ -546,10 +544,8 @@ export interface MultiGrepOptions { */ timeBudgetMs?: number; /** - * Apply `timeBudgetMs` even before anything matched. Off by default: plain and - * regex grep only start counting once matches exist, so a zero-match query - * scans every candidate file. Turn on for a hard bound; `nextCursor` then - * resumes at the first unsearched file. (default: false) + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) */ enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ diff --git a/packages/fff-node/src/fff-api.ts b/packages/fff-node/src/fff-api.ts index 02371d097..4573b8d22 100644 --- a/packages/fff-node/src/fff-api.ts +++ b/packages/fff-node/src/fff-api.ts @@ -433,10 +433,8 @@ export interface GrepOptions { */ timeBudgetMs?: number; /** - * Apply `timeBudgetMs` even before anything matched. Off by default: plain and - * regex grep only start counting once matches exist, so a zero-match query - * scans every candidate file. Turn on for a hard bound; `nextCursor` then - * resumes at the first unsearched file. (default: false) + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) */ enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ @@ -546,10 +544,8 @@ export interface MultiGrepOptions { */ timeBudgetMs?: number; /** - * Apply `timeBudgetMs` even before anything matched. Off by default: plain and - * regex grep only start counting once matches exist, so a zero-match query - * scans every candidate file. Turn on for a hard bound; `nextCursor` then - * resumes at the first unsearched file. (default: false) + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) */ enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ diff --git a/packages/shared/fff-api.ts b/packages/shared/fff-api.ts index d451164c4..4331ca571 100644 --- a/packages/shared/fff-api.ts +++ b/packages/shared/fff-api.ts @@ -427,10 +427,8 @@ export interface GrepOptions { */ timeBudgetMs?: number; /** - * Apply `timeBudgetMs` even before anything matched. Off by default: plain and - * regex grep only start counting once matches exist, so a zero-match query - * scans every candidate file. Turn on for a hard bound; `nextCursor` then - * resumes at the first unsearched file. (default: false) + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) */ enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */ @@ -540,10 +538,8 @@ export interface MultiGrepOptions { */ timeBudgetMs?: number; /** - * Apply `timeBudgetMs` even before anything matched. Off by default: plain and - * regex grep only start counting once matches exist, so a zero-match query - * scans every candidate file. Turn on for a hard bound; `nextCursor` then - * resumes at the first unsearched file. (default: false) + * Apply `timeBudgetMs` even before anything matched. Off by default, so a + * zero-match query scans every candidate file. (default: false) */ enforceTimeBudget?: boolean; /** Number of context lines to include before each match (default: 0) */