diff options
Diffstat (limited to 'src/file_watch.zig')
| -rw-r--r-- | src/file_watch.zig | 218 |
1 files changed, 218 insertions, 0 deletions
diff --git a/src/file_watch.zig b/src/file_watch.zig new file mode 100644 index 00000000..b616d481 --- /dev/null +++ b/src/file_watch.zig @@ -0,0 +1,218 @@ +//! 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 +//! 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. +const std = @import("std"); +const builtin = @import("builtin"); +const libc = std.c; +const linux = std.os.linux; +const pardes = @import("pardes.zig"); +const look = @import("look.zig"); +const message = @import("message.zig"); + +pub const Identity = struct { + inode: std.Io.File.INode, + size: u64, + mtime_ns: i96, + ctime_ns: i96, + + pub fn eql(a: Identity, b: Identity) bool { + return std.meta.eql(a, b); + } +}; + +/// The last disk generation accepted by a native shell. Text panes already +/// need their bytes, so their exact content hash is cheapest. PDF panes reopen +/// the pathname directly and use metadata, avoiding a second whole-document +/// allocation merely to discover whether MuPDF should reopen it. +pub const Generation = union(enum) { + text: u64, + pdf: ?Identity, +}; + +/// One native Linux directory mark. The descriptor belongs to the containing +/// directory because editor-style rename-over saves replace the file inode. +/// `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, + serial: u32, + generation: Generation, +}; + +pub const Table = [pardes.MAX_PANES]?Watch; + +/// Mark or unmark one pane pathname. Panes in the same directory share an +/// inotify descriptor, so removing one pane drops the kernel mark only after +/// its last user disappears. +pub fn watchPane( + fd: c_int, + watches: *Table, + id: u8, + path: ?[]const u8, + serial: u32, + generation: Generation, +) void { + if (comptime builtin.os.tag != .linux) return; + if (fd < 0) return; + if (watches[id]) |old| { + var shared = false; + for (watches, 0..) |other, i| { + const candidate = other orelse continue; + if (i != id and candidate.wd == old.wd) shared = true; + } + if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); + watches[id] = null; + } + const watched_path = path orelse return; + const dir = std.fs.path.dirname(watched_path) orelse "."; + var dir_buf: [4096:0]u8 = undefined; + 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); + if (wd < 0) return; + watches[id] = .{ .wd = wd, .serial = serial, .generation = generation }; +} + +/// Reconcile one marked pane. A true result requests an immediate second pass: +/// the PDF pathname changed between the stat before MuPDF reopened it and the +/// stat after, so committing either identity would lose a generation. +pub fn reloadPane( + core: *pardes.Pardes, + io: std.Io, + gpa: std.mem.Allocator, + watches: *Table, + id: usize, + announce: bool, +) bool { + const watched = watches[id] orelse return false; + const pane = core.panes[id] orelse return false; + if (pane.serial != watched.serial) return false; + + if (pane.file) |file| { + const bytes = look.readFile(gpa, file.path) catch return false; + defer gpa.free(bytes); + const hash = std.hash.Wyhash.hash(0, bytes); + switch (watched.generation) { + .text => |accepted| if (accepted == hash) return false, + .pdf => {}, + } + if (!core.reloadWatchedFile(@intCast(id), bytes)) return false; + const current = core.panes[id] orelse return false; + if (current.serial != watched.serial) return false; + const current_file = current.file orelse return false; + if (!std.mem.eql(u8, current_file.content, bytes)) return false; + if (watches[id]) |*live| { + if (live.serial == watched.serial) + live.generation = .{ .text = hash }; + } + if (announce) { + var msg_buf: [256]u8 = undefined; + core.setMessage(id, message.stamp(&msg_buf, "reloaded", current_file.path)); + } + return false; + } + + const path = pane.pdfPath() orelse return false; + const before = identify(io, path) catch return false; + switch (watched.generation) { + .pdf => |accepted| if (accepted) |identity| if (identity.eql(before)) return false, + .text => {}, + } + if (!core.reloadWatchedFile(@intCast(id), &.{})) return false; + const current = core.panes[id] orelse return false; + if (current.serial != watched.serial) return false; + const current_path = current.pdfPath() orelse return false; + const after = identify(io, current_path) catch return true; + if (!before.eql(after)) return true; + if (watches[id]) |*live| { + if (live.serial == watched.serial) + live.generation = .{ .pdf = after }; + } + if (announce) { + var msg_buf: [256]u8 = undefined; + core.setMessage(id, message.stamp(&msg_buf, "reloaded", current_path)); + } + return false; +} + +/// Install the directory mark before reconciling the open bytes/path. This +/// closes the drain-time race without requiring another filesystem event. +pub fn applyEffect( + core: *pardes.Pardes, + io: std.Io, + gpa: std.mem.Allocator, + fd: c_int, + watches: *Table, + id: u8, + on: bool, +) bool { + var path: ?[]const u8 = null; + var serial: u32 = 0; + var generation: Generation = .{ .text = 0 }; + if (on) if (core.panes[id]) |pane| { + serial = pane.serial; + if (pane.file) |file| { + path = file.path; + generation = .{ .text = std.hash.Wyhash.hash(0, file.content) }; + } else if (pane.pdfPath()) |pdf_path| { + path = pdf_path; + generation = .{ .pdf = null }; + } + }; + watchPane(fd, watches, id, path, serial, generation); + return on and watches[id] != null and reloadPane(core, io, gpa, watches, id, false); +} + +/// Reconcile every mark after a coalesced directory wake. +pub fn reloadChanged( + core: *pardes.Pardes, + io: std.Io, + gpa: std.mem.Allocator, + watches: *Table, +) bool { + var retry = false; + for (watches, 0..) |slot, id| { + if (slot != null) retry = reloadPane(core, io, gpa, watches, id, true) or retry; + } + return retry; +} + +pub fn identify(io: std.Io, path: []const u8) !Identity { + const stat = try std.Io.Dir.cwd().statFile(io, path, .{}); + if (stat.kind != .file) return error.NotFile; + return .{ + .inode = stat.inode, + .size = stat.size, + .mtime_ns = stat.mtime.nanoseconds, + .ctime_ns = stat.ctime.nanoseconds, + }; +} + +test "file identity changes for in-place and rename-over writes" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "a" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path}); + const before = try identify(io, path); + try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "longer" }); + const after = try identify(io, path); + try std.testing.expect(!before.eql(after)); + + // The dominant editor save shape keeps size/content arbitrary but swaps + // the inode. Keep this replacement the same length to prove identity does + // not rely on size changing. + try tmp.dir.writeFile(io, .{ .sub_path = "replacement", .data = "swap!!" }); + try tmp.dir.rename("replacement", tmp.dir, "watched", io); + const replaced = try identify(io, path); + try std.testing.expect(after.inode != replaced.inode); + try std.testing.expect(!after.eql(replaced)); +} |
