diff --git a/src/bun.js/node/fs_events.zig b/src/bun.js/node/fs_events.zig index 217af119a3f..53039a82695 100644 --- a/src/bun.js/node/fs_events.zig +++ b/src/bun.js/node/fs_events.zig @@ -576,7 +576,7 @@ pub const FSEventsWatcher = struct { recursive: bool, ctx: ?*anyopaque, - pub const Callback = PathWatcher.Callback; + pub const Callback = *const fn (ctx: ?*anyopaque, event: Event, is_file: bool) void; pub const UpdateEndCallback = *const fn (ctx: ?*anyopaque) void; pub fn init(loop: *FSEventsLoop, path: string, recursive: bool, callback: Callback, updateEnd: UpdateEndCallback, ctx: ?*anyopaque) *FSEventsWatcher { @@ -640,11 +640,9 @@ pub fn closeAndWait() void { const string = []const u8; const std = @import("std"); +const EventType = @import("./path_watcher.zig").PathWatcher.EventType; const Semaphore = std.Thread.Semaphore; -const PathWatcher = @import("./path_watcher.zig").PathWatcher; -const EventType = PathWatcher.EventType; - const bun = @import("bun"); const Mutex = bun.Mutex; const UnboundedQueue = bun.threading.UnboundedQueue; diff --git a/src/bun.js/node/path_watcher.zig b/src/bun.js/node/path_watcher.zig index b4497eff14e..4e584838a24 100644 --- a/src/bun.js/node/path_watcher.zig +++ b/src/bun.js/node/path_watcher.zig @@ -1,1068 +1,958 @@ -var default_manager_mutex: Mutex = .{}; +//! POSIX backend for `fs.watch()`. +//! +//! This is deliberately independent of `bun.Watcher` (the bundler/--watch/--hot +//! watcher). `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()`, and routing `fs.watch()` +//! through it required a 1k-line shim (the old version of this file) full of +//! lock-ordering workarounds, a WorkPool directory crawler, and a bolted-on FSEvents +//! side-channel. +//! +//! The Windows backend (`win_watcher.zig`, libuv `uv_fs_event`) never went through +//! `bun.Watcher` and is a quarter of the size; this file gives Linux/macOS/FreeBSD +//! the same shape: +//! +//! 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 +//! +//! A second `fs.watch()` on the same path returns the existing PathWatcher with a +//! new handler appended. `detach()` removes a handler; the last one out tears down +//! the OS watch. + +/// Process-global manager. Created on first `fs.watch()`, never destroyed (matches +/// the FSEvents loop and Windows libuv loop lifetimes). var default_manager: ?*PathWatcherManager = null; +var default_manager_mutex: Mutex = .{}; -pub const PathWatcherManager = struct { - const options = @import("../../options.zig"); - const log = Output.scoped(.PathWatcherManager, .visible); - main_watcher: *Watcher, - - watchers: bun.BabyList(?*PathWatcher) = .{}, - watcher_count: u32 = 0, - vm: *jsc.VirtualMachine, - file_paths: bun.StringHashMap(PathInfo), - current_fd_task: bun.FDHashMap(*DirectoryRegisterTask), - deinit_on_last_watcher: bool = false, - pending_tasks: u32 = 0, - deinit_on_last_task: bool = false, - has_pending_tasks: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - mutex: Mutex, - const PathInfo = struct { - fd: FD = .invalid, - is_file: bool = true, - path: [:0]const u8, - dirname: string, - refs: u32 = 0, - hash: Watcher.HashType, - }; - - fn refPendingTask(this: *PathWatcherManager) bool { - this.mutex.lock(); - defer this.mutex.unlock(); - if (this.deinit_on_last_task) return false; - this.pending_tasks += 1; - this.has_pending_tasks.store(true, .release); - return true; - } - - fn hasPendingTasks(this: *PathWatcherManager) callconv(.c) bool { - return this.has_pending_tasks.load(.acquire); - } - - fn unrefPendingTask(this: *PathWatcherManager) void { - // deinit() may destroy(this). Defer it until after unlock so we don't - // unlock() a freed mutex. Zig defers fire LIFO, so registering this - // defer before the lock/unlock pair makes it fire last (after unlock). - var should_deinit = false; - defer if (should_deinit) this.deinit(); - - this.mutex.lock(); - defer this.mutex.unlock(); - this.pending_tasks -= 1; - if (this.deinit_on_last_task and this.pending_tasks == 0) { - this.has_pending_tasks.store(false, .release); - should_deinit = true; - } - } +const log = Output.scoped(.@"fs.watch", .hidden); - fn _fdFromAbsolutePathZ( - this: *PathWatcherManager, - path: [:0]const u8, - ) bun.sys.Maybe(PathInfo) { - this.mutex.lock(); - defer this.mutex.unlock(); - - if (this.file_paths.getEntry(path)) |entry| { - var info = entry.value_ptr; - info.refs += 1; - return .{ .result = info.* }; - } +pub const PathWatcherManager = struct { + /// Guards `watchers` and all per-platform dispatch maps. The reader thread holds + /// this while dispatching, so `detach()` on the JS thread cannot free a PathWatcher + /// mid-emit. A single lock here replaces the three interacting mutexes of the old + /// design. + mutex: Mutex = .{}, + + /// Dedup map: dedup key → PathWatcher. The key is the resolved path with a one-byte + /// suffix encoding `recursive` (so `fs.watch(p)` and `fs.watch(p, {recursive:true})` + /// don't share — they want different OS registrations on every platform). + watchers: bun.StringArrayHashMapUnmanaged(*PathWatcher) = .{}, + + /// Platform-specific state (inotify fd / kqueue fd + dispatch maps + thread). + /// On macOS this is empty — FSEvents owns its own thread via `fs_events.zig`. + platform: Platform = .{}, + + pub fn get() bun.sys.Maybe(*PathWatcherManager) { + // No unlocked fast path: `default_manager` is a plain global and an unsynchronized + // read here would be textbook broken DCLP (a concurrent Worker's first `fs.watch()` + // on ARM64 could observe the non-null pointer before `m.* = .{}` is visible and + // lock a garbage `m.mutex`). `get()` runs once per `fs.watch()` call; the mutex is + // uncontended after initialization. + default_manager_mutex.lock(); + defer default_manager_mutex.unlock(); + if (default_manager) |m| return .{ .result = m }; - switch (switch (Environment.os) { - else => bun.sys.open(path, bun.O.DIRECTORY | bun.O.RDONLY, 0), - // windows bun.sys.open does not pass iterable=true, - .windows => bun.sys.openDirAtWindowsA(bun.FD.cwd(), path, .{ .iterable = true, .read_only = true }), - }) { + const m = bun.handleOom(bun.default_allocator.create(PathWatcherManager)); + m.* = .{}; + switch (Platform.init(m)) { .err => |e| { - if (e.errno == @intFromEnum(bun.sys.E.NOTDIR)) { - const file = switch (bun.sys.open(path, 0, 0)) { - .err => |file_err| return .{ .err = file_err.withPath(path) }, - .result => |r| r, - }; - const cloned_path = bun.handleOom(bun.default_allocator.dupeZ(u8, path)); - const result = PathInfo{ - .fd = file, - .is_file = true, - .path = cloned_path, - // if is really a file we need to get the dirname - .dirname = std.fs.path.dirname(cloned_path) orelse cloned_path, - .hash = Watcher.getHash(cloned_path), - .refs = 1, - }; - _ = bun.handleOom(this.file_paths.put(cloned_path, result)); - return .{ .result = result }; - } - return .{ .err = e.withPath(path) }; - }, - .result => |iterable_dir| { - const cloned_path = bun.handleOom(bun.default_allocator.dupeZ(u8, path)); - const result = PathInfo{ - .fd = iterable_dir, - .is_file = false, - .path = cloned_path, - .dirname = cloned_path, - .hash = Watcher.getHash(cloned_path), - .refs = 1, - }; - _ = bun.handleOom(this.file_paths.put(cloned_path, result)); - return .{ .result = result }; + bun.default_allocator.destroy(m); + return .{ .err = e }; }, + .result => {}, } + default_manager = m; + return .{ .result = m }; } - const PathWatcherManagerError = std.mem.Allocator.Error || - std.posix.KQueueError || - error{KQueueError} || - std.posix.INotifyInitError || - std.Thread.SpawnError; - - pub fn init(vm: *jsc.VirtualMachine) PathWatcherManagerError!*PathWatcherManager { - const this = bun.handleOom(bun.default_allocator.create(PathWatcherManager)); - errdefer bun.default_allocator.destroy(this); - var watchers = bun.handleOom(bun.BabyList(?*PathWatcher).initCapacity(bun.default_allocator, 1)); - errdefer watchers.deinit(bun.default_allocator); - - const manager = PathWatcherManager{ - .file_paths = bun.StringHashMap(PathInfo).init(bun.default_allocator), - .current_fd_task = bun.FDHashMap(*DirectoryRegisterTask).init(bun.default_allocator), - .watchers = watchers, - .main_watcher = try Watcher.init( - PathWatcherManager, - this, - vm.transpiler.fs, - bun.default_allocator, - ), - .vm = vm, - .watcher_count = 0, - .mutex = .{}, - }; + /// Build the dedup key into `buf`. Not null-terminated; only used as a hashmap key. + fn makeKey(buf: []u8, resolved_path: []const u8, recursive: bool) []const u8 { + @memcpy(buf[0..resolved_path.len], resolved_path); + buf[resolved_path.len] = if (recursive) 'R' else 'N'; + return buf[0 .. resolved_path.len + 1]; + } - this.* = manager; - try this.main_watcher.start(); - return this; + /// Remove `watcher` from the dedup map. Caller holds `mutex`. + fn unlinkWatcherLocked(this: *PathWatcherManager, watcher: *PathWatcher) void { + if (std.mem.indexOfScalar(*PathWatcher, this.watchers.values(), watcher)) |i| { + bun.default_allocator.free(this.watchers.keys()[i]); + this.watchers.swapRemoveAt(i); + } } +}; - pub fn onFileUpdate( - this: *PathWatcherManager, - events: []Watcher.WatchEvent, - changed_files: []?[:0]u8, - watchlist: Watcher.WatchList, - ) void { - var slice = watchlist.slice(); - const file_paths = slice.items(.file_path); +pub const PathWatcher = struct { + manager: ?*PathWatcherManager, - var counts = slice.items(.count); - const kinds = slice.items(.kind); - var _on_file_update_path_buf: bun.PathBuffer = undefined; + /// Canonical absolute path (realpath of the user-supplied path). Owned. + path: [:0]const u8, + recursive: bool, + is_file: bool, - var ctx = this.main_watcher; - defer ctx.flushEvictions(); + /// JS `FSWatcher` contexts sharing this OS watch. Each gets its own ChangeEvent + /// for per-handler duplicate suppression (same as win_watcher.zig). Guarded by + /// `manager.mutex` on all platforms — every emit path (inotify/kqueue reader + /// threads and the Darwin FSEvents callback) holds it while iterating, so + /// attach/detach can never race with dispatch. + handlers: std.AutoArrayHashMapUnmanaged(*anyopaque, ChangeEvent) = .{}, - const timestamp = std.time.milliTimestamp(); + /// Per-platform per-watch state (inotify wds, kqueue fds, or the FSEventsWatcher). + platform: Platform.Watch = .{}, - this.mutex.lock(); - defer this.mutex.unlock(); + pub const new = bun.TrivialNew(PathWatcher); - const watchers = this.watchers.slice(); + pub const EventType = enum { + rename, + change, - for (events) |event| { - if (event.index >= file_paths.len) continue; - const file_path = file_paths[event.index]; - const update_count = counts[event.index] + 1; - counts[event.index] = update_count; - const kind = kinds[event.index]; + pub fn toEvent(event_type: EventType, path: FSWatcher.EventPathString) Event { + return switch (event_type) { + inline else => |t| @unionInit(Event, @tagName(t), path), + }; + } + }; + + /// Per-handler duplicate suppression. + /// + /// The predicate is intentionally identical to `win_watcher.zig` and the old + /// `path_watcher.zig` so POSIX and Windows agree on which bursts are coalesced. + /// It suppresses only when, within the same millisecond, *both* the hash and + /// the event type match the previous emission — arguably too aggressive, but + /// changing it here would diverge from Windows; fixing all three together is + /// a separate change. + pub const ChangeEvent = struct { + hash: u64 = 0, + event_type: EventType = .change, + timestamp: i64 = 0, - if (comptime Environment.isDebug) { - log("[watch] {s} ({s}, {f})", .{ file_path, @tagName(kind), event.op }); + fn shouldEmit(this: *ChangeEvent, hash: u64, timestamp: i64, event_type: EventType) bool { + const time_diff = timestamp - this.timestamp; + if ((this.timestamp == 0 or time_diff > 1) or + this.event_type != event_type and this.hash != hash) + { + this.timestamp = timestamp; + this.event_type = event_type; + this.hash = hash; + return true; } + return false; + } + }; - switch (kind) { - .file => { - if (event.op.delete) { - ctx.removeAtIndex( - event.index, - 0, - &.{}, - .file, - ); - } + pub const Callback = *const fn (ctx: ?*anyopaque, event: Event, is_file: bool) void; + pub const UpdateEndCallback = *const fn (ctx: ?*anyopaque) void; - if (event.op.write or event.op.delete or event.op.rename) { - const event_type: PathWatcher.EventType = if (event.op.delete or event.op.rename or event.op.move_to) .rename else .change; - const hash = Watcher.getHash(file_path); - - for (watchers) |w| { - if (w) |watcher| { - if (comptime Environment.isMac) { - if (watcher.fsevents_watcher != null) continue; - } - const entry_point = watcher.path.dirname; - var path = file_path; - - if (path.len < entry_point.len) { - continue; - } - if (watcher.path.is_file) { - if (watcher.path.hash != hash) { - continue; - } - } else { - if (!bun.strings.startsWith(path, entry_point)) { - continue; - } - } - // Remove common prefix, unless the watched folder is "/" - if (!(path.len == 1 and entry_point[0] == '/')) { - path = path[entry_point.len..]; - - // Ignore events with path equal to directory itself - if (path.len <= 1) { - continue; - } - - if (bun.strings.startsWithChar(path, '/')) { - // Skip forward slash - path = path[1..]; - } - } - - // Do not emit events from subdirectories (without option set) - if (path.len == 0 or (bun.strings.containsChar(path, '/') and !watcher.recursive)) { - continue; - } - watcher.emit(event_type.toEvent(path), hash, timestamp, true); - } - } - } - }, - .directory => { - const affected = event.names(changed_files); - - // kqueue NOTE_WRITE on a directory carries no filenames, so - // `affected` is always empty on FreeBSD (macOS bypasses this - // via FSEvents). Emit a single nameless 'rename' so the JS - // callback fires — matches libuv's FreeBSD behavior, where - // the caller is expected to re-scan. - if (comptime Environment.isFreeBSD) { - if (affected.len == 0 and (event.op.write or event.op.delete or event.op.rename)) { - const dir_hash = Watcher.getHash(file_path); - for (watchers) |w| { - const watcher = w orelse continue; - const entry_point = watcher.path.dirname; - if (watcher.path.is_file or file_path.len < entry_point.len or !bun.strings.startsWith(file_path, entry_point)) { - continue; - } - watcher.emit((PathWatcher.EventType.rename).toEvent(""), dir_hash, timestamp, false); - } - } - } - - for (affected) |changed_name_| { - const changed_name: []const u8 = bun.asByteSlice(changed_name_.?); - if (changed_name.len == 0 or changed_name[0] == '~' or changed_name[0] == '.') continue; - - const file_path_without_trailing_slash = std.mem.trimRight(u8, file_path, std.fs.path.sep_str); - - @memcpy(_on_file_update_path_buf[0..file_path_without_trailing_slash.len], file_path_without_trailing_slash); - - _on_file_update_path_buf[file_path_without_trailing_slash.len] = std.fs.path.sep; - - @memcpy(_on_file_update_path_buf[file_path_without_trailing_slash.len + 1 ..][0..changed_name.len], changed_name); - const len = file_path_without_trailing_slash.len + changed_name.len; - const path_slice = _on_file_update_path_buf[0 .. len + 1]; - - const hash = Watcher.getHash(path_slice); - - // skip consecutive duplicates - // If it's a create, delete, rename, or move event, emit "rename" - // If it's a pure write (modify) event, emit "change" - const event_type: PathWatcher.EventType = if (event.op.create or event.op.delete or event.op.rename or event.op.move_to) .rename else .change; - for (watchers) |w| { - if (w) |watcher| { - if (comptime Environment.isMac) { - if (watcher.fsevents_watcher != null) continue; - } - const entry_point = watcher.path.dirname; - var path = path_slice; - - if (watcher.path.is_file or path.len < entry_point.len or !bun.strings.startsWith(path, entry_point)) { - continue; - } - // Remove common prefix, unless the watched folder is "/" - if (!(path.len == 1 and entry_point[0] == '/')) { - path = path[entry_point.len..]; - - // Skip leading slash - if (bun.strings.startsWithChar(path, '/')) { - path = path[1..]; - } - } - - // Do not emit events from subdirectories (without option set) - if (path.len == 0 or (bun.strings.containsChar(path, '/') and !watcher.recursive)) { - continue; - } - - watcher.emit(event_type.toEvent(path), hash, timestamp, false); - } - } - } - }, + /// Called from the platform reader thread with `manager.mutex` held. + /// `rel_path` is borrowed — `onPathUpdatePosix` dupes it before enqueuing. + fn emit(this: *PathWatcher, event_type: EventType, rel_path: []const u8, is_file: bool) void { + const timestamp = std.time.milliTimestamp(); + const hash = bun.hash(rel_path); + for (this.handlers.keys(), this.handlers.values()) |ctx, *last| { + if (last.shouldEmit(hash, timestamp, event_type)) { + onPathUpdateFn(ctx, event_type.toEvent(rel_path), is_file); } } + } - if (comptime Environment.isDebug) { - Output.flush(); + fn emitError(this: *PathWatcher, err: bun.sys.Error) void { + for (this.handlers.keys()) |ctx| { + onPathUpdateFn(ctx, .{ .@"error" = err }, false); } - for (watchers) |w| { - if (w) |watcher| { - if (watcher.needs_flush) watcher.flush(); - } + } + + /// Signals end-of-batch so `FSWatcher` can flush its queued events to the JS thread. + /// Caller holds `manager.mutex`. + fn flush(this: *PathWatcher) void { + for (this.handlers.keys()) |ctx| { + onUpdateEndFn(ctx); } } - pub fn onError( - this: *PathWatcherManager, - err: bun.sys.Error, - ) void { - { - this.mutex.lock(); - defer this.mutex.unlock(); - const watchers = this.watchers.slice(); - const timestamp = std.time.milliTimestamp(); - - // stop all watchers - for (watchers) |w| { - if (w) |watcher| { - log("[watch] error: {f}", .{err}); - watcher.emit(.{ .@"error" = err }, 0, timestamp, false); - watcher.flush(); - } - } + /// JS-thread entry point from `FSWatcher.detach()`. Removes one handler; if it was + /// the last, tears down the OS watch and frees. + /// + /// All bookkeeping (handlers, dedup map, platform dispatch maps) happens under + /// `manager.mutex` in one critical section so a concurrent `watch()` from another + /// Worker cannot observe a zero-handler PathWatcher still present in the dedup map. + /// + /// On macOS the FSEvents unregister happens *after* releasing `manager.mutex`: + /// `FSEventsWatcher.deinit()` takes the FSEvents loop mutex, and the CF thread's + /// `_events_cb` holds that mutex while calling into `onFSEvent` (which takes + /// `manager.mutex`). Holding both here would be AB/BA with the CF thread. Once + /// `fse.deinit()` returns, `_events_cb` has released the loop mutex and nulled our + /// slot, so no further callbacks will fire and `destroy()` is safe. + pub fn detach(this: *PathWatcher, ctx: *anyopaque) void { + const manager = this.manager orelse { + _ = this.handlers.swapRemove(ctx); + if (this.handlers.count() == 0) this.destroy(); + return; + }; - // we need a new manager at this point - default_manager_mutex.lock(); - defer default_manager_mutex.unlock(); - default_manager = null; + manager.mutex.lock(); + _ = this.handlers.swapRemove(ctx); + if (this.handlers.count() > 0) { + manager.mutex.unlock(); + return; } - // deinit manager when all watchers are closed - this.deinit(); - } + // Last handler gone — make this watcher unreachable before dropping the lock. + manager.unlinkWatcherLocked(this); + this.manager = null; + if (comptime !Environment.isMac) { + Platform.removeWatch(manager, this); + } + manager.mutex.unlock(); - pub const DirectoryRegisterTask = struct { - manager: *PathWatcherManager, - path: PathInfo, - task: jsc.WorkPoolTask = .{ .callback = callback }, - watcher_list: bun.BabyList(*PathWatcher) = .{}, - - pub fn callback(task: *jsc.WorkPoolTask) void { - var routine: *@This() = @fieldParentPtr("task", task); - defer routine.deinit(); - routine.run(); + if (comptime Environment.isMac) { + // Takes fsevents_loop.mutex; must not hold manager.mutex (see doc comment). + Platform.removeWatch(manager, this); } + this.destroy(); + } - fn schedule(manager: *PathWatcherManager, watcher: *PathWatcher, path: PathInfo) !void { - // unrefPendingDirectory() may cascade through PathWatcher.deinit() - // → manager.unregisterWatcher() → manager.deinit() → destroy(manager). - // Register this defer FIRST so it fires LAST (after the errdefer - // below and after manager.mutex is released). - var needs_unref_pending_directory = false; - defer if (needs_unref_pending_directory) watcher.unrefPendingDirectory(); - - // keep the path alive. errdefer registered after the defer above so - // LIFO ordering fires _decrementPathRef BEFORE unrefPendingDirectory - // — otherwise the latter could destroy(manager) and this would UAF. - manager._incrementPathRef(path.path); - errdefer manager._decrementPathRef(path.path); - - var routine: *DirectoryRegisterTask = undefined; - { - manager.mutex.lock(); - defer manager.mutex.unlock(); - - // use the same thread for the same fd to avoid race conditions - if (manager.current_fd_task.getEntry(path.fd)) |entry| { - routine = entry.value_ptr.*; - - if (watcher.refPendingDirectory()) { - routine.watcher_list.append(bun.default_allocator, watcher) catch |err| { - needs_unref_pending_directory = true; - return err; - }; - } else { - return error.UnexpectedFailure; - } - return; - } + fn destroy(this: *PathWatcher) void { + this.handlers.deinit(bun.default_allocator); + Platform.Watch.deinit(&this.platform); + bun.default_allocator.free(this.path); + bun.destroy(this); + } +}; - routine = try bun.default_allocator.create(DirectoryRegisterTask); - routine.* = DirectoryRegisterTask{ - .manager = manager, - .path = path, - .watcher_list = bun.BabyList(*PathWatcher).initCapacity(bun.default_allocator, 1) catch |err| { - bun.default_allocator.destroy(routine); - return err; - }, - }; - errdefer routine.deinit(); - if (watcher.refPendingDirectory()) { - routine.watcher_list.append(bun.default_allocator, watcher) catch |err| { - needs_unref_pending_directory = true; - return err; - }; - } else { - return error.UnexpectedFailure; - } - manager.current_fd_task.put(path.fd, routine) catch |err| { - needs_unref_pending_directory = true; - return err; - }; - } - if (manager.refPendingTask()) { - jsc.WorkPool.schedule(&routine.task); - return; - } - return error.UnexpectedFailure; - } +pub fn watch( + vm: *VirtualMachine, + path: [:0]const u8, + recursive: bool, + comptime callback: PathWatcher.Callback, + comptime updateEnd: PathWatcher.UpdateEndCallback, + ctx: *anyopaque, +) bun.sys.Maybe(*PathWatcher) { + // The callback/updateEnd are comptime so the emit path can call them directly + // without an indirect-call-per-event; assert they're what node_fs_watcher passes. + comptime bun.assert(callback == onPathUpdateFn); + comptime bun.assert(updateEnd == onUpdateEndFn); + _ = vm; + + const manager = switch (PathWatcherManager.get()) { + .err => |e| return .{ .err = e }, + .result => |m| m, + }; + + // Resolve to a canonical path so `fs.watch("./x")` and `fs.watch("/abs/x")` dedup; + // FSEvents reports events by realpath so macOS needs this for prefix matching too. + // + // Open with O_PATH|O_DIRECTORY first and retry without O_DIRECTORY on ENOTDIR — + // that tells us file-vs-dir without a separate stat, follows symlinks, and the + // resulting fd feeds `getFdPath` for the realpath. One or two syscalls instead + // of lstat + open + (stat) in the old code. `O.PATH` is 0 on macOS (degrades to + // O_RDONLY, which is what F_GETPATH needs anyway). + const resolve_buf = bun.path_buffer_pool.get(); + defer bun.path_buffer_pool.put(resolve_buf); + var is_file = false; + const probe_fd: bun.FD = switch (bun.sys.open(path, bun.O.PATH | bun.O.DIRECTORY | bun.O.CLOEXEC, 0)) { + .result => |f| f, + .err => |e| if (e.getErrno() == .NOTDIR) retry: { + is_file = true; + break :retry switch (bun.sys.open(path, bun.O.PATH | bun.O.CLOEXEC, 0)) { + .result => |f| f, + .err => |e2| return .{ .err = e2.withoutPath() }, + }; + } else return .{ .err = e.withoutPath() }, + }; + defer probe_fd.close(); + const resolved: [:0]const u8 = switch (bun.sys.getFdPath(probe_fd, resolve_buf)) { + .err => path, // fall back to the caller's path; best effort + .result => |r| brk: { + resolve_buf[r.len] = 0; + break :brk resolve_buf[0..r.len :0]; + }, + }; - fn getNext(this: *DirectoryRegisterTask) ?*PathWatcher { - this.manager.mutex.lock(); - defer this.manager.mutex.unlock(); + const key_buf = bun.path_buffer_pool.get(); + defer bun.path_buffer_pool.put(key_buf); + const key = PathWatcherManager.makeKey(key_buf, resolved, recursive); - const watcher = this.watcher_list.pop(); - if (watcher == null) { - // no more work todo, release the fd and path - _ = this.manager.current_fd_task.remove(this.path.fd); - this.manager._decrementPathRefNoLock(this.path.path); - return null; - } - return watcher; - } + manager.mutex.lock(); - fn processWatcher( - this: *DirectoryRegisterTask, - watcher: *PathWatcher, - buf: *bun.PathBuffer, - ) bun.sys.Maybe(void) { - if (Environment.isWindows) @compileError("use win_watcher.zig"); - - const manager = this.manager; - const path = this.path; - const fd = path.fd; - var iter = fd.stdDir().iterate(); - - // now we iterate over all files and directories - while (iter.next() catch |err| { - return .{ - .err = .{ - .errno = @truncate(@intFromEnum(switch (err) { - error.AccessDenied, error.PermissionDenied => bun.sys.E.ACCES, - error.SystemResources => bun.sys.E.NOMEM, - error.Unexpected, - error.InvalidUtf8, - => bun.sys.E.INVAL, - })), - .syscall = .watch, - }, - }; - }) |entry| { - var parts = [2]string{ path.path, entry.name }; - const entry_path = Path.joinAbsStringBuf( - Fs.FileSystem.instance.topLevelDirWithoutTrailingSlash(), - buf, - &parts, - .auto, - ); - - buf[entry_path.len] = 0; - const entry_path_z = buf[0..entry_path.len :0]; - - const child_path = switch (manager._fdFromAbsolutePathZ(entry_path_z)) { - .result => |result| result, - .err => |e| return .{ .err = e }, - }; - - { - watcher.mutex.lock(); - const append_result = watcher.file_paths.append(bun.default_allocator, child_path.path); - watcher.mutex.unlock(); - // On error, drop the ref we took in _fdFromAbsolutePathZ. Must do - // this AFTER releasing watcher.mutex: _decrementPathRef acquires - // manager.mutex, and unregisterWatcher acquires manager.mutex before - // watcher.mutex — inverting here would AB/BA deadlock. - append_result catch |err| { - manager._decrementPathRef(entry_path_z); - return switch (err) { - error.OutOfMemory => .{ .err = .{ - .errno = @truncate(@intFromEnum(bun.sys.E.NOMEM)), - .syscall = .watch, - } }, - }; - }; - } + const gop = bun.handleOom(manager.watchers.getOrPut(bun.default_allocator, key)); + if (gop.found_existing) { + const existing = gop.value_ptr.*; + bun.handleOom(existing.handlers.put(bun.default_allocator, ctx, .{})); + manager.mutex.unlock(); + return .{ .result = existing }; + } - // we need to call this unlocked - if (child_path.is_file) { - switch (manager.main_watcher.addFile( - child_path.fd, - child_path.path, - child_path.hash, - options.Loader.file, - .invalid, - null, - false, - )) { - .err => |err| return .{ .err = err }, - .result => {}, - } - } else { - if (watcher.recursive and !watcher.isClosed()) { - // this may trigger another thread with is desired when available to watch long trees - switch (manager._addDirectory(watcher, child_path)) { - .err => |err| return .{ .err = err.withPath(child_path.path) }, - .result => {}, - } - } - } - } - return .success; - } + // New watcher: own the key and path. + gop.key_ptr.* = bun.handleOom(bun.default_allocator.dupe(u8, key)); + const watcher = PathWatcher.new(.{ + .manager = manager, + .path = bun.handleOom(bun.default_allocator.dupeZ(u8, resolved)), + .recursive = recursive, + .is_file = is_file, + }); + bun.handleOom(watcher.handlers.put(bun.default_allocator, ctx, .{})); + gop.value_ptr.* = watcher; + + // Linux/FreeBSD: `addWatch` mutates the platform dispatch maps (wd_map/entries) + // which live under `manager.mutex`, so call it while still locked. + // + // macOS: `addWatch` calls `FSEvents.watch()` which takes the FSEvents loop mutex. + // The CF thread holds that mutex while calling `onFSEvent`, which in turn takes + // `manager.mutex`. To keep lock order one-way (fsevents → manager), release ours + // first. Another Worker's `watch()` finding this PathWatcher in the interim is + // fine — it just appends a handler; events won't deliver until the FSEventStream + // is scheduled anyway. + if (comptime !Environment.isMac) { + if (Platform.addWatch(manager, watcher).asErr()) |err| { + // Still under the same lock as the map insertion, so no other thread + // can have observed `watcher` yet — unconditional destroy is safe. + manager.unlinkWatcherLocked(watcher); + manager.mutex.unlock(); + watcher.manager = null; + watcher.destroy(); + // `Linux.addOne` builds the error with `.path = watcher.path`, which we + // just freed; strip it like every other return in this function. + return .{ .err = err.withoutPath() }; + } + manager.mutex.unlock(); + return .{ .result = watcher }; + } - fn run(this: *DirectoryRegisterTask) void { - if (comptime Environment.isWindows) { - return bun.todo(@src(), {}); - } + manager.mutex.unlock(); + + if (Platform.addWatch(manager, watcher).asErr()) |err| { + // `watcher` was visible in the dedup map while we were unlocked above; a + // concurrent Worker's `fs.watch()` on the same path may have attached a + // handler and already returned `watcher` to its caller. Only destroy if + // ours was the last handler; otherwise surface the error to the survivors + // and leave `watcher.manager` set so their `detach()` takes the locked path + // (→ `unlinkWatcherLocked` no-ops, `removeWatch` no-ops on null `fsevents`, + // then frees). Never free memory another thread holds. + manager.mutex.lock(); + manager.unlinkWatcherLocked(watcher); + _ = watcher.handlers.swapRemove(ctx); + if (watcher.handlers.count() > 0) { + watcher.emitError(err); + watcher.flush(); + manager.mutex.unlock(); + return .{ .err = err.withoutPath() }; + } + watcher.manager = null; + manager.mutex.unlock(); + watcher.destroy(); + return .{ .err = err.withoutPath() }; + } + return .{ .result = watcher }; +} - var buf: bun.PathBuffer = undefined; - - while (this.getNext()) |watcher| { - defer watcher.unrefPendingDirectory(); - switch (this.processWatcher(watcher, &buf)) { - .err => |err| { - log("[watch] error registering directory: {f}", .{err}); - watcher.emit(.{ .@"error" = err }, 0, std.time.milliTimestamp(), false); - watcher.flush(); - }, - .result => {}, - } - } +// -------------------------------------------------------------------------------------- +// Platform backends +// -------------------------------------------------------------------------------------- + +/// Shared recursive directory walk for Linux and Kqueue: open `abs_dir`, iterate, +/// and for every entry call `cb` with (abs, rel, is_file); recurse into +/// subdirectories. When `dirs_only`, non-directory entries are skipped entirely +/// (inotify delivers file events on the parent dir's wd so we only need a watch +/// per directory; kqueue needs an fd per file too). Best-effort — an unreadable +/// subdirectory just stops that branch (matches Node). Uses `bun.sys` / +/// `bun.DirIterator` / `bun.path` throughout; no std.fs. +fn walkSubtree( + abs_dir: [:0]const u8, + rel_dir: []const u8, + comptime dirs_only: bool, + ctx: anytype, + comptime cb: fn (ctx: @TypeOf(ctx), abs: [:0]const u8, rel: []const u8, is_file: bool) void, +) void { + const dfd = switch (bun.sys.open(abs_dir, bun.O.RDONLY | bun.O.DIRECTORY | bun.O.CLOEXEC, 0)) { + .err => return, + .result => |f| f, + }; + defer dfd.close(); + var it = bun.DirIterator.iterate(dfd, .u8); + const abs_buf = bun.path_buffer_pool.get(); + defer bun.path_buffer_pool.put(abs_buf); + const rel_buf = bun.path_buffer_pool.get(); + defer bun.path_buffer_pool.put(rel_buf); + while (switch (it.next()) { + .err => return, + .result => |r| r, + }) |entry| { + const child_is_file = entry.kind != .directory; + if (dirs_only and child_is_file) continue; + const name = entry.name.slice(); + const child_abs = bun.path.joinZBuf(abs_buf, &[_][]const u8{ abs_dir, name }, .posix); + const child_rel: []const u8 = if (rel_dir.len == 0) + name + else + bun.path.joinStringBuf(rel_buf, &[_][]const u8{ rel_dir, name }, .posix); + cb(ctx, child_abs, child_rel, child_is_file); + if (!child_is_file) walkSubtree(child_abs, child_rel, dirs_only, ctx, cb); + } +} - this.manager.unrefPendingTask(); +const Platform = switch (Environment.os) { + .linux => Linux, + .mac => Darwin, + .freebsd => Kqueue, + // win_watcher.zig imports PathWatcher.EventType from this file, so this type must + // resolve on Windows even though none of the code paths run. The stub keeps the + // struct fields typed while the actual Windows backend lives in win_watcher.zig. + .windows => struct { + pub const Watch = struct { + pub fn deinit(_: *@This()) void {} + }; + fn init(_: *PathWatcherManager) bun.sys.Maybe(void) { + return .{ .err = .{ .errno = @intFromEnum(bun.sys.E.NOTSUP), .syscall = .watch } }; } - - fn deinit(this: *DirectoryRegisterTask) void { - bun.default_allocator.destroy(this); + fn addWatch(_: *PathWatcherManager, _: *PathWatcher) bun.sys.Maybe(void) { + return .{ .err = .{ .errno = @intFromEnum(bun.sys.E.NOTSUP), .syscall = .watch } }; } + fn removeWatch(_: *PathWatcherManager, _: *PathWatcher) void {} + }, + .wasm => @compileError("unsupported"), +}; + +/// Linux: one inotify fd, one blocking reader thread, wd → {PathWatcher, subpath} map. +/// Recursive watches are implemented by walking the tree at subscribe time and adding +/// a wd per directory, then adding new subdirectories as they appear (IN_CREATE|IN_ISDIR). +const Linux = struct { + fd: bun.FD = bun.invalid_fd, + running: std.atomic.Value(bool) = .init(true), + /// wd → list of owners. `inotify_add_watch` returns the same wd for the same + /// inode on a given inotify fd, so two PathWatchers whose roots overlap (e.g. + /// a recursive watch on `/a` plus a watch on `/a/sub`) end up sharing a wd. Each + /// owner gets its own subpath so the event can be reported relative to the right + /// root, and `inotify_rm_watch` is only issued when the last owner detaches. + wd_map: std.AutoHashMapUnmanaged(i32, std.ArrayListUnmanaged(WdOwner)) = .{}, + + const WdOwner = struct { + watcher: *PathWatcher, + /// Path of the watched directory/file relative to `watcher.path`. Empty for + /// the root. Owned; freed when this owner is removed from the wd. + subpath: [:0]const u8, }; - // this should only be called if thread pool is not null - fn _addDirectory(this: *PathWatcherManager, watcher: *PathWatcher, path: PathInfo) bun.sys.Maybe(void) { - const fd = path.fd; - switch (this.main_watcher.addDirectory(fd, path.path, path.hash, false)) { - .err => |err| return .{ .err = err.withPath(path.path) }, - .result => {}, + pub const Watch = struct { + /// All wds belonging to this PathWatcher (one for a file/non-recursive dir, + /// many for a recursive dir). + wds: std.ArrayListUnmanaged(i32) = .{}, + + pub fn deinit(this: *Watch) void { + this.wds.deinit(bun.default_allocator); } + }; - return .{ - .result = DirectoryRegisterTask.schedule(this, watcher, path) catch |err| return .{ - .err = .{ - .errno = @truncate(@intFromEnum(switch (err) { - error.OutOfMemory => bun.sys.E.NOMEM, - error.UnexpectedFailure => bun.sys.E.INVAL, - })), - }, - }, + const IN = std.os.linux.IN; + const watch_file_mask: u32 = IN.MODIFY | IN.ATTRIB | IN.MOVE_SELF | IN.DELETE_SELF; + const watch_dir_mask: u32 = IN.MODIFY | IN.ATTRIB | IN.CREATE | IN.DELETE | IN.DELETE_SELF | + IN.MOVED_FROM | IN.MOVED_TO | IN.MOVE_SELF | IN.ONLYDIR; + + fn init(manager: *PathWatcherManager) bun.sys.Maybe(void) { + const rc = bun.sys.syscall.inotify_init1(IN.CLOEXEC); + if (bun.sys.Maybe(void).errnoSys(rc, .watch)) |err| return err; + manager.platform.fd = .fromNative(@intCast(rc)); + // The manager is process-global and never torn down, so the reader thread is + // a daemon — detach it instead of stashing a handle we'd never join. + var thread = std.Thread.spawn(.{}, threadMain, .{manager}) catch { + manager.platform.fd.close(); + return .{ .err = .{ .errno = @intFromEnum(bun.sys.E.NOMEM), .syscall = .watch } }; }; + thread.detach(); + return .success; } - // register is always called form main thread - fn registerWatcher(this: *PathWatcherManager, watcher: *PathWatcher) !void { - { - this.mutex.lock(); - defer this.mutex.unlock(); - - if (this.watcher_count == this.watchers.len) { - this.watcher_count += 1; - this.watchers.append(bun.default_allocator, watcher) catch |err| { - this.watcher_count -= 1; - return err; - }; - } else { - var watchers = this.watchers.slice(); - for (watchers, 0..) |w, i| { - if (w == null) { - watchers[i] = watcher; - this.watcher_count += 1; - break; - } - } - } + /// Caller holds `manager.mutex`. + fn addWatch(manager: *PathWatcherManager, watcher: *PathWatcher) bun.sys.Maybe(void) { + switch (addOne(manager, watcher, watcher.path, "")) { + .err => |e| return .{ .err = e }, + .result => {}, + } + if (watcher.recursive and !watcher.is_file) { + walkAndAdd(manager, watcher, watcher.path, ""); } + return .success; + } - const path = watcher.path; - if (path.is_file) { - try this.main_watcher.addFile(path.fd, path.path, path.hash, .file, .invalid, null, false).unwrap(); - } else { - if (comptime Environment.isMac) { - if (watcher.fsevents_watcher != null) { - return; + /// Add a single inotify watch and record ownership. Caller holds `manager.mutex`. + fn addOne( + manager: *PathWatcherManager, + watcher: *PathWatcher, + abs_path: [:0]const u8, + subpath: []const u8, + ) bun.sys.Maybe(void) { + const plat = &manager.platform; + const mask: u32 = if (watcher.is_file and subpath.len == 0) watch_file_mask else watch_dir_mask; + const rc = bun.sys.syscall.inotify_add_watch(plat.fd.cast(), abs_path, mask); + if (bun.sys.Maybe(void).errnoSysP(rc, .watch, abs_path)) |err| { + // ENOTDIR/ENOENT during a recursive walk just means we raced; skip. + if (subpath.len > 0) return .success; + return err; + } + const wd: i32 = @intCast(rc); + const gop = bun.handleOom(plat.wd_map.getOrPut(bun.default_allocator, wd)); + if (!gop.found_existing) gop.value_ptr.* = .{}; + // This wd may already have this watcher as an owner: + // - IN_CREATE raced the initial walk (same subpath → the reassign is a no-op) + // - a subdirectory was *renamed* within the tree: IN_MOVED_TO re-adds it, + // inotify returns the same wd (it watches by inode), and the cached subpath + // is now stale. Overwrite so later events under the moved dir report the + // new name. `walkAndAdd` never follows symlinks (`entry.kind == .directory`, + // not `.sym_link`), so this can't pick a longer alias via a cycle. + for (gop.value_ptr.items) |*o| { + if (o.watcher == watcher) { + if (!bun.strings.eql(o.subpath, subpath)) { + const old = o.subpath; + o.subpath = bun.handleOom(bun.default_allocator.dupeZ(u8, subpath)); + bun.default_allocator.free(old); } + return .success; } - try this._addDirectory(watcher, path).unwrap(); } + bun.handleOom(gop.value_ptr.append(bun.default_allocator, .{ + .watcher = watcher, + .subpath = bun.handleOom(bun.default_allocator.dupeZ(u8, subpath)), + })); + bun.handleOom(watcher.platform.wds.append(bun.default_allocator, wd)); + log("inotify_add_watch({s}) → wd={d} sub='{s}' owners={d}", .{ abs_path, wd, subpath, gop.value_ptr.items.len }); + return .success; } - fn _incrementPathRef(this: *PathWatcherManager, file_path: [:0]const u8) void { - this.mutex.lock(); - defer this.mutex.unlock(); - if (this.file_paths.getEntry(file_path)) |entry| { - var path = entry.value_ptr; - if (path.refs > 0) { - path.refs += 1; + /// Best-effort recursive directory walk. inotify watches are per-directory (events + /// for files arrive on their parent's wd), so only descend into subdirectories. + fn walkAndAdd(manager: *PathWatcherManager, watcher: *PathWatcher, abs_dir: [:0]const u8, rel_dir: []const u8) void { + const Ctx = struct { m: *PathWatcherManager, w: *PathWatcher }; + walkSubtree(abs_dir, rel_dir, true, Ctx{ .m = manager, .w = watcher }, struct { + fn f(ctx: Ctx, abs: [:0]const u8, rel: []const u8, _: bool) void { + _ = addOne(ctx.m, ctx.w, abs, rel); } - } + }.f); } - fn _decrementPathRefNoLock(this: *PathWatcherManager, file_path: [:0]const u8) void { - if (this.file_paths.getEntry(file_path)) |entry| { - var path = entry.value_ptr; - if (path.refs > 0) { - path.refs -= 1; - if (path.refs == 0) { - const path_ = path.path; - this.main_watcher.remove(path.hash); - _ = this.file_paths.remove(path_); - bun.default_allocator.free(path_); - } + /// Caller holds `manager.mutex`. Drops this watcher's ownership of each of its + /// wds; only issues `inotify_rm_watch` once a wd has no remaining owners. + fn removeWatch(manager: *PathWatcherManager, watcher: *PathWatcher) void { + const plat = &manager.platform; + for (watcher.platform.wds.items) |wd| { + const owners = plat.wd_map.getPtr(wd) orelse continue; + var j: usize = 0; + while (j < owners.items.len) { + if (owners.items[j].watcher == watcher) { + bun.default_allocator.free(owners.items[j].subpath); + _ = owners.swapRemove(j); + } else j += 1; + } + if (owners.items.len == 0) { + owners.deinit(bun.default_allocator); + _ = plat.wd_map.remove(wd); + _ = bun.sys.syscall.inotify_rm_watch(plat.fd.cast(), wd); } } + watcher.platform.wds.clearRetainingCapacity(); } - fn _decrementPathRef(this: *PathWatcherManager, file_path: [:0]const u8) void { - this.mutex.lock(); - defer this.mutex.unlock(); - this._decrementPathRefNoLock(file_path); - } - - // unregister is always called from main thread - fn unregisterWatcher(this: *PathWatcherManager, watcher: *PathWatcher) void { - // Must defer deinit() to AFTER releasing this.mutex, for two reasons: - // 1. deinit() re-acquires this.mutex when hasPendingTasks() is true. - // The mutex is non-recursive, so calling deinit() while holding - // the lock self-deadlocks (observed as __ulock_wait2 hang on macOS). - // 2. deinit() may destroy(this). Unlocking a freed mutex is UAF. - // Zig defers fire LIFO, so registering this defer before the lock/unlock - // pair makes it fire last (after unlock). - var should_deinit = false; - defer if (should_deinit) this.deinit(); - - this.mutex.lock(); - defer this.mutex.unlock(); - - var watchers = this.watchers.slice(); - - for (watchers, 0..) |w, i| { - if (w) |item| { - if (item == watcher) { - watchers[i] = null; - // if is the last one just pop - if (i == watchers.len - 1) { - this.watchers.len -= 1; + /// The kernel `struct inotify_event` header. Shared with the bundler watcher; + /// field naming there is `watch_descriptor` / `name_len`. + const InotifyEvent = @import("../../watcher/INotifyWatcher.zig").Event; + + fn threadMain(manager: *PathWatcherManager) void { + Output.Source.configureNamedThread("fs.watch"); + const plat = &manager.platform; + // Large enough for a burst of events; inotify guarantees whole events per read. + var buf: [64 * 1024]u8 align(@alignOf(InotifyEvent)) = undefined; + var path_buf: bun.PathBuffer = undefined; + + while (plat.running.load(.acquire)) { + const rc = bun.sys.syscall.read(plat.fd.cast(), &buf, buf.len); + switch (bun.sys.getErrno(rc)) { + .SUCCESS => {}, + .AGAIN, .INTR => continue, + else => |errno| { + // Fatal: surface to every watcher, then exit the thread. + const err: bun.sys.Error = .{ + .errno = @truncate(@intFromEnum(errno)), + .syscall = .read, + }; + manager.mutex.lock(); + for (manager.watchers.values()) |w| { + w.emitError(err); + w.flush(); } - this.watcher_count -= 1; - - this._decrementPathRefNoLock(watcher.path.path); - if (comptime Environment.isMac) { - if (watcher.fsevents_watcher != null) { - break; + manager.mutex.unlock(); + return; + }, + } + const n: usize = @intCast(rc); + if (n == 0) continue; + + manager.mutex.lock(); + // Track which PathWatchers got at least one event so we flush() each once. + var touched: std.AutoArrayHashMapUnmanaged(*PathWatcher, void) = .{}; + defer touched.deinit(bun.default_allocator); + + var i: usize = 0; + while (i < n) { + const ev: *align(1) const InotifyEvent = @ptrCast(buf[i..].ptr); + i += @sizeOf(InotifyEvent) + ev.name_len; + const wd = ev.watch_descriptor; + + // Kernel retired this wd (rm_watch, or the watched inode is gone). + if (ev.mask & IN.IGNORED != 0) { + if (plat.wd_map.getPtr(wd)) |owners| { + for (owners.items) |o| { + bun.default_allocator.free(o.subpath); + if (std.mem.indexOfScalar(i32, o.watcher.platform.wds.items, wd)) |idx| { + _ = o.watcher.platform.wds.swapRemove(idx); + } } + owners.deinit(bun.default_allocator); + _ = plat.wd_map.remove(wd); } + continue; + } - { - watcher.mutex.lock(); - defer watcher.mutex.unlock(); - while (watcher.file_paths.pop()) |file_path| { - this._decrementPathRefNoLock(file_path); - } + if (plat.wd_map.getPtr(wd) == null) continue; + + const name: []const u8 = if (ev.name_len > 0) blk: { + const name_ptr: [*:0]const u8 = @ptrCast(buf[i - ev.name_len ..].ptr); + break :blk bun.sliceTo(name_ptr, 0); + } else ""; + + const is_dir_child = ev.mask & IN.ISDIR != 0; + const event_type: PathWatcher.EventType = if (ev.mask & + (IN.CREATE | IN.DELETE | IN.DELETE_SELF | IN.MOVE_SELF | IN.MOVED_FROM | IN.MOVED_TO) != 0) + .rename + else + .change; + + // Dispatch to every owner of this wd. The recursive branch below calls + // `addOne`/`walkAndAdd`, which insert into `wd_map` via `getOrPut` and + // may rehash — that would invalidate any pointer into the map's value + // storage. Re-fetch the owners list by key each iteration rather than + // caching `getPtr(wd)` across the loop. + var oi: usize = 0; + while (true) : (oi += 1) { + const owners = plat.wd_map.getPtr(wd) orelse break; + if (oi >= owners.items.len) break; + const owner = owners.items[oi]; + const watcher = owner.watcher; + // `owner.subpath` is heap-owned by the entry and stays valid across a + // rehash (only the ArrayList header moves), so copying it out here is + // not required. + + // Build the path relative to this owner's root. + const rel: []const u8 = if (watcher.is_file) + bun.path.basename(watcher.path) + else if (owner.subpath.len == 0) + name + else if (name.len == 0) + owner.subpath + else + bun.path.joinStringBuf(&path_buf, &[_][]const u8{ owner.subpath, name }, .posix); + + watcher.emit(event_type, rel, !is_dir_child and !(ev.mask & (IN.DELETE_SELF | IN.MOVE_SELF) != 0 and !watcher.is_file)); + _ = bun.handleOom(touched.getOrPut(bun.default_allocator, watcher)); + + // Recursive: a new directory appeared under this owner's tree — + // start watching it so future events inside it are delivered. + // This is what makes `{recursive: true}` track structure changes + // after the initial crawl (#15939/#15085). + if (watcher.recursive and is_dir_child and (ev.mask & (IN.CREATE | IN.MOVED_TO) != 0) and name.len > 0) { + const abs_buf = bun.path_buffer_pool.get(); + defer bun.path_buffer_pool.put(abs_buf); + const child_abs = bun.path.joinZBuf(abs_buf, &[_][]const u8{ watcher.path, owner.subpath, name }, .posix); + // These may rehash `wd_map`; `owners` is re-fetched next iteration. + _ = addOne(manager, watcher, child_abs, rel); + walkAndAdd(manager, watcher, child_abs, rel); } - break; } } - } - - should_deinit = this.deinit_on_last_watcher and this.watcher_count == 0; - } - fn deinit(this: *PathWatcherManager) void { - // enable to create a new manager - default_manager_mutex.lock(); - defer default_manager_mutex.unlock(); - if (default_manager == this) { - default_manager = null; + for (touched.keys()) |w| w.flush(); + manager.mutex.unlock(); } + } +}; - // only deinit if no watchers are registered - if (this.watcher_count > 0) { - // wait last watcher to close - this.deinit_on_last_watcher = true; - return; +/// macOS: delegate to `fs_events.zig`, which already runs one CFRunLoop thread with +/// one FSEventStream covering every watched path. The PathWatcher itself is the +/// FSEventsWatcher's opaque ctx — `fs_events.zig` calls back via `onFSEvent` below, +/// and we fan out to the JS handlers. +/// +/// Unlike the old design, FSEvents is used for both files and directories (same as +/// libuv), so `fs.watch()` no longer spins up a second kqueue thread. +const Darwin = struct { + /// No manager-level state — FSEvents has its own process-global loop. + pub const Watch = struct { + fsevents: ?*FSEvents.FSEventsWatcher = null, + + pub fn deinit(this: *Watch) void { + if (this.fsevents) |fse| { + this.fsevents = null; + fse.deinit(); + } } + }; - if (this.hasPendingTasks()) { - this.mutex.lock(); - defer this.mutex.unlock(); - // deinit when all tasks are done - this.deinit_on_last_task = true; - return; - } + fn init(_: *PathWatcherManager) bun.sys.Maybe(void) { + return .success; + } - this.main_watcher.deinit(false); + /// Caller does NOT hold `manager.mutex` — `FSEvents.watch()` takes the FSEvents + /// loop mutex, and the CF thread holds that while calling `onFSEvent` (which + /// takes `manager.mutex`). Keeping this call outside `manager.mutex` makes the + /// lock order one-way: fsevents_loop.mutex → manager.mutex. + fn addWatch(_: *PathWatcherManager, watcher: *PathWatcher) bun.sys.Maybe(void) { + watcher.platform.fsevents = FSEvents.watch( + watcher.path, + watcher.recursive, + onFSEvent, + onFSEventFlush, + @ptrCast(watcher), + ) catch |e| return .{ .err = .{ + .errno = @intFromEnum(switch (e) { + error.FailedToCreateCoreFoudationSourceLoop => bun.sys.E.INVAL, + else => bun.sys.E.NOMEM, + }), + .syscall = .watch, + } }; + return .success; + } - if (this.watcher_count > 0) { - while (this.watchers.pop()) |watcher| { - if (watcher) |w| { - // unlink watcher - w.manager = null; - } - } + /// Caller does NOT hold `manager.mutex` (same lock-order reasoning as `addWatch`). + /// `FSEventsWatcher.deinit()` → `unregisterWatcher()` blocks on the FSEvents loop + /// mutex, which `_events_cb` holds for the whole dispatch; once this returns no + /// further `onFSEvent` calls will arrive for `watcher`. + fn removeWatch(_: *PathWatcherManager, watcher: *PathWatcher) void { + if (watcher.platform.fsevents) |fse| { + watcher.platform.fsevents = null; + fse.deinit(); } + } - // close all file descriptors and free paths - var it = this.file_paths.iterator(); - while (it.next()) |*entry| { - const path = entry.value_ptr.*; - path.fd.close(); - bun.default_allocator.free(path.path); + /// Called from the CFRunLoop thread (`fs_events.zig`'s `_events_cb`) with the + /// FSEvents loop mutex held. Take `manager.mutex` so iterating `handlers` can't + /// race with `watch()`/`detach()` mutating it. The JS thread never holds + /// `manager.mutex` across a call into FSEvents, so this is deadlock-free. + /// + /// `watcher` itself is kept alive by the FSEvents loop mutex: `detach()` → + /// `removeWatch()` → `fse.deinit()` → `unregisterWatcher()` blocks until + /// `_events_cb` releases it, so `destroy()` cannot run under us. The + /// `watcher.manager == null` check catches the window where detach has already + /// unlinked us but hasn't yet called `fse.deinit()`. + fn onFSEvent(ctx: ?*anyopaque, event: Event, is_file: bool) void { + const watcher: *PathWatcher = @ptrCast(@alignCast(ctx.?)); + const manager = default_manager orelse return; + manager.mutex.lock(); + defer manager.mutex.unlock(); + if (watcher.manager == null) return; + switch (event) { + inline .rename, .change => |path, tag| { + watcher.emit(@field(PathWatcher.EventType, @tagName(tag)), path, is_file); + }, + .@"error" => |err| watcher.emitError(err), + else => {}, } + } - this.file_paths.deinit(); - this.watchers.deinit(bun.default_allocator); - this.current_fd_task.deinit(); - bun.default_allocator.destroy(this); + fn onFSEventFlush(ctx: ?*anyopaque) void { + const watcher: *PathWatcher = @ptrCast(@alignCast(ctx.?)); + const manager = default_manager orelse return; + manager.mutex.lock(); + defer manager.mutex.unlock(); + if (watcher.manager == null) return; + watcher.flush(); } }; -pub const PathWatcher = struct { - path: PathWatcherManager.PathInfo, - callback: Callback, - flushCallback: UpdateEndCallback, - manager: ?*PathWatcherManager, - recursive: bool, - needs_flush: bool = false, - ctx: ?*anyopaque, - // all watched file paths (including subpaths) except by path it self - file_paths: bun.BabyList([:0]const u8) = .{}, - last_change_event: ChangeEvent = .{}, - // on MacOS we use this to watch for changes on directories and subdirectories - fsevents_watcher: ?*FSEvents.FSEventsWatcher, - mutex: Mutex, - pending_directories: u32 = 0, - // only used on macOS - resolved_path: ?string = null, - has_pending_directories: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - closed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - pub const ChangeEvent = struct { - hash: Watcher.HashType = 0, - event_type: EventType = .change, - time_stamp: i64 = 0, +/// FreeBSD (and any future kqueue-only platform): one kqueue fd, one blocking reader +/// thread, per-watch open file descriptors registered with EVFILT_VNODE. kqueue gives +/// no filenames, so directory events surface as a bare `rename` with an empty path — +/// same behaviour as libuv on FreeBSD; callers are expected to re-scan. +const Kqueue = struct { + kq: bun.FD = bun.invalid_fd, + running: std.atomic.Value(bool) = .init(true), + /// ident (fd number) → entry (by value — avoids a per-entry heap alloc for + /// recursive trees). `udata` on the kevent carries a monotonic generation number + /// so the reader can reject stale events after the fd is recycled. + entries: std.AutoArrayHashMapUnmanaged(i32, KqEntry) = .{}, + /// Bumped on every `addOne` and stored in both `KqEntry.gen` and `kev.udata`. + next_gen: usize = 1, + + const KqEntry = struct { + watcher: *PathWatcher, + fd: bun.FD, + /// Relative to watcher.path; empty for the root. Owned. + subpath: [:0]const u8, + gen: usize, + is_file: bool, }; - pub const EventType = enum { - rename, - change, + pub const Watch = struct { + fds: std.ArrayListUnmanaged(i32) = .{}, - pub fn toEvent(event_type: EventType, path: FSWatcher.EventPathString) Event { - return switch (event_type) { - inline else => |t| @unionInit(Event, @tagName(t), path), - }; + pub fn deinit(this: *Watch) void { + this.fds.deinit(bun.default_allocator); } }; - pub const Callback = *const fn (ctx: ?*anyopaque, detail: Event, is_file: bool) void; - const UpdateEndCallback = *const fn (ctx: ?*anyopaque) void; - - pub fn init(manager: *PathWatcherManager, path: PathWatcherManager.PathInfo, recursive: bool, callback: Callback, updateEndCallback: UpdateEndCallback, ctx: ?*anyopaque) !*PathWatcher { - var this = try bun.default_allocator.create(PathWatcher); - - if (comptime Environment.isMac) { - if (!path.is_file) { - var buffer: bun.PathBuffer = undefined; - const resolved_path_temp = std.os.getFdPath(path.fd.cast(), &buffer) catch |err| { - bun.default_allocator.destroy(this); - return err; - }; - const resolved_path = bun.default_allocator.dupeZ(u8, resolved_path_temp) catch |err| { - bun.default_allocator.destroy(this); - return err; - }; - this.* = PathWatcher{ - .path = path, - .callback = callback, - .fsevents_watcher = FSEvents.watch( - resolved_path, - recursive, - callback, - updateEndCallback, - bun.cast(*anyopaque, ctx), - ) catch |err| { - bun.default_allocator.free(resolved_path); - bun.default_allocator.destroy(this); - return err; - }, - .manager = manager, - .recursive = recursive, - .flushCallback = updateEndCallback, - .file_paths = .{}, - .ctx = ctx, - .mutex = .{}, - .resolved_path = resolved_path, - }; - - errdefer this.deinit(); - - // TODO: unify better FSEvents with PathWatcherManager - try manager.registerWatcher(this); - - return this; - } - } - - this.* = PathWatcher{ - .fsevents_watcher = null, - .path = path, - .callback = callback, - .manager = manager, - .recursive = recursive, - .flushCallback = updateEndCallback, - .ctx = ctx, - .mutex = .{}, - .file_paths = bun.BabyList([:0]const u8).initCapacity(bun.default_allocator, 1) catch |err| { - bun.default_allocator.destroy(this); - return err; - }, + fn init(manager: *PathWatcherManager) bun.sys.Maybe(void) { + const rc = bun.sys.syscall.kqueue(); + if (bun.sys.Maybe(void).errnoSys(rc, .kqueue)) |err| return err; + manager.platform.kq = .fromNative(rc); + // Daemon reader — the manager is process-global and never torn down. + var thread = std.Thread.spawn(.{}, threadMain, .{manager}) catch { + manager.platform.kq.close(); + return .{ .err = .{ .errno = @intFromEnum(bun.sys.E.NOMEM), .syscall = .watch } }; }; - - errdefer this.deinit(); - - try manager.registerWatcher(this); - return this; - } - - pub fn refPendingDirectory(this: *PathWatcher) bool { - this.mutex.lock(); - defer this.mutex.unlock(); - if (this.isClosed()) return false; - this.pending_directories += 1; - this.has_pending_directories.store(true, .release); - return true; - } - - pub fn hasPendingDirectories(this: *PathWatcher) callconv(.c) bool { - return this.has_pending_directories.load(.acquire); - } - - pub fn isClosed(this: *PathWatcher) bool { - return this.closed.load(.acquire); - } - - pub fn setClosed(this: *PathWatcher) void { - this.mutex.lock(); - defer this.mutex.unlock(); - this.closed.store(true, .release); + thread.detach(); + return .success; } - pub fn unrefPendingDirectory(this: *PathWatcher) void { - // deinit() calls setClosed() which re-locks this.mutex, and may then - // proceed to destroy(this). Defer it until after unlock so we don't - // self-deadlock or unlock() a freed mutex. Zig defers fire LIFO, so - // registering this defer before the lock/unlock pair makes it fire last. - var should_deinit = false; - defer if (should_deinit) this.deinit(); - - this.mutex.lock(); - defer this.mutex.unlock(); - this.pending_directories -= 1; - if (this.isClosed() and this.pending_directories == 0) { - this.has_pending_directories.store(false, .release); - should_deinit = true; + /// Caller holds `manager.mutex`. + fn addWatch(manager: *PathWatcherManager, watcher: *PathWatcher) bun.sys.Maybe(void) { + switch (addOne(manager, watcher, watcher.path, "", watcher.is_file)) { + .err => |e| return .{ .err = e }, + .result => {}, } - } - - pub fn emit(this: *PathWatcher, event: Event, hash: Watcher.HashType, time_stamp: i64, is_file: bool) void { - switch (event) { - .change, .rename => { - const event_type = switch (event) { - inline .change, .rename => |_, t| @field(EventType, @tagName(t)), - else => unreachable, // above switch guarentees this subset - }; - - const time_diff = time_stamp - this.last_change_event.time_stamp; - if (!((this.last_change_event.time_stamp == 0 or time_diff > 1) or - this.last_change_event.event_type != event_type and - this.last_change_event.hash != hash)) - { - // skip consecutive duplicates - return; + if (watcher.recursive and !watcher.is_file) { + // kqueue needs an open fd per *file* as well as per directory. + const Ctx = struct { m: *PathWatcherManager, w: *PathWatcher }; + walkSubtree(watcher.path, "", false, Ctx{ .m = manager, .w = watcher }, struct { + fn f(ctx: Ctx, abs: [:0]const u8, rel: []const u8, is_file: bool) void { + _ = addOne(ctx.m, ctx.w, abs, rel, is_file); } - - this.last_change_event.time_stamp = time_stamp; - this.last_change_event.event_type = event_type; - }, - else => {}, - } - - this.needs_flush = true; - if (this.isClosed()) { - return; + }.f); } - this.callback(this.ctx, event, is_file); + return .success; } - pub fn flush(this: *PathWatcher) void { - this.needs_flush = false; - if (this.isClosed()) return; - this.flushCallback(this.ctx); - } + fn addOne( + manager: *PathWatcherManager, + watcher: *PathWatcher, + abs_path: [:0]const u8, + subpath: []const u8, + is_file: bool, + ) bun.sys.Maybe(void) { + const plat = &manager.platform; + // O_EVTONLY: we only need the fd for kevent registration, never for I/O. + // (No-op on FreeBSD where EVTONLY is 0; semantic here for kqueue-on-macOS.) + const fd = switch (bun.sys.open(abs_path, bun.O.EVTONLY | bun.O.RDONLY | bun.O.CLOEXEC, 0)) { + .err => |e| { + if (subpath.len > 0) return .success; // best-effort on children + return .{ .err = e.withoutPath() }; + }, + .result => |f| f, + }; - pub fn detach(this: *PathWatcher, _: *anyopaque) void { - this.deinit(); + const gen = plat.next_gen; + plat.next_gen +%= 1; + + var kev = std.mem.zeroes(std.c.Kevent); + kev.ident = @intCast(fd.native()); + kev.filter = std.c.EVFILT.VNODE; + kev.flags = std.c.EV.ADD | std.c.EV.CLEAR | std.c.EV.ENABLE; + kev.fflags = std.c.NOTE.WRITE | std.c.NOTE.DELETE | std.c.NOTE.RENAME | + std.c.NOTE.EXTEND | std.c.NOTE.ATTRIB | std.c.NOTE.LINK | std.c.NOTE.REVOKE; + kev.udata = gen; + var changes = [_]std.c.Kevent{kev}; + const krc = bun.sys.syscall.kevent(plat.kq.native(), &changes, 1, &changes, 0, null); + if (krc < 0) { + // Registration failed (ENOMEM/EINVAL on a bad fd, etc.). Don't leave a + // dead entry in the map that will never deliver events. + const errno = bun.sys.getErrno(krc); + fd.close(); + if (subpath.len > 0) return .success; // best-effort on children + return .{ .err = .{ .errno = @truncate(@intFromEnum(errno)), .syscall = .kevent } }; + } + + bun.handleOom(plat.entries.put(bun.default_allocator, @intCast(fd.native()), .{ + .watcher = watcher, + .fd = fd, + .subpath = bun.handleOom(bun.default_allocator.dupeZ(u8, subpath)), + .gen = gen, + .is_file = is_file, + })); + bun.handleOom(watcher.platform.fds.append(bun.default_allocator, @intCast(fd.native()))); + return .success; } - pub fn deinit(this: *PathWatcher) void { - this.setClosed(); - if (this.hasPendingDirectories()) { - // will be freed on last directory - return; - } - - if (this.manager) |manager| { - if (comptime Environment.isMac) { - if (this.fsevents_watcher) |watcher| { - // first unregister on FSEvents - watcher.deinit(); - manager.unregisterWatcher(this); - } else { - manager.unregisterWatcher(this); - this.file_paths.deinit(bun.default_allocator); - } - } else { - manager.unregisterWatcher(this); - this.file_paths.deinit(bun.default_allocator); + /// Caller holds `manager.mutex`. + fn removeWatch(manager: *PathWatcherManager, watcher: *PathWatcher) void { + const plat = &manager.platform; + for (watcher.platform.fds.items) |ident| { + if (plat.entries.fetchSwapRemove(ident)) |kv| { + // Closing the fd auto-removes the kevent. + kv.value.fd.close(); + bun.default_allocator.free(kv.value.subpath); } } + watcher.platform.fds.clearRetainingCapacity(); + } - if (comptime Environment.isMac) { - if (this.resolved_path) |path| { - bun.default_allocator.free(path); + fn threadMain(manager: *PathWatcherManager) void { + Output.Source.configureNamedThread("fs.watch"); + const plat = &manager.platform; + var events: [128]std.c.Kevent = undefined; + while (plat.running.load(.acquire)) { + const count = bun.sys.syscall.kevent(plat.kq.native(), &events, 0, &events, events.len, null); + if (count <= 0) continue; + + manager.mutex.lock(); + var touched: std.AutoArrayHashMapUnmanaged(*PathWatcher, void) = .{}; + defer touched.deinit(bun.default_allocator); + + for (events[0..@intCast(count)]) |kev| { + // Validate via the map — the entry may have been freed by a racing + // removeWatch between kevent() returning and us taking the lock. POSIX + // recycles the lowest fd on open(), so the ident could also now belong + // to an *unrelated* watch registered in that same window; `udata` was + // set to a monotonic generation at registration and survives in the + // already-delivered event, so compare it to the current entry's gen + // to reject stale fd-reuse hits. + const entry = plat.entries.getPtr(@intCast(kev.ident)) orelse continue; + if (entry.gen != kev.udata) continue; + const watcher = entry.watcher; + + const event_type: PathWatcher.EventType = if (kev.fflags & + (std.c.NOTE.DELETE | std.c.NOTE.RENAME | std.c.NOTE.REVOKE | std.c.NOTE.LINK) != 0) + .rename + else + .change; + + // kqueue has no filenames. For a file watch, report the basename; for a + // directory, report the subpath (empty for root → caller re-scans). + const rel: []const u8 = if (entry.is_file and entry.subpath.len == 0) + bun.path.basename(watcher.path) + else + entry.subpath; + + watcher.emit(event_type, rel, entry.is_file); + _ = bun.handleOom(touched.getOrPut(bun.default_allocator, watcher)); } - } - bun.default_allocator.destroy(this); + for (touched.keys()) |w| w.flush(); + manager.mutex.unlock(); + } } }; -pub fn watch( - vm: *VirtualMachine, - path: [:0]const u8, - recursive: bool, - comptime callback: PathWatcher.Callback, - comptime updateEnd: PathWatcher.UpdateEndCallback, - ctx: ?*anyopaque, -) bun.sys.Maybe(*PathWatcher) { - const manager = default_manager orelse brk: { - default_manager_mutex.lock(); - defer default_manager_mutex.unlock(); - if (default_manager == null) { - default_manager = PathWatcherManager.init(vm) catch |e| { - return .{ .err = .{ - .errno = @truncate(@intFromEnum(switch (e) { - error.SystemResources, error.LockedMemoryLimitExceeded, error.OutOfMemory => bun.sys.E.NOMEM, - - error.ProcessFdQuotaExceeded, - error.SystemFdQuotaExceeded, - error.ThreadQuotaExceeded, - => bun.sys.E.MFILE, - - error.Unexpected => bun.sys.E.NOMEM, - - error.KQueueError => bun.sys.E.INVAL, - })), - .syscall = .watch, - } }; - }; - } - break :brk default_manager.?; - }; +const FSEvents = if (Environment.isMac) @import("./fs_events.zig") else struct {}; - const path_info = switch (manager._fdFromAbsolutePathZ(path)) { - .result => |result| result, - .err => |_err| { - var err = _err; - err.syscall = .watch; - return .{ .err = err }; - }, - }; - - const watcher = PathWatcher.init(manager, path_info, recursive, callback, updateEnd, ctx) catch |e| { - bun.handleErrorReturnTrace(e, @errorReturnTrace()); - manager._decrementPathRef(path); - - return .{ .err = .{ - .errno = @truncate(@intFromEnum(switch (e) { - error.Unexpected, - error.UnexpectedFailure, - error.WatchAlreadyExists, - error.NameTooLong, - error.BadPathName, - error.InvalidUtf8, - error.InvalidWtf8, - => bun.sys.E.INVAL, - - error.OutOfMemory, - error.SystemResources, - => bun.sys.E.NOMEM, - - error.FileNotFound, - error.NetworkNotFound, - error.NoDevice, - => bun.sys.E.NOENT, - - error.DeviceBusy => bun.sys.E.BUSY, - error.AccessDenied => bun.sys.E.PERM, - error.InvalidHandle => bun.sys.E.BADF, - error.SymLinkLoop => bun.sys.E.LOOP, - error.NotDir => bun.sys.E.NOTDIR, - - error.ProcessFdQuotaExceeded, - error.SystemFdQuotaExceeded, - error.UserResourceLimitReached, - => bun.sys.E.MFILE, - - else => bun.sys.E.INVAL, - })), - .syscall = .watch, - } }; - }; - - return .{ .result = watcher }; -} - -const string = []const u8; - -const FSEvents = @import("./fs_events.zig"); -const Fs = @import("../../fs.zig"); -const Path = @import("../../resolver/resolve_path.zig"); const std = @import("std"); const bun = @import("bun"); const Environment = bun.Environment; -const FD = bun.FD; const Mutex = bun.Mutex; const Output = bun.Output; -const Watcher = bun.Watcher; - -const FSWatcher = bun.api.node.fs.Watcher; -const Event = FSWatcher.Event; const jsc = bun.jsc; const VirtualMachine = jsc.VirtualMachine; + +const FSWatcher = bun.jsc.Node.fs.Watcher; +const Event = FSWatcher.Event; +const onPathUpdateFn = FSWatcher.onPathUpdate; +const onUpdateEndFn = FSWatcher.onUpdateEnd; diff --git a/test/internal/ban-limits.json b/test/internal/ban-limits.json index e52ff90a42d..c7341583944 100644 --- a/test/internal/ban-limits.json +++ b/test/internal/ban-limits.json @@ -7,7 +7,7 @@ ".arguments_old(": 262, ".jsBoolean(false)": 0, ".jsBoolean(true)": 0, - ".stdDir()": 42, + ".stdDir()": 41, ".stdFile()": 16, "// autofix": 148, ": [^=]+= undefined,$": 258, diff --git a/test/js/node/watch/fs.watch.rewrite.test.ts b/test/js/node/watch/fs.watch.rewrite.test.ts new file mode 100644 index 00000000000..2a363a85e9e --- /dev/null +++ b/test/js/node/watch/fs.watch.rewrite.test.ts @@ -0,0 +1,215 @@ +// Tests that exercise the decoupled fs.watch backend (src/bun.js/node/path_watcher.zig), +// which no longer routes through bun.Watcher. +// +// The old backend piggy-backed on the bundler's watcher, carrying +// `options.Loader`/`*PackageJSON` per watch item and doing a one-shot WorkPool +// directory crawl for recursive. The rewrite owns inotify/FSEvents/kqueue directly +// and dedupes by (realpath, recursive). These tests pin behaviour the old design +// couldn't provide. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isFreeBSD, isWindows, tempDir } from "harness"; +import fs from "node:fs"; +import path from "node:path"; + +// #15939 / #15085 / #24875: the old recursive implementation crawled the directory +// tree once on a WorkPool task and registered a watch per existing entry. Directories +// created *after* fs.watch() was called were never added to the watch set, so their +// contents were invisible. The rewritten Linux backend adds a new inotify wd on +// IN_CREATE|IN_ISDIR and walks the new subtree; FSEvents/Windows are recursive natively. +// +// FreeBSD's kqueue has no dir-child events; this case is inherently out of reach there +// and the backend emits a bare event on the parent instead (libuv behaviour). +describe.skipIf(isFreeBSD)("fs.watch recursive tracks post-watch structure", () => { + test("sees files inside a directory created after watch()", async () => { + using dir = tempDir("fs-watch-recursive-new-subdir", { "seed.txt": "x" }); + const root = String(dir); + + const seen: string[] = []; + const watcher = fs.watch(root, { recursive: true }, (_ev, filename) => { + if (typeof filename === "string") seen.push(filename.replaceAll("\\", "/")); + }); + + try { + // Give the backend a beat to register the root (FSEvents has ~50ms latency, + // inotify is synchronous). + await Bun.sleep(100); + + const sub = path.join(root, "made-after"); + fs.mkdirSync(sub); + + // The crux: write into the directory that didn't exist when watch() ran. + // Retry the write until the watcher has picked up the new subdir — avoids a + // fixed sleep that would race with inotify IN_CREATE delivery. + const target = path.join(sub, "inside.txt"); + let ok = false; + for (let i = 0; i < 80 && !ok; i++) { + fs.writeFileSync(target, String(i)); + await Bun.sleep(50); + ok = seen.some(p => p === "made-after/inside.txt" || p.endsWith("inside.txt")); + } + + // Old backend: `seen` would contain "made-after" (the mkdir on root) but never + // "made-after/inside.txt" — the subdir was never registered. New backend adds + // a wd for it on IN_CREATE|IN_ISDIR and the inner write shows up. + expect(seen.some(p => p.includes("inside.txt"))).toBe(true); + } finally { + watcher.close(); + } + }); +}); + +// inotify watches by inode, so renaming a subdirectory inside a recursive watch keeps +// the same wd. On IN_MOVED_TO the dispatch loop re-adds the moved dir, inotify returns +// the *same* wd, and the backend must replace the owner's cached subpath — otherwise +// writes under the new name would be reported under the old name. (macOS/Windows are +// path-based natively; FreeBSD has no dir-child events so is skipped as above.) +test.skipIf(isFreeBSD)("recursive watch reports new path after subdirectory rename", async () => { + using dir = tempDir("fs-watch-recursive-rename", { "a/seed.txt": "x" }); + const root = String(dir); + + const seen: string[] = []; + const watcher = fs.watch(root, { recursive: true }, (_ev, filename) => { + if (typeof filename === "string") seen.push(filename.replaceAll("\\", "/")); + }); + + try { + // Poke until the backend has picked up the subdir (sync on Linux, ~50ms on FSEvents). + for (let i = 0; i < 80 && !seen.some(p => p.startsWith("a/")); i++) { + fs.writeFileSync(path.join(root, "a", "seed.txt"), String(i)); + await Bun.sleep(50); + } + + fs.renameSync(path.join(root, "a"), path.join(root, "b")); + seen.length = 0; + + let ok = false; + for (let i = 0; i < 80 && !ok; i++) { + fs.writeFileSync(path.join(root, "b", "inside.txt"), String(i)); + await Bun.sleep(50); + ok = seen.some(p => p === "b/inside.txt"); + } + + // Must surface as "b/inside.txt"; a stale subpath would have emitted "a/inside.txt". + expect(seen).toContain("b/inside.txt"); + expect(seen.some(p => p.startsWith("a/"))).toBe(false); + } finally { + watcher.close(); + } +}); + +// Dedup: two fs.watch() calls on the same path share one OS watch. Both must receive +// events, and closing one must not silence the other. Previously each call routed to +// a shared bun.Watcher but through separate PathWatcher shims with their own +// file-path refcounting; the new design puts both handlers on one PathWatcher. +test("two watchers on the same path both receive events; closing one keeps the other alive", async () => { + using dir = tempDir("fs-watch-dedup", { "a.txt": "1" }); + const root = String(dir); + const file = path.join(root, "a.txt"); + + const got = { a: 0, b: 0 }; + const wa = fs.watch(root, () => void got.a++); + const wb = fs.watch(root, () => void got.b++); + + try { + await Bun.sleep(100); + for (let i = 0; i < 60 && (got.a === 0 || got.b === 0); i++) { + fs.writeFileSync(file, String(i)); + await Bun.sleep(50); + } + expect(got.a).toBeGreaterThan(0); + expect(got.b).toBeGreaterThan(0); + + // Drop one handler. The surviving watcher must keep firing — detaching one ctx + // must not rm_watch the shared wd. + wa.close(); + const bBefore = got.b; + for (let i = 0; i < 60 && got.b === bBefore; i++) { + fs.writeFileSync(file, "after-" + i); + await Bun.sleep(50); + } + expect(got.b).toBeGreaterThan(bBefore); + } finally { + wa.close(); + wb.close(); + } +}); + +// Linux shares one inotify fd, and inotify_add_watch returns the same wd for the +// same inode. A recursive watch on /a and a plain watch on /a/sub therefore share +// the wd for /a/sub. Closing the inner watch must not rm_watch that wd out from +// under the recursive parent. (macOS/Windows don't have this aliasing but the test +// is still a valid behavioural check there.) +test.skipIf(isFreeBSD)("closing an inner watch does not break an overlapping recursive parent", async () => { + using dir = tempDir("fs-watch-overlap", { + "sub/seed.txt": "x", + }); + const root = String(dir); + const sub = path.join(root, "sub"); + const target = path.join(sub, "seed.txt"); + + let parentHits = 0; + const parent = fs.watch(root, { recursive: true }, () => void parentHits++); + const inner = fs.watch(sub, () => {}); + try { + await Bun.sleep(100); + // Close the inner watch. On Linux this must drop *its* ownership of the shared + // wd without issuing inotify_rm_watch (parent still owns it). + inner.close(); + + for (let i = 0; i < 60 && parentHits === 0; i++) { + fs.writeFileSync(target, String(i)); + await Bun.sleep(50); + } + expect(parentHits).toBeGreaterThan(0); + } finally { + inner.close(); + parent.close(); + } +}); + +// The old PathWatcherManager was created with `vm.transpiler.fs` and wired into +// bun.Watcher's `top_level_dir`. The new backend has no such dependency — fs.watch() +// must work even on a completely cold VM that never touched the transpiler. Run a +// child process that does nothing but fs.watch to prove there's no hidden ordering +// dependency on the module-graph watcher. +test.skipIf(isWindows)("fs.watch works without any module-graph watcher state", async () => { + using dir = tempDir("fs-watch-cold-vm", { + "watched.txt": "init", + "main.js": ` + const fs = require("fs"); + const path = require("path"); + const file = path.join(__dirname, "watched.txt"); + let done = false; + const w = fs.watch(file, () => { + if (done) return; + done = true; + console.log("HIT"); + w.close(); + }); + // Poke the file until the watcher fires. Bounded retry loop; bails out as + // soon as the callback flips 'done'. 25ms between attempts so macOS (where + // file watches now go through FSEvents — async-scheduled stream + ~50ms + // coalescing latency) can't exhaust the budget before the first callback. + let i = 0; + const tick = () => { + if (done) return; + if (i++ > 200) { console.log("MISS"); process.exit(1); } + fs.writeFileSync(file, String(i)); + setTimeout(tick, 25); + }; + setTimeout(tick, 25); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain("HIT"); + expect(exitCode).toBe(0); +});