Fix file watching permanently stopping when macOS FSEvents exhausts its file descriptor budget - #61072
Fix file watching permanently stopping when macOS FSEvents exhausts its file descriptor budget#61072marcalc wants to merge 2 commits into
Conversation
|
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 check |
|
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'. |
|
The cla-bot has been summoned, and re-checked this pull request! |
|
@cla-bot check |
|
The cla-bot has been summoned, and re-checked this pull request! |
…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.
a64bb4e to
764739b
Compare
|
@SomeoneToIgnore Pinging here, as I think this PR is not showing on the community board any longer. |
|
@SomeoneToIgnore Hi Kiril, would you have any feedback? Is this something we still want to pursue or should I close the PR? |
|
@SomeoneToIgnore Am I missing anything here? Have received no response from the team. |
|
@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. |
|
@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! |
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 streamerrors (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):
RLIMIT_NOFILEof 256 (hard: unlimited) from launchd. Apple's frameworks silently raise it to 2560 on first FSEvents use. Zed never raises it itself, and thezedCLI launches the app viaLSOpenFromURLSpec, so launching from a terminal doesn't help.FSEventStream(notify's design), and that stream costs ~11 file descriptors per watched path, so the effective budget is ~230 watched paths. Roslyn registeringdidChangeWatchedFilesfor ~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.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 laterwatch()retries the same oversized set and fails forever — observed live as continuous failures at only 146 open fds.is_max_files_watch_erroronly matches inotify'sMaxFilesWatch, 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 tomin(hard, OPEN_MAX)at startup on Unix — the formula documented insetrlimit(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 atkFSEventStreamEventIdSinceNow, 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
FSEventStreamandFSEventStreamStartfails permanently once the fd budget is exhausted (log flooded withunable 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 aroundFS_WATCH_LATENCYand 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 utilclean;cargo check -p zedpasses.zed-industries/notifyrevfaecbc3), run withbash -c 'ulimit -n 256; ...'to simulate GUI launch limits:open), 400+ paths watch successfully.unable to start FSEvent stream; external edits, git changes, and new files stop appearing once it triggers.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.#[cfg(unix)]-gated) or Linux beyond compilation; on Linux the new saturation arm only adds behavior already exercised by theMaxFilesWatchpath.Self-Review Checklist:
Release Notes: