In a yarn workspaces monorepo, Zed (0.231.1, Linux) fails to detect external file modifications to files inside workspace packages. Changes to root-level files (e.g. README.md) are detected correctly.
This appears related to previous Linux fs-watcher work, but is a distinct failure mode.
- Prior issue: zed-industries/zed#38109
- Prior PR: zed-industries/zed#51208
- See also: notify-rs/notify#381 (I'm unsure but this might be related)
Original collaborator guidance from probably-neb (posted ~3 weeks before this investigation):
Closing this as I expect many of the remaining problems described in this issue's comments to be fixed by #51208.
Once that PR makes it to stable (expected March 25, 2026), please create new issues if you are still experiencing problems. You can also try using preview in the meantime and let us know if you continue experiencing issues there.
It would help us greatly if before you create the issue, you put the following in settings:
{ "log": { "worktree": "trace", "fs": "trace" } }
And include the logs that occurred while experiencing problems with the issue, as well as reproduction steps.
- Open a yarn workspaces monorepo in Zed on Linux. The repo has
client/as a workspace, which yarn symlinks intonode_modules/client→../client. - Externally modify a file deep in the tree, e.g.
client/src/some-feature/SomeComponent.tsx. - Zed does not pick up the change. The buffer stays stale.
- Externally modify
README.mdat the repo root. - Zed picks up the change immediately.
With "log": { "worktree": "trace", "fs": "trace" } in settings, the fs_watcher trace logs show:
- Hundreds of
Access(Open(Any))events per second, all with paths undernode_modules/client/src/...(the symlinked path), notclient/src/...(the real path). - No
Modifyevents appear for either path when files are externally changed. - The
Accessevents are filtered out byhandle_eventinfs_watcher.rs:408and never reach any callback — they are noise from LSP servers scanning through the symlink.
The key observation: inotify is reporting event paths using the node_modules/client/src/... prefix, not client/src/.... This means the underlying inotify watch descriptor is associated with the symlinked path.
Every event path uses the node_modules/client/src/... symlink prefix — not a single event references the real client/src/... path. Events fire continuously (~every second), flooding the watcher with Access(Open(Any)) from LSP scanning through the symlink:
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/foo"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/bar"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/bar/mock"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/baz"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/baz/mock"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/qux"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/qux/sub"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/quux"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/quux/hooks"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/corge"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/corge/state"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/grault"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/node_modules/client/src/grault/mock"], ... })
Note: every path is duplicated (two identical events per directory per cycle). The directory containing the file we modified externally appears only via the node_modules/ symlink path — never via the real client/src/ path. The .git/ directory events also appear in each cycle, confirming the watcher is doing a full tree walk repeatedly:
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/.git/HEAD"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/.git/config"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/.git/packed-refs"], ... })
TRACE [fs::fs_watcher] global handle event: Ok(Event { kind: Access(Open(Any)), paths: ["/home/user/project/.git/index"], ... })
The bug is a collision between two inotify watches on the same inode registered via different paths, combined with a starts_with path filter that silently drops events when the reported path doesn't match the expected prefix.
Zed scans client/ (a real, non-ignored directory). For each subdirectory, scan_dir calls self.watcher.add(job.abs_path) (worktree.rs:4845). This registers inotify watches on real paths like:
/home/user/repo/client/src/some-feature/
Each FsWatcher::add call stores a callback whose root_path is this real path (fs_watcher.rs:82). The callback filters incoming events with event_path.starts_with(&root_path) (fs_watcher.rs:108).
node_modules/ is gitignored, so should_scan_directory (worktree.rs:4951) initially skips it. However, the function has escape hatches:
|| self.scanned_dirs.contains(&entry.id) // "If we've ever scanned it, keep scanning"
|| self.paths_to_scan.iter().any(|p| p.starts_with(&entry.path))
|| self.path_prefixes_to_scan.iter().any(|p| entry.path.starts_with(p))An LSP server (tsgo, vtsls) resolving imports, or Zed's own go-to-definition following the node_modules/client symlink, can trigger forcibly_load_paths (worktree.rs:4496), which enqueues scan jobs for node_modules/client/src/... directories. Once scanned, they enter scanned_dirs and persist.
This second scan calls self.watcher.add(job.abs_path) with the symlinked path:
/home/user/repo/node_modules/client/src/some-feature/
On Linux, inotify_add_watch operates on inodes, not paths. Since node_modules/client is a symlink to ../client, both paths resolve to the same inode.
When notify calls inotify_add_watch for node_modules/client/src/some-feature/, the kernel returns the same watch descriptor as the existing watch on client/src/some-feature/ (same inode). The notify crate updates its internal path mapping to use the new path.
This mechanism is directly confirmed by both source inspection and a local inotify call test.
In notify/src/inotify.rs:
- The event loop stores a map from watch descriptor to path:
paths: HashMap<WatchDescriptor, PathBuf>- On every incoming inotify event, emitted path is derived from that descriptor map:
Some(name) => self.paths.get(&event.wd).map(|root| root.join(name)),
None => self.paths.get(&event.wd).cloned(),- On
add_watch, notify writes/overwrites that map entry:
self.paths.insert(w, path);So if two add_watch calls on aliased paths return the same WatchDescriptor, the latter path replaces the former descriptor mapping.
A direct test using libc inotify_add_watch on:
- real path:
/tmp/.../client/src/some-feature - symlink alias path:
/tmp/.../node_modules/client/src/some-feature(withnode_modules/client -> ../client)
returned:
wd_real=1
wd_alias=1
same_watch_descriptor=True
This confirms that same-inode real/symlink aliases can share a descriptor, which is exactly the condition that allows notify's wd -> path map to flip to the symlinked prefix.
When a file is modified, notify constructs the event path by joining the watch's stored path (now node_modules/client/src/some-feature/) with the filename from the inotify event. The resulting event path is:
/home/user/repo/node_modules/client/src/some-feature/SomeComponent.tsx
The callback registered in step 1 filters events at fs_watcher.rs:106-112:
event_path.starts_with(&root_path).then(|| PathEvent { ... })Where root_path = /home/user/repo/client/src/some-feature/ but event_path = /home/user/repo/node_modules/client/src/some-feature/SomeComponent.tsx.
starts_with fails. The event is silently dropped. The buffer is never refreshed.
README.md is in the repo root directory. There is no symlink alias for the root — only one path ever watches the root inode, so no collision occurs.
crates/fs/src/fs_watcher.rs:87-88— Linux usesNonRecursivemode (per-directory inotify watches)crates/fs/src/fs_watcher.rs:95-113— Watch callback withstarts_withpath filtercrates/fs/src/fs_watcher.rs:402-411—handle_eventtraces then filtersAccesseventscrates/worktree/src/worktree.rs:4845—watcher.add(job.abs_path)uses the scan job's path (which may be a symlink path)crates/worktree/src/worktree.rs:4785-4786— Scan jobs usechild_abs_path(not canonicalized)crates/worktree/src/worktree.rs:4951-4964—should_scan_directoryescape hatches that allow ignored dirs to be scannednotify/src/inotify.rs(revce58c24) —paths: HashMap<WatchDescriptor, PathBuf>, event path derivation fromevent.wd, andself.paths.insert(w, path)inadd_watch- Related upstream context:
zed#38109,zed#51208(stable target noted as March 25, 2026 in maintainer comment)
- Canonicalize paths before registering inotify watches — resolve symlinks in
FsWatcher::addso watches are always registered on the canonical path, preventing collisions. - Canonicalize the scan job
abs_path— when creatingScanJobfor symlinked directories (worktree.rs:4785), use the canonical path instead of the symlink path. - Match events against all registered callbacks — instead of filtering by
starts_with, match by inode or canonical path.
Preferred approach: Fix at the Zed watcher layer (crates/fs/src/fs_watcher.rs) by canonicalizing watch registration paths and using canonical-path-consistent filtering.
Why this is the right level:
- The bug is fundamentally about watch descriptor/path aliasing semantics.
worktreeshould not need to own low-level watch alias normalization.- Changing upstream
notifypath semantics would be broader and riskier for all consumers. - Changing Zed's pinned
notifyfork is Zed-scoped, but still needs careful handling of watcher add/remove and descriptor/path mapping invariants.
- Canonicalize before
global().add(...)on Linux, so aliased paths collapse to one canonical watch identity. - Ensure
remove(...)bookkeeping remains correct (if needed, map requested path -> canonical watch path / registration id). - Keep behavior unchanged on macOS/Windows except where shared code paths are explicitly safe.
- Moderate, not trivial: touches watcher registration/removal identity and path filtering assumptions.
- Surgical: contained to the FS watcher boundary, with clear regression-testability.
- Not a hack if paired with Linux symlink-alias regression tests.
If desired, land a fix in Zed's pinned notify fork and open a parallel upstream notify discussion about descriptor/path alias semantics; do not block the Zed fix on upstream changes.