Skip to content

fs.watch: decouple from bun.Watcher, own inotify/FSEvents/kqueue directly#29952

Merged
Jarred-Sumner merged 10 commits into
mainfrom
farm/a8a44bfa/fs-watch-decouple
Apr 30, 2026
Merged

fs.watch: decouple from bun.Watcher, own inotify/FSEvents/kqueue directly#29952
Jarred-Sumner merged 10 commits into
mainfrom
farm/a8a44bfa/fs-watch-decouple

Conversation

@robobun

@robobun robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

What

Rewrite the POSIX fs.watch() backend (src/bun.js/node/path_watcher.zig) to talk to inotify/FSEvents/kqueue directly instead of routing through bun.Watcher (the bundler/--watch/--hot watcher). bun.Watcher itself is unchanged.

Why

bun.Watcher is shaped around a module graph: its WatchItem carries options.Loader, *PackageJSON, a *bun.fs.FileSystem, and on Windows is pinned to top_level_dir. None of that applies to fs.watch(). Adapting it required a 1068-line shim with:

win_watcher.zig never went through bun.Watcher — it wraps uv_fs_event directly in ~300 lines. This PR gives the other platforms the same shape.

How

PathWatcherManager        process-global, lazy, owns the OS resource
  ├─ Linux:   one inotify fd + one reader thread, wd → PathWatcher map
  ├─ macOS:   delegates to fs_events.zig (one CFRunLoop thread, one FSEventStream)
  └─ FreeBSD: one kqueue fd + one reader thread, fd → PathWatcher map

PathWatcher               one per unique (realpath, recursive) — deduped
  └─ handlers[]           the JS FSWatcher contexts sharing this watch
  • Dedup: two fs.watch() on the same path share one PathWatcher (one OS watch, handlers list). detach() removes one handler; last one out tears down the OS watch. Dedup key is realpath + recursive-bit since recursive/non-recursive need different registrations.
  • One mutex guards the watchers map + platform dispatch maps. The reader thread holds it while dispatching so detach() can't free mid-emit. Replaces the three interacting mutexes.
  • Linux recursive now adds a new inotify wd on IN_CREATE|IN_ISDIR (and walks the new subtree once), so files inside directories created after watch() are delivered. Fixes fs.watch {recusive:true} does not react to new items. #15939 / fs.watch cannot detect changes in files that are created after bun starts #15085.
  • macOS uses FSEvents for both files and directories (matching libuv), so fs.watch() no longer runs two watcher threads. The FSEvents callback routes through PathWatcher.emit()handlers[], same as the other platforms.
  • FreeBSD uses a shared kqueue fd with EVFILT_VNODE per path. kqueue gives no filenames; directory events surface as a bare rename with empty path (libuv semantics).
  • No more vm.transpiler.fs / options.Loader / *PackageJSON anywhere in the fs.watch path.

