diff options
Diffstat (limited to 'src/tty/tty.zig')
| -rw-r--r-- | src/tty/tty.zig | 157 |
1 files changed, 155 insertions, 2 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig index b4bd4175..e2fe10e8 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -6,6 +6,7 @@ const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; +const linux = std.os.linux; const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); @@ -36,10 +37,32 @@ pub const Command = struct { paste: []const u8, /// a language query finished on a worker; rows are gpa-owned lsp_done: struct { id: u32, rows: []u8 }, + /// something happened in a watched directory (see watchFiles) + files_changed, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); +/// The files on open panes, watched through ONE inotify instance. +/// +/// `wd` is the descriptor for the file's CONTAINING DIRECTORY, not for the +/// file: almost nothing rewrites a file in place. An editor, `git checkout`, a +/// formatter — they write a temp file and rename it over the target, which +/// swaps the inode, and a watch on the file itself keeps reporting on the old +/// unlinked one forever. The directory sees the new inode arrive (MOVED_TO / +/// CREATE). std.Build.Watch marks directories for exactly this reason; it uses +/// fanotify because it watches thousands of them across mount points and needs +/// FID-keyed dedup, whereas sixteen panes' worth of inotify is a third of the +/// code, hands us no privilege question, and has no kernel version floor. +/// +/// `hash` is what we last saw ON DISK. A reload only fires when it moves, so +/// pardes's own Save — which restamps the hash from the bytes it just wrote — +/// never looks like an external change and never touches the undo stack. +/// A hash rather than mtime+size because we have to read the file to hand the +/// core its bytes anyway, so the exact answer costs nothing extra and there is +/// no clock granularity to reason about. +const Watch = struct { wd: c_int, hash: u64 }; + /// One language query, owned by the worker that runs it. Everything the /// backend may read is copied in here before the worker starts: the core goes /// on editing the moment the effect is drained, so a borrowed slice would be a @@ -127,6 +150,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var gens: [pardes.MAX_PANES]u32 = @splat(0); // the single in-flight language query (see the .lsp effect) var lsp_task: ?std.Io.Future(anyerror!void) = null; + // One inotify instance for every watched pane, opened here — before any + // thread exists — so the pre-loop effect drain below can already mark the + // file a positional path argument opened. -1 off linux: watchPane goes + // quiet and the core simply never gets a file_changed event. + var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1; + var watches: [pardes.MAX_PANES]?Watch = @splat(null); + var watch_task: ?std.Io.Future(anyerror!void) = null; var loop: Loop = .init(io, &tty, &vx); defer { // reap the reader tasks (cancel interrupts a blocked read) before @@ -144,6 +174,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { t.cancel(io) catch {}; lsp_task = null; } + // same contract as the pty readers: cancel unblocks the watcher's + // read, and only then is the fd safe to close + if (watch_task) |*t| { + t.cancel(io) catch {}; + watch_task = null; + } + if (inotify_fd >= 0) { + _ = libc.close(inotify_fd); + inotify_fd = -1; + } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), .paste => |b| gpa.free(@constCast(b)), @@ -154,7 +194,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // Perform the initial spawns BEFORE any worker thread exists: forkpty from // a multithreaded process can wedge the child before exec. - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, false); + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, false); try loop.start(); defer loop.stop(); @@ -207,7 +247,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| { pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, gens[id], &loop }); }; + // ...and the one file watcher. Started here rather than lazily on the + // first watched pane because the fd already exists and an unwatched + // inotify instance just parks in read(2) — one thread for the process, + // however many panes come and go. + if (inotify_fd >= 0) watch_task = io.concurrent(watchFiles, .{ io, inotify_fd, &loop }) catch null; + var check_files = false; var pending: ?@TypeOf(Command.value) = .tick; while (!core.quit) { var event = if (pending) |ev| blk: { @@ -279,6 +325,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { core.update(.{ .paste = bytes }); gpa.free(@constCast(bytes)); }, + // Coalesced on purpose: a burst of writes (a formatter, a + // build, a `git checkout`) collapses into ONE pass below, so + // it cannot queue a reload — or an undo entry — per write. + .files_changed => check_files = true, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); gpa.free(d.rows); @@ -297,7 +347,29 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { tz_event.end(); if (stop) break; - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, true); + // A watched directory moved. The wake says only THAT something + // happened, so re-read every watched pane and hand the core the ones + // whose bytes actually changed — that comparison is what keeps our own + // Save, and any write that lands on identical content, out of the undo + // stack. Reading here rather than on the watcher thread because the + // core is the only thing that knows which pane a path belongs to, and + // opening a file pane already reads synchronously on this thread. + if (check_files) { + check_files = false; + for (&watches, 0..) |*slot, id| { + if (slot.* == null) continue; + const pane = core.panes[id] orelse continue; + const f = pane.file orelse continue; + const bytes = look.readFile(gpa, f.path) catch continue; + defer gpa.free(bytes); + const h = std.hash.Wyhash.hash(0, bytes); + if (h == slot.*.?.hash) continue; + slot.*.?.hash = h; + core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } }); + } + } + + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; the live @@ -316,6 +388,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { slot.* = null; gens[pid] +%= 1; }; + // the replay core's pane ids mean new things, and the dying core's + // `watch off` effects go into a queue nobody drains — drop the lot + // here. The new core emits its own `on`s as it builds its panes. + for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0); kitty_handles = @splat(null); core.deinit(); core = nc; @@ -401,6 +477,8 @@ fn drainEffects( vx: *vaxis.Vaxis, tty: *vaxis.Tty, lsp_task: *?std.Io.Future(anyerror!void), + inotify_fd: c_int, + watches: *[pardes.MAX_PANES]?Watch, threads_ok: bool, ) void { while (core.nextEffect()) |effect| switch (effect) { @@ -454,6 +532,9 @@ fn drainEffects( if (fd < 0) continue; writeFd(fd, f.content); _ = libc.close(fd); + // our own write is about to come back as a watch event: restamp + // from the bytes we just put there so it reads as "no change" + if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); }, .write_dump => { const out = core.dump_out orelse continue; @@ -517,6 +598,17 @@ fn drainEffects( continue; }; }, + .watch => |w| { + // starting, the path and the on-disk bytes are read off the core + // (same split as save_file); stopping, the pane is already gone + var path: ?[]const u8 = null; + var hash: u64 = 0; + if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| { + path = f.path; + hash = std.hash.Wyhash.hash(0, f.content); + }; + watchPane(inotify_fd, watches, w.pane, path, hash); + }, .quit => {}, }; } @@ -545,6 +637,67 @@ fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void { return; } +/// Mark or unmark one pane's file (`path` null = unmark). Linux only: on any +/// other OS this returns silently, the core never receives a file_changed +/// event, and the feature is simply off — a state the core has to tolerate +/// regardless, since the browser shell has no filesystem at all. +/// ponytail: darwin wants the FSEvents half of std.Build.Watch here. +fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void { + if (comptime builtin.os.tag != .linux) return; + if (fd < 0) return; + if (watches[id]) |old| { + // inotify hands out ONE descriptor per directory, so two panes on + // files in the same directory share it: drop the mark only when the + // last of them lets go, or closing one blinds the other. + var shared = false; + for (watches, 0..) |other, i| { + const o = other orelse continue; + if (i != id and o.wd == old.wd) shared = true; + } + if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); + watches[id] = null; + } + const p = path orelse return; + const dir = std.fs.path.dirname(p) orelse "."; + var dbuf: [4096:0]u8 = undefined; + if (dir.len >= dbuf.len) return; + @memcpy(dbuf[0..dir.len], dir); + dbuf[dir.len] = 0; + // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one + // per write(2), which is most of the debounce for free. MOVED_TO and + // CREATE catch the rename-over and the delete-then-recreate that are how + // files are actually replaced (see Watch). + const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR; + const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask); + if (wd < 0) return; + watches[id] = .{ .wd = wd, .hash = hash }; +} + +/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the +/// events: the loop re-reads every watched pane anyway, so the only thing an +/// event carries that we need is THAT something happened, and parsing would +/// mean sharing the watch table with the thread that mutates it. Same shape as +/// readPty — block off the loop, hand the loop an event, keep the core a state +/// machine that never blocks. Going through std.Io.File rather than a raw +/// read(2) is what lets the teardown `cancel` interrupt it. +/// +/// ponytail: churn in a watched directory that never touches the watched file +/// still costs a wake and a re-read per event. The ceiling is one directory +/// per open file pane; filter by basename here if it ever shows up in a +/// profile. +fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { + const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; + var read_buf: [4096]u8 = undefined; + var reader = file.readerStreaming(io, &read_buf); + while (true) { + var buf: [4096]u8 = undefined; + var vec = [_][]u8{&buf}; + const n = reader.interface.readVec(&vec) catch break; + if (n == 0) break; + loop.postEvent(.files_changed) catch break; + } +} + /// Consume SIGWINCH synchronously (it is blocked in every thread) and post /// the new size as a winsize event from normal thread context — the one place /// vaxis's Io-backed queue is safe to touch on a resize. |
