Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
5a93a85500 Fix fanotify integer truncation and implement FD-to-path resolution
Fixed critical bugs in fanotify watcher implementation:

- Fixed integer cast truncation in fanotify_init by properly casting
  usize -> isize -> i32 for file descriptor values
- Removed per-path index tracking (fanotify doesn't work that way)
- Implemented /proc/self/fd/ readlink for resolving FDs to paths
- Rewrote watchLoopCycle to properly match fanotify events against watchlist
- Simplified watchPath/watchDir to return 0 (fanotify doesn't use indices)

Current status:
- Compiles successfully
- First test showed file creation/modification working
- Still hits 'unreachable code' panic in event loop/cleanup
- Needs further debugging of event loop integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 17:01:49 +00:00
Claude Bot
e59937428e Implement fanotify backend for Linux filesystem watcher
Added a new fanotify-based watcher implementation for Linux that provides
filesystem-wide monitoring capabilities as an alternative to inotify.

Changes:
- Created src/sys/fanotify.zig: Clean wrapper around fanotify syscalls
  following bun.sys patterns with Maybe return types
- Created src/watcher/FanotifyWatcher.zig: Full fanotify watcher implementation
  with recursive directory monitoring support
- Modified src/Watcher.zig: Switched Linux platform from INotifyWatcher to
  FanotifyWatcher
- Updated src/sys.zig: Exported fanotify module for Linux platforms
- Updated src/bun.js/node/path_watcher.zig: Handle Watcher.init errors properly

Implementation notes:
- Fanotify provides filesystem-wide monitoring similar to Windows watcher
- Supports FAN_EVENT_ON_CHILD for recursive directory monitoring
- Currently enabled as the primary watcher (inotify disabled for testing)
- Requires appropriate permissions (may need CAP_SYS_ADMIN)
- Returns error.Unexpected instead of custom error to match existing error sets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 16:06:18 +00:00
5 changed files with 558 additions and 8 deletions

View File

@@ -93,7 +93,10 @@ pub fn init(comptime T: type, ctx: *T, fs: *bun.fs.FileSystem, allocator: std.me
.changed_filepaths = [_]?[:0]u8{null} ** max_count,
};
try Platform.init(&watcher.platform, fs.top_level_dir);
Platform.init(&watcher.platform, fs.top_level_dir) catch |err| {
allocator.destroy(watcher);
return err;
};
return watcher;
}
@@ -132,7 +135,7 @@ pub const max_eviction_count = 8096;
// 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"),
.linux => @import("./watcher/FanotifyWatcher.zig"),
.mac => @import("./watcher/KEventWatcher.zig"),
.windows => WindowsWatcher,
else => @compileError("Unsupported platform"),

View File

@@ -115,16 +115,21 @@ pub const PathWatcherManager = struct {
var watchers = bun.handleOom(bun.BabyList(?*PathWatcher).initCapacity(bun.default_allocator, 1));
errdefer watchers.deinit(bun.default_allocator);
const main_watcher = Watcher.init(
PathWatcherManager,
this,
vm.transpiler.fs,
bun.default_allocator,
) catch |err| {
watchers.deinit(bun.default_allocator);
return err;
};
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,
),
.main_watcher = main_watcher,
.vm = vm,
.watcher_count = 0,
.mutex = .{},

View File

@@ -301,6 +301,7 @@ pub const Tag = enum(u8) {
pub const Error = @import("./sys/Error.zig");
pub const PosixStat = @import("./sys/PosixStat.zig").PosixStat;
pub const fanotify = if (Environment.isLinux) @import("./sys/fanotify.zig") else struct {};
pub fn Maybe(comptime ReturnTypeT: type) type {
return bun.api.node.Maybe(ReturnTypeT, Error);

228
src/sys/fanotify.zig Normal file
View File

@@ -0,0 +1,228 @@
const std = @import("std");
const bun = @import("../bun.zig");
const Maybe = bun.sys.Maybe;
const linux = std.os.linux;
const posix = std.posix;
/// fanotify_init flags
pub const InitFlags = packed struct(u32) {
/// Close-on-exec flag
cloexec: bool = false,
/// Non-blocking flag
nonblock: bool = false,
_padding1: u30 = 0,
pub fn toInt(self: InitFlags) u32 {
var flags: u32 = 0;
if (self.cloexec) flags |= FAN_CLOEXEC;
if (self.nonblock) flags |= FAN_NONBLOCK;
return flags | FAN_CLASS_NOTIF | FAN_UNLIMITED_QUEUE | FAN_UNLIMITED_MARKS;
}
};
/// fanotify event flags (open flags for the file descriptors)
pub const EventFlags = packed struct(u32) {
rdonly: bool = false,
largefile: bool = false,
cloexec: bool = false,
_padding: u29 = 0,
pub fn toInt(self: EventFlags) u32 {
var flags: u32 = 0;
// RDONLY is 0, so we don't need to add it
if (self.rdonly) flags |= 0;
if (self.largefile) flags |= 0x8000; // O_LARGEFILE on linux
if (self.cloexec) flags |= 0x80000; // O_CLOEXEC on linux
return flags;
}
};
/// fanotify_mark flags
pub const MarkFlags = enum(u32) {
add = FAN_MARK_ADD,
remove = FAN_MARK_REMOVE,
flush = FAN_MARK_FLUSH,
};
/// fanotify event mask
pub const EventMask = packed struct(u64) {
access: bool = false,
modify: bool = false,
close_write: bool = false,
close_nowrite: bool = false,
open: bool = false,
open_exec: bool = false,
attrib: bool = false,
create: bool = false,
delete: bool = false,
delete_self: bool = false,
moved_from: bool = false,
moved_to: bool = false,
move_self: bool = false,
open_perm: bool = false,
access_perm: bool = false,
open_exec_perm: bool = false,
_padding1: u14 = 0,
ondir: bool = false,
event_on_child: bool = false,
_padding2: u32 = 0,
pub fn toInt(self: EventMask) u64 {
var mask: u64 = 0;
if (self.access) mask |= FAN_ACCESS;
if (self.modify) mask |= FAN_MODIFY;
if (self.close_write) mask |= FAN_CLOSE_WRITE;
if (self.close_nowrite) mask |= FAN_CLOSE_NOWRITE;
if (self.open) mask |= FAN_OPEN;
if (self.open_exec) mask |= FAN_OPEN_EXEC;
if (self.attrib) mask |= FAN_ATTRIB;
if (self.create) mask |= FAN_CREATE;
if (self.delete) mask |= FAN_DELETE;
if (self.delete_self) mask |= FAN_DELETE_SELF;
if (self.moved_from) mask |= FAN_MOVED_FROM;
if (self.moved_to) mask |= FAN_MOVED_TO;
if (self.move_self) mask |= FAN_MOVE_SELF;
if (self.open_perm) mask |= FAN_OPEN_PERM;
if (self.access_perm) mask |= FAN_ACCESS_PERM;
if (self.open_exec_perm) mask |= FAN_OPEN_EXEC_PERM;
if (self.ondir) mask |= FAN_ONDIR;
if (self.event_on_child) mask |= FAN_EVENT_ON_CHILD;
return mask;
}
};
// fanotify_init flags
const FAN_CLOEXEC = 0x00000001;
const FAN_NONBLOCK = 0x00000002;
const FAN_CLASS_NOTIF = 0x00000000;
const FAN_UNLIMITED_QUEUE = 0x00000010;
const FAN_UNLIMITED_MARKS = 0x00000020;
// fanotify_mark flags
const FAN_MARK_ADD = 0x00000001;
const FAN_MARK_REMOVE = 0x00000002;
const FAN_MARK_FLUSH = 0x00000080;
// fanotify events
const FAN_ACCESS = 0x00000001;
const FAN_MODIFY = 0x00000002;
const FAN_CLOSE_WRITE = 0x00000008;
const FAN_CLOSE_NOWRITE = 0x00000010;
const FAN_OPEN = 0x00000020;
const FAN_OPEN_EXEC = 0x00001000;
const FAN_ATTRIB = 0x00000004;
const FAN_CREATE = 0x00000100;
const FAN_DELETE = 0x00000200;
const FAN_DELETE_SELF = 0x00000400;
const FAN_MOVED_FROM = 0x00000040;
const FAN_MOVED_TO = 0x00000080;
const FAN_MOVE_SELF = 0x00000800;
const FAN_OPEN_PERM = 0x00010000;
const FAN_ACCESS_PERM = 0x00020000;
const FAN_OPEN_EXEC_PERM = 0x00040000;
const FAN_ONDIR = 0x40000000;
const FAN_EVENT_ON_CHILD = 0x08000000;
/// fanotify event metadata structure
pub const EventMetadata = extern struct {
event_len: u32,
vers: u8,
reserved: u8,
metadata_len: u16,
mask: u64,
fd: i32,
pid: i32,
pub fn size(self: *align(1) const EventMetadata) u32 {
return self.event_len;
}
pub fn isDir(self: *align(1) const EventMetadata) bool {
return (self.mask & FAN_ONDIR) != 0;
}
pub fn hasValidFd(self: *align(1) const EventMetadata) bool {
return self.fd >= 0;
}
};
/// Initialize fanotify
pub fn init(flags: InitFlags, event_flags: EventFlags) Maybe(bun.FileDescriptor) {
const rc = linux.syscall2(
.fanotify_init,
@as(usize, @intCast(flags.toInt())),
@as(usize, @intCast(event_flags.toInt())),
);
const errno = posix.errno(rc);
if (errno != .SUCCESS) {
return .{ .err = bun.sys.Error.fromCode(errno, .open) };
}
// syscall returns usize, but file descriptors are i32
// Cast to isize first to properly handle signed values
const fd: std.posix.fd_t = @intCast(@as(isize, @bitCast(rc)));
return .{ .result = bun.FileDescriptor.fromNative(fd) };
}
/// Add or remove a mark on a filesystem object
pub fn mark(
fanotify_fd: bun.FileDescriptor,
flags: MarkFlags,
mask: EventMask,
dirfd: bun.FileDescriptor,
pathname: ?[:0]const u8,
) Maybe(void) {
const path_ptr = if (pathname) |p| @intFromPtr(p.ptr) else 0;
const dfd: i32 = if (pathname == null) linux.AT.FDCWD else dirfd.cast();
const rc = linux.syscall5(
.fanotify_mark,
@as(usize, @bitCast(@as(isize, fanotify_fd.cast()))),
@as(usize, @intCast(@intFromEnum(flags))),
@as(usize, @intCast(mask.toInt())),
@as(usize, @bitCast(@as(isize, dfd))),
path_ptr,
);
const errno = posix.errno(rc);
if (errno != .SUCCESS) {
return .{ .err = bun.sys.Error.fromCode(errno, .watch) };
}
return .{ .result = {} };
}
/// Read events from fanotify file descriptor
pub fn readEvents(
fanotify_fd: bun.FileDescriptor,
buffer: []align(@alignOf(EventMetadata)) u8,
) Maybe([]const u8) {
const rc = linux.read(fanotify_fd.cast(), buffer.ptr, buffer.len);
const errno = posix.errno(rc);
if (errno != .SUCCESS) {
return .{ .err = bun.sys.Error.fromCode(errno, .read) };
}
return .{ .result = buffer[0..@intCast(rc)] };
}
/// Iterator for fanotify events
pub const EventIterator = struct {
buffer: []const u8,
offset: usize = 0,
pub fn next(self: *EventIterator) ?*align(1) const EventMetadata {
if (self.offset >= self.buffer.len) return null;
const event: *align(1) const EventMetadata = @ptrCast(@alignCast(self.buffer[self.offset..][0..@sizeOf(EventMetadata)].ptr));
self.offset += event.size();
return event;
}
pub fn reset(self: *EventIterator) void {
self.offset = 0;
}
};

View File

@@ -0,0 +1,313 @@
//! Bun's filesystem watcher implementation for linux using fanotify
//! https://man7.org/linux/man-pages/man7/fanotify.7.html
//!
//! Fanotify provides filesystem-wide monitoring with recursive capabilities.
//! Note: fanotify requires appropriate permissions (CAP_SYS_ADMIN or similar)
const FanotifyWatcher = @This();
const log = Output.scoped(.watcher, .visible);
const fanotify = bun.sys.fanotify;
// fanotify events are variable-sized, so a byte buffer is used
const eventlist_bytes_size = 4096 * 32; // 128KB buffer for events
const EventListBytes = [eventlist_bytes_size]u8;
fd: bun.FileDescriptor = bun.invalid_fd,
loaded: bool = false,
// Avoid statically allocating because it increases the binary size.
eventlist_bytes: *EventListBytes = undefined,
allocator: std.mem.Allocator = undefined,
/// Store root path being monitored
root_path: []const u8 = "",
watch_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
/// nanoseconds
coalesce_interval: isize = 100_000,
pub const EventListIndex = i32;
pub const Event = fanotify.EventMetadata;
pub fn watchPath(this: *FanotifyWatcher, pathname: [:0]const u8) bun.sys.Maybe(EventListIndex) {
bun.assert(this.loaded);
const old_count = this.watch_count.fetchAdd(1, .release);
defer if (old_count == 0) Futex.wake(&this.watch_count, 10);
// For files, we watch for modifications and deletions
const mask = fanotify.EventMask{
.modify = true,
.close_write = true,
.delete_self = true,
.move_self = true,
.event_on_child = true,
};
switch (fanotify.mark(this.fd, .add, mask, bun.invalid_fd, pathname)) {
.err => |err| {
log("fanotify_mark({}, file, {s}) failed: {}", .{ this.fd, pathname, err });
return .{ .err = err };
},
.result => {},
}
log("fanotify_mark({}, file, {s}) = success", .{ this.fd, pathname });
// Fanotify doesn't return per-path descriptors, just return 0
return .{ .result = 0 };
}
pub fn watchDir(this: *FanotifyWatcher, pathname: [:0]const u8) bun.sys.Maybe(EventListIndex) {
bun.assert(this.loaded);
const old_count = this.watch_count.fetchAdd(1, .release);
defer if (old_count == 0) Futex.wake(&this.watch_count, 10);
// For directories, we watch for creates, deletes, and modifications
// event_on_child makes this apply recursively to all children
const mask = fanotify.EventMask{
.create = true,
.delete = true,
.delete_self = true,
.move_self = true,
.moved_from = true,
.moved_to = true,
.modify = true,
.close_write = true,
.ondir = true,
.event_on_child = true,
};
switch (fanotify.mark(this.fd, .add, mask, bun.invalid_fd, pathname)) {
.err => |err| {
log("fanotify_mark({}, dir, {s}) failed: {}", .{ this.fd, pathname, err });
return .{ .err = err };
},
.result => {},
}
log("fanotify_mark({}, dir, {s}) = success", .{ this.fd, pathname });
// Fanotify doesn't return per-path descriptors, just return 0
return .{ .result = 0 };
}
pub fn unwatch(this: *FanotifyWatcher, _: EventListIndex) void {
bun.assert(this.loaded);
_ = this.watch_count.fetchSub(1, .release);
}
pub fn init(this: *FanotifyWatcher, cwd: []const u8) !void {
bun.assert(!this.loaded);
this.loaded = true;
if (bun.getenvZ("BUN_FANOTIFY_COALESCE_INTERVAL")) |env| {
this.coalesce_interval = std.fmt.parseInt(isize, env, 10) catch 100_000;
}
// Initialize fanotify with notification class
const init_flags = fanotify.InitFlags{
.cloexec = true,
.nonblock = false,
};
const event_flags = fanotify.EventFlags{
.rdonly = true,
.largefile = true,
.cloexec = true,
};
switch (fanotify.init(init_flags, event_flags)) {
.err => |err| {
log("fanotify_init failed: {}", .{err});
// Return Unexpected to match the error set that callers expect
return error.Unexpected;
},
.result => |fd| this.fd = fd,
}
this.allocator = bun.default_allocator;
this.eventlist_bytes = try bun.default_allocator.create(EventListBytes);
this.root_path = cwd;
log("{} init (fanotify)", .{this.fd});
}
/// Read a path from a file descriptor using /proc/self/fd/
fn readlinkFd(fd: i32, buffer: []u8) ![]const u8 {
var path_buf: [64]u8 = undefined;
const proc_path = std.fmt.bufPrint(&path_buf, "/proc/self/fd/{d}", .{fd}) catch unreachable;
const result = std.posix.readlink(proc_path, buffer) catch |err| {
return err;
};
return result;
}
pub fn stop(this: *FanotifyWatcher) void {
log("{} stop", .{this.fd});
if (this.fd != bun.invalid_fd) {
this.fd.close();
this.fd = bun.invalid_fd;
}
}
/// Repeatedly called by the main watcher until the watcher is terminated.
pub fn watchLoopCycle(this: *bun.Watcher) bun.sys.Maybe(void) {
defer Output.flush();
// Read raw fanotify events
const read_result = std.posix.system.read(
this.platform.fd.cast(),
this.platform.eventlist_bytes,
this.platform.eventlist_bytes.len,
);
const errno = std.posix.errno(read_result);
if (errno != .SUCCESS) {
if (errno == .AGAIN or errno == .INTR) {
return .success;
}
return .{ .err = bun.sys.Error.fromCode(errno, .read) };
}
const bytes_read = @as(usize, @intCast(read_result));
if (bytes_read == 0) return .success;
log("fanotify read {} bytes", .{bytes_read});
// Process fanotify events and match them against watchlist
var offset: usize = 0;
var event_id: usize = 0;
var path_buffer: [bun.MAX_PATH_BYTES]u8 = undefined;
while (offset < bytes_read) {
const event: *align(1) const fanotify.EventMetadata = @ptrCast(this.platform.eventlist_bytes[offset..][0..@sizeOf(fanotify.EventMetadata)].ptr);
offset += event.size();
// Close the file descriptor and get its path
if (event.hasValidFd()) {
defer _ = std.posix.close(event.fd);
// Resolve FD to path
const event_path = readlinkFd(event.fd, &path_buffer) catch |err| {
log("Failed to readlink fd {}: {}", .{ event.fd, err });
continue;
};
log("fanotify event on path: {s} (mask=0x{x})", .{ event_path, event.mask });
// Match this path against our watchlist
const item_paths = this.watchlist.items(.file_path);
for (item_paths, 0..) |watch_path, idx| {
// Check if event path matches or is within watched path
const is_match = brk: {
// Exact match
if (std.mem.eql(u8, event_path, watch_path)) break :brk true;
// Event is within watched directory
if (std.mem.startsWith(u8, event_path, watch_path)) {
// Make sure it's actually within (not just prefix match)
if (event_path.len > watch_path.len) {
const next_char = event_path[watch_path.len];
if (next_char == '/' or watch_path[watch_path.len - 1] == '/') {
break :brk true;
}
}
}
break :brk false;
};
if (is_match) {
if (event_id >= this.watch_events.len) {
// Process current batch
switch (processFanotifyEventBatch(this, event_id)) {
.err => |err| return .{ .err = err },
.result => {},
}
event_id = 0;
}
this.watch_events[event_id] = watchEventFromFanotifyEvent(event, @intCast(idx));
event_id += 1;
log("Matched event to watchlist index {}", .{idx});
}
}
}
}
// Process any remaining events
if (event_id > 0) {
switch (processFanotifyEventBatch(this, event_id)) {
.err => |err| return .{ .err = err },
.result => {},
}
}
return .success;
}
fn processFanotifyEventBatch(this: *bun.Watcher, event_count: usize) bun.sys.Maybe(void) {
if (event_count == 0) {
return .success;
}
var all_events = this.watch_events[0..event_count];
std.sort.pdq(WatchEvent, all_events, {}, WatchEvent.sortByIndex);
var last_event_index: usize = 0;
var last_event_id: WatchItemIndex = std.math.maxInt(WatchItemIndex);
for (all_events, 0..) |_, i| {
if (all_events[i].index == last_event_id) {
all_events[last_event_index].merge(all_events[i]);
continue;
}
last_event_index = i;
last_event_id = all_events[i].index;
}
if (all_events.len == 0) return .success;
this.mutex.lock();
defer this.mutex.unlock();
if (this.running) {
this.onFileUpdate(this.ctx, all_events[0 .. last_event_index + 1], this.changed_filepaths[0..0], this.watchlist);
}
return .success;
}
pub fn watchEventFromFanotifyEvent(event: *align(1) const Event, index: WatchItemIndex) WatchEvent {
const mask = event.mask;
const FAN_DELETE = 0x00000200;
const FAN_DELETE_SELF = 0x00000400;
const FAN_MOVE_SELF = 0x00000800;
const FAN_MOVED_TO = 0x00000080;
const FAN_MODIFY = 0x00000002;
const FAN_CLOSE_WRITE = 0x00000008;
return .{
.op = .{
.delete = (mask & FAN_DELETE_SELF) > 0 or (mask & FAN_DELETE) > 0,
.rename = (mask & FAN_MOVE_SELF) > 0,
.move_to = (mask & FAN_MOVED_TO) > 0,
.write = (mask & FAN_MODIFY) > 0 or (mask & FAN_CLOSE_WRITE) > 0,
},
.index = index,
};
}
const std = @import("std");
const bun = @import("bun");
const Environment = bun.Environment;
const Futex = bun.Futex;
const Output = bun.Output;
const WatchEvent = bun.Watcher.Event;
const WatchItemIndex = bun.Watcher.WatchItemIndex;
const max_count = bun.Watcher.max_count;