summaryrefslogtreecommitdiff
path: root/src/file_watch.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/file_watch.zig')
-rw-r--r--src/file_watch.zig355
1 files changed, 341 insertions, 14 deletions
diff --git a/src/file_watch.zig b/src/file_watch.zig
index a5347844..dfdc0556 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -1,9 +1,13 @@
-//! Native file-watch state and the Linux mark/reconcile transaction shared by
-//! the TTY and SDL hosts. Their event loops still own blocking waits, wake
+//! Native file-watch state and the mark/reconcile transaction shared by the
+//! TTY and SDL hosts. Their event loops still own blocking waits, wake
//! coalescing, and retry scheduling; this file owns the identical synchronous
//! operation each wake performs. Text buffers already own the bytes whose hash
//! is their generation. Large PDFs use metadata which changes for in-place and
//! rename-over saves, avoiding a second whole-document allocation.
+//!
+//! Two kernels, one transaction: inotify on linux, kqueue on macos. `init`,
+//! `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are the whole of that
+//! difference, and nothing above them knows which one it is on.
const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
@@ -32,12 +36,27 @@ pub const Generation = union(enum) {
pdf: ?Identity,
};
-/// One native Linux directory mark. The descriptor belongs to the containing
-/// directory because editor-style rename-over saves replace the file inode.
+/// One native directory mark, plus what macos needs beside it.
+///
+/// `wd` is an inotify wd on linux and an open EVTONLY directory descriptor on
+/// macos. Either way it marks the CONTAINING directory, because editor-style
+/// rename-over saves replace the file inode.
+///
+/// `file_wd` and `kq` exist only on macos, and only because a kqueue directory
+/// filter reports changes to the DIRECTORY — entries added, removed, renamed —
+/// and never a write to a file already inside it. inotify's CLOSE_WRITE covers
+/// that from the directory mark; kqueue has no equivalent, so an in-place save
+/// needs a second filter on the file itself. `kq` is the queue both filters
+/// live on, kept here so `reloadPane` can re-arm the file filter after a
+/// rename-over gave the pathname a new inode, without every caller between it
+/// and a host having to carry the descriptor.
+///
/// `generation` is the last snapshot the core accepted, not merely one a host
/// observed; serial prevents a reused pane slot from committing stale data.
pub const Watch = struct {
wd: c_int,
+ file_wd: c_int = -1,
+ kq: c_int = -1,
serial: u32,
generation: Generation,
};
@@ -48,6 +67,300 @@ pub const Watch = struct {
pub const theme_slot = pardes.MAX_PANES;
pub const Table = [pardes.MAX_PANES + 1]?Watch;
+/// Whether this OS has a watcher at all. Off it every entry point below
+/// returns the inert answer: `watchPath` installs nothing, so `watches` stays
+/// empty and `reloadPane` returns false for every slot. The core then keeps its
+/// own record of what was asked and simply never gets a `file_changed`, which
+/// is what a host with no watcher has always done here.
+pub const supported = builtin.os.tag == .linux or builtin.os.tag == .macos;
+
+/// The wake filter's `ident` on macos. `ident` is a directory descriptor for
+/// every other filter on this queue, and no descriptor is `maxInt(usize)`, so
+/// nothing can collide with it.
+const wake_ident: usize = std.math.maxInt(usize);
+
+/// Create the descriptor a host waits on, and the ONE place the difference
+/// between the two kernels is spelled.
+///
+/// linux: an inotify instance; a mark is a wd added to it.
+/// macos: a kqueue; a mark is an EVFILT_VNODE filter whose `ident` is an open
+/// directory descriptor, plus the one EVFILT_USER filter registered here that
+/// `stop` triggers to end a parked `wait`.
+///
+/// `polled` is the only flag that survives the port. On linux it adds
+/// IN_NONBLOCK, because a host that finds this descriptor ready in its own
+/// `poll(2)` (detached/server.zig) must be able to stop draining it, while a
+/// host that parks a thread in `wait` (tty.zig, gui.zig) wants the blocking
+/// read. On macos neither host needs either flag: a kqueue is not inherited
+/// across `fork(2)`, so there is no CLOEXEC to ask for, and whether a wait
+/// blocks is the timeout argument to `kevent(2)` rather than a property of the
+/// queue.
+///
+/// -1 when there is no watcher to make, which every entry point below reads as
+/// "mark nothing".
+pub fn init(polled: bool) c_int {
+ switch (builtin.os.tag) {
+ .linux => return libc.inotify_init1(if (polled)
+ linux.IN.CLOEXEC | linux.IN.NONBLOCK
+ else
+ linux.IN.CLOEXEC),
+ .macos => {
+ const kq = libc.kqueue();
+ if (kq < 0) return -1;
+ const changes = [_]libc.Kevent{.{
+ .ident = wake_ident,
+ .filter = libc.EVFILT.USER,
+ .flags = libc.EV.ADD | libc.EV.CLEAR,
+ .fflags = 0,
+ .data = 0,
+ .udata = 0,
+ }};
+ var events: [1]libc.Kevent = undefined;
+ if (libc.kevent(kq, &changes, 1, &events, 0, null) < 0) {
+ _ = libc.close(kq);
+ return -1;
+ }
+ return kq;
+ },
+ else => return -1,
+ }
+}
+
+/// Park until a marked directory changes. False ends the watcher: `stop` was
+/// called, or the queue died.
+///
+/// macos only, because it exists for the one thing a kqueue cannot do the way
+/// an inotify descriptor can — be read. The linux hosts keep reading theirs
+/// (tty.zig through `std.Io.File`, so teardown's `cancel` interrupts it), and
+/// this is the shape that replaces that where there is nothing to read.
+///
+/// Deliberately does NOT report WHAT changed, for the reason the watcher
+/// threads in tty.zig and gui.zig already give: the loop re-reads every marked
+/// pane anyway.
+pub fn wait(fd: c_int) bool {
+ if (comptime builtin.os.tag != .macos) return false;
+ if (fd < 0) return false;
+ const none = [_]libc.Kevent{};
+ var events: [8]libc.Kevent = undefined;
+ while (true) {
+ const n = libc.kevent(fd, &none, 0, &events, events.len, null);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return false;
+ }
+ if (n == 0) continue;
+ for (events[0..@intCast(n)]) |event| {
+ if (event.filter == libc.EVFILT.USER) return false;
+ }
+ return true;
+ }
+}
+
+/// Wake a host parked in `wait` so it returns BEFORE the descriptor it is
+/// parked on is closed. A no-op on linux, where cancelling the future reading
+/// that descriptor is what ends the watcher.
+pub fn stop(fd: c_int) void {
+ if (comptime builtin.os.tag != .macos) return;
+ if (fd < 0) return;
+ const changes = [_]libc.Kevent{.{
+ .ident = wake_ident,
+ .filter = libc.EVFILT.USER,
+ .flags = 0,
+ .fflags = libc.NOTE.TRIGGER,
+ .data = 0,
+ .udata = 0,
+ }};
+ var events: [1]libc.Kevent = undefined;
+ _ = libc.kevent(fd, &changes, 1, &events, 0, null);
+}
+
+/// Consume every pending edge without blocking, for a host that found the
+/// descriptor ready in its own `poll(2)` rather than parking a thread on it.
+/// True when at least one of them was a directory change.
+///
+/// DRAINED TO EMPTY on both kernels, and for the same reason: the descriptor is
+/// level-triggered, so a queue left partly full makes the next `poll` return
+/// ready immediately, and each of those rounds is a whole pump.
+pub fn drain(fd: c_int) bool {
+ if (fd < 0) return false;
+ switch (builtin.os.tag) {
+ .linux => {
+ var buf: [4096]u8 = undefined;
+ var seen = false;
+ while (libc.read(fd, &buf, buf.len) > 0) seen = true;
+ return seen;
+ },
+ .macos => {
+ const none = [_]libc.Kevent{};
+ const now: libc.timespec = .{ .sec = 0, .nsec = 0 };
+ var events: [16]libc.Kevent = undefined;
+ var seen = false;
+ while (true) {
+ const n = libc.kevent(fd, &none, 0, &events, events.len, &now);
+ if (n <= 0) return seen;
+ for (events[0..@intCast(n)]) |event| {
+ if (event.filter != libc.EVFILT.USER) seen = true;
+ }
+ if (n < events.len) return seen;
+ }
+ },
+ else => return false,
+ }
+}
+
+/// Install the kernel mark on `dir_z` and return its handle, or -1.
+///
+/// The handle is an inotify wd on linux and an open directory descriptor on
+/// macos, and BOTH have to answer "the same directory twice is the same
+/// handle": `watchPath`'s sharing loop compares handles to decide when the last
+/// user of a mark is gone, and `Watch` keeps no path to compare instead.
+/// inotify does that deduplication itself. On macos it is done here, by device
+/// and inode, because each EVFILT_VNODE filter needs a descriptor of its own.
+fn markDir(fd: c_int, dir_z: [:0]const u8, watches: *const Table) c_int {
+ switch (builtin.os.tag) {
+ .linux => {
+ // CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE
+ // cover rename-over and delete-then-recreate saves.
+ const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
+ return libc.inotify_add_watch(fd, dir_z, mask);
+ },
+ .macos => {
+ // EVTONLY is the point of the descriptor: it marks the directory
+ // without counting as a reference that would keep an unmounting
+ // volume busy. DIRECTORY is inotify's ONLYDIR.
+ const dir_fd = libc.open(dir_z, .{
+ .ACCMODE = .RDONLY,
+ .EVTONLY = true,
+ .CLOEXEC = true,
+ .DIRECTORY = true,
+ }, @as(libc.mode_t, 0));
+ if (dir_fd < 0) return -1;
+ var want: libc.Stat = undefined;
+ if (libc.fstat(dir_fd, &want) != 0) {
+ _ = libc.close(dir_fd);
+ return -1;
+ }
+ for (watches) |other| {
+ const candidate = other orelse continue;
+ var have: libc.Stat = undefined;
+ if (libc.fstat(candidate.wd, &have) != 0) continue;
+ if (have.dev != want.dev or have.ino != want.ino) continue;
+ _ = libc.close(dir_fd);
+ return candidate.wd;
+ }
+ // What a DIRECTORY filter reports is its entries changing, which
+ // is a rename-over or a delete-then-recreate — MOVED_TO and CREATE
+ // above. It does NOT report a write to a file already inside it,
+ // which is what CLOSE_WRITE covers there; `markFile` is that half.
+ // RENAME and DELETE here are the marked directory itself going
+ // away.
+ //
+ // EV_CLEAR is not optional: without it the filter stays triggered
+ // once it has fired and every `wait` returns immediately forever.
+ const changes = [_]libc.Kevent{.{
+ .ident = @intCast(dir_fd),
+ .filter = libc.EVFILT.VNODE,
+ .flags = libc.EV.ADD | libc.EV.CLEAR,
+ .fflags = libc.NOTE.WRITE | libc.NOTE.RENAME | libc.NOTE.DELETE,
+ .data = 0,
+ .udata = 0,
+ }};
+ var events: [1]libc.Kevent = undefined;
+ if (libc.kevent(fd, &changes, 1, &events, 0, null) < 0) {
+ _ = libc.close(dir_fd);
+ return -1;
+ }
+ return dir_fd;
+ },
+ else => return -1,
+ }
+}
+
+/// The other half of a macos mark: the filter on the watched FILE, which is the
+/// only thing that reports an in-place save. -1 when there is no file there yet
+/// — a path that does not exist is still worth marking the directory for, and
+/// the create arrives on that mark.
+///
+/// Not deduplicated, unlike the directory: two panes on the same file are two
+/// slots that each want their own wake, and a file filter has exactly one user.
+fn markFile(kq: c_int, path_z: [:0]const u8) c_int {
+ if (comptime builtin.os.tag != .macos) return -1;
+ const file_fd = libc.open(path_z, .{
+ .ACCMODE = .RDONLY,
+ .EVTONLY = true,
+ .CLOEXEC = true,
+ }, @as(libc.mode_t, 0));
+ if (file_fd < 0) return -1;
+ // EXTEND and ATTRIB beside WRITE because an append and a truncate-in-place
+ // are both saves, and DELETE/RENAME because losing this inode is how a
+ // rename-over reaches the pane whose file it replaced.
+ const changes = [_]libc.Kevent{.{
+ .ident = @intCast(file_fd),
+ .filter = libc.EVFILT.VNODE,
+ .flags = libc.EV.ADD | libc.EV.CLEAR,
+ .fflags = libc.NOTE.WRITE | libc.NOTE.EXTEND | libc.NOTE.ATTRIB |
+ libc.NOTE.RENAME | libc.NOTE.DELETE | libc.NOTE.REVOKE,
+ .data = 0,
+ .udata = 0,
+ }};
+ var events: [1]libc.Kevent = undefined;
+ if (libc.kevent(kq, &changes, 1, &events, 0, null) < 0) {
+ _ = libc.close(file_fd);
+ return -1;
+ }
+ return file_fd;
+}
+
+/// Re-point a live mark's file filter at whatever the pathname is NOW. Called
+/// from `reloadPane`, because a rename-over leaves the old filter on an inode
+/// that no longer answers to this name and a later in-place save would then
+/// wake nobody. A no-op off macos, where the directory mark covers both cases
+/// by itself and there is no second filter to move.
+fn remarkFile(live: *Watch, path: ?[]const u8) void {
+ if (comptime builtin.os.tag != .macos) return;
+ if (live.kq < 0) return;
+ const watched = path orelse return;
+ var path_buf: [4096:0]u8 = undefined;
+ if (watched.len >= path_buf.len) return;
+ @memcpy(path_buf[0..watched.len], watched);
+ path_buf[watched.len] = 0;
+ // Mark first, compare second. There is no `stat` in std.c on this target —
+ // only `fstat` — and opening the path is what tells us both whether it is
+ // there and which inode it is now.
+ const fresh = markFile(live.kq, path_buf[0..watched.len :0]);
+ if (fresh < 0) return; // nothing at that name now; keep whatever we had
+ if (live.file_wd >= 0) {
+ var have: libc.Stat = undefined;
+ var want: libc.Stat = undefined;
+ // Same inode as the filter already on it? Then that one still reports
+ // this file and the registration just made is a duplicate to drop.
+ if (libc.fstat(live.file_wd, &have) == 0 and libc.fstat(fresh, &want) == 0 and
+ have.dev == want.dev and have.ino == want.ino)
+ {
+ _ = libc.close(fresh);
+ return;
+ }
+ _ = libc.close(live.file_wd);
+ }
+ live.file_wd = fresh;
+}
+
+/// Drop one slot's marks. The DIRECTORY mark goes only when its last slot is
+/// gone — several panes share it — while the file filter has exactly one user
+/// and always goes. On macos a filter dies with the descriptor it was
+/// registered against, so the close IS the removal.
+fn unmark(fd: c_int, old: Watch, drop_dir: bool) void {
+ if (comptime builtin.os.tag == .macos) {
+ if (old.file_wd >= 0) _ = libc.close(old.file_wd);
+ }
+ if (!drop_dir) return;
+ switch (builtin.os.tag) {
+ .linux => _ = libc.inotify_rm_watch(fd, old.wd),
+ .macos => _ = libc.close(old.wd),
+ else => {},
+ }
+}
+
fn watchPath(
fd: c_int,
watches: *Table,
@@ -56,7 +369,7 @@ fn watchPath(
serial: u32,
generation: Generation,
) void {
- if (comptime builtin.os.tag != .linux) return;
+ if (comptime !supported) return;
if (fd < 0) return;
if (watches[slot]) |old| {
var shared = false;
@@ -64,8 +377,8 @@ fn watchPath(
const candidate = other orelse continue;
if (i != slot and candidate.wd == old.wd) shared = true;
}
- if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
watches[slot] = null;
+ unmark(fd, old, !shared);
}
const watched_path = path orelse return;
const dir = std.fs.path.dirname(watched_path) orelse ".";
@@ -73,12 +386,13 @@ fn watchPath(
if (dir.len >= dir_buf.len) return;
@memcpy(dir_buf[0..dir.len], dir);
dir_buf[dir.len] = 0;
- // CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE cover
- // rename-over and delete-then-recreate saves.
- const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
- const wd = libc.inotify_add_watch(fd, dir_buf[0..dir.len :0], mask);
+ const wd = markDir(fd, dir_buf[0..dir.len :0], watches);
if (wd < 0) return;
- watches[slot] = .{ .wd = wd, .serial = serial, .generation = generation };
+ watches[slot] = .{ .wd = wd, .kq = fd, .serial = serial, .generation = generation };
+ // The file half, which only macos has and only for a path that exists yet.
+ if (comptime builtin.os.tag == .macos) {
+ if (watches[slot]) |*live| remarkFile(live, watched_path);
+ }
}
/// Mark or unmark one pane pathname. Panes in the same directory share an
@@ -111,6 +425,14 @@ pub fn reloadPane(
const pane = core.panes[id] orelse return false;
if (pane.serial != watched.serial) return false;
+ // Re-point the macos file filter at whatever this pathname is now, BEFORE
+ // deciding anything: a rename-over left the old filter on a dead inode, and
+ // the next in-place save would otherwise wake nobody. A no-op elsewhere,
+ // and a no-op here too when the inode has not moved.
+ if (comptime builtin.os.tag == .macos) {
+ if (watches[id]) |*live| remarkFile(live, if (pane.file) |f| f.path else pane.pdfPath());
+ }
+
if (pane.file) |file| {
const bytes = look.readFile(gpa, file.path) catch return false;
defer gpa.free(bytes);
@@ -240,6 +562,11 @@ pub fn reloadTheme(
) bool {
const watched = watches[theme_slot] orelse return false;
const request = core.themeFileRequest(watched.serial) orelse return false;
+ // Same re-arm as reloadPane's, for the same reason: a .zon saved by rename
+ // moves the inode the file filter is on.
+ if (comptime builtin.os.tag == .macos) {
+ if (watches[theme_slot]) |*entry| remarkFile(entry, request.path);
+ }
const bytes = look.readFile(gpa, request.path) catch |err| {
core.failThemeFile(watched.serial, err);
return false;
@@ -295,7 +622,7 @@ test "file identity changes for in-place and rename-over writes" {
}
test "theme watch reloads valid ZON and keeps the last theme across a bad save" {
- if (comptime builtin.os.tag != .linux or pardes.platform == .web) return;
+ if (comptime !supported or pardes.platform == .web) return;
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
@@ -322,8 +649,8 @@ test "theme watch reloads valid ZON and keeps the last theme across a bad save"
else => {},
} else return error.MissingThemeFileEffect;
- const fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK);
- if (fd < 0) return error.InotifyInitFailed;
+ const fd = init(true);
+ if (fd < 0) return error.WatchInitFailed;
defer _ = libc.close(fd);
var watches: Table = @splat(null);
defer _ = applyThemeEffect(core, gpa, fd, &watches, 0, false, false);