Skip to content

Instantly share code, notes, and snippets.

@sebinsua
Last active April 10, 2026 15:32
Show Gist options
  • Select an option

  • Save sebinsua/55ddf81af1e9b3dba99c58b1ca115141 to your computer and use it in GitHub Desktop.

Select an option

Save sebinsua/55ddf81af1e9b3dba99c58b1ca115141 to your computer and use it in GitHub Desktop.

Symlink inotify watch collision bug

Observed behavior

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.

Prior related issue/PR context

This appears related to previous Linux fs-watcher work, but is a distinct failure mode.

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.

Reproduction

  1. Open a yarn workspaces monorepo in Zed on Linux. The repo has client/ as a workspace, which yarn symlinks into node_modules/client../client.
  2. Externally modify a file deep in the tree, e.g. client/src/some-feature/SomeComponent.tsx.
  3. Zed does not pick up the change. The buffer stays stale.
  4. Externally modify README.md at the repo root.
  5. Zed picks up the change immediately.

What the logs show

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 under node_modules/client/src/... (the symlinked path), not client/src/... (the real path).
  • No Modify events appear for either path when files are externally changed.
  • The Access events are filtered out by handle_event in fs_watcher.rs:408 and 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.

Log excerpt

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"], ... })

Root cause analysis

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.

How it happens

Step 1: Initial scan watches the real path

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).

Step 2: Something triggers scanning of the symlinked path

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/

Step 3: inotify watch replacement

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.

Additional confirmation (kernel + notify internals)

This mechanism is directly confirmed by both source inspection and a local inotify call test.

notify internals (zed patch ce58c24)

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.

Kernel-level check

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 (with node_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.

Step 4: Events are reported with the wrong path

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

Step 5: The callback filter drops the event

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.

Why root-level files work

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.

Relevant code

  • crates/fs/src/fs_watcher.rs:87-88 — Linux uses NonRecursive mode (per-directory inotify watches)
  • crates/fs/src/fs_watcher.rs:95-113 — Watch callback with starts_with path filter
  • crates/fs/src/fs_watcher.rs:402-411handle_event traces then filters Access events
  • crates/worktree/src/worktree.rs:4845watcher.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 use child_abs_path (not canonicalized)
  • crates/worktree/src/worktree.rs:4951-4964should_scan_directory escape hatches that allow ignored dirs to be scanned
  • notify/src/inotify.rs (rev ce58c24) — paths: HashMap<WatchDescriptor, PathBuf>, event path derivation from event.wd, and self.paths.insert(w, path) in add_watch
  • Related upstream context: zed#38109, zed#51208 (stable target noted as March 25, 2026 in maintainer comment)

Possible fixes

  1. Canonicalize paths before registering inotify watches — resolve symlinks in FsWatcher::add so watches are always registered on the canonical path, preventing collisions.
  2. Canonicalize the scan job abs_path — when creating ScanJob for symlinked directories (worktree.rs:4785), use the canonical path instead of the symlink path.
  3. Match events against all registered callbacks — instead of filtering by starts_with, match by inode or canonical path.

Recommended fix choice

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.
  • worktree should not need to own low-level watch alias normalization.
  • Changing upstream notify path semantics would be broader and riskier for all consumers.
  • Changing Zed's pinned notify fork is Zed-scoped, but still needs careful handling of watcher add/remove and descriptor/path mapping invariants.

Engineering notes

  • 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.

Complexity / risk

  • 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.

Follow-up (optional)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment