Rewatch a directory that was deleted and recreated (#61372)
# Objective
Addresses the delete-then-recreate cause reported under #53901
(@Akizay's reproduction) — a different root cause from the macOS
FSEvents fd-saturation case in #61072, so this intentionally does not
use a closing keyword.
Supersedes #64151 (rescan stale empty directories on expand), which
recovers from the same stale watch later in the UI.
When a directory is deleted and quickly recreated while it is being
repopulated — e.g. a build or test step that clears an output directory
and then rewrites it — most of the newly-written files never appear in
the project panel (nor do buffers/git status reflect them) until the
workspace is reloaded. @Akizay reproduced this on Linux with a small
Python script that `rmtree`s a directory and immediately recreates it
with 20 files + 20 subdirectories; only the first one or two show up.
## Root cause
Proven empirically on Linux/inotify (see Testing): the failure is a
stale watch registration in the `fs` crate, not fd budgets and not an
LSP.
1. Each watched directory has its own inotify watch (inotify is not
recursive). `FsWatcher` tracks these in a `registrations` map keyed by
path.
2. When the directory is deleted, the kernel silently invalidates its
inotify watch (`IN_IGNORED`), but `FsWatcher`'s `registrations` entry
lingers, because the delete and the recreate coalesce within one
`FS_WATCH_LATENCY` (100ms) debounce window, so the worktree processes
the path as still-present (metadata exists again) and never unwatches
it.
3. When the worktree rescans the recreated directory and calls
`watcher.add(path)`, both `FsWatcher` and the shared per-backend
registration state still mark the path as watched, so no new OS watch is
installed. Files written into the new directory produce no events until
an unrelated rescan.
4. Separately, `scan_dir` established the directory's watch only *after*
enumerating its contents (`read_dir`), leaving a TOCTOU window even when
a fresh watch is created.
5. The same stale state happens when a watched directory is renamed. Its
inotify watch, and the watches on its watched subdirectories, follow the
moved directory, so a directory recreated at the old path goes
unwatched. It also happens when an inotify queue overflow drops the
removal event.
This matches @Akizay's observation that inserting a `sleep` longer than
`FS_WATCH_LATENCY` between the delete and the recreate fixes it: with
the delete processed in its own window, the worktree unwatches the path,
so the later `add` re-registers cleanly.
## Solution
- `fs`: the shared registration state for each path gets a `stale` flag.
On native non-recursive (Linux) watches, `dispatch` sets it before
callbacks run:
- for `Remove` or rename events on a registered path;
- on a rename, also for every registered path under it;
- for every path on a queue overflow.
The next `add` for a stale path calls `unwatch`, then `watch`, and
clears the flag. One repair covers every subscriber that shares the
watch. A directory that stays deleted costs only a hash lookup per
event; it is cleaned up through the existing `remove`.
- `fs`: backend `watch`/`unwatch` calls are now made while holding the
per-backend state lock. A concurrent add can't treat a path as watched
before its new watch exists, and a concurrent final remove can't
interleave with a repair. Backend callbacks only enqueue events, so this
can't deadlock.
- `worktree`: `scan_dir` installs the directory's watch before
`read_dir`, so a child created during enumeration still produces an
event.
Polling and macOS/Windows native watches are recursive and unchanged.
Known limitations:
- A stale watch is repaired only when something calls `add` for that
path again. The worktree scanner does this when it rescans the
directory.
- A poll watcher's first registration walks its tree while holding the
poll backend's state lock. Poll events and other poll adds wait until it
finishes; no events are lost.
Disclosure per the AI policy: I investigated and developed this with an
LLM agent (Claude Code / Opus 4.8), reviewing and directing each step;
the measurements below are from real runs I can defend in review.
## Testing
- `fs_watcher::tests::removed_or_renamed_path_is_rewatched_once` (Linux
only, fake backend). Two subscribers share a directory, and its
subdirectory is watched too. The test covers a removal, an overflow, a
rename of the parent directory, and the same rename on a
case-insensitive path. It asserts the backend's full `watch`/`unwatch`
calls after a repair. Each case fails without its part of the fix.
- `test_new_directory_scan_does_not_miss_event_before_adding_watcher`
(worktree, Linux only). A FakeFs hook creates a file without an event
while the new directory's watch is being installed. The test fails if
`scan_dir` reads before watching.
- `test_root_rescan_keeps_root_watcher_registered` is updated. On Linux
the overflow rescan now reinstalls the root watch before reading the
root, so it expects 2 root `watch` calls. On macOS and Windows it still
expects 1.
## Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Release Notes:
- Fixed files created in a directory that was deleted and immediately
recreated (for example by a build or test step that clears and
repopulates an output directory) not appearing until the workspace was
reloaded
---------
Co-authored-by: Ben Kunkle <ben@zed.dev>
20d29fc6bcMarcos Alcantara committed on 10/1/2026, 9:21:35 PM· committed by GitHubparent36b6d09