Fix: windows mmap and search correctness - #63
Conversation
Bug #1 next_byte_after_trigram used raw regex pattern instead of parsed literal: Introduced TrigramQuery struct (inspired by PythonicNinja/trigrep) that stores expected_next byte computed at plan-build time from HIR-extracted literals. Removed the pattern parameter from execute_plan_with_masks entirely. This fixes false negatives for patterns with regex escaping (e.g. \[TestMethod\]). Bug #2 reader swap race condition during flush: Snapshot Arc<IndexReader> once at the start of execute_query/execute_query_with_masks and pass it through to all trigram lookups via new helper methods. Prevents intersecting posting lists from different reader versions when a concurrent flush swaps the reader between trigram lookups. Design fix write_index_from_snapshot loses mask data: Changed full_snapshot() and write_index_from_snapshot to carry PostingEntry (with loc_mask/next_mask) instead of bare u32 file IDs. Added all_trigram_postings_with_masks() to IndexReader. Bloom-filter optimizations now survive flush/auto-save cycles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After move_staged_files renames index files into place, NTFS metadata can transiently report zero-length for the freshly-renamed lookup.bin. IndexReader::open then creates a reader with files but no trigrams (lookup=None, num_entries=0), and the flush code swaps it in and prunes the overlay leaving the server unable to answer any query until an external event (auto-save, stale check) re-opens the reader. Changes: - reader.rs: Use file-handle metadata (fstat) instead of path metadata (stat) for the empty-index size check. The file handle obtained from File::open always reflects the current file, avoiding stale NTFS directory-entry metadata after renames. - reader.rs: Add is_degenerate() method that detects the files-but-no- trigrams state. - serve.rs (flush_index_to_disk): Retry reader open up to 5 times with back-off when a degenerate reader is detected. Never swap a degenerate reader. - serve.rs (auto_save_loop): Same degenerate check skip swap and keep the live overlay when the reader has files but no trigrams. - Trace output now includes trigram count alongside file count for easier diagnosis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On Windows, File::metadata().len() (fstat) can transiently return zero for files just renamed into place. Since memmap2's Mmap::map() uses metadata internally, this caused zero-length mmaps for non-empty files, resulting in 0-candidate searches after every flush. Changes: - IndexReader::open: use file.seek(SeekFrom::End(0)) to get true file size, pass it explicitly via MmapOptions::len() to bypass memmap2's stale metadata path - Add validate_lookup() method that validates sort order and posting bounds, and as a side-effect warms all lookup.bin pages into the OS page cache before any search can use the reader - Call validate_lookup() at startup (HybridIndex::open), flush, and auto-save paths - Reject degenerate readers (files but 0 trigrams) at startup - Add diagnostic logging for zero-candidate analysis in query execution and search handler filtering - Use checked arithmetic in posting-range validation to prevent overflow on corrupt input Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace per-search filter diagnostics (fired on ~98% of searches) with narrow-condition logging: - serve.rs: only log when index returns 0 raw candidates (not when glob filtering reduces results, which is normal for path-scoped searches) - query.rs: only log when an empty posting list is detected (indicates index corruption), not on every zero-candidate AND intersection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR targets Windows index reliability by fixing a zero-candidate search failure after flush (degenerate zero-length mmaps) and tightening trigram query planning/execution correctness, including mask preservation across snapshot/flush cycles.
Changes:
- Update
IndexReaderto open mmaps using explicit lengths (seek-based sizing), add degenerate-reader detection, and add lookup validation/warming. - Introduce
TrigramQuerywith literal-derivedexpected_next, snapshot the active reader per search, and preserve posting masks end-to-end. - Add retry/validation logic around reader reopen after flush and reduce noisy diagnostics; bump workspace version to 0.1.17.
Show a summary per file
| File | Description |
|---|---|
| tgrep-core/src/reader.rs | Seek-based mmap sizing; add is_degenerate() and validate_lookup(); add tests |
| tgrep-core/src/query.rs | New TrigramQuery + expected-next handling; mask-aware execution changes |
| tgrep-core/src/hybrid.rs | Snapshot reader per query; full snapshot preserves masks |
| tgrep-core/src/live.rs | Add O(1) get_masks() accessor for snapshot/flush |
| tgrep-core/src/builder.rs | Persist PostingEntry masks when writing snapshot indexes |
| tgrep-cli/src/serve.rs | Use new mask-aware query API; add flush reopen retry + validation; reduce logging noise |
| tgrep-cli/src/search.rs | Update callers for new query API and plan summary |
| tgrep-core/tests/case_insensitive_roundtrip.rs | Update test to build masked postings |
| Cargo.toml / Cargo.lock | Version bump to 0.1.17 |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comments suppressed due to low confidence (1)
tgrep-core/src/reader.rs:219
validate_lookup()castsentry.offset(u64) andentry.lengthintousizebefore doingchecked_add, so an out-of-range offset/length can truncate on 32-bit and incorrectly appear in-bounds. Consider performing the range math inu64(usingchecked_mul/checked_addon u64) and comparing againstpostings_len as u64to ensure corrupted offsets are reliably rejected.
}
}
let byte_len = (entry.length as usize).checked_mul(POSTING_ENTRY_SIZE);
let end = byte_len.and_then(|bl| (entry.offset as usize).checked_add(bl));
match end {
Some(e) if e <= postings_len => {}
_ => {
return Err(format!(
"lookup entry {i} (trigram {:#x}): posting range \
[offset={}, length={}] exceeds index.bin length {postings_len}",
entry.trigram, entry.offset, entry.length
));
- Files reviewed: 9/10 changed files
- Comments generated: 4
- Run cargo fmt across all crates - Fix clippy::manual_is_multiple_of in reader.rs - Fix clippy::collapsible_if in reader.rs and concurrent_search.rs - Add concurrent search integration tests - Add serde_json dev-dependency for test assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The byte-index slice &pattern[..N] panics when N falls inside a multi-byte UTF-8 character. Use chars().take(40) instead, which is always safe regardless of input encoding. Addresses Copilot code review comment on PR #63. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 11/12 changed files
- Comments generated: 6
1. reader.rs: Use usize::try_from() instead of 'as usize' for file sizes from seek(). Prevents silent truncation on 32-bit targets and keeps the modulo validation in u64 before narrowing. 2. hybrid.rs/live.rs: Remove unnecessary OVERLAY_BIT masking from trigram keys. OVERLAY_BIT is only meaningful for file IDs; trigram hashes use only the low 24 bits and never have bit 31 set. The masking was a no-op that created a hidden coupling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Shengyu Fu <shengyfu@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Shengyu Fu <shengyfu@microsoft.com>
- Use assert_cmd::cargo::cargo_bin() instead of manual binary path derivation, matching the convention in ripgrep_compat.rs - Redirect server stderr to null to prevent pipe buffer deadlock under concurrent request load Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 10/11 changed files
- Comments generated: 3
…ading comment - is_degenerate() now detects mmap sections non-empty with 0 entries (structural inconsistency) instead of files present with 0 trigrams, which is valid for files shorter than 3 bytes - Updated HybridIndex::open error message to match new semantics - Fixed misleading 'Map files first' comment in reader.rs to describe the actual open -> seek -> mmap order of operations - Updated test to match new is_degenerate() behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add raw_candidates count to every search trace log line to distinguish index-level failures (raw=0) from filter-level failures (raw>0, filtered=0) - Remove conditional guard on AND-plan diagnostic so it fires whenever intersection produces 0 candidates (not just when a posting list is empty) - Lift raw_candidate_count out of the index lock block so it's available at the search result log Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When raw_candidates > 0 but filtered = 0, log which filter stage dropped the candidates (no_path, type_filtered, glob_filtered). This will definitively identify whether file_path(fid) returns None for valid file IDs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
execute_query_with_masks now returns the Arc<IndexReader> snapshot alongside the file IDs. The search handler in serve.rs uses resolve_path / resolve_full_path with that snapshot instead of calling file_path (which acquires a fresh snapshot internally). This eliminates a race where a concurrent swap_reader between the query and path resolution would cause ALL file_path lookups to fail silently dropping every candidate. Also adds 5 new snapshot-consistency tests covering: - Basic invariant: all query IDs resolve via the returned snapshot - Snapshot survives a reader swap (old IDs resolve with old snapshot) - New query after swap works correctly - MatchAll plan uses consistent snapshot - Concurrent swap during query (snapshot-based resolution is safe) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The runtime may send glob patterns with Windows backslash separators (e.g. **\*.cs) while index paths use forward slashes. Normalize \ to / in simple_glob_match before building the regex. Also adds 'csproj' to builtin file types and glob match unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The runtime sends glob_filters=['!.git'] meaning 'exclude .git paths'. simple_glob_match treated '!.git' as a literal pattern that must match, which never matched any path, rejecting ALL candidates. New glob_filter_matches() splits patterns into include/exclude lists: - '!' prefix = exclusion (path must NOT match) - No prefix = inclusion (path must match at least one) - Exclusion-only lists pass all paths except excluded ones Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 12/13 changed files
- Comments generated: 5
| )); | ||
| } | ||
| let byte_len = (entry.length as usize).checked_mul(POSTING_ENTRY_SIZE); | ||
| let end = byte_len.and_then(|bl| (entry.offset as usize).checked_add(bl)); | ||
| match end { | ||
| Some(e) if e <= postings_len => {} | ||
| _ => { | ||
| return Err(format!( | ||
| "lookup entry {i} (trigram {:#x}): posting range \ | ||
| [offset={}, length={}] exceeds index.bin length {postings_len}", | ||
| entry.trigram, entry.offset, entry.length | ||
| )); |
There was a problem hiding this comment.
validate_lookup() narrows entry.offset (u64) to usize via entry.offset as usize before bounds checking. On 32-bit targets or for corrupted indexes with large offsets, this cast can truncate and potentially let an out-of-bounds posting range pass validation. Prefer performing the range math and comparisons in u64 (against postings_len as u64), and only convert to usize after ensuring the values fit.
| // Fabricate a case where lookup.bin has data but num_entries is 0. | ||
| // This simulates the stale-metadata corruption path. | ||
| let (lookup, postings) = make_sorted_index(&[0x616263]); | ||
| // Write non-empty lookup/postings but *no* files — after open, | ||
| // num_entries > 0, so is_degenerate is false. That's fine; we need | ||
| // to test the *other* direction: counters say 0 but mmaps are | ||
| // non-empty. We can't easily create that via public API because | ||
| // open() derives num_entries from lookup len. Instead, verify the | ||
| // "files but empty sections" case is NOT degenerate (valid index). | ||
| let files = ondisk::encode_file_entry(0, "a.rs").unwrap(); | ||
| write_index(tmp.path(), &[], &[], &files); | ||
| let reader = IndexReader::open(tmp.path()).expect("should open"); | ||
| // Files present but empty lookup/postings is valid (files <3 bytes) | ||
| assert!( | ||
| !reader.is_degenerate(), | ||
| "files with empty sections is valid, not degenerate" | ||
| ); | ||
|
|
||
| // Non-empty sections with entries IS also not degenerate | ||
| let tmp2 = TempDir::new().unwrap(); | ||
| write_index(tmp2.path(), &lookup, &postings, &files); | ||
| let reader2 = IndexReader::open(tmp2.path()).expect("should open"); | ||
| assert!( | ||
| !reader2.is_degenerate(), | ||
| "well-formed index is not degenerate" |
There was a problem hiding this comment.
is_degenerate_detects_mmap_counter_disagreement doesn’t actually exercise a degenerate reader state (it only asserts non-degenerate cases). As written, it can’t fail even if is_degenerate() is broken. Consider constructing a truly inconsistent reader state (e.g., by writing a non-empty lookup.bin while forcing num_entries/mmap length observed at open to be zero, or by directly instantiating IndexReader in a unit test if the module allows) and asserting is_degenerate() == true.
| // Fabricate a case where lookup.bin has data but num_entries is 0. | |
| // This simulates the stale-metadata corruption path. | |
| let (lookup, postings) = make_sorted_index(&[0x616263]); | |
| // Write non-empty lookup/postings but *no* files — after open, | |
| // num_entries > 0, so is_degenerate is false. That's fine; we need | |
| // to test the *other* direction: counters say 0 but mmaps are | |
| // non-empty. We can't easily create that via public API because | |
| // open() derives num_entries from lookup len. Instead, verify the | |
| // "files but empty sections" case is NOT degenerate (valid index). | |
| let files = ondisk::encode_file_entry(0, "a.rs").unwrap(); | |
| write_index(tmp.path(), &[], &[], &files); | |
| let reader = IndexReader::open(tmp.path()).expect("should open"); | |
| // Files present but empty lookup/postings is valid (files <3 bytes) | |
| assert!( | |
| !reader.is_degenerate(), | |
| "files with empty sections is valid, not degenerate" | |
| ); | |
| // Non-empty sections with entries IS also not degenerate | |
| let tmp2 = TempDir::new().unwrap(); | |
| write_index(tmp2.path(), &lookup, &postings, &files); | |
| let reader2 = IndexReader::open(tmp2.path()).expect("should open"); | |
| assert!( | |
| !reader2.is_degenerate(), | |
| "well-formed index is not degenerate" | |
| let (lookup, postings) = make_sorted_index(&[0x616263]); | |
| let files = ondisk::encode_file_entry(0, "a.rs").unwrap(); | |
| // Create a real non-empty mmap-backed reader first. | |
| write_index(tmp.path(), &lookup, &postings, &files); | |
| let opened = IndexReader::open(tmp.path()).expect("should open"); | |
| assert!( | |
| opened.lookup.as_ref().map_or(0, |m| m.len()) >= LOOKUP_ENTRY_SIZE, | |
| "fixture should have a non-empty lookup mmap" | |
| ); | |
| // Then fabricate the inconsistent internal state that open() cannot | |
| // produce: mmap-backed lookup/postings are present, but num_entries is | |
| // forced to 0. This should be considered degenerate. | |
| let reader = IndexReader { | |
| lookup: opened.lookup, | |
| postings: opened.postings, | |
| file_paths: opened.file_paths, | |
| num_entries: 0, | |
| }; | |
| assert!( | |
| reader.is_degenerate(), | |
| "non-empty mmap with num_entries == 0 must be degenerate" |
| // Log when the AND intersection produces 0 candidates — helps diagnose | ||
| // whether the issue is empty posting lists, mask filtering, or intersection. | ||
| if candidates.is_empty() && queries.len() >= 3 { | ||
| let any_empty = list_sizes.iter().any(|(_, sz)| *sz == 0); | ||
| let min_size = list_sizes | ||
| .iter() | ||
| .map(|(_, sz)| sz) | ||
| .min() | ||
| .copied() | ||
| .unwrap_or(0); | ||
| let max_size = list_sizes | ||
| .iter() | ||
| .map(|(_, sz)| sz) | ||
| .max() | ||
| .copied() | ||
| .unwrap_or(0); | ||
| eprintln!( | ||
| "[trace] AND plan: 0 candidates from {} trigrams. \ | ||
| any_empty={any_empty} min_list={min_size} max_list={max_size} \ | ||
| sizes={list_sizes:?}", | ||
| queries.len(), | ||
| ); | ||
| } |
There was a problem hiding this comment.
execute_plan_with_masks now emits an unconditional eprintln! trace whenever an AND plan yields 0 candidates (and has >=3 trigrams). This will fire for normal “no matches” searches and can add significant stderr noise for both the CLI (local index) and server. Consider gating this behind an explicit debug/trace flag, a feature, or a logger with configurable level instead of always printing from the core query engine.
| /// Check whether `path` passes the glob filter list. | ||
| /// | ||
| /// Glob semantics: | ||
| /// - Patterns starting with `!` are **exclusion** patterns (path must NOT match). | ||
| /// - All other patterns are **inclusion** patterns (path must match at least one). | ||
| /// - If only exclusion patterns are present, the path passes unless it matches | ||
| /// an exclusion. | ||
| /// - If inclusion patterns are present, the path must match at least one AND | ||
| /// must not match any exclusion. | ||
| fn glob_filter_matches(globs: &[String], path: &str) -> bool { | ||
| if globs.is_empty() { | ||
| return true; | ||
| } | ||
| let mut has_include = false; | ||
| let mut included = false; | ||
| for g in globs { | ||
| if let Some(neg) = g.strip_prefix('!') { | ||
| if simple_glob_match(neg, path) { | ||
| return false; // excluded | ||
| } | ||
| } else { | ||
| has_include = true; | ||
| if !included && simple_glob_match(g, path) { | ||
| included = true; | ||
| } | ||
| } | ||
| } | ||
| // If there were no inclusion patterns, the path passes (only exclusions existed) | ||
| !has_include || included | ||
| } |
There was a problem hiding this comment.
Server-side glob filtering now implements include+exclude semantics with ! negation, but the local search path (tgrep-cli/src/search.rs’s passes_glob_filters/glob_matches) still uses simple “match any” inclusion semantics and doesn’t handle negation. This makes --glob behave differently depending on whether the query is served locally or via tgrep serve. Consider sharing a single glob-filter implementation (or keeping semantics aligned) to avoid surprising users.
| /// Returns `true` when the reader state is structurally inconsistent. | ||
| /// | ||
| /// A well-formed index may legitimately contain files that produce no | ||
| /// trigrams (for example, files shorter than 3 bytes), so | ||
| /// `num_files() > 0 && num_trigrams() == 0` is not inherently | ||
| /// degenerate. The zero-trigram case is only suspicious when one of the | ||
| /// mmap-backed binary sections is nevertheless present and non-empty, | ||
| /// indicating that the in-memory counters and on-disk metadata disagree | ||
| /// (observed on Windows NTFS after rapid file renames when stale | ||
| /// metadata causes a zero-length mmap despite non-empty files on disk). | ||
| pub fn is_degenerate(&self) -> bool { | ||
| self.num_entries == 0 | ||
| && (self.lookup.as_ref().is_some_and(|m| !m.is_empty()) | ||
| || self.postings.as_ref().is_some_and(|m| !m.is_empty())) |
There was a problem hiding this comment.
is_degenerate() currently only returns true when num_entries == 0 and an mmap is present/non-empty. Given open() derives num_entries directly from the lookup mmap length, this condition can’t occur in normal construction, and it does not match the call sites that treat “{files} files but 0 trigrams” as degenerate. Consider redefining this check to align with the intended Windows stale-metadata failure mode (e.g., compare against meta.json’s num_trigrams, or validate on-disk section sizes before/after mmap) so the retry/validation logic can actually trigger when needed.
| /// Returns `true` when the reader state is structurally inconsistent. | |
| /// | |
| /// A well-formed index may legitimately contain files that produce no | |
| /// trigrams (for example, files shorter than 3 bytes), so | |
| /// `num_files() > 0 && num_trigrams() == 0` is not inherently | |
| /// degenerate. The zero-trigram case is only suspicious when one of the | |
| /// mmap-backed binary sections is nevertheless present and non-empty, | |
| /// indicating that the in-memory counters and on-disk metadata disagree | |
| /// (observed on Windows NTFS after rapid file renames when stale | |
| /// metadata causes a zero-length mmap despite non-empty files on disk). | |
| pub fn is_degenerate(&self) -> bool { | |
| self.num_entries == 0 | |
| && (self.lookup.as_ref().is_some_and(|m| !m.is_empty()) | |
| || self.postings.as_ref().is_some_and(|m| !m.is_empty())) | |
| /// Returns `true` when the reader is in the suspicious "files present | |
| /// but no trigrams loaded" state. | |
| /// | |
| /// In normal operation, an index with files but zero trigrams can be | |
| /// legitimate (for example, when every file is shorter than 3 bytes). | |
| /// However, callers use this method as a heuristic to detect the stale | |
| /// Windows metadata failure mode where the reader observes indexed files | |
| /// but ends up with an empty trigram table and should therefore retry or | |
| /// force validation. | |
| pub fn is_degenerate(&self) -> bool { | |
| self.num_files() > 0 && self.num_trigrams() == 0 |
…emove trace log, align glob filters - validate_lookup: perform bounds math in u64 to prevent truncation on 32-bit targets - is_degenerate test: fabricate inconsistent IndexReader state to actually exercise the degenerate positive path - is_degenerate doc: clarify that open() cannot produce this state and callers needing Windows stale-metadata heuristic should compare num_files() vs num_trigrams() independently - Remove unconditional eprintln trace in execute_plan_with_masks that spammed stderr on normal no-match queries - Align passes_glob_filters in search.rs with serve.rs: support ! prefix negation and backslash normalization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
fix: Windows mmap correctness and search reliability (v0.1.17)
Summary
Fixes a critical bug where ~98% of trigram searches returned 0 candidates on Windows after an index flush cycle. Root cause: NTFS metadata caching causes
File::metadata().len()to transiently return 0 afterstd::fs::rename, producing zero-length mmaps forlookup.binandindex.bin. Also fixes several correctness bugs in search query planning, glob filtering, and reader snapshot consistency discovered during investigation.Impact: Confirmed fix against a 285K-file / 921K-trigram production index. Searches now return valid candidates after flush.
Changes
fix: three correctness bugs in trigram search (
aed3202)expected_nextused raw regex pattern instead of parsed literal bytes —next_byte_after_trigramwas called with the user's regex string, not the extracted literal. IntroducedTrigramQuerystruct withexpected_nextcomputed at plan-build time from actual literal bytes.Arcat search start.write_index_from_snapshotdiscardedloc_mask/next_mask, writing0xFFfor all entries. Now preservesPostingEntrymasks end-to-end.full_snapshot()— addedget_masks()O(1) accessor toLiveIndexto avoid scanning the overlay per posting.fix: prevent degenerate reader swap after flush on Windows (
9cc2360)fstat(open file handle) instead ofstat(path-based) for file size checks — avoids stale NTFS metadata.is_degenerate()validation to detect structurally inconsistent readers (mmap sections present but counters say zero entries).fix: bypass stale metadata in IndexReader::open and warm lookup mmap (
df2d6f5)This is the primary fix for the zero-candidate bug.
File::metadata().len()returns 0 afterrename()on Windows NTFS due to stale metadata cache.memmap2::Mmap::map()also callsmetadata()internally, so even mapping first doesn't help.file.seek(SeekFrom::End(0))to read the true file size from the kernel file object (callsSetFilePointerExon Windows), thenMmapOptions::new().len(size)to create the mmap with an explicit size, bypassing memmap2's internal metadata path.validate_lookup()method that validates sort order + posting-list bounds and warms alllookup.binpages into the working set.usize::try_from()instead ofas usizefor file sizes to prevent silent truncation on 32-bit targets.fix: consistent reader snapshot for query + path resolution (
d8f817a)execute_query_with_masksnow returns theArc<IndexReader>snapshot alongside file IDs.swap_readerbetween query and path resolution would silently drop all candidates.fix: glob filter correctness (
f851c0c,9f48a25,72bf727)!.gitmeaning "exclude .git paths". The oldsimple_glob_matchtreated!.gitas a literal pattern, rejecting ALL candidates. Newglob_filter_matches()splits patterns into include/exclude lists.\to/in glob patterns before regex matching so Windows-style globs work with forward-slash index paths.passes_glob_filtersinsearch.rsto support!prefix negation and backslash normalization, matching the server-sideglob_filter_matchesimplementation.fix: safe UTF-8 truncation for pattern preview (
439fdad)&pattern[..N]byte-index slice panics on multi-byte UTF-8. Usechars().take(40)instead.fix: remove unnecessary OVERLAY_BIT masking from trigram keys (
806566c)OVERLAY_BITis only meaningful for file IDs; trigram hashes use only the low 24 bits. The masking was a no-op that created a hidden coupling.chore: reduce diagnostic log volume (
bccc7fe,72bf727)eprintln!trace inexecute_plan_with_masksthat spammed stderr on normal no-match queries.chore: improve test coverage and reliability
assert_cmd::cargo_bin()instead of manual binary path derivation in integration tests.is_degeneratetest now fabricates inconsistentIndexReaderstate to exercise the positive path (not just non-degenerate cases).validate_lookupbounds-checking in u64 to prevent truncation on 32-bit targets.chore: bump version to 0.1.17 (
517dc2d)Testing
Files changed
tgrep-core/src/reader.rsseek-based file size,MmapOptions::len(),validate_lookup()(u64 math),is_degenerate(),usize::try_from()for sizes, 7 new teststgrep-core/src/query.rsTrigramQuerystruct,literals_to_query_plan(), mask filtering, removed unconditional trace logtgrep-core/src/hybrid.rsvalidate_lookup()+ degenerate check at open,full_snapshotwith masks, removed OVERLAY_BIT maskingtgrep-core/src/live.rsget_masks()O(1) accessortgrep-core/src/builder.rswrite_index_from_snapshotpreservesPostingEntrymaskstgrep-core/src/ondisk.rsPostingEntryencode/decode helperstgrep-core/src/gitignore.rstgrep-core/src/filetypes.rscsprojto builtin file typestgrep-cli/src/serve.rstgrep-cli/src/search.rstgrep-cli/tests/concurrent_search.rstgrep-core/tests/snapshot_consistency.rstgrep-core/tests/case_insensitive_roundtrip.rsPostingEntryAPICargo.toml/Cargo.lock