//! 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); }