Files
bun.sh/src/bun.js/node/path_watcher.zig
Claude Bot d139875751 fix(fs): emit rename event when watched directory is deleted on Linux
Fixes #23306

On Linux with inotify, when a watched directory is deleted:
1. The watcher now correctly emits a "rename" event with the directory's
   basename (matching Node.js behavior)
2. After closing a watcher on a deleted directory and recreating the
   directory, new watchers correctly receive file change events

The root cause was that Bun kept file descriptors open for watched
directories. Since inotify watches by inode, keeping the FD open kept
the inode alive, preventing IN_DELETE_SELF events from being generated
when the directory was deleted via rmdir().

Changes:
- Close directory FDs immediately after setting up inotify watches on
  Linux (inotify watches by path/inode, not FD)
- Handle IN_DELETE_SELF events by emitting rename and cleaning up the
  file_paths HashMap entry
- Fix potential deadlock in unrefPendingDirectory by releasing mutex
  before calling deinit
- Add validity checks before closing FDs to prevent double-close errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:23:44 +00:00

1124 lines
42 KiB
Zig

var default_manager_mutex: Mutex = .{};
var default_manager: ?*PathWatcherManager = null;
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 {
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);
this.deinit();
}
}
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.* };
}
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 }),
}) {
.err => |e| {
if (e.errno == @intFromEnum(bun.sys.E.NOTDIR)) {
// It's a file, not a directory
// On Linux, we don't need to keep FDs open for inotify
const file_fd = if (comptime Environment.isLinux)
bun.FD.invalid
else 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_fd,
.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| {
// On Linux, we don't need to keep directory FDs open for inotify.
// Close immediately and store invalid to prevent double-close.
if (comptime Environment.isLinux) {
iterable_dir.close();
}
const cloned_path = bun.handleOom(bun.default_allocator.dupeZ(u8, path));
const result = PathInfo{
.fd = if (comptime Environment.isLinux) bun.FD.invalid else 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 };
},
}
}
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 = .{},
};
this.* = manager;
try this.main_watcher.start();
return this;
}
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);
var counts = slice.items(.count);
const kinds = slice.items(.kind);
var _on_file_update_path_buf: bun.PathBuffer = undefined;
var ctx = this.main_watcher;
defer ctx.flushEvictions();
const timestamp = std.time.milliTimestamp();
this.mutex.lock();
defer this.mutex.unlock();
const watchers = this.watchers.slice();
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];
if (comptime Environment.isDebug) {
log("[watch] {s} ({s}, {f})", .{ file_path, @tagName(kind), event.op });
}
switch (kind) {
.file => {
if (event.op.delete) {
ctx.removeAtIndex(
event.index,
0,
&.{},
.file,
);
}
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 => {
// Handle files/directories changed WITHIN the watched directory
const affected = event.names(changed_files);
// Handle the watched directory itself being deleted (IN_DELETE_SELF)
// IN_DELETE_SELF has no filename (affected.len == 0), while IN_DELETE (file inside dir) has a filename
if (event.op.delete and affected.len == 0) {
log("[watch] directory self-delete event: {s}", .{file_path});
const hash = Watcher.getHash(file_path);
// Emit a rename event for the deleted directory to watchers watching it
// Do this BEFORE removing from watchlist so the path is still valid
for (watchers) |w| {
if (w) |watcher| {
if (comptime Environment.isMac) {
if (watcher.fsevents_watcher != null) continue;
}
// Check if this watcher is watching the deleted directory
if (watcher.path.hash == hash) {
// For a watcher watching the directory itself, emit rename with basename
const basename = std.fs.path.basename(file_path);
if (basename.len > 0) {
watcher.emit(PathWatcher.EventType.rename.toEvent(basename), hash, timestamp, false);
}
}
}
}
// Remove the directory from the watchlist since it no longer exists
ctx.removeAtIndex(
event.index,
hash,
&.{},
.directory,
);
// Clean up the file_paths entry for this deleted directory
// This allows a recreated directory at the same path to get a fresh watch
// NOTE: We must NOT free the path string here because it's shared with the watchlist
// The watchlist will free it when flushEvictions is called
if (this.file_paths.getEntry(file_path)) |entry| {
const path_info = entry.value_ptr;
// Close the now-invalid FD
if (path_info.fd.isValid()) {
path_info.fd.close();
}
// Remove from HashMap but DON'T free the string - watchlist owns it
_ = this.file_paths.remove(file_path);
}
// Don't process this as a file-inside-directory event
continue;
}
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);
}
}
}
},
}
}
if (comptime Environment.isDebug) {
Output.flush();
}
for (watchers) |w| {
if (w) |watcher| {
if (watcher.needs_flush) watcher.flush();
}
}
}
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();
}
}
// we need a new manager at this point
default_manager_mutex.lock();
defer default_manager_mutex.unlock();
default_manager = null;
}
// deinit manager when all watchers are closed
this.deinit();
}
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();
}
fn schedule(manager: *PathWatcherManager, watcher: *PathWatcher, path: PathInfo) !void {
// keep the path alive
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| {
watcher.unrefPendingDirectory();
return err;
};
} else {
return error.UnexpectedFailure;
}
return;
}
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| {
watcher.unrefPendingDirectory();
return err;
};
} else {
return error.UnexpectedFailure;
}
manager.current_fd_task.put(path.fd, routine) catch |err| {
watcher.unrefPendingDirectory();
return err;
};
}
if (manager.refPendingTask()) {
jsc.WorkPool.schedule(&routine.task);
return;
}
return error.UnexpectedFailure;
}
fn getNext(this: *DirectoryRegisterTask) ?*PathWatcher {
this.manager.mutex.lock();
defer this.manager.mutex.unlock();
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;
}
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;
// On Linux, we close the directory FD early to allow IN_DELETE_SELF events,
// so we need to open it here for iteration. On macOS, we can use the stored FD.
var opened_dir: ?std.fs.Dir = null;
defer if (opened_dir) |*d| d.close();
const dir = if (comptime Environment.isLinux) brk: {
opened_dir = std.fs.openDirAbsoluteZ(path.path, .{ .iterate = true }) catch |err| {
return .{
.err = .{
.errno = @truncate(@intFromEnum(switch (err) {
error.AccessDenied => bun.sys.E.ACCES,
error.FileNotFound => bun.sys.E.NOENT,
error.NotDir => bun.sys.E.NOTDIR,
else => bun.sys.E.INVAL,
})),
.syscall = .watch,
},
};
};
break :brk opened_dir.?;
} else path.fd.stdDir();
var iter = dir.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();
defer watcher.mutex.unlock();
watcher.file_paths.append(bun.default_allocator, child_path.path) catch |err| {
manager._decrementPathRef(entry_path_z);
return switch (err) {
error.OutOfMemory => .{ .err = .{
.errno = @truncate(@intFromEnum(bun.sys.E.NOMEM)),
.syscall = .watch,
} },
};
};
}
// 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;
}
fn run(this: *DirectoryRegisterTask) void {
if (comptime Environment.isWindows) {
return bun.todo(@src(), {});
}
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 => {},
}
}
this.manager.unrefPendingTask();
}
fn deinit(this: *DirectoryRegisterTask) void {
bun.default_allocator.destroy(this);
}
};
// this should only be called if thread pool is not null
fn _addDirectory(this: *PathWatcherManager, watcher: *PathWatcher, path: PathInfo) bun.sys.Maybe(void) {
// On Linux, close the directory fd BEFORE setting up the inotify watch and pass invalid.
// inotify watches by path (inode), so we don't need to keep the fd open.
// Keeping it open prevents IN_DELETE_SELF events when the directory is deleted
// because the inode stays alive due to the open fd reference.
// On macOS/BSD, we need to keep the fd open for kqueue which watches by fd.
const fd = if (comptime Environment.isLinux) fd_blk: {
if (path.fd.isValid()) {
path.fd.close();
}
// Also mark the fd in file_paths as invalid
if (this.file_paths.getEntry(path.path)) |entry| {
entry.value_ptr.fd = .invalid;
}
break :fd_blk bun.FD.invalid;
} else path.fd;
switch (this.main_watcher.addDirectory(fd, path.path, path.hash, false)) {
.err => |err| return .{ .err = err.withPath(path.path) },
.result => {},
}
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,
})),
},
},
};
}
// 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;
}
}
}
}
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;
}
}
try this._addDirectory(watcher, path).unwrap();
}
}
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;
}
}
}
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;
const fd = path.fd;
this.main_watcher.remove(path.hash);
_ = this.file_paths.remove(path_);
// Close the file descriptor to prevent FD leak
if (fd.isValid()) {
fd.close();
}
bun.default_allocator.free(path_);
}
}
}
}
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 form main thread
fn unregisterWatcher(this: *PathWatcherManager, watcher: *PathWatcher) void {
this.mutex.lock();
defer this.mutex.unlock();
var watchers = this.watchers.slice();
defer {
if (this.deinit_on_last_watcher and this.watcher_count == 0) {
this.deinit();
}
}
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;
}
this.watcher_count -= 1;
this._decrementPathRefNoLock(watcher.path.path);
if (comptime Environment.isMac) {
if (watcher.fsevents_watcher != null) {
break;
}
}
{
watcher.mutex.lock();
defer watcher.mutex.unlock();
while (watcher.file_paths.pop()) |file_path| {
this._decrementPathRefNoLock(file_path);
}
}
break;
}
}
}
}
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;
}
// only deinit if no watchers are registered
if (this.watcher_count > 0) {
// wait last watcher to close
this.deinit_on_last_watcher = true;
return;
}
if (this.hasPendingTasks()) {
this.mutex.lock();
defer this.mutex.unlock();
// deinit when all tasks are done
this.deinit_on_last_task = true;
return;
}
this.main_watcher.deinit(false);
if (this.watcher_count > 0) {
while (this.watchers.pop()) |watcher| {
if (watcher) |w| {
// unlink watcher
w.manager = null;
}
}
}
// close all file descriptors and free paths
var it = this.file_paths.iterator();
while (it.next()) |*entry| {
const path = entry.value_ptr.*;
if (path.fd.isValid()) path.fd.close();
bun.default_allocator.free(path.path);
}
this.file_paths.deinit();
this.watchers.deinit(bun.default_allocator);
this.current_fd_task.deinit();
bun.default_allocator.destroy(this);
}
};
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,
};
pub const EventType = enum {
rename,
change,
pub fn toEvent(event_type: EventType, path: FSWatcher.EventPathString) Event {
return switch (event_type) {
inline else => |t| @unionInit(Event, @tagName(t), path),
};
}
};
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.resolved_path = resolved_path;
this.* = PathWatcher{
.path = path,
.callback = callback,
.fsevents_watcher = FSEvents.watch(
resolved_path,
recursive,
callback,
updateEndCallback,
bun.cast(*anyopaque, ctx),
) catch |err| {
bun.default_allocator.destroy(this);
return err;
},
.manager = manager,
.recursive = recursive,
.flushCallback = updateEndCallback,
.file_paths = .{},
.ctx = ctx,
.mutex = .{},
};
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;
},
};
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);
}
pub fn unrefPendingDirectory(this: *PathWatcher) void {
var should_deinit = false;
{
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;
}
}
// Call deinit outside the lock to avoid deadlock (deinit calls setClosed which locks)
if (should_deinit) {
this.deinit();
}
}
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;
}
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;
}
this.callback(this.ctx, event, is_file);
}
pub fn flush(this: *PathWatcher) void {
this.needs_flush = false;
if (this.isClosed()) return;
this.flushCallback(this.ctx);
}
pub fn detach(this: *PathWatcher, _: *anyopaque) void {
this.deinit();
}
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);
}
}
if (comptime Environment.isMac) {
if (this.resolved_path) |path| {
bun.default_allocator.free(path);
}
}
bun.default_allocator.destroy(this);
}
};
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 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;