diff --git a/build.zig b/build.zig index ff47a9d..4c4d36a 100644 --- a/build.zig +++ b/build.zig @@ -12,8 +12,17 @@ pub fn build(b: *std.Build) void { ) orelse false; } else false; + const linux_read_thread = if (target.result.os.tag == .linux) blk: { + break :blk b.option( + bool, + "linux_read_thread", + "Use a background thread on Linux (like macOS/Windows) instead of requiring the caller to drive the event loop via poll_fd/handle_read_ready", + ) orelse false; + } else false; + const options = b.addOptions(); options.addOption(bool, "use_fsevents", use_fsevents); + options.addOption(bool, "linux_read_thread", linux_read_thread); const options_mod = options.createModule(); const mod = b.addModule("nightwatch", .{ diff --git a/src/main.zig b/src/main.zig index 2d07231..ee32037 100644 --- a/src/main.zig +++ b/src/main.zig @@ -21,7 +21,7 @@ const CliHandler = struct { const vtable = nightwatch.Handler.VTable{ .change = change_cb, .rename = rename_cb, - .wait_readable = if (builtin.os.tag == .linux) wait_readable_cb else {}, + .wait_readable = if (nightwatch.linux_poll_mode) wait_readable_cb else {}, }; fn change_cb(h: *nightwatch.Handler, path: []const u8, event_type: nightwatch.EventType) error{HandlerFailed}!void { @@ -166,7 +166,7 @@ pub fn main() !void { try stderr.interface.print("on watch: {s}\n", .{path}); } - if (builtin.os.tag == .linux) { + if (nightwatch.linux_poll_mode) { try run_linux(&watcher); } else if (builtin.os.tag == .windows) { run_windows(); diff --git a/src/nightwatch.zig b/src/nightwatch.zig index 3158864..8a2a7f3 100644 --- a/src/nightwatch.zig +++ b/src/nightwatch.zig @@ -20,13 +20,19 @@ pub const Error = error{ WatchFailed, }; +/// True when the Linux inotify backend runs in poll mode (caller drives the +/// event loop via poll_fd / handle_read_ready). False on all other platforms +/// and on Linux when the `linux_read_thread` build option is set. +pub const linux_poll_mode = builtin.os.tag == .linux and !build_options.linux_read_thread; + pub const Handler = struct { vtable: *const VTable, pub const VTable = struct { change: *const fn (handler: *Handler, path: []const u8, event_type: EventType) error{HandlerFailed}!void, rename: *const fn (handler: *Handler, src_path: []const u8, dst_path: []const u8) error{HandlerFailed}!void, - wait_readable: if (builtin.os.tag == .linux) *const fn (handler: *Handler) error{HandlerFailed}!ReadableStatus else void, + /// Only present in Linux poll mode (linux_poll_mode == true). + wait_readable: if (linux_poll_mode) *const fn (handler: *Handler) error{HandlerFailed}!ReadableStatus else void, }; fn change(handler: *Handler, path: []const u8, event_type: EventType) error{HandlerFailed}!void { @@ -38,7 +44,11 @@ pub const Handler = struct { } fn wait_readable(handler: *Handler) error{HandlerFailed}!ReadableStatus { - return handler.vtable.wait_readable(handler); + if (comptime linux_poll_mode) { + return handler.vtable.wait_readable(handler); + } else { + unreachable; + } } }; @@ -97,14 +107,18 @@ pub fn unwatch(self: *@This(), path: []const u8) void { self.interceptor.backend.remove_watch(self.allocator, path); } +/// Drive event delivery by reading from the inotify fd. +/// Only available in Linux poll mode (linux_poll_mode == true). pub fn handle_read_ready(self: *@This()) !void { + comptime if (!linux_poll_mode) @compileError("handle_read_ready is only available in Linux poll mode; use linux_read_thread=true for a background-thread model"); try self.interceptor.backend.handle_read_ready(self.allocator); } /// Returns the inotify file descriptor that should be polled for POLLIN -/// before calling handle_read_ready(). Only available on Linux. +/// before calling handle_read_ready(). +/// Only available in Linux poll mode (linux_poll_mode == true). pub fn poll_fd(self: *const @This()) std.posix.fd_t { - comptime if (builtin.os.tag != .linux) @compileError("poll_fd is only available on Linux"); + comptime if (!linux_poll_mode) @compileError("poll_fd is only available in Linux poll mode; use linux_read_thread=true for a background-thread model"); return self.interceptor.backend.inotify_fd; } @@ -121,7 +135,7 @@ const Interceptor = struct { const vtable = Handler.VTable{ .change = change_cb, .rename = rename_cb, - .wait_readable = if (builtin.os.tag == .linux) wait_readable_cb else {}, + .wait_readable = if (linux_poll_mode) wait_readable_cb else {}, }; fn change_cb(h: *Handler, path: []const u8, event_type: EventType) error{HandlerFailed}!void { @@ -172,6 +186,9 @@ const INotifyBackend = struct { handler: *Handler, inotify_fd: std.posix.fd_t, watches: std.AutoHashMapUnmanaged(i32, []u8), // wd -> owned path + // Used only in linux_read_thread mode: + stop_pipe: if (build_options.linux_read_thread) [2]std.posix.fd_t else void, + thread: if (build_options.linux_read_thread) ?std.Thread else void, const IN = std.os.linux.IN; @@ -181,27 +198,68 @@ const INotifyBackend = struct { const in_flags: std.os.linux.O = .{ .NONBLOCK = true }; - fn init(handler: *Handler) error{ ProcessFdQuotaExceeded, SystemFdQuotaExceeded, SystemResources, Unexpected }!@This() { - return .{ - .handler = handler, - .inotify_fd = try std.posix.inotify_init1(@bitCast(in_flags)), - .watches = .empty, - }; + fn init(handler: *Handler) !@This() { + const inotify_fd = try std.posix.inotify_init1(@bitCast(in_flags)); + errdefer std.posix.close(inotify_fd); + if (comptime build_options.linux_read_thread) { + const stop_pipe = try std.posix.pipe(); + return .{ + .handler = handler, + .inotify_fd = inotify_fd, + .watches = .empty, + .stop_pipe = stop_pipe, + .thread = null, + }; + } else { + return .{ + .handler = handler, + .inotify_fd = inotify_fd, + .watches = .empty, + .stop_pipe = {}, + .thread = {}, + }; + } } fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + if (comptime build_options.linux_read_thread) { + // Signal thread to stop and wait for it to exit. + _ = std.posix.write(self.stop_pipe[1], "x") catch {}; + if (self.thread) |t| t.join(); + std.posix.close(self.stop_pipe[0]); + std.posix.close(self.stop_pipe[1]); + } var it = self.watches.iterator(); while (it.next()) |entry| allocator.free(entry.value_ptr.*); self.watches.deinit(allocator); std.posix.close(self.inotify_fd); } - fn arm(self: *@This(), _: std.mem.Allocator) error{HandlerFailed}!void { - return switch (self.handler.wait_readable() catch |e| switch (e) { - error.HandlerFailed => |e_| return e_, - }) { - .will_notify => {}, + fn arm(self: *@This(), allocator: std.mem.Allocator) error{HandlerFailed}!void { + if (comptime build_options.linux_read_thread) { + if (self.thread != null) return; // already running + self.thread = std.Thread.spawn(.{}, thread_fn, .{ self, allocator }) catch return error.HandlerFailed; + } else { + return switch (self.handler.wait_readable() catch |e| switch (e) { + error.HandlerFailed => |e_| return e_, + }) { + .will_notify => {}, + }; + } + } + + fn thread_fn(self: *@This(), allocator: std.mem.Allocator) void { + var pfds = [_]std.posix.pollfd{ + .{ .fd = self.inotify_fd, .events = std.posix.POLL.IN, .revents = 0 }, + .{ .fd = self.stop_pipe[0], .events = std.posix.POLL.IN, .revents = 0 }, }; + while (true) { + _ = std.posix.poll(&pfds, -1) catch return; + if (pfds[1].revents & std.posix.POLL.IN != 0) return; // stop signal + if (pfds[0].revents & std.posix.POLL.IN != 0) { + self.handle_read_ready(allocator) catch return; + } + } } fn add_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) error{ OutOfMemory, WatchFailed }!void { diff --git a/src/nightwatch_test.zig b/src/nightwatch_test.zig index 0b33985..a290875 100644 --- a/src/nightwatch_test.zig +++ b/src/nightwatch_test.zig @@ -49,7 +49,7 @@ const TestHandler = struct { const vtable = nw.Handler.VTable{ .change = change_cb, .rename = rename_cb, - .wait_readable = if (builtin.os.tag == .linux) wait_readable_cb else {}, + .wait_readable = if (nw.linux_poll_mode) wait_readable_cb else {}, }; fn change_cb(handler: *nw.Handler, path: []const u8, event_type: nw.EventType) error{HandlerFailed}!void { @@ -170,7 +170,7 @@ fn removeTempDir(path: []const u8) void { /// - Linux: call handle_read_ready() so inotify events are processed. /// - Others: the backend uses its own thread/callback; sleep briefly. fn drainEvents(watcher: *Watcher) !void { - if (builtin.os.tag == .linux) { + if (nw.linux_poll_mode) { try watcher.handle_read_ready(); } else { std.Thread.sleep(300 * std.time.ns_per_ms);