src/Watcher.zig, src/watcher/*, hot_reloader.zig, bake/DevServer.zig are untouched — --watch/--hot keep their own watcher.

Verification

  • zig:check-all green on linux/mac/freebsd/windows × debug/release
  • All 20 Node test/js/node/test/parallel/test-fs-watch*.js pass
  • test/js/node/watch/*.test.ts green (the two permission tests that fail are pre-existing; they fail on main too when running as root)
  • test/cli/hot/watch.test.ts green (bun.Watcher unchanged)
  • New fs.watch.rewrite.test.ts: recursive-new-subdir test fails on main (the fs.watch {recusive:true} does not react to new items. #15939 bug), passes here

Size

path_watcher.zig: 1068 → 636 lines (−40%), fs_events.zig −3 lines, net −204 lines with tests included.

Fixes #15939
Fixes #15085

…ctly

The POSIX fs.watch() backend (path_watcher.zig) previously routed every
watch through a full bun.Watcher instance — the bundler/--watch/--hot
watcher, whose WatchItem carries options.Loader, *PackageJSON, and a
*bun.fs.FileSystem, and whose Windows backend is pinned to top_level_dir.
Adapting that for fs.watch() required:

  * a 1068-line shim with three interacting mutexes and deferred-deinit
    workarounds (60 lock/atomic sites)
  * a WorkPool DirectoryRegisterTask that crawled the tree once and
    registered every file with the bundler watcher (recursive never saw
    directories created after watch())
  * a bolted-on FSEvents side-channel for macOS directories, so
    fs.watch(dir) on macOS spun up two watcher threads (kqueue +
    CFRunLoop), guarded by 'if (fsevents_watcher != null) continue'
    everywhere
  * O(watchers×events) string-prefix re-fanout in onFileUpdate

win_watcher.zig never went through bun.Watcher — it wraps uv_fs_event
directly in 310 lines. This gives Linux/macOS/FreeBSD the same shape:

  PathWatcherManager   process-global, one mutex, one inotify/kqueue fd
                       + reader thread (or FSEvents CFRunLoop on macOS)
  PathWatcher          one per unique (realpath, recursive) — deduped
    handlers[]         JS FSWatcher contexts sharing this OS watch

Duplicate fs.watch() on the same path appends a handler to the existing
PathWatcher; detach() removes one, last-out tears down the OS watch.

Linux recursive now adds an inotify wd on IN_CREATE|IN_ISDIR so new
subdirectories are tracked (fixes #15939/#15085). macOS uses FSEvents
for both files and directories (matching libuv) so fs.watch() no longer
runs two watcher threads. bun.Watcher is unchanged; --watch/--hot/
DevServer still use it.

1068 → 636 lines, one mutex instead of three.
@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:43 PM PT - Apr 29th, 2026

@robobun, your commit dd8732c has 2 failures in Build #49273 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29952

That installs a local version of the PR into your bun-29952 executable, so you can run:

bun-29952 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 8 issues this PR may fix:

  1. fs.watch with recursive: true: inconsistent filename (basename vs relative path) #29677 - PR rewrites Linux recursive-watch via direct inotify with proper subdirectory tracking, directly affecting how filenames are reported for events in subdirs
  2. fs.watch does not work after previous .close() #18919 - PR introduces proper lifecycle management with dedup by realpath and clean teardown, fixing broken reuse-after-close behavior
  3. fs.watch() throws unhandled EACCES when a file inside watched directory is not readable #28038 - PR switches Linux backend to pure inotify without opening watched files, eliminating the EACCES crash from the old backend
  4. fs.watch does not work as expected #23306 - PR's full rewrite of the Linux inotify backend and lifecycle/teardown logic addresses all three reported sub-bugs (rename on deletion, change on modify, events after close+rewatch)
  5. --hot/--watch causes fs.watch to drop filechange events #4854 - PR decouples fs.watch from bun.Watcher, removing the shared-backend interference that caused --watch/--hot to drop fs.watch events
  6. fs.watch goes to infinite loop with big files #6955 - PR replaces macOS dual kqueue+FSEvents backend with FSEvents-only, removing the repeated-callback loop triggered by large-file writes
  7. watch doesn't trigger on file writes on macOS #14568 - PR delegates macOS backend entirely to FSEvents, which correctly detects writes from external processes unlike the old kqueue path
  8. Background ENOENT error triggered by fs.watch in WSL2 #20260 - PR's pure inotify approach does not open files during event dispatch, eliminating the spurious ENOENT on WSL2

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #29677
Fixes #18919
Fixes #28038
Fixes #23306
Fixes #4854
Fixes #6955
Fixes #14568
Fixes #20260

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(fs): align fs.watch with libuv — stop opening files, add IN_ATTRIB, auto-watch new subdirs #28290 - Also reworks the fs.watch POSIX backend in path_watcher.zig to align with libuv; entirely subsumed by this rewrite
  2. fix(fs.watch): register inotify watches on newly-created subdirectories #29678 - Fixes fs.watch {recusive:true} does not react to new items. #15939 (same issue) by adding inotify watches for new subdirectories in path_watcher.zig; fully replaced by this PR
  3. Fix inotify for directory watches #25236 - Draft PR fixing inotify directory watching in the same code area that this PR rewrites
  4. fs.watch: fix PathWatcher double-free race between close() and DirectoryRegisterTask #29936 - Fixes double-free race in PathWatcher close/DirectoryRegisterTask; moot since this PR replaces that code entirely
  5. fix(watcher): resolve lock ordering inversion in PathWatcherManager #26385 - Fixes lock ordering inversion in PathWatcherManager; moot since this PR removes PathWatcherManager
  6. fix(fs.watch): fix second watcher not receiving events after close #27044 - Fixes second-watcher eviction bug in PathWatcherManager; moot since this PR removes PathWatcherManager

🤖 Generated with Claude Code

The manager is process-global and never torn down, so the inotify/kqueue
reader is a daemon — detach() it at spawn instead of stashing a
std.Thread handle that defaulted to undefined (ban-words lint) and
would never be joined anyway.
@Jarred-Sumner Jarred-Sumner marked this pull request as ready for review April 29, 2026 20:36
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@claude review

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun theres only one global watcher thread right? It doesn’t create one per fs.Watch.

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

theres only one global watcher thread right? It doesn’t create one per fs.Watch.

Correct — one thread total for all fs.watch() calls, not per call.

Platform Threads Where
Linux 1 Linux.init() spawns one reader on the first fs.watch(); every subsequent call just inotify_add_watch()s on the same fd and adds a wd_map entry. 100 watchers → 1 thread, 1 inotify fd, 100 wds.
FreeBSD 1 Same shape with one kqueue fd.
macOS 0 new Delegates to fs_events.zig's existing process-global CFRunLoop thread (one FSEventStream for all paths). Files now go through it too, so fs.watch() no longer also starts a kqueue thread like before.
Windows 0 win_watcher.zig unchanged — libuv on the main loop.

The reader thread is detached (daemon) since the manager is process-global and never torn down. --hot/--watch still run their own separate bun.Watcher thread — the two don't share.

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Replaces the previous bun.Watcher-based fs.watch with a process-global PathWatcherManager (deduplicated by resolved path + recursive flag), adds platform-specific backends (Linux inotify, macOS FSEvents, FreeBSD kqueue), changes the FSEvents callback type, and adds fs.watch behavioral tests.

Changes

Cohort / File(s) Summary
FSEventsWatcher type
src/bun.js/node/fs_events.zig
Replaced FSEventsWatcher.Callback alias to PathWatcher.Callback with an explicit function-pointer type *const fn (ctx: ?*anyopaque, event: Event, is_file: bool) void. Imports EventType directly from ./path_watcher.zig. Callback invocation remains this.callback(this.ctx, event, is_file).
PathWatcher backend rewrite
src/bun.js/node/path_watcher.zig
Replaced bun.Watcher/task-based implementation with a process-global PathWatcherManager that deduplicates by canonical resolved path and recursive flag. Introduces per-PathWatcher handler maps, change-event coalescing, handler-driven detach() teardown, and consolidated locking via manager.mutex. Adds platform backends: Linux inotify (reader thread, wd→owners map, recursive expansion), macOS via fs_events.zig (callback fan-out under manager.mutex), and FreeBSD kqueue (reader thread, fd-based vnode entries, directory events without filenames). watch() now canonicalizes paths, attaches handlers to deduped watchers, registers OS watches via Platform.addWatch(), and rolls back on registration errors.
Tests and infra
test/js/node/watch/fs.watch.rewrite.test.ts, test/internal/ban-limits.json
Adds new tests validating recursive creation, deduplication, overlapping-watch behavior, and a cold-VM invocation of fs.watch. Adjusts test/internal/ban-limits.json to decrement the ".stdDir()" limit from 42 to 41.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: decoupling fs.watch from bun.Watcher and implementing direct platform API usage.
Description check ✅ Passed The pull request description comprehensively covers both required template sections (What and How) with detailed explanations of motivation, architecture, and verification.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/node/path_watcher.zig`:
- Around line 438-452: The wd_map entry is currently overwritten with a single
owner (gop.value_ptr.* = { .watcher = watcher, ... }), which breaks overlapping
recursive watches when inotify reuses the same wd; instead, change the wd_map
value to track a collection of owners (e.g., an owners list/set of PathWatcher
pointers and associated subpaths), update the insertion code to append the new
watcher+subpath to that owners collection (rather than freeing and replacing
subpath), update watcher.platform.wds.append usage to register the wd but not to
remove previous owners, and modify rm_watch/cleanup to remove only the departing
watcher from the owners collection and only call inotify_rm_watch/free the
wd_map entry when the owners collection becomes empty; update any
allocation/free logic around value_ptr.subpath to manage per-owner subpaths
accordingly.
- Around line 131-141: The predicate in shouldEmit is using a logical AND
between event_type and hash comparisons which suppresses valid events; change
the condition so that a differing event_type OR a differing hash triggers
emission (i.e., replace the combined "this.event_type != event_type and
this.hash != hash" with an OR-based check), keeping the existing timestamp check
and updating this.timestamp, this.event_type, and this.hash only when the
function decides to emit; this change should be made in the shouldEmit function
to correctly detect per-path or per-type differences.
- Around line 465-478: The watcher code constructs file paths using
std.fmt.bufPrint* and std.fs.path functions (e.g., creating child_abs and
child_rel with abs_buf/rel_buf and using basename elsewhere); replace those
manual joins and basename uses with Bun's path helpers (bun.path.join,
bun.path.basename, and related bun.path APIs) and use bun.path_buffer_pool where
appropriate so code uses bun.path utilities instead of std.fmt/std.fs.path;
update the blocks that build child_abs/child_rel (variables abs_buf, rel_buf,
child_abs, child_rel) and the other mentioned ranges (around lines 566-575,
777-789, 836-839) to call bun.path functions and bun.path_buffer_pool
equivalents.
- Around line 660-677: onFSEvent() is iterating watcher.handlers on the
CFRunLoop thread without synchronizing with watch()/detach(), which can
rehash/move the AutoArrayHashMapUnmanaged under manager.mutex; fix by taking
manager.mutex (the same mutex used by watch()/detach()) or by creating a safe
snapshot of watcher.handlers under manager.mutex and then iterating the snapshot
without the lock; update onFSEvent() (and onFSEventFlush() if it touches
handlers) to either lock manager.mutex around map access or copy entries into a
temporary array while holding manager.mutex, then release the mutex and call
watcher.emit()/emitError() on the snapshot; keep detach()’s
emit_in_progress/pending_deinit behavior unchanged so frees remain deferred.

In `@test/js/node/watch/fs.watch.rewrite.test.ts`:
- Around line 33-35: Replace the fixed Bun.sleep(100) readiness delay before
calling mkdir with an explicit await of the watch callback observing the
readiness file (seed.txt) so the test only proceeds once the root watch is
actually registered; specifically, remove the Bun.sleep(100) and add logic to
await the watch handler/event that reports creation or presence of "seed.txt"
(or another explicit readiness signal observed by the fs.watch callback used in
this test) before invoking mkdir so the mkdir event is not lost and retries can
succeed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5eb7fd16-4a6f-4f82-be5f-bc56fc29be38

📥 Commits

Reviewing files that changed from the base of the PR and between 6c21a7e and 158296a.

📒 Files selected for processing (4)
  • src/bun.js/node/fs_events.zig
  • src/bun.js/node/path_watcher.zig
  • test/internal/ban-limits.json
  • test/js/node/watch/fs.watch.rewrite.test.ts

Comment thread src/bun.js/node/path_watcher.zig
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread test/js/node/watch/fs.watch.rewrite.test.ts
Linux wd_map: inotify_add_watch returns the same wd for the same inode,
so a recursive watch on /a and a plain watch on /a/sub share /a/sub's
wd. The single-owner map meant the inner watch stole dispatch from the
parent, and closing it rm_watch'd the wd out from under the parent.
wd_map is now wd → list of {watcher, subpath}; dispatch iterates every
owner and inotify_rm_watch only fires when the last owner detaches.
New test covers this.

detach() now does the full teardown (handlers, dedup-map removal,
platform dispatch-map removal) inside one manager.mutex critical
section. Previously it dropped the lock between 'handlers empty' and
'remove from map', so a concurrent watch() from another Worker could
find and reuse a PathWatcher that was about to be destroyed.

macOS lock order: onFSEvent/onFSEventFlush now take manager.mutex so
iterating watcher.handlers can't race with attach/detach mutating the
map. To avoid AB/BA with the FSEvents loop mutex (which _events_cb
holds while calling onFSEvent), Darwin.addWatch/removeWatch are now
called *outside* manager.mutex — the JS thread never holds both, so
the order is one-way: fsevents_loop.mutex → manager.mutex. With every
emit path now under manager.mutex, the emit_in_progress/pending_deinit
deferred-free machinery is no longer needed and is removed.

The shouldEmit predicate is left as-is — it is copied verbatim from
win_watcher.zig (and the old path_watcher.zig); changing it here would
diverge POSIX from Windows.
Comment thread src/bun.js/node/path_watcher.zig
Comment thread src/bun.js/node/path_watcher.zig Outdated
The owners loop cached `plat.wd_map.getPtr(ev.wd)` once and iterated
its .items. When a recursive owner saw IN_CREATE|IN_ISDIR it called
addOne/walkAndAdd, which do wd_map.getOrPut() and can rehash — moving
the ArrayList header the cached pointer was pointing at, so the next
`oi < owners.items.len` read dangling memory. The existing comment
guarded against the inner ArrayList's .items growing but missed that
the pointer to the ArrayList itself lives in the hashmap's
reallocatable storage.

Now re-fetch wd_map.getPtr(ev.wd) at the top of each iteration. The
owner's heap-owned subpath slice survives a rehash (only the header
moves), so no further copying is needed.

Also drop an unused `const plat` local in Linux.addWatch.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/node/path_watcher.zig`:
- Around line 819-824: The kevent() return value is ignored, so registration
failures still add the entry and return success; update the logic around the
std.posix.system.kevent call (the call using plat.kq.native() and var changes)
to check its return and errno, and if it fails propagate an appropriate Zig
error instead of proceeding to bun.handleOom(plat.entries.put(...)) and
watcher.platform.fds.append(...); only insert the entry and append the fd on
successful kevent registration, and ensure any allocated resources are cleaned
up on failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 69649f79-058a-4b99-969e-77d4d1e505dc

📥 Commits

Reviewing files that changed from the base of the PR and between f2b14bc and a3a0c85.

📒 Files selected for processing (1)
  • src/bun.js/node/path_watcher.zig

Comment thread src/bun.js/node/path_watcher.zig
Previously discarded the return value of the EVFILT_VNODE registration
kevent(), so a failed registration left a dead entry in the map that
would never deliver events. Now close the fd, free the entry, and
return the errno to the caller (best-effort skip for children of a
recursive walk, matching the inotify backend).
Comment thread src/bun.js/node/path_watcher.zig
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig
…ip err.path

- On macOS the PathWatcher is published into the dedup map before the
  lock is released for Darwin.addWatch (required by the fsevents→manager
  lock order). If addWatch then fails after a concurrent Worker attached
  a handler, the old error path freed the watcher unconditionally,
  leaving the other thread's FSWatcher holding a freed pointer. Now
  remove only our handler under the lock and let any survivors' detach()
  free it.
- Linux/FreeBSD addWatch-error return: strip .path before returning;
  Linux.addOne's error borrowed watcher.path which we destroy() two
  lines earlier. Caller only reads errno today, but every other return
  here already does .withoutPath().
- PathWatcherManager.get(): drop the unlocked fast-path read of
  default_manager (broken DCLP on weakly-ordered targets). get() runs
  once per fs.watch(); always taking default_manager_mutex is fine.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bd354eb8-25c6-4350-8a0b-cbabd98689fe

📥 Commits

Reviewing files that changed from the base of the PR and between 7bdde12 and 211e35d.

📒 Files selected for processing (1)
  • src/bun.js/node/path_watcher.zig

Comment thread src/bun.js/node/path_watcher.zig
When a subdirectory inside a recursive watch is renamed, inotify keeps
the wd attached to the inode. IN_MOVED_TO on the parent triggers
addOne() for the new name, inotify_add_watch returns the *same* wd,
and the existing owner entry's subpath was left stale — subsequent
events under the moved directory were reported under the old name.

addOne() now overwrites the owner's subpath when the watcher already
owns the returned wd. walkAndAdd only descends into entry.kind ==
.directory (symlinks are .sym_link), so this can't pick a longer alias
via a cycle; the IN_CREATE-race case passes the same subpath so the
reassign is a no-op.

Adds a test that renames root/a → root/b under a recursive watch and
asserts writes surface as b/inside.txt (matches Node).
Comment thread src/bun.js/node/path_watcher.zig
Comment thread test/js/node/watch/fs.watch.rewrite.test.ts
Comment thread src/bun.js/node/path_watcher.zig Outdated
…; pace cold-VM test for FSEvents

Kqueue: between kevent() returning and the reader taking manager.mutex,
a JS thread can removeWatch (close fd N, drop entries[N]) and addOne for
an unrelated path — POSIX hands back the lowest fd, so entries[N] now
points at the new watch and the stale event would be dispatched to it.
kev.udata already carries the original entry pointer; compare it against
the map lookup and skip on mismatch.

Test: the cold-VM fixture's setImmediate retry loop can exhaust its 200
writes in ~20ms on macOS, before the async-scheduled FSEventStream (with
kFSEventStreamEventIdSinceNow + 50ms latency) delivers anything. Pace it
at 25ms/tick like the other tests in this file.
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig Outdated
Comment thread src/bun.js/node/path_watcher.zig Outdated
…pe walk, O_PATH probe, by-value KqEntry

watch():
- Replace lstat + conditional open + stat with a single open(O_PATH|
  O_DIRECTORY) → retry without O_DIRECTORY on ENOTDIR. The retry tells
  us file-vs-dir, open() follows symlinks, and the fd feeds getFdPath
  for the realpath. One/two syscalls instead of up to four.

Linux:
- inotify_init1/add_watch/rm_watch and read() via bun.sys.syscall with
  Maybe.errnoSys instead of catching Zig errors.
- Reuse INotifyWatcher.Event for the kernel inotify_event header
  (watch_descriptor/name_len field names).
- bun.path.basename / joinZBuf / joinStringBuf instead of std.fs.path
  and std.fmt.bufPrint.

Shared:
- walkSubtree() replaces the duplicated Linux/Kqueue walkAndAdd bodies;
  uses bun.sys.open + bun.DirIterator (no std.fs) and bun.path joins.

Kqueue (FreeBSD):
- Open monitored fds with O_EVTONLY|O_RDONLY|O_CLOEXEC.
- Store KqEntry by value in the entries map — drops the per-entry
  allocator.create() so a recursive tree costs one map growth plus one
  subpath dupe per entry instead of two allocations.
- Generation counter in KqEntry.gen / kev.udata for fd-reuse detection
  (entry pointer is no longer stable, so @intFromPtr no longer works).
- kqueue()/kevent() via bun.sys.syscall.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun its suspicious that this test is now failing in this branch on Windows arm64:


[17/501] test/cli/hot/hot.test.ts - code 1
--
2026-04-30 00:33:33 UTC | bun test v1.3.14-canary.1 (dd8732cc)
2026-04-30 00:33:33 UTC | ........
2026-04-30 00:33:33 UTC |  
2026-04-30 00:33:33 UTC | 528 \|         const col = match[1];
2026-04-30 00:33:33 UTC | 529 \|         expect(Number(col)).toBe(1 + "throw new ".length + counter * 2);
2026-04-30 00:33:33 UTC | 530 \|       },
2026-04-30 00:33:33 UTC | 531 \|     });
2026-04-30 00:33:33 UTC | 532 \|     await runner.exited;
2026-04-30 00:33:33 UTC | 533 \|     expect(reloadCounter).toBe(50);
2026-04-30 00:33:33 UTC | ^
2026-04-30 00:33:33 UTC | error: expect(received).toBe(expected)
2026-04-30 00:33:33 UTC |  
2026-04-30 00:33:33 UTC | Expected: 50
2026-04-30 00:33:33 UTC | Received: 7
2026-04-30 00:33:33 UTC |  
2026-04-30 00:33:33 UTC | at <anonymous> (C:\buildkite-agent\build\test\cli\hot\hot.test.ts:533:27)
2026-04-30 00:33:33 UTC |  
2026-04-30 00:33:33 UTC | ✗ should work with sourcemap generation [107.97ms]
2026-04-30 00:33:33 UTC | ...


@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author

This one's a pre-existing flake on main — not touched by this PR.

hot.test.ts "should work with sourcemap generation" (expect(reloadCounter).toBe(50) at :533) flaking on recent main builds, including the exact Windows 11 aarch64 lane:

main build commit platforms it flaked on
#49220 0d072e1 🪟 2019 x64-baseline, 🪟 11 aarch64, 🐧 3.23 x64 (×2)
#49247 aa3f980 🐧 13 x64-asan, 🐧 25.04 x64 (×2), 🐧 25.04 aarch64, 🪟 11 aarch64 ← identical Received: N failure
#49252 e257f2d 🪟 2019 x64-baseline, 🪟 11 aarch64, 🐧 13 aarch64, 🐧 25.04 x64 (×2)

(All marked (1 retry) in the flaky annotation — it's in the retry-allowlist.)

This PR doesn't touch anything --hot reaches on Windows:

$ git diff 6c21a7e7f3..HEAD --name-only
src/bun.js/node/fs_events.zig            # macOS FSEvents only
src/bun.js/node/path_watcher.zig         # POSIX fs.watch; Windows stub is type-only
test/internal/ban-limits.json
test/js/node/watch/fs.watch.rewrite.test.ts

--hot on Windows goes bun.Watchersrc/watcher/WindowsWatcher.zig — untouched. fs.watch() on Windows goes through win_watcher.zig (libuv uv_fs_event) — also untouched. The only Windows-reachable change in path_watcher.zig is the Platform = .windows => struct { … } stub that exists so win_watcher.zig can import PathWatcher.EventType.

Every other lane on #49273 that shares the same shard (ubuntu x64, alpine, debian asan, macOS x64+aarch64, windows x64-baseline) passed hot.test.ts.

@Jarred-Sumner Jarred-Sumner merged commit 6228e35 into main Apr 30, 2026
75 of 77 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/a8a44bfa/fs-watch-decouple branch April 30, 2026 21:22
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…ctly (oven-sh#29952)

## What

Rewrite the POSIX `fs.watch()` backend
(`src/bun.js/node/path_watcher.zig`) to talk to inotify/FSEvents/kqueue
directly instead of routing through `bun.Watcher` (the
bundler/`--watch`/`--hot` watcher). `bun.Watcher` itself is unchanged.

## Why

`bun.Watcher` is shaped around a module graph: its `WatchItem` carries
`options.Loader`, `*PackageJSON`, a `*bun.fs.FileSystem`, and on Windows
is pinned to `top_level_dir`. None of that applies to `fs.watch()`.
Adapting it required a 1068-line shim with:

- Three interacting mutexes + two atomics, 60 lock sites, five separate
"defer deinit after unlock or UAF" workarounds
- A `WorkPool` `DirectoryRegisterTask` that crawled the tree **once**
and registered every entry with the bundler watcher — `{recursive:
true}` never tracked directories created after `watch()` (→ oven-sh#15939,
oven-sh#15085, oven-sh#24875)
- A bolted-on FSEvents side-channel for macOS directories, so
`fs.watch(dir)` on macOS spun up **two** watcher threads (kqueue via
`bun.Watcher` + `CFRunLoop` via `fs_events.zig`), with `if
(watcher.fsevents_watcher != null) continue` guards scattered through
the dispatch loop
- O(watchers × events) string-prefix re-fanout in `onFileUpdate`
- Dummy `.loader = .file`, `.package_json = null` threaded through every
call

`win_watcher.zig` never went through `bun.Watcher` — it wraps
`uv_fs_event` directly in ~300 lines. This PR gives the other platforms
the same shape.

## How

```
PathWatcherManager        process-global, lazy, owns the OS resource
  ├─ Linux:   one inotify fd + one reader thread, wd → PathWatcher map
  ├─ macOS:   delegates to fs_events.zig (one CFRunLoop thread, one FSEventStream)
  └─ FreeBSD: one kqueue fd + one reader thread, fd → PathWatcher map

PathWatcher               one per unique (realpath, recursive) — deduped
  └─ handlers[]           the JS FSWatcher contexts sharing this watch
```

- **Dedup:** two `fs.watch()` on the same path share one `PathWatcher`
(one OS watch, `handlers` list). `detach()` removes one handler; last
one out tears down the OS watch. Dedup key is `realpath + recursive-bit`
since recursive/non-recursive need different registrations.
- **One mutex** guards the watchers map + platform dispatch maps. The
reader thread holds it while dispatching so `detach()` can't free
mid-emit. Replaces the three interacting mutexes.
- **Linux recursive** now adds a new inotify `wd` on
`IN_CREATE|IN_ISDIR` (and walks the new subtree once), so files inside
directories created after `watch()` are delivered. Fixes oven-sh#15939 /
oven-sh#15085.
- **macOS** uses FSEvents for both files *and* directories (matching
libuv), so `fs.watch()` no longer runs two watcher threads. The FSEvents
callback routes through `PathWatcher.emit()` → `handlers[]`, same as the
other platforms.
- **FreeBSD** uses a shared kqueue fd with `EVFILT_VNODE` per path.
kqueue gives no filenames; directory events surface as a bare `rename`
with empty path (libuv semantics).
- No more `vm.transpiler.fs` / `options.Loader` / `*PackageJSON`
anywhere in the `fs.watch` path.

`src/Watcher.zig`, `src/watcher/*`, `hot_reloader.zig`,
`bake/DevServer.zig` are untouched — `--watch`/`--hot` keep their own
watcher.

## Verification

- `zig:check-all` green on linux/mac/freebsd/windows × debug/release
- All 20 Node `test/js/node/test/parallel/test-fs-watch*.js` pass
- `test/js/node/watch/*.test.ts` green (the two permission tests that
fail are pre-existing; they fail on main too when running as root)
- `test/cli/hot/watch.test.ts` green (bun.Watcher unchanged)
- New `fs.watch.rewrite.test.ts`: recursive-new-subdir test **fails** on
`main` (the oven-sh#15939 bug), passes here

## Size

`path_watcher.zig`: 1068 → 636 lines (−40%), `fs_events.zig` −3 lines,
net −204 lines with tests included.


Fixes oven-sh#15939
Fixes oven-sh#15085

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fs.watch {recusive:true} does not react to new items. fs.watch cannot detect changes in files that are created after bun starts

2 participants