Keep the watcher off the index until the gitignore matcher exists - #94
Merged
Conversation
On a warm start the server has a complete index on disk, so it never sets `indexing` and the watcher is live from the first event. The `.gitignore` matcher, though, is built on a background thread. In that gap `should_skip_watcher_path` sees `gitignore == None`, applies no ignore rules, and indexes exactly the build output the walk skips. Reproduced on a 30k-file git repo: writing `build/leaked.txt` right after "file watcher started" logged `reindex: modified build/leaked.txt` and the content stayed searchable, while the stale check correctly reported the tree up to date -- the watcher was the only way it got in. Gate the watcher on a `gitignore_pending` flag, and build the matcher before the stale check rather than after, so the stale walk that follows recovers the events dropped during the gap. Verified: an edit made inside the window is dropped by the watcher and then picked up by the stale check. Only the first publish is gated. Later rebuilds swap the matcher atomically and leave the previous one readable, so events keep being filtered by slightly stale but valid rules instead of being dropped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9162aa2a-9639-412f-9c72-29d8418bb961
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a warm-start race where the file watcher could index gitignored build output before the .gitignore matcher is available, permanently polluting the on-disk index.
Changes:
- Add a
gitignore_pendingatomic gate to prevent the watcher from touching the index until the initial matcher publish completes. - Reorder warm-start initialization so the matcher is built before the stale-refresh pass.
- Add an integration regression test covering the warm-start watcher/gitignore gap.
Show a summary per file
| File | Description |
|---|---|
| tgrep-cli/src/serve.rs | Introduces gitignore_pending and warm-start ordering changes to ensure watcher events are filtered (or dropped/recovered) until ignore rules are ready. |
| tgrep-cli/tests/warm_start_gitignore.rs | Adds an integration test to reproduce and prevent the warm-start gitignore indexing leak. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
Shengyu Fu (shengyfu)
force-pushed
the
shengyfu-warm-start-gitignore
branch
from
August 19, 2026 07:06
47d2211 to
ff553b7
Compare
Contributor
There was a problem hiding this comment.
Review details
Suppressed comments (1)
tgrep-cli/tests/warm_start_gitignore.rs:161
- This hammer loop runs for a fixed 3s before
wait_for_port(). Iftgrep servetakes longer than 3s to become reachable on a slow/loaded CI runner, the writes can finish before the watcher is active, and the test may pass even without the fix (no event occurred during the warm-start window). Consider waiting for the server (and ideallywatcher_active) before starting the hammer loop to make the regression test robust.
// Hammer a gitignored file for the whole startup window. Rewriting in a
// loop rather than once removes the race: whatever the exact moment the
// watcher comes up and the matcher lands, some write falls between them.
let ignored = root.join("build").join("leaked.txt");
let writer_deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < writer_deadline {
let _ = fs::write(&ignored, "gitignored_leak_marker build artifact\n");
thread::sleep(Duration::from_millis(10));
}
let server = ServerGuard {
child,
port: wait_for_port(&index_dir),
};
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
Two fixes from review feedback. The regression test could pass vacuously. It hammered the gitignored file for a fixed 3s *before* waiting for serve to be ready, so on a slow or loaded runner every write could land before the watcher existed, no event would reach the matcher-build gap, and the test would report success having exercised nothing. The writer now runs on its own thread that starts before serve is spawned and keeps going until two seconds after the port opens, so writes straddle the whole startup window regardless of how long it takes. Write errors are no longer discarded: successful writes are counted and asserted non-zero, so a probe that never ran says so instead of masquerading as a pass. Verified the restructured test still fails against the unfixed serve.rs from main (left: 1, right: 0), so the added robustness did not come at the cost of the thing it exists to catch. The warm-start thread comment also claimed "The watcher is already live by now", which is false -- start_file_watcher runs after this thread is spawned. Correctness never depended on that ordering, because the gitignore_pending gate is armed during ServerState construction, before either starts. Comment now says that instead. Also take ownership of the serve child process immediately after spawn so it is reaped even if the readiness wait times out and panics.
msftsiwei
approved these changes
Aug 19, 2026
Updates the workspace version, which both crates inherit, and syncs Cargo.lock in the same commit. Every release target builds with --locked (release.yml and sign-and-release.yml), so a bump that leaves the lockfile behind fails all six builds rather than producing a wrong version -- verified here by running the exact release command, `cargo build --release --locked -p tgrep-cli --bin tgrep`. fuzz/Cargo.lock is deliberately untouched: fuzz is not a workspace member, cargo fuzz does not run with --locked, and that lockfile has long since diverged (it still pins tgrep-core 0.1.4).
msftsiwei
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
On a warm start (a complete index already on disk), the file watcher indexes gitignored files.
ServerState.indexingis initialized toneeds_build. On a warm start that isfalse, so theindexingguard inhandle_fs_eventnever engages — butstate.gitignoreisRwLock::new(None)and is only populated later, on a background thread. In that windowshould_skip_watcher_pathgetsNoneand applies no ignore rules at all, so the watcher indexes exactly the build output the initial walk was careful to skip. The next flush persists it.On a big repo the matcher build is slow enough for this to matter, so the window is wide open in practice.
Reproduction
Built a 30k-file fixture as a real git repo with
build/in.gitignore, ranserve, and wrotebuild/leaked.txtright afterfile watcher started:The content was searchable, and
Files: 30003confirmed it reached disk. The stale check independently reported "index is up-to-date", which proves the watcher was the only path writing that file into the index.The fix
Two parts, both needed:
gitignore_pending: AtomicBoolgate. Set at construction when the watcher will actually use ignore rules (!no_watch && !no_ignore), cleared at all three matcher-publish sites. While set,handle_fs_eventdrops events rather than indexing them unfiltered.background_refresh_stale.The reorder is what makes the gate safe: events dropped during the gap are recovered by the stale check that follows. I verified this rather than assuming — an edit made inside the window is dropped by the watcher and then picked up by the stale check.
Only the first publish is gated. Later rebuilds (e.g. an edited
.gitignore) swap the matcher atomically and leave the previous one readable, so events keep being filtered by slightly stale but valid rules instead of being dropped.No wedge risk: both
bootstrap_index_buildfailure paths fall through tobackground_index_build, which publishes and clears the flag.build_gitignore_matcher_after_readyalso clears the flag when the matcher isNone, since a repo with no ignore rules is a legitimate final answer rather than a missing matcher.Verification
LEAKED, fixedclean (no results).FOUND, a gitignored fileabsent).warm_start_gitignore.rs— uses a real (empty).gitdir so.gitignoreis honored, hammers a gitignored file for 3 s to remove the race, and asserts a positive control (a normal source edit is indexed) before asserting the leak marker is absent. Verified to fail without the fix (left: 1, right: 0).clippy -D warningsclean,fmtapplied.