From c1990b3e6e196ad41379aa432bf3ccca8a65d9f0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 20 Sep 2026 00:36:50 -0300 Subject: Flatten the 9P control tree and move it out of fs.zig The served tree loses the self/ level: /index /ctl /new /log /screen /listeners /pane//... /os, with /src only in -Dembed-sources=true builds (default off, on for esp32p4). ctl speaks the editor's own language with two lowercase verbs, look TEXT and exec TEXT, plus acme's addr verbs; the new/ factory directory becomes one clone file; cons is gone (exec Msg); name and sel are files; stats report real lengths, modes and mtimes; /log streams pane new/del/rename/save events. The tree code lives in src/ninep/ (tree, pane, ctl, addr, pty, events, screen, sources); fs.zig keeps host access, mounts, resolution and find/grep. Same engine and transports. README (fs-help.txt) and docs rewritten; tests updated and extended. Co-Authored-By: Claude Fable 5.1 --- src/ninep/pty.zig | 388 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 src/ninep/pty.zig (limited to 'src/ninep/pty.zig') diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig new file mode 100644 index 00000000..e6696b85 --- /dev/null +++ b/src/ninep/pty.zig @@ -0,0 +1,388 @@ +//! A terminal pane's pty/: ctl (winsize, sig, exec), status and the raw data stream. +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const tree = @import("tree.zig"); +const pane_files = @import("pane.zig"); + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const Req = tree.Req; +const Reply = tree.Reply; +const E = tree.E; +const PaneFile = tree.PaneFile; + +pub fn fileNamed(name: []const u8) ?PaneFile { + if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; + if (std.mem.eql(u8, name, "status")) return .pty_status; + if (std.mem.eql(u8, name, "data")) return .pty_data; + return null; +} + +const Verb = enum { winsize, sig, exec }; + +fn dimension(word: []const u8) ?u16 { + if (word.len == 0 or word.len > 5) return null; + for (word) |c| if (c < '0' or c > '9') return null; + const n = std.fmt.parseInt(u16, word, 10) catch return null; + return if (n == 0) null else n; +} + +fn signalNamed(word: []const u8) ?pardes.PtySignal { + if (std.mem.eql(u8, word, "INT")) return .int; + if (std.mem.eql(u8, word, "TERM")) return .term; + if (std.mem.eql(u8, word, "HUP")) return .hup; + if (std.mem.eql(u8, word, "QUIT")) return .quit; + if (std.mem.eql(u8, word, "KILL")) return .kill; + return null; +} + +pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply { + for ([2]bool{ false, true }) |apply| { + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (!verb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { + const pane = p.panes[id] orelse return false; + var words = std.mem.tokenizeAny(u8, line, " \t"); + const v = std.meta.stringToEnum(Verb, words.next() orelse return false) orelse return false; + switch (v) { + .winsize => { + const cols = dimension(words.next() orelse return false) orelse return false; + const rows = dimension(words.next() orelse return false) orelse return false; + if (words.next() != null) return false; + if (!apply) return true; + p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); + }, + .sig => { + const which = signalNamed(words.next() orelse return false) orelse return false; + if (words.next() != null) return false; + if (!apply) return true; + p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); + }, + .exec => { + if (words.next() != null) return false; + if (pane.cwdSlice().len > pardes.effect_path_cap) return false; + if (!apply) return true; + p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); + }, + } + return true; +} + +pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + const out = p.fs.stage(p.gpa); + out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ + pane.cols, + pane.rows, + @intFromBool(p.hostTtyTaken(id)), + }) catch {}; + return tree.stagedReply(p, req); +} + +pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply { + if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; + const out = p.fs.stage(p.gpa); + while (out.items.len < req.size) { + const chunk = pf.pty_out.peek() orelse break; + const n = @min(chunk.len, req.size - out.items.len); + out.appendSlice(p.gpa, chunk[0..n]) catch break; + pf.pty_out.popFront(n); + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +pub fn writeData(p: *Pardes, req: Req, id: usize) Reply { + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + const take = pane_files.wholeUtf8(req.data); + p.emitWrite(id, req.data[0..take]); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +// ---- tests ---- + +const testing = std.testing; +const th = @import("testing.zig"); +const events = @import("events.zig"); +const call = th.call; +const rd = th.rd; +const wr = th.wr; +const rdir = th.rdir; +const look_up = th.look_up; +const withFile = th.withFile; +const withTerm = th.withTerm; +const serialOf = th.serialOf; +const Node = tree.Node; +const Status = tree.Status; + +test "a pane that is not a terminal has no pty/ at all" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + const dir = Node.of(serial, .dir); + + try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno()); + try testing.expectEqual(E.NOENT, call(p, .{ + .tag = 1, + .op = .getattr, + .node = Node.of(serial, .pty), + }).errno()); + try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno()); + try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno()); + try testing.expectEqual(E.NOENT, call(p, .{ + .tag = 2, + .op = .open, + .node = Node.of(serial, .pty_data), + }).errno()); + try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); + + var buf: [32]th.Dirent = undefined; + const files = th.dirents(rdir(p, dir, 0).bytes, &buf); + try testing.expectEqual(@as(usize, 10), files.len); + try testing.expect(th.nameAt(files, "pty") == null); + + try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); + try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); +} + +test "a terminal pane's pty/ holds exactly ctl, status and data" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const dir = Node.of(serial, .dir); + + const pty = look_up(p, dir, "pty"); + try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node); + try testing.expect(pty.reply.attr.dir); + try testing.expectEqual(@as(u16, 0o755), pty.reply.attr.mode); + + var buf: [32]th.Dirent = undefined; + const files = th.dirents(rdir(p, dir, 0).bytes, &buf); + try testing.expectEqual(@as(usize, 11), files.len); + try testing.expect(th.nameAt(files, "pty").?.dir); + + const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); + try testing.expectEqual(@as(usize, 3), inside.len); + try testing.expectEqualStrings("ctl", inside[0].name); + try testing.expectEqualStrings("status", inside[1].name); + try testing.expectEqualStrings("data", inside[2].name); + for (inside) |d| try testing.expect(!d.dir); + try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); + + const ctl = look_up(p, Node.of(serial, .pty), "ctl"); + try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node); + try testing.expectEqual(@as(u16, 0o222), ctl.reply.attr.mode); + try testing.expectEqual(@as(u16, 0o444), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno()); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno()); + + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno()); + try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno()); + try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno()); + try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno()); + try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno()); +} + +test "every pty/ctl verb, and every refusal" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const ctl = Node.of(serialOf(p), .pty_ctl); + + const pane = p.panes[0].?; + const cols = pane.cols; + const rows = pane.rows; + const ws = wr(p, ctl, "winsize 132 44\n"); + try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written); + try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols); + try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); + try testing.expectEqual(cols, pane.cols); + try testing.expectEqual(rows, pane.rows); + + for ([_]struct { line: []const u8, want: pardes.PtySignal }{ + .{ .line = "sig INT", .want = .int }, + .{ .line = "sig TERM", .want = .term }, + .{ .line = "sig HUP", .want = .hup }, + .{ .line = "sig QUIT", .want = .quit }, + .{ .line = "sig KILL", .want = .kill }, + }) |c| { + const a = wr(p, ctl, c.line); + try testing.expectEqual(Status.ok, a.reply.status); + try testing.expectEqual(c.want, a.signal.?); + } + + const ex = wr(p, ctl, "exec\n"); + try testing.expectEqual(Status.ok, ex.reply.status); + try testing.expect(ex.spawned); + + const both = wr(p, ctl, "winsize 100 30\nsig TERM"); + try testing.expectEqual(@as(u16, 100), both.winsize.?.cols); + try testing.expectEqual(pardes.PtySignal.term, both.signal.?); + + for ([_][]const u8{ + "winsize", // no arguments + "winsize 80", // one argument + "winsize 80 24 extra", // three + "winsize 0 24", // zero is "unknown", never a width + "winsize 80 0", + "winsize -1 24", // not a decimal + "winsize 999999 24", // wider than a u16 + "sig", // no name + "sig INT TERM", // two + "sig SIGINT", + "sig int", // lower case + "sig 9", // a number is one platform's number + "sig USR1", // a real signal, deliberately not offered + "exec /bin/sh", // the effect carries no argv; refused, never ignored + "raw", + "cooked", + "winsize 80 24\nbogus", // a good verb beside a bad one + "bogus\nwinsize 80 24", + "look x", // a `ctl` verb; the two files share no vocabulary + "exec Del", + }) |bad| { + const a = wr(p, ctl, bad); + try testing.expectEqual(E.INVAL, a.errno()); + try testing.expect(a.winsize == null); + try testing.expect(a.signal == null); + try testing.expect(!a.spawned); + } + + const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); + try testing.expectEqual(Status.ok, spaced.reply.status); + try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); + try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); +} + +test "owned cwd pty exec rejects long paths before applying its batch" { + const p = try withTerm(testing.allocator); + defer p.deinit(); + const pane = p.panes[0].?; + const ctl = Node.of(pane.serial, .pty_ctl); + var path: [1025]u8 = @splat('d'); + path[0] = '/'; + p.setCwd(0, &path); + for ([_][]const u8{ "exec\n", "winsize 100 30\nexec\n", "sig TERM\nexec\n" }) |command| { + const result = wr(p, ctl, command); + try testing.expectEqual(E.INVAL, result.errno()); + try testing.expect(!result.spawned); + try testing.expect(result.signal == null and result.winsize == null); + } + try testing.expectEqualStrings(&path, pane.cwdSlice()); + p.setCwd(0, path[0..pardes.effect_path_cap]); + const accepted = wr(p, ctl, "exec\n"); + try testing.expectEqual(Status.ok, accepted.reply.status); + try testing.expect(accepted.spawned); +} + +test "pty/status reports the grid and who holds the tty" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const pane = p.panes[0].?; + const status = Node.of(pane.serial, .pty_status); + + const a = rd(p, status, 0, 256); + try testing.expectEqual(Status.ok, a.reply.status); + var want: [64]u8 = undefined; + const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); + try testing.expectEqualStrings(whole, a.bytes); + try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); + try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); + + var probe: th.FakeTty = .{ .taken = true }; + p.host = .{ .ctx = &probe, .vtable = &th.FakeTty.vtable }; + const held = rd(p, status, 0, 256); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), + held.bytes, + ); +} + +test "pty/data writes at the shell and reads the raw stream" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const data = Node.of(serial, .pty_data); + + const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" }); + try testing.expectEqual(@as(u32, 6), w.reply.written); + try testing.expectEqualStrings("ls -l\r", w.pty()); + try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); + try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); + + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + _ = call(p, .{ .tag = 5, .op = .open, .node = data }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + try testing.expect(!p.fs.scripted(0)); + + p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes); + try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes); + p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); + + p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } }); + while (p.nextEffect()) |_| {} + _ = call(p, .{ .tag = 6, .op = .release, .node = data }); + try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); + try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + _ = call(p, .{ .tag = 7, .op = .open, .node = data }); + _ = call(p, .{ .tag = 8, .op = .open, .node = data }); + _ = call(p, .{ .tag = 9, .op = .release, .node = data }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); + p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); +} + +test "the pty queue drops the oldest at its cap" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const data = Node.of(serialOf(p), .pty_data); + _ = call(p, .{ .tag = 5, .op = .open, .node = data }); + + const oldest: [4096]u8 = @splat('A'); + const rest: [4096]u8 = @splat('B'); + events.notePtyOutput(p, 0, &oldest); + for (0..events.queue_cap / rest.len + 4) |_| events.notePtyOutput(p, 0, &rest); + const q = &p.fs.panes[0].pty_out; + try testing.expect(q.buf.items.len - q.head <= events.queue_cap); + + var seen: usize = 0; + while (true) { + const a = rd(p, data, 0, 1 << 16); + if (a.reply.status == .again) break; + try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null); + if (a.bytes.len == 0) break; + seen += a.bytes.len; + } + try testing.expect(seen > 0 and seen <= events.queue_cap); +} -- cgit v1.3