Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions crates/fff-core/src/case_insensitive_memmem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,55 @@ mod tests {
assert!(!search_packed_pair(b"", b"x"));
}

#[cfg(unix)]
#[test]
fn guard_page_no_overread() {
// Place each haystack so it ENDS exactly at a PROT_NONE guard page; any
// SIMD load reading >=1 byte past the haystack end faults. This is the
// necessary condition for the production mmap'd-file SIGBUS.
unsafe {
let page = libc::sysconf(libc::_SC_PAGESIZE) as usize;
let total = page * 2;
let base = libc::mmap(
std::ptr::null_mut(),
total,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_ANON | libc::MAP_PRIVATE,
-1,
0,
) as *mut u8;
assert_ne!(base as isize, -1, "mmap failed");
assert_eq!(
libc::mprotect(base.add(page) as *mut _, page, libc::PROT_NONE),
0,
"mprotect failed"
);
let filler = b"the quick brown fox jumps over a lazy dog while searching files";
for i in 0..page {
*base.add(i) = filler[i % filler.len()];
}
for hlen in 1..=600usize {
let start = base.add(page - hlen);
let hay = std::slice::from_raw_parts(start, hlen);
let maxn = core::cmp::min(48, hlen);
for nlen in 2..=maxn {
// (a) needle == haystack tail → match at the very end, so
// verify runs at candidate==last_start near the boundary.
let mut needle = vec![0u8; nlen];
for k in 0..nlen {
needle[k] = ascii_fold_byte(*start.add(hlen - nlen + k));
}
let _ = search_packed_pair(hay, &needle);
// (b) no-match needle → SIMD scans the whole haystack.
let mut nope = needle.clone();
nope[nlen - 1] = b'~';
let _ = search_packed_pair(hay, &nope);
}
}
libc::munmap(base as *mut _, total);
}
}

#[test]
fn packed_pair_matches_search() {
let haystacks: &[&[u8]] = &[
Expand Down
42 changes: 17 additions & 25 deletions crates/fff-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::sync::OnceLock;
use std::sync::atomic::{AtomicI32, AtomicU8, AtomicU64, AtomicUsize, Ordering};

#[cfg(not(target_os = "windows"))]
use crate::constants::{FRESH_MMAP_THRESHOLD, MMAP_THRESHOLD};
use crate::constants::MMAP_THRESHOLD;
use crate::constants::{MAX_CACHED_CONTENT_BYTES, MAX_FFFILE_SIZE, PATH_BUF_SIZE};
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
Expand Down Expand Up @@ -702,38 +702,30 @@ impl FileItem {
base_path: &Path,
budget: &ContentCacheBudget,
) -> Option<&'a [u8]> {
#[cfg(not(target_os = "windows"))]
{
// Fast path: persistent cache hit (zero-copy). Safe here because
// grep callers hold the picker read lock for the lifetime of the
// returned slice — see [`Self::get_cached_content`] safety note.
if let Some(cached) = self.get_cached_content(arena, base_path, budget) {
return Some(cached);
}
}

let max_file_size = budget.max_file_size;
if self.is_binary() || self.size == 0 || self.size > max_file_size {
return None;
}

let abs = self.absolute_path(arena, base_path);

#[cfg(not(target_os = "windows"))]
if self.size >= FRESH_MMAP_THRESHOLD {
let file = std::fs::File::open(&abs).ok()?;
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
let stored = mmap_slot.insert(mmap);
return Some(&stored[..]);
} else {
let _ = (mmap_slot, arena);
// Read the file into an owned, reusable buffer rather than mmap it.
//
// Read into a reusable owned buffer, not mmap: an mmap'd file that is
// truncated/rewritten mid-grep (editor or agent edits) faults with
// SIGBUS on the pages past its new EOF. `buf` is per-worker, reused.
let _ = mmap_slot;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is essentially making the whole mmap absolutely useless - I guess you should simply disable cache at this point and we should surface it as potentially unsafe as there is indeed a race window when truncating file then reading it right after might result in the crash

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, okay let me change this. Thanks for the review!

let file = std::fs::File::open(&abs).ok()?;
buf.clear();
// Cap the read at the budget: `self.size` is the stale indexed size, so
// a file that GREW past `max_file_size` since indexing must not balloon
// memory. Read one byte over the cap and bail if it exceeds it.
file.take(max_file_size.saturating_add(1))
.read_to_end(buf)
.ok()?;
if buf.len() as u64 > max_file_size {
return None;
}

let len = self.size as usize;
buf.resize(len, 0);

let mut file = std::fs::File::open(&abs).ok()?;
file.read_exact(buf).ok()?;
Some(buf.as_slice())
}
}
Expand Down
46 changes: 46 additions & 0 deletions crates/fff-core/tests/grep_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1835,3 +1835,49 @@ fn plain_text_smart_case_finds_uppercase_content_with_lowercase_query() {
"lowercase query should case-insensitively match 'VFIO-KVM'"
);
}

/// Regression: grep must read the CURRENT file content even when the file was
/// rewritten after indexing.
///
/// Grep used to read file content through an mmap — both a per-`FileItem`
/// `OnceLock<Mmap>` cache and a fresh mmap for large files. When an editor or
/// coding agent rewrites a file between greps (the common case for fff's nvim
/// picker / MCP agent), reading it back through the stale/now-shorter mapping
/// either returns stale matches or, once the file shrinks past a page boundary,
/// faults with SIGBUS and aborts the whole host process. Reading grep content
/// into an owned buffer fixes both: a changed file simply yields its new bytes.
#[test]
fn grep_reflects_file_rewrite_without_stale_mmap() {
let tmp = TempDir::new().unwrap();
let full = tmp.path().join("big.txt");

// Large enough to take the mmap path (> FRESH_MMAP_THRESHOLD), needle first.
let filler = "lorem ipsum dolor sit amet consectetur\n".repeat(60_000); // ~2.3 MB
fs::write(&full, format!("NEEDLE_TOKEN\n{filler}")).unwrap();

let mut picker = FilePicker::new(FilePickerOptions {
base_path: tmp.path().to_string_lossy().to_string(),
enable_mmap_cache: true, // exercise the cached-mmap path
watch: false,
..Default::default()
})
.expect("Failed to create FilePicker");
picker.collect_files().expect("Failed to collect files");

let q = parse_grep_query("NEEDLE_TOKEN");
// First grep matches (and, on the old code, caches an mmap of the file).
assert_eq!(picker.grep(&q, &plain_opts()).matches.len(), 1);

// The file is rewritten much SHORTER with the needle removed — exactly what
// an editor / coding agent does mid-session. No re-index (`collect_files`).
fs::write(&full, "tiny replacement, no token here\n").unwrap();

// The second grep must reflect the NEW content: zero matches. The old reader
// returned the stale cached match (== 1) or SIGBUS-aborted on the shrunk
// mapping; both fail this assertion.
assert_eq!(
picker.grep(&q, &plain_opts()).matches.len(),
0,
"grep returned stale content from a cached mmap after the file changed"
);
}