diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-01 13:46:44 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-01 13:57:41 -0300 |
| commit | cce6b18a49870086982f9a0e1fda90ed170b9fba (patch) | |
| tree | e5ce990d0a1d43503375d891bc51f283e0a1c215 /src/file_watch.zig | |
| parent | ae9325a5cb128d0d952afb8f9feaaca68e5e37a2 (diff) | |
| download | pardes-cce6b18a49870086982f9a0e1fda90ed170b9fba.tar.gz pardes-cce6b18a49870086982f9a0e1fda90ed170b9fba.zip | |
macos: one tagline rule for both hosts, a kqueue beside the inotify, and effects that compile
Three things this shell had its own copy of, and in each case the fix is that
it stops having one.
**The tagline band.** A pane tag draws at `gui_tagline_font_percent` of the
body face and the band it sits on shrinks with it, while the grid row stays
body-sized — so something has to decide where the shorter band sits in the
taller row. This shell decided by centring, always, which is precisely the case
`config.gui_topbar_pane_border_px` exists to prevent: the topbar's unused
half-band meets the first pane tag's unused half-band and the window background
shows through the seam. The strip is as wide as the bands are short — on a
20-pixel cell, 4 physical pixels at the default 82%, 10 at 50%, 14 at 30% — so
it grew as the tagline face shrank and read as "the tagline is wrong on the mac"
rather than as one missing rule. The rule is `pardes.taglineBandOffset` in the
core now and both pixel hosts call it: row zero bottom-aligned, the first
pane-tag row top-aligned, the two joined by `gui_topbar_pane_border_px` in the
theme's scrollbar-track colour, every row between centred, and a `Tagbottom`
band on the final row flush with the window edge — with the sub-cell strip
beneath it painted in that band's own colour, because the core grid holds only
whole cells and a window is any height it likes. `pardes_tagline_band_offset`,
`pardes_topbar_pane_border_px` and `pardes_topbar_pane_border_rgb` carry it over
the C ABI as PHYSICAL pixels: the host multiplies its points by the backing
scale going in and divides coming out, which is the snapping `Metrics` already
does for the cell, and is what keeps a one-pixel rule one pixel instead of a
two-pixel smear.
**The watch.** `file_watch.zig` was one mark/reconcile transaction over
`inotify`, so the tty shell, the SDL window and the detached daemon all watched
nothing off Linux: an edit made outside pardes never reached the pane, and a PDF
replaced on disk kept rendering the old inode. It is the same transaction over
two kernels now — `init`, `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are
still the whole of it, and the hosts wait on a kqueue and poll it exactly as
they did the old descriptor. A macOS mark is TWO filters, because a kqueue
directory filter reports its entries changing and never a write to a file
already inside it: the parent mark follows rename-over saves, `markFile` catches
in-place writes, and `remarkFile` re-arms the file filter once a rename has moved
the inode. That is the same pair the AppKit host's DispatchSources already used
for the same reason. Directory marks are deduplicated here by device and inode,
because each `EVFILT_VNODE` filter needs a descriptor of its own and inotify did
that deduplication itself; `stop` and `drain` wake through the one `EVFILT_USER`
filter, since a kqueue cannot simply be read the way an inotify descriptor can.
**The effects.** The three `crt.ci.metal` entry points are
`extern "C" [[stitchable]]`. `CIKernel.kernels(withMetalString:)` compiles that
source at runtime, looks for stitchable functions, and rejects the WHOLE source
with "cannot find a valid stitchable Metal function in the source" when it finds
none — so `ScenePostprocessor.init?` returned nil and every scene effect and
panel transition silently degraded to the plain CoreText draw. The
`effect_sources.zig` test pins the exact spelling of all three, and
`draw-effect` in the e2e suite catches the degradation rather than the spelling.
Beside them, the offscreen harness owes the core a PRESENTATION. Its window is
borderless and never ordered front, so AppKit runs no display cycle and
`pardes_frame_presented` — whose only caller is `draw(_:)` — never fired. The
core holds pointer gestures inert while a layout mutation has not reached a
backend, which for an unpresenting harness is the rest of the script: the first
pane a script opened silently killed every later click, drag and Look. So
`readFrame` presents what it just rendered, into a bitmap nobody reads.
`PARDES_CHROME` also looks under `/Applications`, where a browser's executable
lives inside an application bundle and never on `PATH`. The macOS goldens are
regenerated; docs/macos.md, config.md, detached.md, web.md and the design PDF
follow.
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); |
