Skip to content

kqueue: drop watches directly in Close() instead of going through remove()#740

Merged
mattn merged 2 commits into
fsnotify:mainfrom
SAY-5:fix/kqueue-close-leaks-fds-732
Apr 26, 2026
Merged

kqueue: drop watches directly in Close() instead of going through remove()#740
mattn merged 2 commits into
fsnotify:mainfrom
SAY-5:fix/kqueue-close-leaks-fds-732

Conversation

@SAY-5

@SAY-5 SAY-5 commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #732.

kqueue.Close() calls w.Remove(name) in a loop after w.shared.close() has marked the watcher closed. w.Remove routes through w.remove, which short-circuits on w.isClosed() and returns nil without calling unix.Close on the watch fd or dropping the watches bookkeeping. Every directory watch (and each file inside it — a dir watch opens one fd per child on macOS) leaked its descriptor on Close, so long-running processes that recycle watchers (hot-reload dev servers, test harnesses) hit EMFILE after a few cycles.

Fix

Iterate the paths directly in Close(): register the EV_DELETE kevent, unix.Close the fd, and drop the watches entry. kqueue.remove() is unchanged, so Remove() on a live watcher keeps the same semantics; only Close() stops leaning on it.

Tests

Adds TestCloseClearsWatchState in backend_kqueue_test.go: creates a watcher over a temp dir + file, calls Close, and asserts every watches map is drained.

  • On master: fails — watches.path: 3 entries, watches.wd: 3 entries, watches.byUser: 2 entries, watches.byDir: 3 entries.
  • With the fix: passes.

go test -count=1 ./... green on darwin; go vet clean.

…ove()

Close() called w.Remove(name) in a loop after w.shared.close() marked
the watcher closed, but w.Remove -> w.remove short-circuits on
w.isClosed() and returns nil without calling unix.Close on the watch
fd or touching the watches bookkeeping. Every directory (and each
file inside it - a dir watch opens one fd per child on macOS) leaked
its descriptor on Close, so long-running processes that recycle
watchers (hot-reload dev servers, test harnesses) hit EMFILE after a
few cycles (fsnotify#732).

Iterate the paths directly in Close(): register the EV_DELETE kevent,
unix.Close the fd, and drop the watches entry. The pre-existing
kqueue.remove() path is unchanged, so Remove() on a live watcher
keeps the same semantics; only Close() stops leaning on it.

Adds TestCloseClearsWatchState: creates a watcher over a dir + file,
calls Close, and asserts every watches map is drained. Fails on
master (3 path entries, 3 wd entries, 2 byUser, 3 byDir remain) and
passes with the fix.

Fixes fsnotify#732

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
Comment thread backend_kqueue.go
for _, name := range pathsToRemove {
w.Remove(name)
info, ok := w.watches.byPath(name)
if !ok {

@mattn mattn Apr 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

		if !ok {
			w.watches.remove(0, name)
			continue
		}

Per @mattn's review on fsnotify#740: if the Close loop walks a name from
w.path but byPath can't resolve it to a live w.wd entry, the
w.path / w.byUser entries for that name are still live and would
survive Close. Call w.watches.remove(0, name) on the not-ok branch so
the lookup maps are left consistent even in that inconsistent-state
corner case.
@SAY-5

SAY-5 commented Apr 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @mattn — applied your suggestion. The not-ok branch now calls w.watches.remove(0, name) so stale w.path / w.byUser entries get purged even when byPath can't resolve to a live w.wd entry. go test -count=1 ./... green locally.

@mattn mattn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look this in later again. Thank you

@mattn mattn merged commit eadf267 into fsnotify:main Apr 26, 2026
18 checks passed
@mattn

mattn commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Thank you

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kqueue: Close() leaks all watch file descriptors

2 participants