Skip to content

control r10 (do not merge) - #117

Closed
Shengyu Fu (shengyfu) wants to merge 20 commits into
mainfrom
control-r10
Closed

control r10 (do not merge)#117
Shengyu Fu (shengyfu) wants to merge 20 commits into
mainfrom
control-r10

Conversation

@shengyfu

Copy link
Copy Markdown
Member

control

Shengyu Fu (shengyfu) and others added 20 commits August 26, 2026 20:50
`is_ignore_rules_file` decides whether a filesystem event should rebuild and
republish the matcher in `ServerState::gitignore`. It recognized `.gitignore`
at any depth and root-level `p4ignore.ini`, but not `.ignore`.

`.ignore` is a first-class ignore source everywhere else: the walk collects it
separately, the matcher applies it, and it even outranks `.gitignore`. Unlike
`.gitignore` it is not git-gated, so it is the one source that works outside a
repository.

Because the event never matched, a `.ignore` written while the server was live
never scheduled the refresh, and the write then fell through to the reindex
path where `should_skip_watcher_path` drops any dot-prefixed segment. Nothing
happened at all: the startup matcher stayed published and files under the newly
excluded directory kept being indexed until the hourly reconcile or a restart.

Match `.ignore` by file name alongside `.gitignore`, using the existing
`tgrep_core::gitignore` filename constants. `p4ignore.ini` stays root-scoped,
mirroring the walker.

Fixes #104

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The watcher already discarded events for ignored paths, but only after
the OS had delivered them. On Linux that is too late to matter: inotify
has no recursive mode, so notify's `RecursiveMode::Recursive` walks the
tree and spends one watch descriptor per directory. A repository whose
`target/` or `node_modules/` holds most of its directories therefore
burns most of the per-user `fs.inotify.max_user_watches` budget on
events that are thrown away -- and because notify propagates the first
registration failure, exhausting that budget makes `watch()` return an
error and the server loses its watcher entirely.

Subscribe per directory on inotify backends instead. `watchable_dirs`
walks the tree once, pruning ignored, hidden and `--exclude`d subtrees
before descending, and `WatchRegistry::sync` reconciles the live
subscription set against it. The sync runs whenever the ignore matcher
is published, so relaxing a rule subscribes to the tree it used to hide
and tightening one drops it. A directory that cannot be subscribed is
now reported and skipped rather than taking down the whole watcher.

Non-recursive watches are not extended by notify, so a directory that
appears at runtime is picked up in `watch_new_subtree`, which also
indexes the files already inside it to close the create race.

Windows (ReadDirectoryChangesW) and macOS (FSEvents) subscribe once for
the whole subtree, so there is no per-directory registration to
withhold; they keep the single recursive watch and delivery-time
filtering. The behaviour the two paths must share -- new directories get
indexed, new directories under an ignored path do not -- is tested
everywhere.

Along the way, `state.gitignore` had three publish sites and only one of
them went through the helper. They are unified behind
`publish_ignore_matcher` so the sync hook cannot be missed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
`watch_new_subtree` merged the new subtree into the live subscription
set by cloning both into a union and handing that to `sync`. That is
correct but proportional to the whole watched set, and it runs once per
directory created at runtime -- so on a repository holding tens of
thousands of watched directories, a checkout or a build that creates
many directories does quadratic work copying `PathBuf`s.

Split the additive half of `sync` into `add_all` and call that instead.
It iterates only the new subtree and tests membership, so the cost is
proportional to what actually appeared. `sync` keeps its prune-and-add
behaviour for the whole-tree case and now shares the same code.

The distinction matters beyond performance: passing a subtree to `sync`
would treat the entire rest of the repository as stale and unsubscribe
from it, so a new folder would silently disable file watching. Added a
test that pins both behaviours.

Measured against the layout reported by the Office monorepo team --
40,210 directories, ~200 searchable files, `.git` both hidden and
`--exclude`d -- the subscription set is 202 directories, computed in
13.7ms. The walk cost is set by the tree that survives pruning, not the
physical tree: the same measurement over 8,458 directories takes 12.6ms.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
The watcher fixes on this branch change observable behaviour on Linux --
`tgrep serve` no longer takes an inotify watch per directory -- and the
Office monorepo report that corroborated the bug was filed against the
bundled 1.0.2. A distinct version is what lets that team tell whether a
build contains the fix.

Both crates inherit `version.workspace`, so the manifest change is one
line; `Cargo.lock` is regenerated with `cargo update --workspace` rather
than hand-edited. Note that the third-party `equivalent` crate is also
at 1.0.2 and is deliberately untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Subscriptions were established only after the walk that produced the ignore
matcher, so a file written to a directory in that window was in neither place:
not in the walk's results, and not able to report itself yet. It stayed
invisible until the hourly reconcile. sync_watch_registrations now returns the
directories it newly subscribed to, and the stale check rechecks them once the
merge has settled. It has to be after the merge: stream_merge_stale_changes
replaces file_stamps wholesale, so an earlier scan would be discarded and would
re-read every changed file on the way. reindex_file compares stamps first, so
on a tree that did not move under us this costs one metadata call per file.

The two index-build publishes deliberately skip the scan. There "newly watched"
is the whole repository, and the stale check that follows startup already does
a full walk-versus-index diff, which is a superset. Scanning there would stat
the entire tree while holding the gate, on the path a warm start exists to keep
fast.

watch_new_subtree had two more problems of its own. It read each directory
before subscribing to it, leaving the same race one level down for anything
created in between; it now subscribes to a level before enumerating it. And a
subtree that arrives already populated -- a clone, a mv, a branch switch --
can carry its own .gitignore. Those files are dot-prefixed, so the recovery
scan dropped them silently and indexed the rest of the subtree against rules
that had never heard of it. It now looks for ignore rules first and defers to a
refresh rather than indexing under stale ones.

Finally, Path::is_dir follows symlinks. A link to a directory was therefore
subscribed to and walked through, indexing a target the walker never descends
into and that may sit outside the root entirely. is_real_dir asks the question
we actually mean.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
The kernel releases an inotify watch by itself when its directory is deleted or
moved away, and nothing reports that the descriptor is gone. The path stayed in
`watched`, so a directory recreated at the same location looked subscribed
while receiving no events at all.

Nothing downstream could recover it either. `add_all` skipped it as already
watched, and so did every later `sync`: the path is in `desired` *and* in
`watched`, which is indistinguishable from a live subscription. The entry
stayed poisoned for the life of the process, so `rm -rf build && mkdir build`,
a branch switch or a `git clean` silently stopped the directory being watched
until the server restarted.

Two cheap halves. `forget` clears the entry when a removal event arrives, which
is a single hash lookup — deleting a tree delivers one event per directory in
it, so anything proportional to the whole watched set would make that
quadratic. And `watch_new_subtree` now re-issues subscriptions rather than
trusting `watched`, since a directory that has just appeared is precisely the
case where that belief is worthless; `inotify_add_watch` is idempotent, so
re-adding costs a syscall and returns the existing descriptor. The forced path
still reports only genuinely new directories, so the recovery scan does not
treat a whole subtree as freshly watched.

Descendants carried off by a move deliver no events of their own, but the next
sync no longer finds them under the root and unsubscribes them there.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Three follow-ups from review, all cases where the watcher and the walk
could disagree about the index.

1. The directories returned by the three non-stale subscription passes
   were discarded. The justification comments were wrong: on a warm
   start the stale check runs on a thread spawned before the watcher, so
   it can publish while the registry is still empty, making the startup
   sync the first descendant pass with nothing following it. Files
   written to a directory between the walk reaching it and the
   subscription being taken were then invisible until the hourly
   reconcile. All three sites now hand their directories to
   spawn_recovery_scan, which waits out `indexing` before looking --
   during a cold build the stamps are not yet written and every file
   would read as changed.

2. watch_new_subtree ran for every directory event, including
   Modify(Metadata). A recursive chmod or a branch switch re-walked and
   re-subscribed each subtree once per directory in it. Gated to the
   kinds that can actually introduce a directory: Create and
   Modify(Name).

3. reindex_file ignored the walker's binary-extension and max-filesize
   rules, so a file arriving through the watcher was indexed even when a
   walk of the same tree would reject it -- and the next reconcile
   deleted it again. The check now mirrors walk_file_metadata, and drops
   any entry it already holds when a file stops being eligible.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Symlink escape (high). `reindex_file` stat'd and read through
`std::fs::metadata`, which follows links. The indexer walks with
`follow_links(false)`, where a symlink is neither file nor dir and is
skipped outright, so a link inside the repository had its target's bytes
indexed under the link's own path — and the target need not be under the
served root. Switched to `symlink_metadata` and made `is_file` on the
link's own metadata an eligibility rule, so a link falls into the branch
that drops whatever was indexed there before.

Conditional drop. That branch only deleted when a stamp entry existed, so
a stamp map that no longer describes the overlay left ineligible content
searchable. It now also consults the live overlay. Deliberately not
unconditional the way the removal branch is: removals are rare, this runs
for every ineligible file a recovery scan passes, and `delete_file`
records a tombstone even for a path that was never indexed.

Stamp publication race. `background_index_build` assigned
`state.file_stamps` after dropping the publish gate, but cleared
`indexing` before it. The recovery scan waits on `indexing` and then on
the gate, so it ran in that window against empty stamps: every file read
as changed and the whole repository was re-read — exactly what the wait
exists to prevent — and the assignment then discarded the stamps the scan
had just recorded. The stamps are now published inside the gate, before
the flag flips, and the flush is handed a read guard rather than a clone.

Recovery scan gaps. `reindex_files_in` looked only at files directly
inside each newly watched directory. It now also picks up subdirectories
created in the same window (filtered by subscription membership, so the
startup case stays a hash lookup apiece rather than a re-walk per level),
drops entries for files removed in it, and defers to an ignore-rules
refresh when it finds an ignore file that landed inside the window. That
last test is bounded at both ends by mtime so a clock-skewed network
mount cannot arm a refresh that arms the next one.

Over-subscription. `watch_new_subtree` recorded an ignore-rules file and
kept descending, taking a watch descriptor per level of a tree the rules
it was about to publish would exclude — in the moved-in `node_modules`
case this change exists to fix. It now abandons the descent at the point
of discovery.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Review round 5.

Read the file through one no-follow handle. `symlink_metadata` and a
later `std::fs::read` are two lookups of the same name, and a tree being
rewritten underneath us can swap a regular file for a link in between,
so the bytes indexed were not necessarily the ones judged eligible.
`open_no_follow` opens the path itself -- O_NOFOLLOW on unix, the
reparse point on Windows -- and the type, size, mtime and contents all
come off that handle.

Stop keying the ineligible-file drop on a stamp entry. `ServerState`
accepts an empty stamp map when filestamps.json is missing or
unreadable, and the reader can still hold the path in that state, so the
delete now always happens. An existing tombstone is taken as proof there
is nothing left to do, which keeps a startup scan over a repository full
of binary assets from dirtying the overlay once per file.

Anchor the recovery window at the walk that produced the matcher, not at
the subscription sync that follows it. A nested `.ignore` written
between the two carries an mtime predating a timestamp taken later and
would be read as already accounted for, leaving its subtree indexed
under rules that never saw it. `publish_ignore_matcher` now takes
`since` from the caller.

Replay the events discarded during the initial build. They could not be
applied then -- the stamps do not describe the index yet -- but they are
the only record that those paths moved, and the build's own walk misses
anything written to a directory it has already passed. Buffered, capped,
and replayed as synthetic create events once the build publishes, so
they go through the same filtering an ordinary event gets; an
overflowing burst falls back to a full reconcile. This is also the first
recovery any of this offers on a whole-subtree backend, where there are
no per-directory subscriptions to have raced.

Include the root in the recovery scan. It is subscribed as the watcher
starts, so every later sync sees it as already watched and it never
appears in the newly-watched list -- nothing covered a file written to
the top level while a build walk was deeper in the tree.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Review round 6.

Guard every path component, not just the last one. `O_NOFOLLOW` and
`FILE_FLAG_OPEN_REPARSE_POINT` protect the file being opened and nothing
above it, so `root/a/file` still resolves through `a` if `a` is a link
-- and the file at the end of that is a perfectly ordinary file outside
the tree we serve, which is exactly the containment the walker's
`follow_links(false)` promises. `open_within_root` resolves the path a
component at a time from the root: with `openat` on unix, where the name
is never re-resolved and there is no window to swap a directory for a
link, and by checking each ancestor on Windows, where there is no
`openat` and creating a symlink needs a privilege that is not granted by
default. Non-literal components are refused rather than interpreted.

Serialize the check-read-commit cycle in `reindex_file`. The snapshot
gate is held for read by everything that indexes a file, so a recovery
scan and the watcher worker can both be inside it for the same path,
both see the same old stamp, and the one that read the older content can
commit last -- the newer event already consumed, the stale version
surviving until the next reconcile. The new lock is taken per file, so
the two interleave rather than one waiting out the other, and searches
do not take it at all.

Also open with `O_NONBLOCK`, so a fifo in the tree answers immediately
instead of blocking the watcher until someone opens the write end.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Five follow-ups from review, all about a decision made on evidence that
does not support it.

Replaying a deferred event reconstructed it as a creation regardless of
what actually happened, so a recursive chmod during a build put every
directory through watch_new_subtree. The flag that distinguishes them is
now carried with the path.

The deferral itself re-checked `indexing` outside the buffer lock, so an
event could land in a set that had already been replayed and swapped
out. It is now read under the lock, which is what makes the handoff
provable: the replay cannot swap without that lock.

reindex_file treated any failure to open as proof of ineligibility, so a
permission error or a Windows sharing violation evicted content that was
still perfectly valid — while the stale path, deliberately, keeps
unreadable files and retries them. Only errors that establish something
structural drop the entry now.

A recovery scan could see an ignore file that arrived, by its mtime, but
not one that was deleted, since a deleted file leaves nothing to stat.
The published matcher's sources are recorded and checked directly.

And containment on Windows was still check-then-open: an ancestor could
become a junction between the check and the open. The handle is now
asked where it ended up, which cannot be raced, and the per-ancestor
stat walk goes away with it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
…hat still exists

Three more from review, all about the watcher acting on evidence it does
not have.

read_dir's per-entry errors were being flattened away, and the directory
was then recorded as fully enumerated. A name that failed to yield is
simply missing from `present`, so the sweep read it as a deletion and
tombstoned a file it had no reason to believe was gone. The directory
now has to have enumerated cleanly before it can claim to have been
swept, which is what a failed listing already did.

An indexed file atomically replaced by a fifo, a socket, or a symlink to
a directory is not a removal — the path still exists — and it is not a
directory either, so the non-file branch returned without touching the
index and the old contents stayed searchable. Real directories keep the
subtree handling; everything else drops what was indexed.

And notify registers inotify watches without IN_DONT_FOLLOW, so the
descriptor lands on whatever the name resolves to at that instant, not
on the directory the walk validated earlier. A swap in between left a
descriptor watching outside the root while the registry recorded the
name as covered. The registration is now re-checked no-follow and undone
on a mismatch. That narrows the window rather than closing it, since
notify takes a path and not a handle; what remains is missed events on a
real directory, which the reconcile picks up, and never misplaced
content, which open_within_root settles from the handle it reads.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
A control run showed it passing with the incomplete-listing fix
reverted, which is correct: it exercises sweep_removed_files directly,
so it guards the contract that withholding a directory is sufficient,
not the wiring that withholds it. A per-entry readdir failure cannot be
induced portably, so that half stays untested and the doc comment now
says so.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
…t the cap

Two races the reviewer found in the reconcile path.

The sweep deleted files on the strength of a directory listing taken
earlier in the scan, without the lock `reindex_file` holds. A file
recreated in between has already had its create event consumed by the
watcher, so deleting it here lost it until the next reconcile with
nothing left to replay. Each candidate is now rechecked against the
filesystem while holding `reindex_lock`, so the observation the delete
acts on is its own and cannot be overtaken.

The content read was unbounded even though the size that qualified the
file was stat'd before it. A file appended to in between -- a log, a
build artifact -- was pulled into memory whole and indexed past
`--max-filesize`. `read_within_limit` reads at most one byte past the
cap and drops what the index holds when that byte is there.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
A control run with the read bound reverted left this test passing: the
eligibility check catches a file that outgrew the cap between visits
long before the read does. It pins that gate, not the new bound, and
the name now says so.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Three defects the reviewer found, all in the reconcile path.

A removal handled by the watcher mutated the index without
`reindex_lock`, so a `reindex_file` already holding a file's bytes could
commit them after the delete. That resurrects a file that is gone, with
a fresh stamp, so nothing afterwards disagrees and no further event is
coming to correct it. Both removal branches now take the lock; it is
the caller's rather than `drop_indexed_file`'s because `reindex_file`
calls in while already holding it.

Arrival of an ignore file was detected by mtime alone, and `git
checkout`, `tar -x` and `rsync -a` all restore mtimes from what they
unpack -- so a nested `.gitignore` could arrive dated months ago and
sail past the window. Absence from `ignore_sources` is the exact
question instead: this file did not feed the published matcher. The
mtime window stays for the case the source list cannot answer, an
existing source that has just been edited.

Ignore files reached through a symlink were dropped before either test
ran. `DirEntry::file_type` does not follow links, but the walker
collects rule files with `Path::is_file`, which does -- so those files
carried rules that the recovery scan and the new-subtree descent were
both blind to. The check now runs ahead of the type dispatch and
follows links to decide. They are still never indexed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
The macOS runner caught this: `read_dir` promises no ordering, and there
`.gitignore` routinely comes back after its siblings -- so a per-entry
check indexes part of a directory under the stale rules before it ever
reaches the file that changes them. Across the scan it is worse: rules
in a directory later in `dirs` arrive after earlier ones were indexed.

`changed_ignore_rules_in` answers for the whole scan up front, probing
by name the way the walker discovers these files. That agrees with the
walker by construction, is ordering-independent, and follows symlinks
through `Path::is_file` -- which is also what makes a symlinked
`.gitignore` visible, so the per-entry check it replaces is gone.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant