diff options
Diffstat (limited to 'src/file_watch.zig')
| -rw-r--r-- | src/file_watch.zig | 355 |
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); |
