summaryrefslogtreecommitdiff
path: root/src/tty/tty.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty/tty.zig')
-rw-r--r--src/tty/tty.zig157
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.