//! 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; if (std.mem.eql(u8, name, "run")) return .pty_run; 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 or n > winsize_max) null else n; } /// The fewest rows a winsize write gives a terminal's pty. pub const pty_rows_min = 2; /// As the root ctl's `size` (4096 a side). const winsize_max = 4096; const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x1 to {d}x{d}", .{ winsize_max, winsize_max }); /// A winsize whose numbers are numbers but not sizes: said as such. fn outOfRange(line: []const u8) bool { var words = std.mem.tokenizeAny(u8, line, " \t"); if (!std.mem.eql(u8, words.next() orelse return false, "winsize")) return false; var n: u8 = 0; while (words.next()) |w| : (n += 1) { _ = std.fmt.parseInt(u32, w, 10) catch return false; } return n == 2; } 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; // A command pane's child is its command, done or running: exec // would run its line again with no header, footer or records. if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pane.command != null) return tree.failText(req.tag, E.INVAL, "invalid exec: a command pane does not restart"); // A shell restarted where the directory is gone would not start: // refused before anything runs, as Tty there is. if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pardes.exec.missingDir(p, pane.cwdSlice())) { const dir = pane.cwdSlice(); return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}: no such directory", .{dir[0..@min(dir.len, 256)]}) catch "exec: no such directory"); }; // Nor is a shell that is not there, or a script whose // interpreter is not: refused, and the running shell kept. if (comptime pardes.hosted) if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| { const bin = pane.shell orelse p.shellBin(); var why: [320]u8 = undefined; if (@import("../host_io.zig").Shell.refusal(bin[0..@min(bin.len, 200)], &why)) |refused| return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}", .{refused}) catch "exec: no such shell"); }; if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl); } } 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; // Two rows at least, as a pane is laid out: a one-row pty // loses its prompt's input mark and reads busy for ever. const rows = @max(pty_rows_min, 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 } }); pane.fs.winsize = .{ cols, 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), .serial = pane.serial, .sig = which } }); }, .exec => { if (words.next() != null) return false; if (pane.cwdSlice().len > pardes.effect_path_cap) return false; if (!apply) return true; p.emitSpawn(id, pane.serial, pane.cwdSlice()); }, } return true; } /// ctl reads back its state in the words it takes, as a Plan 9 ctl does. pub fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { const size = winsize(pane); p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ size[0], size[1] }) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } /// The pty's size: what ctl's winsize set, else the pane's grid. fn winsize(pane: *const Pane) [2]u16 { return pane.fs.winsize orelse .{ pane.cols, pane.rows }; } /// Three right-aligned fields: the pty's cols and rows, and busy: whether a /// run written now would be told `busy` (a command runs, or text is typed at /// the prompt). A newline ends the line, as a Plan 9 status file's does. pub const status_len: u64 = 36; pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { // Busy is what a run written now would be told: a command is running // or a line is typed at the prompt. Before the shell's first prompt a // run waits for it rather than answer busy, so that is not busy. // A build with no terminal panes (the board) has no marks to read. const marks = if (comptime !pardes.panes.Terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null; const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false))); const out = p.fs.stage(p.gpa); const size = winsize(pane); out.print(p.gpa, "{d:>11} {d:>11} {d:>11}\n", .{ size[0], size[1], @intFromBool(busy) }) catch {}; return tree.stagedReply(p, req); } // ---- run: one command line in, how it ended out ---- // // factotum's rpc shape (factotum(4)): write a request on an open, read the // answer on that same open, so the open is what ties a result to its // command. The shell's own OSC 133 marks say when the command started (C) // and ended (D, with its status); pardes injects them into bash and fish. /// The most of a command's output an answer carries: its last this-many /// bytes, since that is where a failure says what went wrong. pub const output_cap = 64 * 1024; /// The most rows above a command's end its output is read from: `output_cap` /// at eight bytes a row. The answer is made on the editor's thread, which /// would otherwise render a command's whole 16 MiB of history to keep 64 KiB. pub const output_rows = output_cap / 8; /// What an open of run holds (tree.zig's open records). pub const Run = struct { /// `waiting`: the line is kept until the shell's first prompt (a new /// terminal's shell takes a moment to draw it), then sent. phase: enum { idle, waiting, sent, done } = .idle, /// The line, gpa-owned, while it waits to be sent. line: []u8 = &.{}, /// The shell's command count its C must reach for the end to be ours. want: u32 = 0, /// The shell's prompt count when the line went: a newer prompt with no /// C for it means the shell never ran the line. prompts: u32 = 0, /// The shell's count of lines it handled without running when the line /// went. declined: u32 = 0, /// How the refused line is being taken back. cleared: enum { no, kept_line, continuation } = .no, /// How much of the answer this open has read. A stream: a shell's /// `exec 3<>run` shares one offset between the write and the read, so /// the offset a read arrives with means nothing (acme's event file and /// Plan 9's kprint read the same way). read: usize = 0, /// The first line: how it ended. answer: [96]u8 = undefined, len: u8 = 0, /// Then what the command printed, gpa-owned. output: []u8 = &.{}, }; fn answer(p: *Pardes, slot: *Run, comptime fmt: []const u8, args: anytype) void { // A program's name comes from the host, whatever its length: an answer // longer than the room is cut, its newline kept, never a panic. var w: std.Io.Writer = .fixed(&slot.answer); w.print(fmt ++ "\n", args) catch { slot.answer[slot.answer.len - 1] = '\n'; }; slot.len = @intCast(w.end); slot.phase = .done; p.fs.news = true; // the read held on it can be answered } pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.run; if (slot.phase == .sent or slot.phase == .waiting) return tree.failText(req.tag, E.BUSY, tree.e_in_use); // One line a run, its answer read before the next: a second line (bash // writes printf 'a\nb\n' a line at a time) would run and lose the // first's answer. const one = std.mem.trimEnd(u8, req.data, "\r\n"); if (std.mem.indexOfScalar(u8, one, '\n') != null or (slot.phase == .done and slot.read == 0)) return tree.failText(req.tag, E.INVAL, e_one_line); // A new line on this open: a new answer. p.gpa.free(slot.output); p.gpa.free(slot.line); slot.* = .{}; const line = std.mem.trimEnd(u8, req.data, "\r\n"); if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line); if (comptime !pardes.panes.Terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line); const pf = &pane.fs; const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line); const marks = &state.stream.handler; if (pane.shell_failed) { // Its shell never started: no prompt is coming to wait for. answer(p, slot, "error shell gone", .{}); } else if (pane.command != null) { // Its child is the command, and no shell is there to take a line. answer(p, slot, "error {s}", .{if (pane.command_done) "command done; not a shell" else "a command runs here, not a shell"}); } else if (pf.unmarked) { answer(p, slot, "error no prompt marks", .{}); } else if (waitingRun(p, pane) != null or (marks.prompts > 0 and (marks.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)))) { // Something is running or someone has typed at the prompt: sending // now would type into the middle of it. The phase is pardes's own // marks: a nested shell's prompt (ssh, a shell with its own // integration) looks like an empty prompt to ghostty but is not the // shell this run knows. // Naming what runs, where the host can tell (Linux). var name: [64]u8 = undefined; if (p.hostForegroundName(id, &name)) |program| answer(p, slot, "busy: {s} is running", .{program}) else answer(p, slot, "busy", .{}); } else { // Sent now if the shell is at its prompt, or at its first prompt // when it has not drawn one yet (noteMarks). slot.line = p.gpa.dupe(u8, line) catch return Reply.fail(req.tag, E.NOMEM); slot.phase = .waiting; pf.run = req.handle; noteMarks(p, id, pane); } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } pub fn readRun(p: *Pardes, req: Req) Reply { const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.run; switch (slot.phase) { .idle => return .{ .tag = req.tag, .payload = .{ .staged = 0 } }, .waiting, .sent => return .{ .tag = req.tag, .status = .again }, .done => {}, } // The answer line, then the output: one stream, read from `read` on. const out = p.fs.stage(p.gpa); var at = slot.read; for ([_][]const u8{ slot.answer[0..slot.len], slot.output }) |part| { if (at >= part.len) { at -= part.len; continue; } const n = @min(part.len - at, req.size - out.items.len); out.appendSlice(p.gpa, part[at..][0..n]) catch return Reply.fail(req.tag, E.NOMEM); at = 0; if (out.items.len == req.size) break; } slot.read += out.items.len; return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } /// After each chunk of a terminal's output: once the command the run sent /// has ended and the shell is back at a prompt, the run answers how. Waiting /// for the prompt means whoever reads the answer can send the next command /// at once instead of finding the shell still drawing it. pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { if (comptime !pardes.panes.Terminal.enabled) return; const state = pane.terminal orelse return; const pf = &pane.fs; const slot = waitingRun(p, pane) orelse return; const marks = &state.stream.handler; if (slot.phase == .waiting) { if (pf.unmarked) { answer(p, slot, "error no prompt marks", .{}); pf.run = null; return; } if (marks.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) return; slot.want = marks.started +% 1; slot.prompts = marks.prompts; slot.declined = marks.declined; p.emitWrite(id, slot.line); p.emitWrite(id, "\r"); pardes.panes.Terminal.noteCommand(pane, slot.line); slot.phase = .sent; return; } if (marks.phase != .input) return; // ponytail: a prompt redrawn before the command starts (a resize in that // instant) reads as not run; a per-command id in the marks would tell. if (marks.started != slot.want and marks.prompts != slot.prompts) { // fish keeps a refused line on the prompt for editing, and bash's // continuation prompt holds an unfinished one (its line looks empty // but the command is still pending). Either is the line this run // typed into an empty prompt, so throw it away with Ctrl-C, which // drops the whole pending command, and answer at the prompt after // that, so the next line finds the shell clear. const empty = pardes.panes.Terminal.promptInputEmpty(pane); switch (slot.cleared) { .no => { if (marks.continuation) { // bash holds the start of the command: Ctrl-C drops all of it p.emitWrite(id, "\x03"); slot.cleared = .continuation; slot.prompts = marks.prompts; // gone at the next prompt return; } if (!empty) { // fish keeps the refused line on the prompt; a run's line // is one line, so Ctrl-U takes it all (a Ctrl-C sent while // fish redraws is lost) p.emitWrite(id, "\x15"); slot.cleared = .kept_line; return; } // bash said it handled the line (a D) and cleared its prompt if (marks.declined == slot.declined) return; // Otherwise an empty ordinary prompt means fish has not drawn // the kept line yet, or a redraw landed before the line ran // (its C is still coming): the next output says which. }, .continuation => if (marks.continuation) return, .kept_line => {}, } if (!empty) return; answer(p, slot, "error not run", .{}); pf.run = null; return; } const done = marks.finished orelse return; if (done.seq != slot.want) return; pf.run = null; finish(p, slot, pane, done.status); } /// Answers a run whose command ended with `status`: the header and the /// output it printed. fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void { const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch return answer(p, slot, "error out of memory", .{}); defer p.gpa.free(printed.text); // Keep the tail, cut at a line start so the first line kept is whole. var keep: []const u8 = printed.text; if (keep.len > output_cap) { keep = keep[keep.len - output_cap ..]; if (std.mem.indexOfScalar(u8, keep, '\n')) |nl| keep = keep[nl + 1 ..]; } if (keep.len > 0) { const nl = @intFromBool(keep[keep.len - 1] != '\n'); const owned = p.gpa.alloc(u8, keep.len + nl) catch return answer(p, slot, "error out of memory", .{}); @memcpy(owned[0..keep.len], keep); if (nl == 1) owned[keep.len] = '\n'; slot.output = owned; } // A D that carries no status says nothing of how the command went. var code: [16]u8 = undefined; // unreachable: an exit status fits 16 const status = if (status_code) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?"; // The header is the whole first line, so a count there can never be // mistaken for output; `cut` with no count: its start is not there to // count from. if (!printed.whole) answer(p, slot, "exit {s} cut", .{status}) else if (printed.text.len > keep.len) answer(p, slot, "exit {s} cut {d}", .{ status, printed.text.len - keep.len }) else answer(p, slot, "exit {s}", .{status}); } /// The shell itself exited (a run's `exit 3`, or one typed): a run waiting /// on the command it ran answers with the shell's status and what it /// printed, and the log says `exit` before the pane's `del`. Unknown, the /// status is not guessed: the run hears `error shell gone` when the pane /// goes. pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void { const code = status orelse return; if (waitingRun(p, pane)) |slot| if (slot.phase == .sent) { pane.fs.run = null; pardes.panes.Terminal.endOutputHere(pane); finish(p, slot, pane, code); }; var buf: [4]u8 = undefined; // unreachable: a u8 exit code is at most 3 digits pardes.exec.noteRun(p, pane, "exit", std.fmt.bufPrint(&buf, "{d}", .{code}) catch unreachable); } /// The pane closed or its shell was replaced: the command's end will never /// be reported, so the run says so instead of waiting forever. A line not /// sent yet waits on for the replacement's first prompt. pub fn shellGone(p: *Pardes, pane: *Pane, respawn: bool) void { const slot = waitingRun(p, pane) orelse return; if (respawn and slot.phase == .waiting) return; answer(p, slot, "error shell gone", .{}); pane.fs.run = null; } /// The run waiting on this shell's command. The pane keeps its open's /// handle, which names that open only on the node it was opened on, so it /// is looked up the way a request's is; one that no longer names a run is /// forgotten. fn waitingRun(p: *Pardes, pane: *Pane) ?*Run { const handle = pane.fs.run orelse return null; if (tree.openOf(p, .{ .tag = 0, .op = .read, .node = tree.Node.of(pane.serial, .pty_run), .handle = handle })) |o| if (o.what == .run) return &o.what.run; pane.fs.run = null; return null; } const e_bad_line = "bad command line"; const e_one_line = "invalid run: one line a run, its answer read before the next"; const e_bad_pty_ctl = tree.e_bad_ctl ++ "; takes winsize COLS ROWS, sig INT|TERM|HUP|QUIT|KILL, or exec"; 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.expect(!p.panes[0].?.fs.pty_reader); var buf: [32]th.Dirent = undefined; const files = th.dirents(rdir(p, dir, 0).bytes, &buf); try testing.expectEqual(@as(usize, 18), 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, 19), 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, 4), inside.len); try testing.expectEqualStrings("ctl", inside[0].name); try testing.expectEqualStrings("status", inside[1].name); try testing.expectEqualStrings("data", inside[2].name); try testing.expectEqualStrings("run", inside[3].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, 0o666), 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.expect(std.mem.startsWith(u8, rd(p, Node.of(serial, .pty_ctl), 0, 64).bytes, "winsize ")); 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; var said: [32]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&said, "winsize {d} {d}\n", .{ cols, rows }), rd(p, ctl, 0, 64).bytes); 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); // ctl and status read back the pty's size, until the pane resizes. try testing.expectEqualStrings("winsize 132 44\n", rd(p, ctl, 0, 64).bytes); try testing.expect(std.mem.startsWith(u8, rd(p, Node.of(serialOf(p), .pty_status), 0, 64).bytes, " 132 44 ")); p.update(.{ .resize = .{ .cols = 76, .rows = 24 } }); try testing.expect(pane.cols != 132); try testing.expectEqualStrings(try std.fmt.bufPrint(&said, "winsize {d} {d}\n", .{ pane.cols, pane.rows }), rd(p, ctl, 0, 64).bytes); 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 -1 24", // not a decimal "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 pane command; pty/ctl shares none of that vocabulary "exec Del", }) |bad| { const a = wr(p, ctl, bad); try testing.expectEqual(E.INVAL, a.errno()); try testing.expectEqualStrings(e_bad_pty_ctl, a.reply.ename); // names what it takes try testing.expect(a.winsize == null); try testing.expect(a.signal == null); try testing.expect(!a.spawned); } // Numbers, but no size: said as out of range, the bound size's. for ([_][]const u8{ "winsize 0 24", "winsize 80 0", "winsize 0 0", "winsize 4097 24", "winsize 999999 24" }) |bad| { const a = wr(p, ctl, bad); try testing.expectEqual(E.INVAL, a.errno()); try testing.expectEqualStrings(e_winsize_range, a.reply.ename); try testing.expect(a.winsize == null); } 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].?; // A one-row tag: the long path would wrap it onto rows and resize the pty. pane.tag_expanded = false; 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}\n", .{ 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}\n", .{ 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.panes[0].?.fs.pty_out.buf.items.len); try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); // An open that only writes is not a reader: nothing queues for it. const writer = call(p, .{ .tag = 4, .op = .open, .node = data, .omode = 1 }); try testing.expect(!p.panes[0].?.fs.pty_reader); _ = call(p, .{ .tag = 4, .op = .release, .node = data, .handle = writer.reply.handle }); const reader = call(p, .{ .tag = 5, .op = .open, .node = data }); try testing.expect(p.panes[0].?.fs.pty_reader); try testing.expectEqual(@as(u16, 0), p.fs.listeners); try testing.expect(p.panes[0].?.fs.readers == 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, .handle = reader.reply.handle }); try testing.expect(!p.panes[0].?.fs.pty_reader); try testing.expectEqual(@as(usize, 0), p.panes[0].?.fs.pty_out.buf.capacity); try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); // A second reader would take half the stream from the first: refused. _ = call(p, .{ .tag = 7, .op = .open, .node = data }); const second = call(p, .{ .tag = 8, .op = .open, .node = data, .omode = 2 }); try testing.expectEqual(Status.err, second.reply.status); try testing.expectEqualStrings(tree.e_in_use, second.reply.ename); try testing.expect(p.panes[0].?.fs.pty_reader); p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); while (p.nextEffect()) |_| {} try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); } test "run answers how its command ended once the shell is back at a prompt" { const gpa = testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const node = Node.of(serialOf(p), .pty_run); const out = struct { fn put(pp: *Pardes, bytes: []const u8) void { pp.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); while (pp.nextEffect()) |_| {} } }; const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07"; // Before its first prompt the shell cannot take a line yet: the line // waits for it, and pty/status does not call the shell busy meanwhile. const status = Node.of(serialOf(p), .pty_status); try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0\n")); const early = call(p, .{ .tag = 1, .op = .open, .node = node }); const waiting = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" }); try testing.expectEqualStrings("", waiting.pty()); try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status); try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1\n")); // A respawn before that prompt keeps it waiting for the new shell's. p.acknowledgeShell(0, "/bin/sh", true); try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status); p.update(.{ .output = .{ .pane = 0, .bytes = prompt } }); var sent_late: [16]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |e| if (e == .write) { const b = e.write.bytes.slice(); @memcpy(sent_late[sent_len..][0..b.len], b); sent_len += b.len; }; try testing.expectEqualStrings("true\r", sent_late[0..sent_len]); out.put(p, "\x1b]133;C;aid=pardes\x07\x1b]133;D;0;aid=pardes\x07" ++ prompt); try testing.expectEqualStrings("exit 0\n", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).bytes); _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = early.reply.handle }); const r = call(p, .{ .tag = 5, .op = .open, .node = node }); const h = r.reply.handle; const sent = call(p, .{ .tag = 6, .op = .write, .node = node, .handle = h, .data = "make\n" }); try testing.expectEqualStrings("make\r", sent.pty()); try testing.expectEqual(Status.again, call(p, .{ .tag = 7, .op = .read, .node = node, .handle = h, .size = 64 }).reply.status); // A second line on the same open while the first runs is refused. try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = node, .handle = h, .data = "ls\n" }).reply.status); // Untagged marks (fish's own, a nested shell's, a file being printed) // and bash's C from its PROMPT_COMMAND trap change nothing. out.put(p, "\x1b]133;D;9\x07\x1b]133;C;aid=pardes\x07building\r\n"); out.put(p, "\x1b]133;C;aid=pardes\x07\x1b]133;D;2;aid=pardes\x07"); try testing.expectEqual(Status.again, call(p, .{ .tag = 9, .op = .read, .node = node, .handle = h, .size = 64 }).reply.status); out.put(p, prompt); // A shell's `exec 3<>run; echo make >&3; cat <&3` reads at the offset // its write left (5): the answer is a stream and comes whole anyway. // The answer is how it ended, then what it printed. try testing.expectEqualStrings("exit 2\nbuilding\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .off = 5, .size = 64 }).bytes); try testing.expectEqual(@as(usize, 0), call(p, .{ .tag = 11, .op = .read, .node = node, .handle = h, .size = 64 }).bytes.len); _ = call(p, .{ .tag = 12, .op = .release, .node = node, .handle = h }); // While a command runs, a nested shell's own (untagged) prompt looks // like an empty prompt to the emulator; it is not this shell's. const outer = call(p, .{ .tag = 30, .op = .open, .node = node }); _ = call(p, .{ .tag = 31, .op = .write, .node = node, .handle = outer.reply.handle, .data = "ssh box\n" }); out.put(p, "\x1b]133;C;aid=pardes\x07\x1b]133;A\x07remote$ \x1b]133;B\x07"); const nested = call(p, .{ .tag = 32, .op = .open, .node = node }); _ = call(p, .{ .tag = 33, .op = .write, .node = node, .handle = nested.reply.handle, .data = "true\n" }); try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 34, .op = .read, .node = node, .handle = nested.reply.handle, .size = 64 }).bytes); _ = call(p, .{ .tag = 35, .op = .release, .node = node, .handle = nested.reply.handle }); out.put(p, "logout\r\n\x1b]133;D;0;aid=pardes\x07" ++ prompt); const ssh = call(p, .{ .tag = 36, .op = .read, .node = node, .handle = outer.reply.handle, .size = 64 }).bytes; try testing.expect(std.mem.startsWith(u8, ssh, "exit 0\n") and std.mem.indexOf(u8, ssh, "remote$ logout\n") != null); _ = call(p, .{ .tag = 37, .op = .release, .node = node, .handle = outer.reply.handle }); // Typed text on the prompt line: busy, rather than typing into it. out.put(p, "ls -"); const typed = call(p, .{ .tag = 13, .op = .open, .node = node }); _ = call(p, .{ .tag = 14, .op = .write, .node = node, .handle = typed.reply.handle, .data = "true\n" }); try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 15, .op = .read, .node = node, .handle = typed.reply.handle, .size = 64 }).bytes); _ = call(p, .{ .tag = 16, .op = .release, .node = node, .handle = typed.reply.handle }); out.put(p, "\x08\x08\x08\x08\x1b[K"); // A line the shell refuses without running it (fish on a syntax error): // a fresh prompt and no C. Waiting for a D would wait forever. const refused = call(p, .{ .tag = 21, .op = .open, .node = node }); _ = call(p, .{ .tag = 22, .op = .write, .node = node, .handle = refused.reply.handle, .data = "(exit 7)\n" }); out.put(p, "\r\nfish: command substitutions not allowed\r\n" ++ prompt ++ "(exit 7)"); try testing.expectEqual(Status.again, call(p, .{ .tag = 23, .op = .read, .node = node, .handle = refused.reply.handle, .size = 64 }).reply.status); out.put(p, "^C\r\n" ++ prompt); try testing.expectEqualStrings("error not run\n", call(p, .{ .tag = 23, .op = .read, .node = node, .handle = refused.reply.handle, .size = 64 }).bytes); _ = call(p, .{ .tag = 24, .op = .release, .node = node, .handle = refused.reply.handle }); // bash on a syntax error prints no C but still a D before its prompt. const bad = call(p, .{ .tag = 40, .op = .open, .node = node }); _ = call(p, .{ .tag = 41, .op = .write, .node = node, .handle = bad.reply.handle, .data = "fi\n" }); out.put(p, "fi\r\nbash: syntax error\r\n\x1b]133;D;2;aid=pardes\x07" ++ prompt); try testing.expectEqualStrings("error not run\n", call(p, .{ .tag = 42, .op = .read, .node = node, .handle = bad.reply.handle, .size = 64 }).bytes); _ = call(p, .{ .tag = 43, .op = .release, .node = node, .handle = bad.reply.handle }); // An unfinished line (an open quote) leaves bash at its continuation // prompt, tagged too: the line is thrown away with Ctrl-C, then answered. const open_quote = call(p, .{ .tag = 25, .op = .open, .node = node }); const typed_quote = call(p, .{ .tag = 26, .op = .write, .node = node, .handle = open_quote.reply.handle, .data = "echo 'x\n" }); try testing.expectEqualStrings("echo 'x\r", typed_quote.pty()); const cont = call(p, .{ .tag = 27, .op = .read, .node = node, .handle = open_quote.reply.handle, .size = 64 }); try testing.expectEqual(Status.again, cont.reply.status); out.put(p, "echo 'x\r\n\x1b]133;A;k=c;aid=pardes\x07> \x1b]133;B;aid=pardes\x07"); try testing.expectEqual(Status.again, call(p, .{ .tag = 28, .op = .read, .node = node, .handle = open_quote.reply.handle, .size = 64 }).reply.status); out.put(p, "^C\r\n" ++ prompt); try testing.expectEqualStrings("error not run\n", call(p, .{ .tag = 29, .op = .read, .node = node, .handle = open_quote.reply.handle, .size = 64 }).bytes); _ = call(p, .{ .tag = 29, .op = .release, .node = node, .handle = open_quote.reply.handle }); // The pane going away while a command runs ends the wait. out.put(p, "\r\n" ++ prompt); const gone = call(p, .{ .tag = 17, .op = .open, .node = node }); _ = call(p, .{ .tag = 18, .op = .write, .node = node, .handle = gone.reply.handle, .data = "sleep 9\n" }); try p.removePane(0, null); try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 19, .op = .read, .node = node, .handle = gone.reply.handle, .size = 64 }).bytes); // It still stats while the run is open (a reader's fstat), on the // open or on the file's name, and not once it is released. try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node, .handle = gone.reply.handle }).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node }).reply.status); _ = call(p, .{ .tag = 20, .op = .release, .node = node, .handle = gone.reply.handle }); for (p.fs.opens) |o| try testing.expect(o.node == 0); try testing.expectEqual(E.NOENT, call(p, .{ .tag = 21, .op = .getattr, .node = node }).errno()); } test "a shell that exits under a run answers its status, and the log says exit before del" { const gpa = testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const serial = serialOf(p); const node = Node.of(serial, .pty_run); const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07"; p.update(.{ .output = .{ .pane = 0, .bytes = prompt } }); while (p.nextEffect()) |_| {} const log = @intFromEnum(tree.TopFile.log); const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 1 << 16 }).bytes.len; _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" }); const r = call(p, .{ .tag = 4, .op = .open, .node = node }); _ = call(p, .{ .tag = 5, .op = .write, .node = node, .handle = r.reply.handle, .data = "echo bye; exit 3\n" }); p.update(.{ .output = .{ .pane = 0, .bytes = "echo bye; exit 3\r\n\x1b]133;C;aid=pardes\x07bye\r\n" } }); p.update(.{ .exited = .{ .pane = 0, .status = 3 } }); p.update(.{ .eof = .{ .pane = 0 } }); while (p.nextEffect()) |_| {} try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqualStrings("exit 3\nbye\n", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = r.reply.handle, .size = 64 }).bytes); var want: [128]u8 = undefined; // A followed log reads a record at a time: the exit, then the del. var records: [2][]const u8 = undefined; var at = frozen; for (&records) |*rec| { rec.* = try gpa.dupe(u8, call(p, .{ .tag = 7, .op = .read, .node = log, .handle = f, .off = at, .size = 4096 }).bytes); at += rec.len; } defer for (records) |rec| gpa.free(rec); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "exit {d} 3\n", .{serial}), records[0]); try testing.expect(std.mem.startsWith(u8, records[1], try std.fmt.bufPrint(&want, "del {d} ", .{serial}))); // A write to its pty/data now finds no pane. try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_data), "x").errno()); _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = r.reply.handle }); _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = f }); } test "run on a command pane says no shell is there" { const gpa = testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const node = Node.of(serialOf(p), .pty_run); const pane = p.panes[0].?; pane.command = try gpa.dupe(u8, "make"); for ([_]struct { bool, []const u8 }{ .{ false, "error a command runs here, not a shell\n" }, .{ true, "error command done; not a shell\n" }, }) |case| { pane.command_done = case[0]; const o = call(p, .{ .tag = 1, .op = .open, .node = node }); _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = o.reply.handle, .data = "ls\n" }); try testing.expectEqualStrings(case[1], call(p, .{ .tag = 3, .op = .read, .node = node, .handle = o.reply.handle, .size = 64 }).bytes); _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = o.reply.handle }); } } test "a run's answer says cut when its output's start is gone, and reads a bounded tail" { const gpa = testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const node = Node.of(serialOf(p), .pty_run); const pane = p.panes[0].?; const sh = struct { fn put(pp: *Pardes, bytes: []const u8) void { pp.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); while (pp.nextEffect()) |_| {} } fn send(pp: *Pardes, n: u64) u32 { const h = call(pp, .{ .tag = 1, .op = .open, .node = n }).reply.handle; _ = call(pp, .{ .tag = 2, .op = .write, .node = n, .handle = h, .data = "x\n" }); return h; } /// The whole answer, then the open is closed. fn answered(pp: *Pardes, n: u64, h: u32, into: []u8) []const u8 { defer _ = call(pp, .{ .tag = 4, .op = .release, .node = n, .handle = h }); var len: usize = 0; while (true) { const a = call(pp, .{ .tag = 3, .op = .read, .node = n, .handle = h, .size = 8192 }); if (a.reply.status != .ok or a.bytes.len == 0) return into[0..len]; @memcpy(into[len..][0..a.bytes.len], a.bytes); len += a.bytes.len; } } }; const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07"; const c = "\r\n\x1b]133;C;aid=pardes\x07"; const d = "\x1b]133;D;0;aid=pardes\x07"; const into = try gpa.alloc(u8, 2 * output_cap); defer gpa.free(into); sh.put(p, prompt); // `clear` while it runs erases what it printed before, and the pins then // span rows that no longer hold it (ED 3 even moves one to the next page // without calling it garbage), so only the erase itself tells. var h = sh.send(p, node); sh.put(p, c ++ "before\r\n\x1b[H\x1b[2J\x1b[3Jafter\r\n" ++ d ++ prompt); var got = sh.answered(p, node, h, into); try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n")); // A resize between its start and end reflows the pins with the text. h = sh.send(p, node); sh.put(p, c ++ "one\r\ntwo\r\n"); pardes.panes.Terminal.resizeGrid(pane, gpa, 50, 20); sh.put(p, d ++ prompt); try testing.expectEqualStrings("exit 0\none\ntwo\n", sh.answered(p, node, h, into)); pardes.panes.Terminal.resizeGrid(pane, gpa, pane.cols, pane.rows); // A full-screen program leaves nothing on the primary screen, and a D // with no status is not a success. h = sh.send(p, node); sh.put(p, c ++ "\x1b[?1049hwhole screen\x1b[?1049l\x1b]133;D;aid=pardes\x07" ++ prompt); try testing.expectEqualStrings("exit ?\n", sh.answered(p, node, h, into)); // A megabyte of output: read from at most `output_rows` rows above its // end (the history still holds its start), the last 64 KiB kept. h = sh.send(p, node); sh.put(p, c); var line: [120]u8 = @splat('y'); line[118] = '\r'; line[119] = '\n'; for (0..(1 << 20) / line.len) |i| { _ = std.fmt.bufPrint(&line, "{d:0>8}", .{i}) catch unreachable; sh.put(p, &line); } try testing.expect(!pane.terminal.?.stream.handler.out_start.?.garbage); sh.put(p, d ++ prompt); got = sh.answered(p, node, h, into); try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n")); try testing.expect(got.len <= "exit 0 cut\n".len + output_cap + 1); try testing.expect(std.mem.endsWith(u8, got, "\n") and std.mem.indexOf(u8, got, "00008737") != null); // Its start pruned from a history held to its least: what survived, and // cut. { const q = try withTerm(gpa); defer q.deinit(); const kept = q.panes[0].?; kept.terminal.?.vt.screens.get(.primary).?.pages.explicit_max_size = 1; const run = Node.of(serialOf(q), .pty_run); sh.put(q, prompt); h = sh.send(q, run); sh.put(q, c); const lines = "pruned\r\n" ** 64; var fed: usize = 0; while (!kept.terminal.?.stream.handler.out_start.?.garbage) : (fed += 1) { if (fed == 1000) return error.TestUnexpectedResult; sh.put(q, lines); } sh.put(q, d ++ prompt); got = sh.answered(q, run, h, into); try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\npruned\n")); } // No memory for the answer's output is said, not passed off as a cut. h = sh.send(p, node); sh.put(p, c ++ "short\r\n"); var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); p.gpa = failing.allocator(); sh.put(p, d ++ prompt); p.gpa = gpa; try testing.expectEqualStrings("error out of memory\n", sh.answered(p, node, h, into)); } test "a terminal's ctl counts its body, and a new directory is a rename in the log" { const gpa = testing.allocator; const p = try withTerm(gpa); defer p.deinit(); const serial = serialOf(p); p.update(.{ .output = .{ .pane = 0, .bytes = "some history\r\nmore" } }); while (p.nextEffect()) |_| {} const opened = call(p, .{ .tag = 1, .op = .open, .node = Node.of(serial, .body) }); const body_len = call(p, .{ .tag = 2, .op = .read, .node = Node.of(serial, .body), .handle = opened.reply.handle, .size = 1 << 16 }).bytes.len; _ = call(p, .{ .tag = 3, .op = .release, .node = Node.of(serial, .body), .handle = opened.reply.handle }); try testing.expect(body_len > 0); var fields = std.mem.tokenizeScalar(u8, rd(p, Node.of(serial, .ctl), 0, 4096).bytes, ' '); _ = fields.next(); _ = fields.next(); try testing.expectEqual(body_len, try std.fmt.parseInt(usize, fields.next().?, 10)); const log = @intFromEnum(tree.TopFile.log); const f = call(p, .{ .tag = 4, .op = .open, .node = log }).reply.handle; const frozen = call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .size = 1 << 16 }).bytes.len; _ = call(p, .{ .tag = 6, .op = .write, .node = log, .handle = f, .data = "follow" }); p.setCwd(0, "/somewhere/else"); var want: [64]u8 = undefined; try testing.expectEqualStrings( try std.fmt.bufPrint(&want, "rename {d} /somewhere/else\n", .{serial}), call(p, .{ .tag = 7, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes, ); _ = call(p, .{ .tag = 8, .op = .release, .node = log, .handle = f }); } test "closing a terminal pane asks its shell to hang up the pty" { const p = try withTerm(testing.allocator); defer p.deinit(); _ = try th.newPane(p); const closed = th.rmdir(p, Node.of(serialOf(p), .dir)); try testing.expectEqual(Status.ok, closed.reply.status); try testing.expect(closed.closed); // A text pane has no shell to end. const text = try th.newPane(p); try testing.expect(!th.rmdir(p, Node.of(text, .dir)).closed); } 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.panes[0].?.fs.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); } test "a run's answer longer than its room is cut, its newline kept" { var slot: Run = .{}; const p = try th.withTerm(testing.allocator); defer p.deinit(); answer(p, &slot, "busy: {s} is running", .{"x" ** 200}); try testing.expectEqual(slot.answer.len, slot.len); try testing.expectEqual(@as(u8, '\n'), slot.answer[slot.len - 1]); } test "exec on a command pane is refused: a command pane does not restart" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const serial = th.serialOf(p); const pane = p.panes[p.paneBySerial(serial).?].?; pane.command = try p.gpa.dupe(u8, "echo hi"); const r = th.wr(p, tree.Node.of(serial, .pty_ctl), "exec\n"); try testing.expectEqual(tree.E.INVAL, r.errno()); try testing.expectEqualStrings("invalid exec: a command pane does not restart", r.reply.ename); } test "pty/run takes one line a run: two in one write, or a second before the first's answer is read, are refused" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const serial = th.serialOf(p); const run = tree.Node.of(serial, .pty_run); const h = th.call(p, .{ .tag = 1, .op = .open, .node = run, .omode = 2 }).reply.handle; const two = th.call(p, .{ .tag = 2, .op = .write, .node = run, .handle = h, .data = "true\nfalse\n" }); try testing.expectEqual(tree.E.INVAL, two.errno()); try testing.expectEqualStrings(e_one_line, two.reply.ename); // A finished run whose answer nobody read takes no next line. const slot = &tree.openOf(p, .{ .tag = 0, .op = .read, .node = run, .handle = h }).?.what.run; slot.phase = .done; slot.len = 7; try testing.expectEqual(tree.E.INVAL, th.call(p, .{ .tag = 3, .op = .write, .node = run, .handle = h, .data = "true\n" }).errno()); _ = th.call(p, .{ .tag = 4, .op = .release, .node = run, .handle = h, .opened = true }); }