Skip to content

Fix file watching permanently stopping when macOS FSEvents exhausts its file descriptor budget - #61072

Open
marcalc wants to merge 2 commits into
zed-industries:mainfrom
marcalc:fix-fsevents-fd-saturation
Open

Fix file watching permanently stopping when macOS FSEvents exhausts its file descriptor budget#61072
marcalc wants to merge 2 commits into
zed-industries:mainfrom
marcalc:fix-fsevents-fd-saturation

Conversation

@marcalc

@marcalc marcalc commented Jul 15, 2026

Copy link
Copy Markdown

Objective

Addresses one root cause reported under #53901 (the macOS FSEvents fd-saturation case). #53901 is an umbrella "out of sync with external changes" report with more than one cause — see the Scope note below — so this intentionally does not use a closing keyword.

On macOS, Zed can permanently stop seeing all external file changes — open buffers don't reload, git status freezes, and the project panel stops updating — until the app is restarted. The log fills with unable to start FSEvent stream errors (3,392 from the worktree scanner and 778 from watcher retries in one session's log on my machine).

Solution

Root cause, with each step verified empirically on an affected install (macOS 26.4.1, Zed 1.11.3, C#/Roslyn extension):

  1. GUI-launched processes get a soft RLIMIT_NOFILE of 256 (hard: unlimited) from launchd. Apple's frameworks silently raise it to 2560 on first FSEvents use. Zed never raises it itself, and the zed CLI launches the app via LSOpenFromURLSpec, so launching from a terminal doesn't help.
  2. All native watches share one FSEventStream (notify's design), and that stream costs ~11 file descriptors per watched path, so the effective budget is ~230 watched paths. Roslyn registering didChangeWatchedFiles for ~240 individual out-of-tree files (NuGet DLLs, dotnet SDK files) exceeds it. Agent terminal commands fail with FD exhaustion - 173 MacWatcher threads consuming 2600+ file descriptors #47064 hit the same wall from the other side: 2,603 fds against the 2,560 limit.
  3. The failure is not contained to the new path: notify's watch() rebuilds the stream (stop → append → recreate), so a failed rebuild kills every existing watch, and the failed path stays in notify's path set, so every later watch() retries the same oversized set and fails forever — observed live as continuous failures at only 146 open fds. is_max_files_watch_error only matches inotify's MaxFilesWatch, so on macOS this bypassed the fs: Retry watch registrations skipped during the native watch-limit cooldown #60662 cooldown and the blackout was permanent (consistent with the "fixed by restart" report in Project Panel content isn’t updated automatically #53901).

Changes:

  • util/zed: raise the soft fd limit to min(hard, OPEN_MAX) at startup on Unix — the formula documented in setrlimit(2) COMPATIBILITY. Chromium (8192 on Apple), the JVM, and Go do the same. 10240 fds ≈ 930 watched paths, ~4× the current implicit budget.
  • fs: treat the FSEvents stream-start failure as watcher saturation and recover: unwatch the failed path (rebuilding the previous, known-good stream — verified against real notify that this resurrects all existing watches), broadcast a rescan to native registrations (streams start at kFSEventStreamEventIdSinceNow, so events during the dead window are not replayed), and start the existing native-watch-limit cooldown so a burst of doomed registrations can't thrash stream rebuilds. Skipped paths retry through the pending-registration machinery from fs: Retry watch registrations skipped during the native watch-limit cooldown #60662.

Scope: #53901 collects several distinct failures behind one symptom. This PR fixes the macOS-only case where all watches share one FSEventStream and FSEventStreamStart fails permanently once the fd budget is exhausted (log flooded with unable to start FSEvent stream). It does not address the separate delete-then-recreate race @Akizay reproduced on Linux (only the first ~9–10 of a burst appear, fixed by a sleep > FS_WATCH_LATENCY, no saturation errors in the log) — that's a worktree rescan/debounce issue around FS_WATCH_LATENCY and deserves its own fix. Both are real; they're just not the same bug.

Related: #53480, #58678, #47064. Overlaps #59571, which detects the same error string but only cools down; without the rollback, retries keep rebuilding the oversized path set and never succeed. Complementary: #57346 reduces watched-path demand by normalizing per-file registrations to parent directories; the two compose.

Disclosure per the AI policy: I investigated and developed this with assistance from an LLM agent (Claude Code/Fable 5), reviewing and directing each step; the measurements below are from real runs on my machine and I can defend them in review.

Testing

  • cargo test -p fs: 16/16, including a new test covering rollback + rescan broadcast + cooldown after an FSEvent stream-start failure.
  • ./script/clippy -p fs -p util clean; cargo check -p zed passes.
  • Standalone binary against Zed's notify fork (zed-industries/notify rev faecbc3), run with bash -c 'ulimit -n 256; ...' to simulate GUI launch limits:
    • saturation reproduces at the 22nd watch; at failure all previously working watches are dead (fd count collapses 248 → 5) and retries fail forever with descriptors free;
    • after unwatching the failed path, the previously dead watches deliver events again (fd count returns to 249) — the rollback this PR adds;
    • with the startup rlimit raise (256 → 10240 under Launch-Services conditions, verified via a probe app launched with open), 400+ paths watch successfully.
  • To reproduce the original failure end-to-end: on macOS, open a C# project with the Roslyn extension enabled in a Finder/Dock-launched Zed and watch the log for unable to start FSEvent stream; external edits, git changes, and new files stop appearing once it triggers.
  • Performance: the rollback's stream rebuild measures 4ms/15ms/42ms at 50/200/500 watched paths (linear, background thread), bounded to once per cooldown window; ordinary watch() calls already pay the same rebuild cost. Hot paths are unchanged, and the rescan broadcast reuses the existing coalesced lost-sync rescan machinery from fs: Coalesce queued rescans after watcher overflow #60098.
  • Not tested on Windows (change is #[cfg(unix)]-gated) or Linux beyond compilation; on Linux the new saturation arm only adds behavior already exercised by the MaxFilesWatch path.

Self-Review Checklist:

  • I've reviewed my own diff for quality, security, and reliability
  • Unsafe blocks (if any) have justifying comments
  • The content adheres to Zed's UI standards (UX/UI and icon guidelines)
  • Tests cover the new/changed behavior
  • Performance impact has been considered and is acceptable

Release Notes:

  • Fixed external file changes (open buffers, git status, and the project panel) no longer being detected on macOS after the file watcher exhausted its file descriptor budget, e.g. when language servers watch many files

@cla-bot

cla-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

We require contributors to sign our Contributor License Agreement, and we don't have @marcalc on file. You can sign our CLA at https://zed.dev/cla. Once you've signed, post a comment here that says '@cla-bot check'.

@zed-community-bot zed-community-bot Bot added the first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions label Jul 15, 2026
@marcalc

marcalc commented Jul 15, 2026

Copy link
Copy Markdown
Author

@cla-bot check

@cla-bot

cla-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

We require contributors to sign our Contributor License Agreement, and we don't have @marcalc on file. You can sign our CLA at https://zed.dev/cla. Once you've signed, post a comment here that says '@cla-bot check'.

@cla-bot

cla-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

The cla-bot has been summoned, and re-checked this pull request!

@marcalc

marcalc commented Jul 15, 2026

Copy link
Copy Markdown
Author

@cla-bot check

@cla-bot cla-bot Bot added the cla-signed The user has signed the Contributor License Agreement label Jul 15, 2026
@cla-bot

cla-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

The cla-bot has been summoned, and re-checked this pull request!

@SomeoneToIgnore SomeoneToIgnore added area:scanning Worktree scanning related PRs. platform:macOS Platform-specific feedback for macOS behaviors, features, design, etc labels Jul 16, 2026
marcalc added 2 commits July 16, 2026 21:22
…ts fd budget

GUI-launched processes on macOS inherit a soft RLIMIT_NOFILE of 256
(silently raised to 2560 by Apple's frameworks on first FSEvents use),
and the shared FSEventStream costs ~11 file descriptors per watched
path. A language server registering a few hundred watched files (e.g.
Roslyn watching individual NuGet DLLs) exceeds that budget: the stream
rebuild fails with "unable to start FSEvent stream", every existing
watch dies with it, and because the failed path stays in notify's path
set, every retry rebuilds the same oversized set and fails forever.
File events stop entirely - buffers, git status, and the project panel
go stale until restart.

- Raise the soft fd limit to min(hard, OPEN_MAX) at startup on Unix,
  as Chromium, the JVM, and Go do.
- Treat the FSEvents stream-start failure as watcher saturation (like
  inotify's MaxFilesWatch): roll back the failed path to restore the
  previous working stream, broadcast a rescan to cover the window in
  which the stream was down, and skip registrations for the cooldown.
@marcalc
marcalc force-pushed the fix-fsevents-fd-saturation branch from a64bb4e to 764739b Compare July 16, 2026 11:22
@marcalc

marcalc commented Jul 17, 2026

Copy link
Copy Markdown
Author

@SomeoneToIgnore Pinging here, as I think this PR is not showing on the community board any longer.

@marcalc

marcalc commented Jul 21, 2026

Copy link
Copy Markdown
Author

@SomeoneToIgnore Hi Kiril, would you have any feedback? Is this something we still want to pursue or should I close the PR?

@marcalc

marcalc commented Jul 28, 2026

Copy link
Copy Markdown
Author

@SomeoneToIgnore Am I missing anything here? Have received no response from the team.

@SomeoneToIgnore

Copy link
Copy Markdown
Contributor

@marcalc , you missed https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#sending-changes

Sorry for the long response — I have no clue when someone with the knowledge comes and reviews — yet this is not a reason to ping people for nothing.

@marcalc

marcalc commented Jul 28, 2026

Copy link
Copy Markdown
Author

@SomeoneToIgnore Appreciate the patience, response and for pointing out the instructions, as this is my first PR on the project and surely bound to miss something. Won't ping you after this time, thanks!

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

Labels

area:scanning Worktree scanning related PRs. cla-signed The user has signed the Contributor License Agreement first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions platform:macOS Platform-specific feedback for macOS behaviors, features, design, etc

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants