//! Bun's cross-platform filesystem watcher. Runs on its own thread. const Watcher = @This(); const DebugLogScope = bun.Output.Scoped(.watcher, .visible); const log = DebugLogScope.log; // This will always be [max_count]WatchEvent, // We avoid statically allocating because it increases the binary size. watch_events: []WatchEvent = &.{}, changed_filepaths: [max_count]?[:0]u8, /// The platform-specific implementation of the watcher platform: Platform, watchlist: WatchList, watched_count: usize, mutex: Mutex, fs: *bun.fs.FileSystem, allocator: std.mem.Allocator, watchloop_handle: ?std.Thread.Id = null, cwd: string, thread: std.Thread = undefined, running: bool = true, close_descriptors: bool = false, evict_list: [max_eviction_count]WatchItemIndex = undefined, evict_list_i: WatchItemIndex = 0, ctx: *anyopaque, onFileUpdate: *const fn (this: *anyopaque, events: []WatchEvent, changed_files: []?[:0]u8, watchlist: WatchList) void, onError: *const fn (this: *anyopaque, err: bun.sys.Error) void, thread_lock: bun.safety.ThreadLock = .initUnlocked(), pub const max_count = 128; pub const requires_file_descriptors = switch (Environment.os) { .mac => true, else => false, }; pub const Event = WatchEvent; pub const Item = WatchItem; pub const ItemList = WatchList; pub const WatchList = std.MultiArrayList(WatchItem); pub const HashType = u32; const no_watch_item: WatchItemIndex = std.math.maxInt(WatchItemIndex); /// Initializes a watcher. Each watcher is tied to some context type, which /// receives watch callbacks on the watcher thread. This function does not /// actually start the watcher thread. /// /// const watcher = try Watcher.init(T, instance_of_t, fs, bun.default_allocator) /// errdefer watcher.deinit(false); /// try watcher.start(); /// /// To integrate a started watcher into module resolution: /// /// transpiler.resolver.watcher = watcher.getResolveWatcher(); /// /// To integrate a started watcher into bundle_v2: /// /// bundle_v2.bun_watcher = watcher; pub fn init(comptime T: type, ctx: *T, fs: *bun.fs.FileSystem, allocator: std.mem.Allocator) !*Watcher { const wrapped = struct { fn onFileUpdateWrapped(ctx_opaque: *anyopaque, events: []WatchEvent, changed_files: []?[:0]u8, watchlist: WatchList) void { T.onFileUpdate(@ptrCast(@alignCast(ctx_opaque)), events, changed_files, watchlist); } fn onErrorWrapped(ctx_opaque: *anyopaque, err: bun.sys.Error) void { if (@hasDecl(T, "onWatchError")) { T.onWatchError(@ptrCast(@alignCast(ctx_opaque)), err); } else { T.onError(@ptrCast(@alignCast(ctx_opaque)), err); } } }; const watcher = try allocator.create(Watcher); errdefer allocator.destroy(watcher); watcher.* = .{ .fs = fs, .allocator = allocator, .watched_count = 0, .watchlist = WatchList{}, .mutex = .{}, .cwd = fs.top_level_dir, .ctx = ctx, .onFileUpdate = &wrapped.onFileUpdateWrapped, .onError = &wrapped.onErrorWrapped, .platform = .{}, .watch_events = try allocator.alloc(WatchEvent, max_count), .changed_filepaths = [_]?[:0]u8{null} ** max_count, }; try Platform.init(&watcher.platform, fs.top_level_dir); // Initialize trace file if BUN_WATCHER_TRACE env var is set WatcherTrace.init(); return watcher; } /// Write trace events to the trace file if enabled. /// This runs on the watcher thread, so no locking is needed. pub fn writeTraceEvents(this: *Watcher, events: []WatchEvent, changed_files: []?[:0]u8) void { WatcherTrace.writeEvents(this, events, changed_files); } pub fn start(this: *Watcher) !void { bun.assert(this.watchloop_handle == null); this.thread = try std.Thread.spawn(.{}, threadMain, .{this}); } pub fn deinit(this: *Watcher, close_descriptors: bool) void { if (this.watchloop_handle != null) { this.mutex.lock(); defer this.mutex.unlock(); this.close_descriptors = close_descriptors; this.running = false; } else { if (close_descriptors and this.running) { const fds = this.watchlist.items(.fd); for (fds) |fd| { fd.close(); } } this.watchlist.deinit(this.allocator); const allocator = this.allocator; allocator.destroy(this); } } pub fn getHash(filepath: string) HashType { return @as(HashType, @truncate(bun.hash(filepath))); } pub const WatchItemIndex = u16; pub const max_eviction_count = 8096; // TODO: some platform-specific behavior is implemented in // this file instead of the platform-specific file. // ideally, the constants above can be inlined const Platform = switch (Environment.os) { .linux => @import("./watcher/INotifyWatcher.zig"), .mac => @import("./watcher/KEventWatcher.zig"), .windows => WindowsWatcher, .wasm => @compileError("Unsupported platform"), }; pub const WatchEvent = struct { index: WatchItemIndex, op: Op, name_off: u8 = 0, name_len: u8 = 0, pub fn names(this: WatchEvent, buf: []?[:0]u8) []?[:0]u8 { if (this.name_len == 0) return &[_]?[:0]u8{}; return buf[this.name_off..][0..this.name_len]; } pub const Sorter = void; pub fn sortByIndex(_: Sorter, event: WatchEvent, rhs: WatchEvent) bool { return event.index < rhs.index; } pub fn merge(this: *WatchEvent, other: WatchEvent) void { this.name_len += other.name_len; this.op = Op.merge(this.op, other.op); } pub const Op = packed struct(u8) { delete: bool = false, metadata: bool = false, rename: bool = false, write: bool = false, move_to: bool = false, create: bool = false, _padding: u2 = 0, pub fn merge(before: Op, after: Op) Op { return .{ .delete = before.delete or after.delete, .write = before.write or after.write, .metadata = before.metadata or after.metadata, .rename = before.rename or after.rename, .move_to = before.move_to or after.move_to, .create = before.create or after.create, }; } pub fn format(op: Op, w: *std.Io.Writer) !void { try w.writeAll("{"); var first = true; inline for (comptime std.meta.fieldNames(Op)) |name| { if (comptime std.mem.eql(u8, name, "_padding")) continue; if (@field(op, name)) { if (!first) { try w.writeAll(","); } first = false; try w.writeAll(name); } } try w.writeAll("}"); } }; }; pub const WatchItem = struct { file_path: string, // filepath hash for quick comparison hash: u32, loader: options.Loader, fd: bun.FileDescriptor, count: u32, parent_hash: u32, kind: Kind, package_json: ?*PackageJSON, eventlist_index: if (Environment.isLinux) Platform.EventListIndex else u0 = 0, pub const Kind = enum { file, directory }; }; fn threadMain(this: *Watcher) !void { this.watchloop_handle = std.Thread.getCurrentId(); this.thread_lock.lock(); Output.Source.configureNamedThread("File Watcher"); defer Output.flush(); log("Watcher started", .{}); switch (this.watchLoop()) { .err => |err| { this.watchloop_handle = null; this.platform.stop(); if (this.running) { this.onError(this.ctx, err); } }, .result => {}, } // deinit and close descriptors if needed if (this.close_descriptors) { const fds = this.watchlist.items(.fd); for (fds) |fd| { fd.close(); } } this.watchlist.deinit(this.allocator); // Close trace file if open WatcherTrace.deinit(); const allocator = this.allocator; allocator.destroy(this); } pub fn flushEvictions(this: *Watcher) void { if (this.evict_list_i == 0) return; defer this.evict_list_i = 0; // swapRemove messes up the order // But, it only messes up the order if any elements in the list appear after the item being removed // So if we just sort the list by the biggest index first, that should be fine std.sort.insertion( WatchItemIndex, this.evict_list[0..this.evict_list_i], {}, comptime std.sort.desc(WatchItemIndex), ); var slice = this.watchlist.slice(); const fds = slice.items(.fd); var last_item = no_watch_item; for (this.evict_list[0..this.evict_list_i]) |item| { // catch duplicates, since the list is sorted, duplicates will appear right after each other if (item == last_item) continue; if (!Environment.isWindows) { // on mac and linux we can just close the file descriptor // we don't need to call inotify_rm_watch on linux because it gets removed when the file descriptor is closed if (fds[item].isValid()) { fds[item].close(); } } last_item = item; } last_item = no_watch_item; // This is split into two passes because reading the slice while modified is potentially unsafe. for (this.evict_list[0..this.evict_list_i]) |item| { if (item == last_item or this.watchlist.len <= item) continue; this.watchlist.swapRemove(item); last_item = item; } } fn watchLoop(this: *Watcher) bun.sys.Maybe(void) { while (this.running) { // individual platform implementation will call onFileUpdate switch (Platform.watchLoopCycle(this)) { .err => |err| return .{ .err = err }, .result => |iter| iter, } } return .success; } /// Register a file descriptor with kqueue on macOS without validation. /// /// Preconditions (caller must ensure): /// - `fd` is a valid, open file descriptor /// - `fd` is not already registered with this kqueue /// - `watchlist_id` matches the entry's index in the watchlist /// /// Note: This function does not propagate kevent registration errors. /// If registration fails, the file will not be watched but no error is returned. pub fn addFileDescriptorToKQueueWithoutChecks(this: *Watcher, fd: bun.FileDescriptor, watchlist_id: usize) void { const KEvent = std.c.Kevent; // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kqueue.2.html var event = std.mem.zeroes(KEvent); event.flags = std.c.EV.ADD | std.c.EV.CLEAR | std.c.EV.ENABLE; // we want to know about the vnode event.filter = std.c.EVFILT.VNODE; event.fflags = std.c.NOTE.WRITE | std.c.NOTE.RENAME | std.c.NOTE.DELETE; // id event.ident = @intCast(fd.native()); // Store the index for fast filtering later event.udata = @as(usize, @intCast(watchlist_id)); var events: [1]KEvent = .{event}; // This took a lot of work to figure out the right permutation // Basically: // - We register the event here. // our while(true) loop above receives notification of changes to any of the events created here. _ = std.posix.system.kevent( this.platform.fd.unwrap().?.native(), @as([]KEvent, events[0..1]).ptr, 1, @as([]KEvent, events[0..1]).ptr, 0, null, ); } fn appendFileAssumeCapacity( this: *Watcher, fd: bun.FileDescriptor, file_path: string, hash: HashType, loader: options.Loader, parent_hash: HashType, package_json: ?*PackageJSON, comptime clone_file_path: bool, ) bun.sys.Maybe(void) { if (comptime Environment.isWindows) { // on windows we can only watch items that are in the directory tree of the top level dir const rel = bun.path.isParentOrEqual(this.fs.top_level_dir, file_path); if (rel == .unrelated) { Output.warn("File {s} is not in the project directory and will not be watched\n", .{file_path}); return .success; } } const watchlist_id = this.watchlist.len; const file_path_: string = if (comptime clone_file_path) bun.asByteSlice(bun.handleOom(this.allocator.dupeZ(u8, file_path))) else file_path; var item = WatchItem{ .file_path = file_path_, .fd = fd, .hash = hash, .count = 0, .loader = loader, .parent_hash = parent_hash, .package_json = package_json, .kind = .file, }; if (comptime Environment.isMac) { this.addFileDescriptorToKQueueWithoutChecks(fd, watchlist_id); } else if (comptime Environment.isLinux) { // var file_path_to_use_ = std.mem.trimRight(u8, file_path_, "/"); // var buf: [bun.MAX_PATH_BYTES+1]u8 = undefined; // bun.copy(u8, &buf, file_path_to_use_); // buf[file_path_to_use_.len] = 0; var buf = file_path_.ptr; const slice: [:0]const u8 = buf[0..file_path_.len :0]; item.eventlist_index = switch (this.platform.watchPath(slice)) { .err => |err| return .{ .err = err }, .result => |r| r, }; } this.watchlist.appendAssumeCapacity(item); return .success; } fn appendDirectoryAssumeCapacity( this: *Watcher, stored_fd: bun.FileDescriptor, file_path: string, hash: HashType, comptime clone_file_path: bool, ) bun.sys.Maybe(WatchItemIndex) { if (comptime Environment.isWindows) { // on windows we can only watch items that are in the directory tree of the top level dir const rel = bun.path.isParentOrEqual(this.fs.top_level_dir, file_path); if (rel == .unrelated) { Output.warn("Directory {s} is not in the project directory and will not be watched\n", .{file_path}); return .{ .result = no_watch_item }; } } const fd = brk: { if (stored_fd.isValid()) break :brk stored_fd; break :brk switch (bun.sys.openA(file_path, 0, 0)) { .err => |err| return .{ .err = err }, .result => |fd| fd, }; }; const file_path_: string = if (comptime clone_file_path) bun.asByteSlice(bun.handleOom(this.allocator.dupeZ(u8, file_path))) else file_path; const parent_hash = getHash(bun.fs.PathName.init(file_path_).dirWithTrailingSlash()); const watchlist_id = this.watchlist.len; var item = WatchItem{ .file_path = file_path_, .fd = fd, .hash = hash, .count = 0, .loader = options.Loader.file, .parent_hash = parent_hash, .kind = .directory, .package_json = null, }; if (Environment.isMac) { const KEvent = std.c.Kevent; // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kqueue.2.html var event = std.mem.zeroes(KEvent); event.flags = std.c.EV.ADD | std.c.EV.CLEAR | std.c.EV.ENABLE; // we want to know about the vnode event.filter = std.c.EVFILT.VNODE; // monitor: // - Write // - Rename // - Delete event.fflags = std.c.NOTE.WRITE | std.c.NOTE.RENAME | std.c.NOTE.DELETE; // id event.ident = @intCast(fd.native()); // Store the hash for fast filtering later event.udata = @as(usize, @intCast(watchlist_id)); var events: [1]KEvent = .{event}; // This took a lot of work to figure out the right permutation // Basically: // - We register the event here. // our while(true) loop above receives notification of changes to any of the events created here. _ = std.posix.system.kevent( this.platform.fd.unwrap().?.native(), @as([]KEvent, events[0..1]).ptr, 1, @as([]KEvent, events[0..1]).ptr, 0, null, ); } else if (Environment.isLinux) { const buf = bun.path_buffer_pool.get(); defer { bun.path_buffer_pool.put(buf); } const path: [:0]const u8 = if (clone_file_path and file_path_.len > 0 and file_path_[file_path_.len - 1] == 0) file_path_[0 .. file_path_.len - 1 :0] else brk: { const trailing_slash = if (file_path_.len > 1) std.mem.trimRight(u8, file_path_, &.{ 0, '/' }) else file_path_; @memcpy(buf[0..trailing_slash.len], trailing_slash); buf[trailing_slash.len] = 0; break :brk buf[0..trailing_slash.len :0]; }; item.eventlist_index = switch (this.platform.watchDir(path)) { .err => |err| return .{ .err = err.withPath(file_path) }, .result => |r| r, }; } this.watchlist.appendAssumeCapacity(item); return .{ .result = @as(WatchItemIndex, @truncate(this.watchlist.len - 1)), }; } // Below is platform-independent pub fn appendFileMaybeLock( this: *Watcher, fd: bun.FileDescriptor, file_path: string, hash: HashType, loader: options.Loader, dir_fd: bun.FileDescriptor, package_json: ?*PackageJSON, comptime clone_file_path: bool, comptime lock: bool, ) bun.sys.Maybe(void) { if (comptime lock) this.mutex.lock(); defer if (comptime lock) this.mutex.unlock(); bun.assert(file_path.len > 1); const pathname = bun.fs.PathName.init(file_path); const parent_dir = pathname.dirWithTrailingSlash(); const parent_dir_hash: HashType = getHash(parent_dir); var parent_watch_item: ?WatchItemIndex = null; const autowatch_parent_dir = (comptime FeatureFlags.watch_directories) and this.isEligibleDirectory(parent_dir); if (autowatch_parent_dir) { var watchlist_slice = this.watchlist.slice(); if (dir_fd.isValid()) { const fds = watchlist_slice.items(.fd); if (std.mem.indexOfScalar(bun.FileDescriptor, fds, dir_fd)) |i| { parent_watch_item = @as(WatchItemIndex, @truncate(i)); } } if (parent_watch_item == null) { const hashes = watchlist_slice.items(.hash); if (std.mem.indexOfScalar(HashType, hashes, parent_dir_hash)) |i| { parent_watch_item = @as(WatchItemIndex, @truncate(i)); } } } bun.handleOom(this.watchlist.ensureUnusedCapacity(this.allocator, 1 + @as(usize, @intCast(@intFromBool(parent_watch_item == null))))); if (autowatch_parent_dir) { parent_watch_item = parent_watch_item orelse switch (this.appendDirectoryAssumeCapacity(dir_fd, parent_dir, parent_dir_hash, clone_file_path)) { .err => |err| return .{ .err = err.withPath(parent_dir) }, .result => |r| r, }; } switch (this.appendFileAssumeCapacity( fd, file_path, hash, loader, parent_dir_hash, package_json, clone_file_path, )) { .err => |err| return .{ .err = err.withPath(file_path) }, .result => {}, } if (DebugLogScope.isVisible()) { const cwd_len_with_slash = if (this.cwd[this.cwd.len - 1] == '/') this.cwd.len else this.cwd.len + 1; log("Added {s} to watch list.", .{ if (file_path.len > cwd_len_with_slash and bun.strings.startsWith(file_path, this.cwd)) file_path[cwd_len_with_slash..] else file_path, }); } return .success; } inline fn isEligibleDirectory(this: *Watcher, dir: string) bool { return strings.contains(dir, this.fs.top_level_dir) and !strings.contains(dir, "node_modules"); } pub fn appendFile( this: *Watcher, fd: bun.FileDescriptor, file_path: string, hash: HashType, loader: options.Loader, dir_fd: bun.FileDescriptor, package_json: ?*PackageJSON, comptime clone_file_path: bool, ) bun.sys.Maybe(void) { return appendFileMaybeLock(this, fd, file_path, hash, loader, dir_fd, package_json, clone_file_path, true); } pub fn addDirectory( this: *Watcher, fd: bun.FileDescriptor, file_path: string, hash: HashType, comptime clone_file_path: bool, ) bun.sys.Maybe(WatchItemIndex) { this.mutex.lock(); defer this.mutex.unlock(); if (this.indexOf(hash)) |idx| { return .{ .result = @truncate(idx) }; } bun.handleOom(this.watchlist.ensureUnusedCapacity(this.allocator, 1)); return this.appendDirectoryAssumeCapacity(fd, file_path, hash, clone_file_path); } /// Lazily watch a file by path (slow path). /// /// This function is used when a file needs to be watched but was not /// encountered during the normal import graph traversal. On macOS, it /// opens a file descriptor with O_EVTONLY to obtain an inode reference. /// /// Thread-safe: uses internal locking to prevent race conditions. /// /// Returns: /// - true if the file is successfully added to the watchlist or already watched /// - false if the file cannot be opened or added to the watchlist pub fn addFileByPathSlow( this: *Watcher, file_path: string, loader: options.Loader, ) bool { if (file_path.len == 0) return false; const hash = getHash(file_path); // Check if already watched (with lock to avoid race with removal) { this.mutex.lock(); const already_watched = this.indexOf(hash) != null; this.mutex.unlock(); if (already_watched) { return true; } } // Only open fd if we might need it var fd: bun.FileDescriptor = bun.invalid_fd; if (Environment.isMac) { const path_z = std.posix.toPosixPath(file_path) catch return false; switch (bun.sys.open(&path_z, bun.c.O_EVTONLY, 0)) { .result => |opened| fd = opened, .err => return false, } } const res = this.addFile(fd, file_path, hash, loader, bun.invalid_fd, null, true); switch (res) { .result => { // On macOS, addFile may have found the file already watched (race) // and returned success without using our fd. Close it if unused. if ((comptime Environment.isMac) and fd.isValid()) { this.mutex.lock(); const maybe_idx = this.indexOf(hash); const stored_fd = if (maybe_idx) |idx| this.watchlist.items(.fd)[idx] else bun.invalid_fd; this.mutex.unlock(); // Only close if entry exists and stored fd differs from ours. // Race scenarios: // 1. Entry removed (maybe_idx == null): our fd was stored then closed by flushEvictions → don't close // 2. Entry exists with different fd: another thread added entry, addFile didn't use our fd → close ours // 3. Entry exists with same fd: our fd was stored → don't close if (maybe_idx != null and stored_fd.native() != fd.native()) { fd.close(); } } return true; }, .err => { if (fd.isValid()) fd.close(); return false; }, } } pub fn addFile( this: *Watcher, fd: bun.FileDescriptor, file_path: string, hash: HashType, loader: options.Loader, dir_fd: bun.FileDescriptor, package_json: ?*PackageJSON, comptime clone_file_path: bool, ) bun.sys.Maybe(void) { // This must lock due to concurrent transpiler this.mutex.lock(); defer this.mutex.unlock(); if (this.indexOf(hash)) |index| { if (comptime FeatureFlags.atomic_file_watcher) { // On Linux, the file descriptor might be out of date. if (fd.isValid()) { var fds = this.watchlist.items(.fd); fds[index] = fd; } } return .success; } return this.appendFileMaybeLock(fd, file_path, hash, loader, dir_fd, package_json, clone_file_path, false); } pub fn indexOf(this: *Watcher, hash: HashType) ?u32 { for (this.watchlist.items(.hash), 0..) |other, i| { if (hash == other) { return @as(u32, @truncate(i)); } } return null; } pub fn remove(this: *Watcher, hash: HashType) void { this.mutex.lock(); defer this.mutex.unlock(); if (this.indexOf(hash)) |index| { this.removeAtIndex(@truncate(index), hash, &[_]HashType{}, .file); } } pub fn removeAtIndex(this: *Watcher, index: WatchItemIndex, hash: HashType, parents: []HashType, comptime kind: WatchItem.Kind) void { bun.assert(index != no_watch_item); this.evict_list[this.evict_list_i] = index; this.evict_list_i += 1; if (comptime kind == .directory) { for (parents) |parent| { if (parent == hash) { this.evict_list[this.evict_list_i] = @as(WatchItemIndex, @truncate(parent)); this.evict_list_i += 1; } } } } pub fn getResolveWatcher(watcher: *Watcher) bun.resolver.AnyResolveWatcher { return bun.resolver.ResolveWatcher(*@This(), onMaybeWatchDirectory).init(watcher); } pub fn onMaybeWatchDirectory(watch: *Watcher, file_path: string, dir_fd: bun.StoredFileDescriptorType) void { // We don't want to watch: // - Directories outside the root directory // - Directories inside node_modules if (std.mem.indexOf(u8, file_path, "node_modules") == null and std.mem.indexOf(u8, file_path, watch.fs.top_level_dir) != null) { _ = watch.addDirectory(dir_fd, file_path, getHash(file_path), false); } } const string = []const u8; const WatcherTrace = @import("./watcher/WatcherTrace.zig"); const WindowsWatcher = @import("./watcher/WindowsWatcher.zig"); const options = @import("./options.zig"); const std = @import("std"); const PackageJSON = @import("./resolver/package_json.zig").PackageJSON; const bun = @import("bun"); const Environment = bun.Environment; const FeatureFlags = bun.FeatureFlags; const Mutex = bun.Mutex; const Output = bun.Output; const strings = bun.strings;