watcher(kqueue): drop per-cycle Vec, two-phase init, and raw libc::kevent

Merged
RO
4mo ago
+68-149

Cleans up src/watcher/KEventWatcher.rs per the Rust-cleanup pass: move guarantees into the type system, no perf regression, no behavior change.

What changed

KEventWatcher.rs (143 -> 92 lines)

  • fd: Option<Fd> + init(&mut self) -> fd: Fd + fn new() -> Result<Self>. The .expect("KEventWatcher has an invalid file descriptor") is gone.
  • Two unsafe { c::kevent(...) } blocks -> the existing safe bun_sys::kevent slice wrapper. The file is now unsafe-free. As a side effect, a kevent() error now propagates to on_error via ? instead of panicking in usize::try_from(count).
  • The .to_vec() that copied the deduped event slice every cycle (added only to end a borrow before calling write_trace_events(&self, ...)) is removed.
  • No direct references to mutex, ctx, on_file_update, or changed_filepaths remain; those are accessed via the new dispatch_file_updates below.

Watcher.rs

  • Watcher::init constructs Platform::new(top_level_dir)? directly instead of Platform::default() + in-place init().
  • New dispatch_file_updates(&mut self, event_count, changed_count): lock -> running.load() -> trace -> on_file_update, using disjoint field borrows so the event slice is passed in place. Every platform backend now calls this instead of open-coding the sequence.
  • add_file_descriptor_to_kqueue_without_checks uses the safe bun_sys::kevent wrapper and self.platform.fd (no .unwrap()).
  • Removed the one-line write_trace_events wrapper (only caller was the duplicated dispatch block).

INotifyWatcher.rs / WindowsWatcher.rs

  • Both now expose new() (Windows keeps its in-place init as a private helper since the struct carries a 64 KiB buffer inline).
  • Both call dispatch_file_updates; the Windows backend's per-cycle .to_vec() is gone too.

Preserved semantics

Event coalescing (the 100 us re-poll when count < 64), the dedup/merge pass, and the lock held around on_file_update (which flush_evictions relies on) are unchanged.

Not in this PR

The detached-mutex / fn-pointer-ctx shape now lives in exactly one place (dispatch_file_updates) rather than three, but Watcher.mutex is still a peer field rather than watchlist: Guarded<WatchList>. Wrapping it requires restructuring flush_evictions, which is reached mid-callback via a raw-pointer detour from HotReloader::on_file_update / DevServer::on_file_update while the lock is already held; a Guarded there would deadlock on the non-recursive mutex. That restructure touches the scopeguard drop ordering that keeps the JS-thread EBADF window closed, so it belongs in its own change.

Verification

  • cargo check -p bun_watcher on aarch64-apple-darwin, x86_64-unknown-freebsd, x86_64-unknown-linux-gnu, aarch64-linux-android, x86_64-pc-windows-msvc
  • cargo clippy -p bun_watcher --no-deps clean on darwin/freebsd/linux (windows has pre-existing unrelated lints)
  • bun bd test test/cli/hot/hot.test.ts test/cli/watch/watcher-trace.test.ts test/cli/hot/watch.test.ts test/cli/hot/watch-many-dirs.test.ts all pass on linux (exercises the shared dispatch_file_updates path via inotify)