//! The control tree served over 9P: its nodes, lookup, readdir and the //! dispatch that answers the engine (cloud9.fs.Server, configured in //! src/9p.zig). The backend contract (Req, Reply, Op, Status, E) is cloud9's, //! extended here with the editor's reply payload; fs.zig keeps host access //! and mounts. //! //! /README /index /status /look /exec /log /screen /listeners //! /pane/new an open makes a pane, a read names it //! /pane/ rmdir closes that pane //! /pane//{name,body,tag,ctl,addr,dot,limit,data,xdata,sel, //! dirty,mark,scroll,errors,event,look,exec,pty/} //! /os/... the host filesystem /src/... embedded sources (opt-in) const std = @import("std"); const cloud9 = @import("cloud9"); const pardes = @import("../pardes.zig"); const fs = @import("../fs.zig"); pub const events = @import("events.zig"); pub const pane = @import("pane.zig"); pub const addr = @import("addr.zig"); pub const ctl = @import("ctl.zig"); pub const cols = @import("cols.zig"); const layout = pardes.layout; pub const pty = @import("pty.zig"); pub const sources = @import("sources.zig"); pub const screen = @import("screen.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const MAX_PANES = pardes.MAX_PANES; pub const name_capacity: usize = 255; comptime { // The editor's engine (src/9p.zig) remembers names this long. std.debug.assert(@import("../9p.zig").editor.name_capacity == name_capacity); } // ---- the backend contract: cloud9.fs, with the editor's payload locator ---- pub const Op = cloud9.fs.Op; pub const Status = cloud9.fs.Status; pub const Req = cloud9.fs.Req; pub const E = cloud9.fs.E; /// Where a reply's bytes live. The engine ignores it; `Pardes.fsPayload` /// resolves it into the slice that travels beside the reply. pub const Payload = union(enum) { none, staged: u32, region: struct { pane: u8, serial: u32, off: u32, len: u32 }, }; pub const Reply = cloud9.fs.ReplyWith(Payload); /// 9P answers a failure with a string, not a number (man 5 error): the errno /// still travels, for clients that map it, but the string is what a person /// reads. acme names its refusals the same way, and these are its spellings /// (editors/acme/xfid.c:19) for the ones this tree shares. pub fn failText(tag: u64, errno: u16, text: []const u8) Reply { return .{ .tag = tag, .status = .err, .errno = errno, .ename = text }; } pub const e_bad_addr = "bad address syntax"; pub const e_bad_ctl = "ill-formed control message"; pub const e_bad_event = "bad event syntax"; /// A second open reading a file whose reads consume: rio's word for it /// (windows/rio/xfid.c:25), which 9ns turns into EBUSY. pub const e_in_use = "file in use"; /// A read held on a pane's event or pty/data that the pane closed under. /// acme says "window shut down" (xfid.c:1005), which 9ns reads as EIO; /// these words read as ENOENT, what every other file of a gone pane gives. pub const e_shut_down = "no such pane: its window shut"; /// Tremove closes a pane; nothing else in the tree is created or destroyed by /// the protocol, and wstat stays a truncation. Making a pane is an open of /// /pane/new, so Tcreate is refused everywhere, as it is in acme /// (editors/acme/fsys.c, fsyscreate) -- here rather than by the engine, so /// a create in a pane that has closed says the pane is gone (a shell's `>` /// to a closed pane's file creates when its lookup fails). pub const features: cloud9.fs.Features = .{ .remove = true, .create = true }; /// Whether a request must wait until no step is out in a syscall /// (`pardes.turn`): what would change a pane such a step still points into. /// Making a pane and rendering a screen do not -- every yield sits before /// the mutation of its own step, so the layout and the surface are whole /// under it -- and a step out there is never waiting on a write of its own, /// so what parks here is never the syscall's own request. pub fn needsQuiet(p: *Pardes, req: Req) bool { return switch (req.op) { .write => true, .setattr => truncates(req), .release => req.remove, .read => holdsPage(p, req), else => false, }; } /// A setattr that changes something: a truncate of a pane's file or a /// tag. `> exec` (a shell's truncating open) changes nothing, so it waits /// on nothing: through a mount it hung, as 9ns answers nothing else on the /// mount while it is out, and the editor's step may be out reading a pane's /// file through that mount, the quiet it waited for never coming. fn truncates(req: Req) bool { if (!req.truncate) return false; return switch (Node.target(req.node) orelse return false) { .pane => true, .col => |c| c.file == .tag, .top => |f| f == .tag, }; } /// `needsQuiet`, plus the open that makes a pane: what costs a frame and a /// wake of the editor. pub fn changesPane(p: *Pardes, req: Req) bool { return switch (req.op) { .write => true, .setattr => truncates(req), .open => req.node == @intFromEnum(TopFile.new), .release => req.remove or holdsLines(p, req) or holdsPage(p, req), .read => holdsPage(p, req), .lookup, .getattr, .readdir => false, }; } pub const out_reserve = 4 * 1024; // ---- opens ---- /// What one open holds from its open to its release, the way lib9p hangs a /// server's own state off each fid (`Fid.aux`) and acme keeps it on its Fid /// (editors/acme/dat.h:373-385). An open that holds something answers its /// record's index plus one as the handle; one that holds nothing answers 0. pub const Open = struct { /// The node it was opened on, which every use checks; 0 is a free record. node: u64 = 0, what: union(enum) { /// /screen's frame from the open, a terminal body's history from its /// first read. snapshot: ?[]const u8, log: events.LogOpen, run: pty.Run, /// An open of a pane's event, holding the pane scripted; `true` for /// the one that reads it. event: bool, /// The one open reading a pane's pty/data. pty_data, /// An open that may write a pane's ctl, and so hold its lock. ctl, /// An open of a file that takes lines of commands (look, exec, a /// ctl, tagexec): its `pending` and `results`. lines, /// An open writing a file pane's body, data or xdata: its writes are /// one undo step (pane.zig `joined_by`), another open's its own. writes, /// A listing (`/index`, `/layout`, `/recent`, `/commands`, `/status`, /// `/listeners`, a ctl opened to read) frozen at the open, as /// `/screen` is: reads in several chunks never splice two moments. frozen: []const u8, /// A /pager open past its directory line: the +Pager's serial, the /// text after that line held in `pending` until its read puts it /// in (ctl.fillPager), or its close hands it to the editor's step. pager: u32, } = .{ .snapshot = null }, /// What a write to a file of command lines left unfinished: a last line /// with no newline yet, or an Edit block still open. A write through a /// mount is cut at the 9P message size, so one `echo` of many lines may /// come as several writes, cut anywhere; the rest waits for the open's /// next write, or its release, and never runs as a fragment. pending: std.ArrayList(u8) = .empty, /// The panes this open's last write touched, which a read of look or /// exec on it answers, as /net/tcp/clone answers on the open that /// asked; a fresh open holds the session's last, from before it opened. results: Results = .{}, /// A read that found nothing yet (`.again`), kept to be answered when /// this open's file has something, the way factotum keeps its log's /// waiting reads and answers them on append (security/auth/factotum/ /// log.c:4-52) and acme an event read (editors/acme/xfid.c:994). One at a /// time, as acme's window keeps one `eventx`: a newer read takes the /// place of the older. The listener fills it in and answers it /// (src/9p_io.zig, `answerHeld`). held: ?Held = null, /// The last answer a stream open (a followed log, event, pty/data) gave, /// and the offset it was read at. A read at an offset inside it is /// answered from it again: bash's `read` takes a chunk, keeps a line and /// lseeks back to just past it, and a stream has already moved on. replay: ?Replay = null, /// A `name` open has named its pane: one name an open, so a second line /// on it (bash writes `printf 'a\nb\n'` a line at a time) is refused. named: bool = false, /// A line over 1 MiB was refused: what follows of it, through its /// newline, is dropped rather than run as a line of its own. discarding: bool = false, pub const Replay = struct { off: u64, bytes: []u8 }; pub const Results = struct { list: [pardes.MAX_PANES]u32 = undefined, len: u8 = 0, /// Written on: reads are a stream from the write on, whatever /// offset they come at, since a shell's `exec 3<>look` shares one /// offset between the write and the read (as pty/run does). wrote: bool = false, read: usize = 0, }; /// `asker` is the connection the read came on, which only the listener /// knows. pub const Held = struct { asker: *anyopaque, req: Req, ticket: cloud9.fs.Ticket }; pub fn deinit(o: *Open, gpa: std.mem.Allocator) void { switch (o.what) { .snapshot => |bytes| if (bytes) |b| gpa.free(b), .log => |log| gpa.free(log.bytes), .run => |run| { gpa.free(run.output); gpa.free(run.line); }, .frozen => |bytes| gpa.free(bytes), .event, .pty_data, .ctl, .lines, .writes, .pager => {}, } o.pending.deinit(gpa); if (o.replay) |r| gpa.free(r.bytes); o.* = .{}; } }; /// Records for 64 opens at once; one more that would hold something fails. pub const open_slots = 64; /// The record a request's handle names, when it was opened on that node. pub fn openOf(p: *Pardes, req: Req) ?*Open { if (req.handle == 0 or req.handle > p.fs.opens.len) return null; const o = &p.fs.opens[req.handle - 1]; return if (o.node == req.node) o else null; } // ---- nodes ---- pub const TopFile = enum(u5) { root = 1, README, index, status, look, exec, log, screen, listeners, pane, new, focus, ctl, commands, layout, tag, col, tagexec, /// The files opened lately, closed ones too, a line each: /// `open|closed `, most recent first. recent, /// `pardes -`'s: a directory written, its +Pager made or emptied; a /// read of the same open answers its serial. pager, pub fn fileName(f: TopFile) []const u8 { return if (f == .root) "/" else @tagName(f); } pub fn mode(f: TopFile) u16 { return switch (f) { .root, .pane, .col => 0o755, .look, .exec, .log, .focus, .ctl, .tag, .tagexec, .pager => 0o666, .README, .index, .status, .screen, .listeners, .new, .commands, .layout, .recent => 0o444, }; } pub fn dir(f: TopFile) bool { return f == .root or f == .pane or f == .col; } }; pub const root: u64 = @intFromEnum(TopFile.root); /// Declaration order is readdir order. pub const PaneFile = enum(u5) { dir = 0, name, body, tag, ctl, addr, dot, limit, data, xdata, sel, dirty, mark, scroll, errors, event, look, exec, /// A word run as a click in the pane's tag, as /tagexec is in the /// workspace's and col//exec in a column's. tagexec, pty, pty_ctl, pty_status, pty_data, pty_run, pub fn fileName(f: PaneFile) []const u8 { return switch (f) { .dir => ".", .pty_ctl => "ctl", .pty_status => "status", .pty_data => "data", .pty_run => "run", else => @tagName(f), }; } pub fn mode(f: PaneFile) u16 { return switch (f) { .dir, .pty => 0o755, .errors => 0o222, .pty_status => 0o444, else => 0o666, }; } pub fn isDir(f: PaneFile) bool { return f == .dir or f == .pty; } pub fn inPty(f: PaneFile) bool { return switch (f) { .pty, .pty_ctl, .pty_status, .pty_data, .pty_run => true, else => false, }; } }; /// /col/ and its tag: a column is named by its serial, which stays /// while it lives as a pane's does; this bit, past a pane serial's 32, keeps /// the two apart. pub const ColFile = enum(u5) { dir = 0, tag = 1, ctl = 2, exec = 3, pub fn fileName(f: ColFile) []const u8 { return @tagName(f); } }; const col_bit: u59 = 1 << 58; pub const Node = packed struct(u64) { file: u5 = 0, serial: u59 = 0, pub fn of(serial: u32, file: PaneFile) u64 { std.debug.assert(serial != 0); return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); } pub fn ofCol(serial: u32, file: ColFile) u64 { return @bitCast(Node{ .file = @intFromEnum(file), .serial = col_bit | @as(u59, serial) }); } pub fn target(node: u64) ?Target { const n: Node = @bitCast(node); if (n.serial == 0) { return .{ .top = std.enums.fromInt(TopFile, n.file) orelse return null }; } if (n.serial & col_bit != 0) return .{ .col = .{ .serial = std.math.cast(u32, n.serial & ~col_bit) orelse return null, .file = std.enums.fromInt(ColFile, n.file) orelse return null, } }; return .{ .pane = .{ .serial = std.math.cast(u32, n.serial) orelse return null, .file = std.enums.fromInt(PaneFile, n.file) orelse return null, } }; } }; pub const Target = union(enum) { top: TopFile, pane: struct { serial: u32, file: PaneFile }, col: struct { serial: u32, file: ColFile }, }; /// Opening /pane/new makes a pane and reading the open fid answers its /// serial, which is /net/tcp/clone's mechanism exactly (kernel/network/ip/ /// devip.c, `case Qclone` in ipopen, whose Qctl read prints the number it /// allocated). acme spells the same idea as a directory made by the walk /// (editors/acme/fsys.c:481), which would be the nicer `new/body`, but a /// Plan 9 directory read carries every entry's stat and a kernel or FUSE /// mount instead walks each name it listed: allocating on the walk would /// make a pane every time someone ran `ls -l`. A stat is not an open, so /// this file can be listed. Panes are named by their serial, so the name /// can never collide with one. pub const new_pane = "new"; pub fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { var best: ?u32 = null; for (p.panes) |slot| { const pn = slot orelse continue; if (pn.serial <= after) continue; if (best == null or pn.serial < best.?) best = pn.serial; } return best; } /// An editor path below the root (`pane/3/body`, `index`, `src/...`) to its node. pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { var path_buf: [4096]u8 = undefined; const normalized = fs.normalizeVirtualPath(path, &path_buf) orelse return null; var parts = std.mem.tokenizeScalar(u8, normalized, '/'); const first = parts.next() orelse return root; if (!std.mem.eql(u8, first, "pane")) { // `new` is reached in /pane, where the panes it makes are. const top = std.meta.stringToEnum(TopFile, first) orelse return sources.node(normalized); if (top == .root or top == .new or top == .pane or parts.next() != null) return null; return @intFromEnum(top); } const next = parts.next() orelse return @intFromEnum(TopFile.pane); // `new` is served on the wire but deliberately unreachable from an editor // path: this resolves Look targets and hover previews, and a preview that // opened `new` to see what was there would make a pane per hover. if (std.mem.eql(u8, next, new_pane)) return null; // A pane directory is its serial, in decimal digits and never 0. if (next.len > 10) return null; for (next) |c| if (c < '0' or c > '9') return null; const serial = std.fmt.parseInt(u32, next, 10) catch return null; if (serial == 0) return null; const id = p.paneBySerial(serial) orelse return null; // The pty files are reached inside pty/, not beside it. const file = std.meta.stringToEnum(PaneFile, parts.next() orelse return Node.of(serial, .dir)) orelse return null; if (file == .dir or (file.inPty() and file != .pty)) return null; if (file.inPty() and !p.panes[id].?.isTerminal()) return null; if (parts.next()) |name| { if (file != .pty) return null; const child = pty.fileNamed(name) orelse return null; if (parts.next() != null) return null; return Node.of(serial, child); } return Node.of(serial, file); } // ---- staging ---- pub fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void { if (name.len == 0 or name.len > 255) return; var head: [10]u8 = undefined; std.mem.writeInt(u64, head[0..8], node, .little); head[8] = @intFromBool(dir); head[9] = @intCast(name.len); out.appendSlice(gpa, &head) catch return; out.appendSlice(gpa, name) catch return; } /// Answers a read from whatever the handler staged, windowed by offset and size. pub fn stagedReply(p: *Pardes, req: Req) Reply { const out = &p.fs.out; const off = @min(req.off, out.items.len); const n = @min(out.items.len - off, req.size); if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]); out.shrinkRetainingCapacity(n); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; } // ---- dispatch ---- pub fn handle(p: *Pardes, req: Req) Reply { var reply = serve(p, req); if (reply.status == .err and reply.ename.len == 0) reply.ename = errWords(reply.errno); if (reply.status == .err and reply.ename.len > cloud9.fs.errmax) if (p.scratch.allocator().alloc(u8, cloud9.fs.errmax)) |room| { reply.ename = fitErr(reply.ename, room); } else |_| {}; return reply; } /// A reason longer than an Rerror carries (Plan 9's ERRMAX, 128 bytes) /// keeps its end, which says why: the longest path in it -- else the whole /// text -- gives up its middle to `…`. pub fn fitErr(text: []const u8, out: []u8) []const u8 { const ell = "…"; if (text.len <= out.len) return text; const over = text.len - out.len + ell.len; var from: usize = 0; var to: usize = text.len; var words = std.mem.tokenizeAny(u8, text, " \t"); var longest: usize = 0; while (words.next()) |w| if (std.mem.indexOfScalar(u8, w, '/') != null and w.len > longest and w.len > over + 2) { longest = w.len; from = @intFromPtr(w.ptr) - @intFromPtr(text.ptr); to = from + w.len; }; var a = from + (to - from - over) / 2; var b = a + over; while (a > from and text[a] & 0xC0 == 0x80) a -= 1; while (b < to and text[b] & 0xC0 == 0x80) b += 1; return std.fmt.bufPrint(out, "{s}" ++ ell ++ "{s}", .{ text[0..a], text[b..] }) catch text[0..out.len]; } test "a reason past 128 bytes keeps its end: the path in it gives up its middle" { var out: [128]u8 = undefined; const long = "Save /home/someone/projects/" ++ "deep/" ** 30 ++ "file.txt: no such directory"; const fit = fitErr(long, &out); try testing.expect(fit.len <= 128); try testing.expect(std.mem.startsWith(u8, fit, "Save /home/someone/")); try testing.expect(std.mem.endsWith(u8, fit, "file.txt: no such directory")); try testing.expect(std.mem.indexOf(u8, fit, "…") != null); try testing.expect(std.unicode.utf8ValidateSlice(fit)); // No path: the whole text's middle goes, the end stays. const words = "why " ** 40 ++ "the reason"; try testing.expect(std.mem.endsWith(u8, fitErr(words, &out), "the reason")); try testing.expectEqualStrings("short", fitErr("short", &out)); // Through the tree: a look at a long ./ name not there, a miss, says // why at the end of its err record, the name giving up its middle. const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); var line: [512]u8 = undefined; const r = th.wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "./{s}x.txt\n", .{"no-such-dir/" ** 20})); try testing.expectEqual(Status.ok, r.reply.status); try testing.expect(th.logHas(p, "x.txt: no such file")); } /// A refusal with no reason of its own said in Plan 9's words, not the C /// library's (`Operation not permitted`); each maps back to its errno in /// 9ns (enameToErrno), EPERM's to EACCES as Plan 9's does. pub fn errWords(errno: u16) []const u8 { return switch (errno) { E.PERM => "permission denied", E.NOENT => "file does not exist", E.NOMEM => "out of memory", E.BUSY => "file in use", E.EXIST => "file already exists", E.NOTDIR => "not a directory", E.ISDIR => "is a directory", E.INVAL => "bad argument", E.NFILE => "too many open files", E.NOSPC => "no space left", E.NOSYS => "not supported", E.NOTEMPTY => "directory not empty", else => "i/o error", }; } fn serve(p: *Pardes, req: Req) Reply { // Held writes go in before anything but the next write of their open. if (p.fs.batch.bytes.items.len > 0 and !(req.op == .write and req.node == p.fs.batch.node and req.handle == p.fs.batch.handle)) pane.flushBatch(p); if (req.op == .open and req.create) { if (Node.target(req.node)) |t| if (t == .pane and p.paneBySerial(t.pane.serial) == null) return Reply.fail(req.tag, E.NOENT); return Reply.fail(req.tag, E.PERM); } const host = req.node == fs.os_root or req.node & fs.os_node != 0; const archive = req.node & sources.archive_node != 0; // Only a pane directory is removed. The host tree and the embedded // sources say so, rather than quietly doing nothing. if ((host or archive) and req.remove) return Reply.fail(req.tag, E.PERM); if (host) return fs.osHandle(p, req); if (archive) return sources.handle(p, req); const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), .top, .col => 'F', }; return switch (req.op) { .lookup => lookup(p, req, target), .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse runAttr(p, req, target) orelse return Reply.fail(req.tag, E.NOENT) }, .setattr => setattr(p, req, target), .open => open(p, req, target), .release => release(p, req), .readdir => readdir(p, req, target), .read => read(p, req, target), .write => write(p, req, target), }; } /// A run's answer outlives its pane, so its file still stats while a run /// is open on it (a reader's fstat, cat's), as acme's fid keeps its window. /// The stat names the open when it came on one; a mount's comes on the /// file's walked name, and serials are never reused, so any run open on /// this node is the reader's. fn runAttr(p: *Pardes, req: Req, target: Target) ?Reply.Attr { const t = switch (target) { .pane => |t| t, .top, .col => return null, }; if (t.file != .pty_run) return null; const run = if (req.handle != 0) openOf(p, req) else for (&p.fs.opens) |*o| { if (o.node == req.node and o.what == .run) break o; } else null; if ((run orelse return null).what != .run) return null; return .{ .name = t.file.fileName(), .node = Node.of(t.serial, t.file), .mode = t.file.mode(), .mtime = p.fs.started }; } fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { switch (target) { .top => |f| return .{ .name = f.fileName(), .node = @intFromEnum(f), .dir = f.dir(), .mode = f.mode(), .size = topSize(p, f), .mtime = p.fs.started, .append = f == .tag, }, .col => |c| { if (layout.columnBySerial(p, c.serial) == null) return null; return .{ // unreachable: a u32 serial fits node_name (16) .name = if (c.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{c.serial}) catch unreachable) else c.file.fileName(), .node = Node.ofCol(c.serial, c.file), .dir = c.file == .dir, .mode = switch (c.file) { .dir => 0o755, .ctl => 0o222, .tag, .exec => 0o666, }, .size = switch (c.file) { .tag => cols.headerText(p, c.serial).?.len, .exec => ctl.resultsLen(p), else => 0, }, .mtime = p.fs.started, .append = c.file == .tag, }; }, .pane => |t| { const id = p.paneBySerial(t.serial) orelse return null; const pn = p.panes[id].?; if (t.file.inPty() and !pn.isTerminal()) return null; return .{ // unreachable: a u32 serial fits node_name (16) .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.fileName(), .node = Node.of(t.serial, t.file), .dir = t.file.isDir(), .mode = t.file.mode(), .size = pane.fileSize(p, id, t.file), .mtime = if (pn.file) |*f| (if (f.mtime != 0) f.mtime else p.fs.started) else p.fs.started, // The qid version, which a client polls to watch a file for // change. Only the pane's text can promise one: its revision // counts every edit, and nothing else here changes in step. .version = switch (t.file) { .body, .data, .xdata => if (pn.file) |*f| f.revision else 0, else => 0, }, // A write to body or tag appends whatever its offset says, // which is why acme's dirtab marks both DMAPPEND // (editors/acme/fsys.c:78). .append = t.file == .body or t.file == .tag, }; }, } } fn topSize(p: *Pardes, f: TopFile) u64 { return switch (f) { // Views generated whole by each read have no length, as acme's // generated files have none: /screen until an open renders its // frame, /pane/new until an open has a pane to name, and the index, // layout, recent list and listeners, which a stat would otherwise // have to build to count. .root, .pane, .col, .screen, .new, .layout, .index, .recent, .listeners => 0, .tag => cols.headerText(p, null).?.len, .README => fs.help.len, .status => ctl.statusLen(p), .look, .exec, .tagexec, .pager => ctl.resultsLen(p), // A stream: a length would read as its end to a kernel that caches // one (a 9ns mount's splice), and a follower's reads go past it. .log => 0, .ctl => ctl.rootLen(p), .commands => ctl.commandsLen(), .focus => focus: { if (p.header_focus) break :focus 0; const pn = p.panes[p.active] orelse break :focus 0; var digits: [16]u8 = undefined; // unreachable: a u32 serial and a newline fit 16 break :focus (std.fmt.bufPrint(&digits, "{d}\n", .{pn.serial}) catch unreachable).len; }, }; } fn lookup(p: *Pardes, req: Req, target: Target) Reply { const name = req.data; if (std.mem.eql(u8, name, "..")) { const parent: u64 = switch (target) { .top => |top| if (top.dir()) root else return Reply.fail(req.tag, E.NOTDIR), .pane => |t| switch (t.file) { .dir => @intFromEnum(TopFile.pane), .pty => Node.of(t.serial, .dir), else => return Reply.fail(req.tag, E.NOTDIR), }, .col => |c| if (c.file == .dir) @intFromEnum(TopFile.col) else return Reply.fail(req.tag, E.NOTDIR), }; return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent }); } if (name.len == 0 or std.mem.indexOfAny(u8, name, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); const node: u64 = switch (target) { .top => |f| switch (f) { .root => root: { if (std.mem.eql(u8, name, "os")) return fs.osHandle(p, .{ .tag = req.tag, .op = .getattr, .node = fs.os_root }); // `new` is reached in /pane, where the panes it makes are. if (std.meta.stringToEnum(TopFile, name)) |t| if (t != .root and t != .new) break :root @intFromEnum(t); if (sources.node(name)) |n| return sources.handle(p, .{ .tag = req.tag, .op = .getattr, .node = n }); return Reply.fail(req.tag, E.NOENT); }, .pane => pane: { if (std.mem.eql(u8, name, new_pane)) break :pane @intFromEnum(TopFile.new); // A pane directory is its serial, in decimal digits and never 0. if (name.len > 10) return Reply.fail(req.tag, E.NOENT); for (name) |c| if (c < '0' or c > '9') return Reply.fail(req.tag, E.NOENT); const serial = std.fmt.parseInt(u32, name, 10) catch return Reply.fail(req.tag, E.NOENT); if (serial == 0) return Reply.fail(req.tag, E.NOENT); break :pane Node.of(serial, .dir); }, // A column is its serial, in decimal digits and never 0. .col => col: { if (name.len > 10) return Reply.fail(req.tag, E.NOENT); for (name) |c| if (c < '0' or c > '9') return Reply.fail(req.tag, E.NOENT); const serial = std.fmt.parseInt(u32, name, 10) catch return Reply.fail(req.tag, E.NOENT); if (serial == 0) return Reply.fail(req.tag, E.NOENT); break :col Node.ofCol(serial, .dir); }, else => return Reply.fail(req.tag, E.NOTDIR), }, .col => |c| col: { if (c.file != .dir) return Reply.fail(req.tag, E.NOTDIR); const f = std.meta.stringToEnum(ColFile, name) orelse return Reply.fail(req.tag, E.NOENT); if (f == .dir) return Reply.fail(req.tag, E.NOENT); break :col Node.ofCol(c.serial, f); }, .pane => |t| pane: { _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const f = switch (t.file) { .dir => if (std.meta.stringToEnum(PaneFile, name)) |f| (if (f == .dir or (f.inPty() and f != .pty)) null else f) else null, .pty => pty.fileNamed(name), else => return Reply.fail(req.tag, E.NOTDIR), } orelse return Reply.fail(req.tag, E.NOENT); break :pane Node.of(t.serial, f); }, }; const found = Node.target(node) orelse return Reply.fail(req.tag, E.NOENT); return .{ .tag = req.tag, .attr = attrOf(p, found) orelse return Reply.fail(req.tag, E.NOENT) }; } fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { inline for (comptime std.enums.values(PaneFile)) |f| { if (comptime f == .dir or (f.inPty() and f != .pty)) continue; const present = f != .pty or terminal; if (present) { if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.fileName()); } } } fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { inline for (comptime std.enums.values(PaneFile)) |f| { if (comptime !f.inPty() or f == .pty) continue; if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.fileName()); } } fn readdir(p: *Pardes, req: Req, target: Target) Reply { const out = p.fs.stage(p.gpa); var skip = req.off; switch (target) { .top => |f| switch (f) { .root => { // The guide, then what describes the session, then the // files that act, then the directories. inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.recent, TopFile.look, TopFile.exec, TopFile.pager, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); } if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); sources.stage(p, out, "", &skip); }, .pane => { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(TopFile.new), false, TopFile.new.fileName()); var last: u32 = 0; while (nextSerialAfter(p, last)) |serial| { last = serial; if (skip > 0) { skip -= 1; continue; } var buf: [16]u8 = undefined; // unreachable: a u32 serial fits 16 const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable; stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); } }, .col => for (0..p.ncol) |c| { if (skip > 0) { skip -= 1; continue; } var buf: [16]u8 = undefined; const serial = layout.columnSerial(p, c); // unreachable: a u32 serial fits 16 stageDirent(out, p.gpa, Node.ofCol(serial, .dir), true, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable); }, else => return Reply.fail(req.tag, E.NOTDIR), }, .col => |c| { if (layout.columnBySerial(p, c.serial) == null) return Reply.fail(req.tag, E.NOENT); if (c.file != .dir) return Reply.fail(req.tag, E.NOTDIR); inline for (.{ ColFile.tag, ColFile.ctl, ColFile.exec }) |f| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, Node.ofCol(c.serial, f), false, f.fileName()); } }, .pane => |t| { const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const terminal = p.panes[id].?.isTerminal(); switch (t.file) { .dir => stagePaneFiles(p, out, t.serial, terminal, &skip), .pty => { if (!terminal) return Reply.fail(req.tag, E.NOENT); stagePtyFiles(p, out, t.serial, &skip); }, else => return Reply.fail(req.tag, E.NOTDIR), } }, } return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } fn open(p: *Pardes, req: Req, target: Target) Reply { var pane_of: ?*Pane = null; const what: @FieldType(Open, "what") = switch (target) { .top => |f| switch (f) { // The made pane's serial is the handle: the fid remembers which // pane it made, so reading it twice answers the same one and // closing it leaves the pane alone. .new => { const slot = p.freeSlot() orelse return failText(req.tag, E.NOSPC, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no space for a pane"); p.fs.no_pane_slot = false; // Refused, the open's err is the one record of it (the // one rule): no msg besides. p.fs.capturing = true; p.newScratchFor(p.active, .fs); p.fs.capturing = false; p.fs.failure_len = 0; const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); // No room in its column: it closes at the step's end. if (p.unplaced.isSet(slot)) return failText(req.tag, E.NOSPC, p.fs.ename[0..p.fs.no_pane_slot_len]); return .{ .tag = req.tag, .handle = made.serial }; }, .screen => .{ .snapshot = null }, .log => .{ .log = .{} }, .index, .layout, .recent, .commands, .status, .listeners => .{ .frozen = &.{} }, // The root's ctl opened only to read: its settings, frozen. .ctl => if (linesOpen(target, req)) .lines else .{ .frozen = &.{} }, else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag }, }, .col => |c| if (layout.columnBySerial(p, c.serial) == null) return Reply.fail(req.tag, E.NOENT) else if (linesOpen(target, req)) .lines else if (c.file == .ctl) .{ .frozen = &.{} } else return .{ .tag = req.tag }, .pane => |t| what: { const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); pane_of = pn; const reader = req.omode & 3 != 1; // OWRITE is the one mode that never reads break :what switch (t.file) { .body => if (pn.isTerminal()) .{ .snapshot = null } else if (req.omode & 3 == 0) return .{ .tag = req.tag } else .writes, // sel too: one open's writes run on from its last (wrsel). .data, .xdata, .sel => if (req.omode & 3 != 0) .writes else return .{ .tag = req.tag }, // Reads of these consume, so two readers would each see half // the stream; the second is refused rather than robbed. .event => if (reader and pn.fs.event_reader) return failText(req.tag, E.BUSY, e_in_use) else .{ .event = reader }, .pty_data => if (!reader) return .{ .tag = req.tag } else if (pn.fs.pty_reader) return failText(req.tag, E.BUSY, e_in_use) else .pty_data, .pty_run => .{ .run = .{} }, .ctl => switch (req.omode & 3) { 1, 2 => .ctl, // OWRITE, ORDWR else => return .{ .tag = req.tag }, }, else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag }, }; }, }; const i = for (p.fs.opens, 0..) |o, i| { if (o.node == 0) break i; } else return Reply.fail(req.tag, E.NFILE); const o = &p.fs.opens[i]; o.* = .{ .node = req.node, .what = what }; if (resultsFile(target)) { @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]); o.results.len = p.fs.results_len; } switch (o.what) { // A screen freezes at open; a terminal body at its first read. .snapshot => |*bytes| if (pane_of == null) { bytes.* = screen.render(p) catch { o.* = .{}; return Reply.fail(req.tag, E.NOMEM); }; }, .log => |*log| log.* = events.freezeLog(p) catch { o.* = .{}; return Reply.fail(req.tag, E.NOMEM); }, .frozen => |*bytes| { const whole = readFile(p, .{ .tag = req.tag, .op = .read, .node = req.node, .size = 1 << 22 }, target); bytes.* = if (whole.status == .ok) p.gpa.dupe(u8, p.fsPayload(whole)) catch { o.* = .{}; return Reply.fail(req.tag, E.NOMEM); } else &.{}; }, .event => |reader| { const pn = pane_of.?; pn.fs.readers +|= 1; p.fs.listeners +|= 1; pn.fs.event_reader = pn.fs.event_reader or reader; }, .pty_data => pane_of.?.fs.pty_reader = true, .run, .ctl, .lines, .writes, .pager => {}, } return .{ .tag = req.tag, .handle = @intCast(i + 1) }; } /// Tremove closes a pane. Nothing else in the tree can be removed, and this /// is also where an ORCLOSE open lands, so every node is checked here. fn remove(p: *Pardes, req: Req) Reply { const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); const t = switch (target) { .pane => |t| t, .top => return Reply.fail(req.tag, E.PERM), .col => |c| return cols.remove(p, req, c.serial, c.file), }; if (t.file != .dir) return Reply.fail(req.tag, E.PERM); const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); p.removePane(id, null) catch return Reply.fail(req.tag, E.IO); return .{ .tag = req.tag }; } fn release(p: *Pardes, req: Req) Reply { // The handle's bookkeeping runs whether or not the removal is allowed. releaseHandle(p, req); return if (req.remove) remove(p, req) else .{ .tag = req.tag }; } /// Gives up what the open held, a read held on it included: a hangup pays /// its releases before the connection's slot is reused, so none outlives /// the connection it came on. fn releaseHandle(p: *Pardes, req: Req) void { const t = Node.target(req.node).?; // A truncating write to `tag` is over when its open goes (pane.zig // writeTag): a newline it held back is dropped. if (t == .pane and t.pane.file == .tag) if (p.paneBySerial(t.pane.serial)) |id| { pane.applyTagTruncation(p, p.panes[id].?); p.panes[id].?.fs.tag_rewrite = false; p.panes[id].?.fs.tag_held_newline = false; }; // The writes of one open are one undo step (pane.zig `joined`): its // close ends that step. // A sel open's run of writes ends with it. if (t == .pane and t.pane.file == .sel) if (p.paneBySerial(t.pane.serial)) |id| { const pf = &p.panes[id].?.fs; if (pf.wrsel_by == req.handle) pf.wrsel_by = 0; }; if (t == .pane and (t.pane.file == .body or t.pane.file == .data or t.pane.file == .xdata)) if (p.paneBySerial(t.pane.serial)) |id| { const pf = &p.panes[id].?.fs; if (pf.joined_by == 0 or pf.joined_by == req.handle) pf.joined = null; }; // `: > limit` clears it for good once its open goes. if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| { p.panes[id].?.fs.limit_truncated = null; }; // `: > dot` empties it once its open goes, no write having set it. if (t == .pane and t.pane.file == .dot) if (p.paneBySerial(t.pane.serial)) |id| { const pn = p.panes[id].?; if (pn.fs.dot_truncated) pane.setDot(pn, .{}); pn.fs.dot_truncated = false; }; if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial); if (t == .top and t.top == .tag) cols.released(p, null); const o = openOf(p, req) orelse return; // A /pager open's text, not read for, goes in at the editor's next // step (ctl.fillClosedPagers): this close need not wait for quiet. if (o.what == .pager and o.pending.items.len > 0) { p.fs.pages.append(p.gpa, .{ .serial = o.what.pager, .bytes = o.pending }) catch o.pending.deinit(p.gpa); o.pending = .empty; } // The last line an open wrote with no newline after it runs at the // editor's next step (runClosedLines), as a /pager open's text does. if (o.pending.items.len > 0) { p.fs.closed_lines.append(p.gpa, .{ .node = req.node, .bytes = o.pending }) catch o.pending.deinit(p.gpa); o.pending = .empty; } const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null; if (of) |pn| switch (o.what) { .event => |reader| { if (reader) pn.fs.event_reader = false; pn.fs.readers -|= 1; p.fs.listeners -|= 1; if (pn.fs.readers == 0) pn.fs.tag_snap.clearAndFree(p.gpa); }, .pty_data => { pn.fs.pty_reader = false; pn.fs.pty_out.clearAndFree(p.gpa); }, // The command runs on in the shell; nobody is waiting for it any more. .run => if (pn.fs.run == req.handle) { pn.fs.run = null; }, // Closing the open that holds the lock gives it up, as acme's clunk // of its ctlfid does (editors/acme/xfid.c:211). .ctl => if (pn.fs.lock == req.handle) { pn.fs.lock = null; }, .snapshot, .log, .lines, .writes, .frozen, .pager => {}, }; o.deinit(p.gpa); } fn setattr(p: *Pardes, req: Req, target: Target) Reply { // A rename (`mv`) of a pane's directory, a column's or any file here: // the tree names them, not the client. Refused as not permitted // (EPERM), not taken and then lost, which a mount read as EIO. if (req.set.name) return failText(req.tag, E.PERM, "rename not permitted: the tree names its files (a pane's name file renames its buffer)"); if (req.truncate) switch (target) { .pane => |t| { const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); if ((t.file == .data or t.file == .xdata) and p.panes[id].?.fs.addr_failed) return failText(req.tag, E.INVAL, pane.e_addr_failed); if (pane.truncate(p, p.panes[id].?, t.file) != .ok) return Reply.fail(req.tag, E.NOMEM); }, .col => |c| if (c.file == .tag and cols.truncate(p, c.serial) != .ok) return Reply.fail(req.tag, E.NOENT), .top => |f| if (f == .tag and cols.truncate(p, null) != .ok) return Reply.fail(req.tag, E.NOMEM), }; return .{ .tag = req.tag, .attr = attrOf(p, target) orelse return Reply.fail(req.tag, E.NOENT) }; } fn read(p: *Pardes, req: Req, target: Target) Reply { // A /pager open's text goes in before its read answers the pane. if (openOf(p, req)) |o| if (o.what == .pager) ctl.fillPager(p, o.what.pager, &o.pending) catch return Reply.fail(req.tag, E.NOMEM); // A listing reads what it was at the open. if (openOf(p, req)) |o| if (o.what == .frozen) { const bytes = o.what.frozen; const off = @min(req.off, bytes.len); const n = @min(bytes.len - off, req.size); p.fs.stage(p.gpa).appendSlice(p.gpa, bytes[off..][0..n]) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; }; const stream = if (openOf(p, req)) |o| switch (o.what) { .log => o, .event => |reader| if (reader) o else null, .pty_data => o, else => null, } else null; const s = stream orelse return readFile(p, req, target); // Strictly inside: a client that reads every record at one offset (the // offset is ignored on a stream, as in Plan 9) always gets new ones. if (s.replay) |r| if (req.off > r.off and req.off < r.off + r.bytes.len) { const from = r.bytes[@intCast(req.off - r.off)..]; const n = @min(from.len, req.size); p.fs.stage(p.gpa).appendSlice(p.gpa, from[0..n]) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; }; const reply = readFile(p, req, target); if (reply.status != .ok) return reply; const bytes = p.fsPayload(reply); if (bytes.len == 0) return reply; const kept = p.gpa.dupe(u8, bytes) catch return reply; if (s.replay) |r| p.gpa.free(r.bytes); s.replay = .{ .off = req.off, .bytes = kept }; return reply; } fn readFile(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { .README => help: { const off = @min(req.off, fs.help.len); const bytes = fs.help[off..][0..@min(req.size, fs.help.len - off)]; p.fs.stage(p.gpa).appendSlice(p.gpa, bytes) catch break :help Reply.fail(req.tag, E.NOMEM); break :help .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; }, .index => index: { const out = p.fs.stage(p.gpa); var last: u32 = 0; while (nextSerialAfter(p, last)) |serial| { last = serial; const id = p.paneBySerial(serial).?; const pn = p.panes[id].?; // A name as /log shows it: one line of UTF-8, a // newline in it `\n` (events.shown). var name_buf: [4 * 4096]u8 = undefined; const name = events.shown(pane.nameOf(p, pn), &name_buf); out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), name, pane.columnOf(p, id) }) catch {}; } break :index stagedReply(p, req); }, .status => ctl.readStatus(p, req), .look, .exec, .tagexec, .pager => ctl.readResults(p, req), .log => events.readLog(p, req), .screen => screen.readSnapshot(p, req, null), .listeners => screen.readListeners(p, req), .focus => ctl.readFocus(p, req), .ctl => ctl.readRoot(p, req), .commands => ctl.readCommands(p, req), .recent => recent: { var text: std.Io.Writer.Allocating = .init(p.scratch.allocator()); pardes.panes.Output.recentText(p, &text.writer, false) catch break :recent Reply.fail(req.tag, E.NOMEM); p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch break :recent Reply.fail(req.tag, E.NOMEM); break :recent stagedReply(p, req); }, .layout => layout: { var text: std.Io.Writer.Allocating = .init(p.scratch.allocator()); cols.writeLayout(p, &text.writer) catch break :layout Reply.fail(req.tag, E.NOMEM); p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch break :layout Reply.fail(req.tag, E.NOMEM); break :layout stagedReply(p, req); }, .tag => cols.read(p, req, null), // The serial the open handed this fid, so that two reads of one // fid answer the same pane: the read observes, the open acted. .new => serial: { if (req.handle == 0) break :serial Reply.fail(req.tag, E.IO); p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{req.handle}) catch break :serial Reply.fail(req.tag, E.NOMEM); break :serial stagedReply(p, req); }, .root, .pane, .col => Reply.fail(req.tag, E.PERM), }, .pane => |t| { // A run's answer outlives the pane it ran in. if (t.file == .pty_run and req.handle != 0) return pty.readRun(p, req); const id = p.paneBySerial(t.serial) orelse { // An event reader whose pane closed reads its end: there // are no more records, as a pipe's writer gone is EOF, so // `while read` and `cat` end cleanly. A pty/data read the // pane closed under keeps acme's answer (xfid.c:1005). if (openOf(p, req)) |o| { if (o.what == .event) return .{ .tag = req.tag }; if (o.what == .pty_data) return failText(req.tag, E.NOENT, e_shut_down); } return Reply.fail(req.tag, E.NOENT); }; const pn = p.panes[id].?; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); return pane.read(p, req, id, pn, t.file); }, .col => |c| return switch (c.file) { .tag => cols.read(p, req, c.serial), .exec => ctl.readResults(p, req), else => Reply.fail(req.tag, E.PERM), }, } } /// The files whose writes are lines of commands, run as each line is whole. fn linesFile(target: Target) bool { return switch (target) { .top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec or f == .pager, .col => |c| c.file == .ctl or c.file == .exec, // `name` too: one name, however its writes cut it, applied once // its newline comes, or with its write when that is whole. .pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec or t.file == .name, }; } /// The ones of those a read answers with the panes a write touched. fn resultsFile(target: Target) bool { return switch (target) { .top => |f| f == .look or f == .exec or f == .tagexec or f == .pager, .col => |c| c.file == .exec, .pane => |t| t.file == .look or t.file == .exec or t.file == .tagexec, }; } /// An open that keeps lines or results: any that may write one of those /// files, and any that may read a look or exec. fn linesOpen(target: Target, req: Req) bool { if (!linesFile(target)) return false; return req.omode & 3 != 0 or resultsFile(target); } /// The editor's step: the last lines of closed opens run, each as a write /// of its own; a refusal is the log's err, as no open is left to answer. pub fn runClosedLines(p: *Pardes) void { for (p.fs.closed_lines.items) |*line| { defer line.bytes.deinit(p.gpa); const t = Node.target(line.node) orelse continue; const w: Req = .{ .tag = 0, .op = .write, .node = line.node, .data = line.bytes.items }; const reply = writeNow(p, w, t); if (reply.status == .err) events.noteError(p, w, reply); } p.fs.closed_lines.clearRetainingCapacity(); } /// A close that hands its open's held text, a last line or a /pager's /// page, to the editor's step: answered at once, never waiting on it. pub fn handsOff(p: *Pardes, req: Req) bool { return (req.op == .release and holdsLines(p, req)) or holdsPage(p, req); } /// A release that will run a line its open held back. fn holdsLines(p: *Pardes, req: Req) bool { const o = openOf(p, req) orelse return false; return o.pending.items.len > 0 and o.what != .pager; } /// A line held back longer than this is refused rather than kept growing. const pending_cap = 1 << 20; /// The ctl files, whose lines are commands as look's and exec's are. fn ctlFile(target: Target) bool { return switch (target) { .top => |f| f == .ctl, .col => |c| c.file == .ctl, .pane => |t| t.file == .ctl, }; } /// /pager on an open: its first line names the directory and runs as any /// line does; what follows it, in that write and the open's later ones, is /// a program's output, escapes and all, held whole and never run as lines. fn write(p: *Pardes, req: Req, target: Target) Reply { const o = (if (target == .top and target.top == .pager) openOf(p, req) else null) orelse return writeLines(p, req, target); if (o.what == .pager) { if (o.pending.items.len + req.data.len > pager_cap) return failText(req.tag, E.NOSPC, "pager: no space: the text is over a file's limit"); o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } const nl = std.mem.indexOfScalar(u8, req.data, '\n') orelse return writeLines(p, req, target); var head = req; head.data = req.data[0 .. nl + 1]; const reply = writeLines(p, head, target); if (reply.status != .ok or o.results.len == 0) return reply; o.what = .{ .pager = o.results.list[0] }; o.pending.clearRetainingCapacity(); o.pending.appendSlice(p.gpa, req.data[nl + 1 ..]) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } /// The most a /pager open holds: a file's limit, and `pardes -`'s note /// that its stdin was cut there. const pager_cap = @import("../memory.zig").limits.max_file_bytes + 4096; /// A /pager open holding text: its read puts it in (ctl.fillPager), its /// close hands it to the editor's step. pub fn holdsPage(p: *Pardes, req: Req) bool { const o = openOf(p, req) orelse return false; return o.what == .pager and o.pending.items.len > 0; } fn writeLines(p: *Pardes, req: Req, target: Target) Reply { // A command line holds no control character but a tab: the whole write // is refused at once, not held to fail unseen at the close. if (resultsFile(target) or ctlFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control); // A NUL no name may hold fails its write at once, not the close. if (target == .pane and target.pane.file == .name and std.mem.indexOfScalar(u8, req.data, 0) != null) return failText(req.tag, E.INVAL, pane.e_name_char ++ ": a NUL"); const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target); if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target); // The rest of a line refused for its length goes through its newline // unread: its one refusal said it, and its tail is no command of its own. var data = req.data; if (o.discarding) { const nl = std.mem.indexOfScalar(u8, data, '\n') orelse return .{ .tag = req.tag, .written = @intCast(req.data.len) }; o.discarding = false; data = data[nl + 1 ..]; if (data.len == 0) return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } o.pending.appendSlice(p.gpa, data) catch return Reply.fail(req.tag, E.NOMEM); // A line runs when its newline comes, or, the last one, when its open // is let go (release). Nothing is inferred from a write's size: a mount // cuts a long write anywhere (9ns sends 512 KiB as 65512-byte Twrites, // stdio and the page cache at 4096 multiples), and a piece shorter than // its message is no whole line. An Edit block is whole when it closes. const end = ctl.completeEnd(p, o.pending.items); if (end == 0) { if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) }; o.pending.clearRetainingCapacity(); o.discarding = true; return failText(req.tag, E.INVAL, "invalid write: a line or Edit block over 1 MiB"); } if (target == .pane and target.pane.file == .name) { if (o.named or std.mem.count(u8, o.pending.items[0..end], "\n") > 1) { o.pending.clearRetainingCapacity(); return failText(req.tag, E.INVAL, "invalid file name: one name a write, on one line"); } o.named = true; } const whole = p.scratch.allocator().dupe(u8, o.pending.items[0..end]) catch return Reply.fail(req.tag, E.NOMEM); const rest = o.pending.items.len - end; std.mem.copyForwards(u8, o.pending.items[0..rest], o.pending.items[end..]); o.pending.shrinkRetainingCapacity(rest); var w = req; w.data = whole; // The command pane this open was answered holds no lease against its // own next command (exec.leased): that one may run there again. if (resultsFile(target)) o.results.len = 0; var reply = writeNow(p, w, target); if (resultsFile(target)) { @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]); o.results = .{ .list = o.results.list, .len = p.fs.results_len, .wrote = true }; } if (reply.status == .ok) reply.written = @intCast(req.data.len); return reply; } fn writeNow(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { .look, .exec => ctl.command(p, req, null, f == .exec, false), .log => events.writeLog(p, req), .focus => ctl.writeFocus(p, req), .ctl => ctl.writeRoot(p, req), .pager => ctl.writePager(p, req), .tag => cols.write(p, req, null), .tagexec => cols.writeExec(p, req, null), else => Reply.fail(req.tag, E.PERM), }, .pane => |t| { const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const pn = p.panes[id].?; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); return pane.write(p, req, id, pn, t.file); }, .col => |c| return switch (c.file) { .tag => cols.write(p, req, c.serial), .ctl => cols.writeCtl(p, req, c.serial), .exec => cols.writeExec(p, req, c.serial), .dir => Reply.fail(req.tag, E.PERM), }, } } // ---- tests ---- test { _ = events; _ = pane; _ = addr; _ = ctl; _ = pty; _ = cols; _ = sources; _ = screen; } const testing = std.testing; const th = @import("testing.zig"); const config = @import("../config.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 serialOf = th.serialOf; test "a listing read in chunks is what it was at the open, however the panes change between reads" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); for ([_]TopFile{ .index, .layout, .recent, .ctl }) |file| { const node = @intFromEnum(file); const whole = try testing.allocator.dupe(u8, th.rd(p, node, 0, 1 << 16).bytes); defer testing.allocator.free(whole); const h = th.call(p, .{ .tag = 1, .op = .open, .node = node }).reply.handle; try testing.expect(h != 0); var got: std.ArrayList(u8) = .empty; defer got.deinit(testing.allocator); try got.appendSlice(testing.allocator, th.call(p, .{ .tag = 2, .op = .read, .node = node, .handle = h, .size = 7 }).bytes); // Something changes between the chunks. const made = try th.newPane(p); _ = th.wr(p, Node.of(made, .name), "/tmp/pardes-frozen-listing.txt\n"); _ = th.wr(p, @intFromEnum(TopFile.ctl), "Verbose off\n"); while (true) { const more = th.call(p, .{ .tag = 2, .op = .read, .node = node, .handle = h, .off = got.items.len, .size = 7 }).bytes; if (more.len == 0) break; try got.appendSlice(testing.allocator, more); } _ = th.call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); try testing.expectEqualStrings(whole, got.items); _ = th.wr(p, @intFromEnum(TopFile.ctl), "Verbose on\n"); } } test "filesystem inspection preserves pending and displayed Look hover" { const delay = config.look_preview_delay_frames orelse return; const gpa = testing.allocator; const p = try withFile(gpa, "alpha beta gamma\n"); defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = rect.x + config.GUTTER + config.PREFIX_W + 7, .row = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, } }); try testing.expect(p.look_hover_wait != null); const body = Node.of(serialOf(p), .body); const screen_node = @intFromEnum(TopFile.screen); for (0..2) |phase| { if (phase == 1) { for (0..delay) |_| p.update(.tick); try testing.expect(p.look_hover_preview != null); } const waiting = p.look_hover_wait; const preview = p.look_hover_preview; const requests = [_]Req{ .{ .tag = 1, .op = .lookup, .node = root, .data = "screen" }, .{ .tag = 1, .op = .lookup, .node = root, .data = "status" }, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.pane) }, .{ .tag = 2, .op = .getattr, .node = body }, .{ .tag = 3, .op = .open, .node = body }, .{ .tag = 4, .op = .read, .node = body, .size = 5 }, .{ .tag = 5, .op = .release, .node = body, .handle = 1 }, .{ .tag = 6, .op = .readdir, .node = root, .size = 4096 }, .{ .tag = 7, .op = .open, .node = screen_node }, .{ .tag = 8, .op = .read, .node = screen_node, .handle = 1, .size = 32 }, .{ .tag = 9, .op = .release, .node = screen_node, .handle = 1 }, }; for (requests) |req| { const answer = call(p, req); try testing.expectEqual(Status.ok, answer.reply.status); try testing.expect(std.meta.eql(waiting, p.look_hover_wait)); try testing.expect(std.meta.eql(preview, p.look_hover_preview)); try testing.expect(p.raw_hover_intent); } } try testing.expectEqual(Status.ok, wr(p, body, "changed").reply.status); try testing.expect(p.look_hover_wait == null); try testing.expect(p.look_hover_preview == null); try testing.expect(!p.raw_hover_intent); } test "log stats 0 however much it holds, a stream as acme's generated files are" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); _ = th.wr(p, Node.of(th.serialOf(p), .exec), "Msg a record for the log\n"); try testing.expect(th.logHas(p, "a record for the log")); try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.log) }).reply.attr.size); } test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ // Opening `new` is what makes a pane now; walking to it makes none, // and so must leave a pending hover alone. .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) }, .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, }; for (requests) |request| { const p = try withFile(testing.allocator, "word\n"); defer p.deinit(); p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = serialOf(p) }; p.raw_hover_intent = true; var req = request; if (req.op == .setattr) req.node = Node.of(serialOf(p), .body); try testing.expectEqual(Status.ok, call(p, req).reply.status); try testing.expect(p.look_hover_wait == null); try testing.expect(p.look_hover_preview == null); try testing.expect(!p.raw_hover_intent); } } test "README names every file the tree serves" { // /README is the whole manual a client gets; a file it never mentions // is one nobody can find without reading this source. const words = struct { fn has(name: []const u8) bool { var it = std.mem.tokenizeAny(u8, fs.help, " \t\n,.:;()'\"$<>|=/"); while (it.next()) |w| if (std.mem.eql(u8, w, name)) return true; return false; } }; inline for (@typeInfo(TopFile).@"enum".fields) |f| { const t: TopFile = @enumFromInt(f.value); if (t != .root and !words.has(t.fileName())) { std.debug.print("README does not mention /{s}\n", .{t.fileName()}); return error.Undocumented; } } inline for (@typeInfo(PaneFile).@"enum".fields) |f| { const t: PaneFile = @enumFromInt(f.value); const ok = if (t == .dir) true else if (t.inPty() and t != .pty) std.mem.indexOf(u8, fs.help, "pty/" ++ comptime @as(PaneFile, @enumFromInt(f.value)).fileName()) != null else words.has(t.fileName()); if (!ok) { std.debug.print("README does not mention pane file {s}\n", .{@tagName(t)}); return error.Undocumented; } } } test "readdir lists the root and a pane directory without creating anything" { const gpa = testing.allocator; const p = try withFile(gpa, "hello\n"); defer p.deinit(); const serial = serialOf(p); var buf: [32]th.Dirent = undefined; const before = p.next_serial; const top_reply = rdir(p, root, 0); try testing.expectEqual(Status.ok, top_reply.reply.status); var top_bytes: [4096]u8 = undefined; @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); try testing.expect(top.len >= 10); for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "recent", "look", "exec", "pager", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); try testing.expectEqual(i >= 16, top[i].dir); } try testing.expectEqual(@intFromEnum(TopFile.pane), top[16].node); try testing.expectEqual(@intFromEnum(TopFile.col), top[17].node); try testing.expectEqual(fs.os_root, top[18].node); var idbuf: [16]u8 = undefined; try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); try testing.expect(th.nameAt(top, "self") == null); try testing.expect(th.nameAt(top, "cons") == null); var rest_buf: [32]th.Dirent = undefined; const rest = rdir(p, root, 3); try testing.expectEqual(top.len - 3, th.dirents(rest.bytes, &rest_buf).len); const eof = rdir(p, root, 99); try testing.expectEqual(Status.ok, eof.reply.status); try testing.expectEqual(@as(usize, 0), eof.bytes.len); // Walk, stat and open-for-read every top entry a browser would, then every pane file. for (top) |entry| { if (std.mem.eql(u8, entry.name, "os")) continue; const inspected = look_up(p, root, entry.name); try testing.expectEqual(Status.ok, inspected.reply.status); try testing.expectEqual(entry.node, inspected.reply.attr.node); try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }).reply.status); try testing.expectEqual(before, p.next_serial); } const panes_dir = rdir(p, @intFromEnum(TopFile.pane), 0); const listed = th.dirents(panes_dir.bytes, &buf); var idname: [16]u8 = undefined; const own = th.nameAt(listed, try std.fmt.bufPrint(&idname, "{d}", .{serial})).?; try testing.expectEqual(Node.of(serial, .dir), own.node); try testing.expect(own.dir); // `new` is listed, so `ls` shows it. Walking and stat-ing it allocates // nothing — only an open does — which is what keeps `ls -l` and any other // client that stats every name a listing gave it inert. const shown = th.nameAt(listed, new_pane).?; try testing.expect(!shown.dir); try testing.expectEqual(Status.ok, look_up(p, @intFromEnum(TopFile.pane), new_pane).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) }).reply.status); try testing.expectEqual(before, p.next_serial); const dir = rdir(p, Node.of(serial, .dir), 0); const files = th.dirents(dir.bytes, &buf); try testing.expectEqual(@as(usize, 18), files.len); for ([_][]const u8{ "name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata", "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", "tagexec", }, 0..) |name, i| try testing.expectEqualStrings(name, files[i].name); try testing.expect(th.nameAt(files, ".") == null); try testing.expect(th.nameAt(files, "rdsel") == null); try testing.expectEqual(Node.of(serial, .body), th.nameAt(files, "body").?.node); for (files) |entry| { const stat = call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }); try testing.expectEqual(Status.ok, stat.reply.status); try testing.expectEqual(p.fs.started, stat.reply.attr.mtime); } try testing.expectEqual(before, p.next_serial); try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); try testing.expectEqual(E.NOTDIR, rdir(p, @intFromEnum(TopFile.index), 0).errno()); } test "lookup resolves top files, pane serials and pane files" { const gpa = testing.allocator; const p = try withFile(gpa, "hello\n"); defer p.deinit(); const serial = serialOf(p); try testing.expectEqual(@as(u64, @intFromEnum(TopFile.index)), look_up(p, root, "index").reply.attr.node); try testing.expect(!look_up(p, root, "status").reply.attr.dir); try testing.expectEqual(@as(u16, 0o444), look_up(p, root, "status").reply.attr.mode); try testing.expectEqual(@as(u16, 0o666), look_up(p, root, "look").reply.attr.mode); try testing.expectEqual(E.NOENT, look_up(p, root, "new").errno()); try testing.expect(look_up(p, root, "pane").reply.attr.dir); try testing.expect(look_up(p, root, "os").reply.attr.dir); try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); try testing.expectEqual(E.NOENT, look_up(p, root, "self").errno()); try testing.expectEqual(E.NOENT, look_up(p, root, "cons").errno()); try testing.expectEqual(E.NOTDIR, look_up(p, @intFromEnum(TopFile.index), "x").errno()); var idbuf: [16]u8 = undefined; const serial_name = try std.fmt.bufPrint(&idbuf, "{d}", .{serial}); try testing.expectEqual(E.NOENT, look_up(p, root, serial_name).errno()); const dir = look_up(p, @intFromEnum(TopFile.pane), serial_name); try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); try testing.expect(dir.reply.attr.dir); try testing.expectEqualStrings(serial_name, dir.reply.attr.name); try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.pane), "99999").errno()); try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.pane), "0").errno()); const body = look_up(p, Node.of(serial, .dir), "body"); try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) }); try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size); try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size); try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno()); try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "rdsel").errno()); try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); } test "opening /pane/new makes a pane and removing one closes it" { const gpa = testing.allocator; const p = try withFile(gpa, "first\n"); defer p.deinit(); const before = p.next_serial; const panes_dir = @intFromEnum(TopFile.pane); // Browsing /pane creates nothing, and neither does walking or stat-ing // `new` itself: only an open does. That is what lets `new` be listed at // all, since `ls -l` stats every name a listing handed it. try testing.expectEqual(Status.ok, rdir(p, panes_dir, 0).reply.status); try testing.expectEqual(Status.ok, look_up(p, panes_dir, new_pane).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) }).reply.status); try testing.expectEqual(before, p.next_serial); // The open makes the pane and hands back its serial; the read only // observes, so two reads of one fid answer the same pane. const made = call(p, .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) }); try testing.expectEqual(Status.ok, made.reply.status); try testing.expectEqual(before + 1, p.next_serial); const serial = before + 1; try testing.expectEqual(serial, made.reply.handle); var expected: [16]u8 = undefined; const want = try std.fmt.bufPrint(&expected, "{d}\n", .{serial}); for (0..2) |_| { const answer = call(p, .{ .tag = 3, .op = .read, .node = @intFromEnum(TopFile.new), .handle = made.reply.handle, .size = 64 }); try testing.expectEqualStrings(want, answer.bytes); } const id = p.paneBySerial(serial).?; try testing.expectEqualStrings("", p.panes[id].?.file.?.content); // Its directory is the session's, not that of /test.txt, the pane // that happened to have the keyboard. var named: [4096]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&named, "{s}/+New", .{p.sessionDir()}), p.panes[id].?.file.?.path); _ = wr(p, Node.of(serial, .body), "hi"); // A second open is a second pane, and releasing a clone fid leaves the // pane it made standing. const second = call(p, .{ .tag = 4, .op = .open, .node = @intFromEnum(TopFile.new) }); try testing.expectEqual(Status.ok, second.reply.status); try testing.expect(second.reply.handle != made.reply.handle); _ = call(p, .{ .tag = 5, .op = .release, .node = @intFromEnum(TopFile.new), .handle = made.reply.handle, .opened = true }); try testing.expect(p.paneBySerial(serial) != null); try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); // Every create is refused, a pane's that has closed as gone, and `new` // is the only name in /pane that is not a serial. try testing.expectEqual(E.PERM, call(p, .{ .tag = 6, .op = .open, .node = panes_dir, .create = true, .data = "scratch" }).errno()); try testing.expectEqual(E.PERM, call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .dir), .create = true, .data = "x" }).errno()); try testing.expectEqual(E.NOENT, call(p, .{ .tag = 6, .op = .open, .node = Node.of(999_999, .dir), .create = true, .data = "exec" }).errno()); try testing.expectEqual(E.NOENT, look_up(p, panes_dir, "scratch").errno()); try testing.expectEqual(E.NOENT, look_up(p, root, new_pane).errno()); // ...and it is listed, because the user asked to see it in `ls`. var listing: [32]th.Dirent = undefined; const shown = rdir(p, panes_dir, 0); try testing.expect(th.nameAt(th.dirents(shown.bytes, &listing), new_pane) != null); // Tremove closes the pane it names, and nothing else in the tree. try testing.expectEqual(E.PERM, th.rmdir(p, Node.of(serial, .body)).errno()); try testing.expectEqual(E.PERM, th.rmdir(p, @intFromEnum(TopFile.index)).errno()); try testing.expect(p.paneBySerial(serial) != null); try testing.expectEqual(Status.ok, th.rmdir(p, Node.of(serial, .dir)).reply.status); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(E.NOENT, th.rmdir(p, Node.of(serial, .dir)).errno()); } test "every open that holds something is one record, named by its handle only on its own node" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const serial = serialOf(p); const log = @intFromEnum(TopFile.log); const event = Node.of(serial, .event); const run = Node.of(serial, .pty_run); const logged = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const reader = call(p, .{ .tag = 2, .op = .open, .node = event }).reply.handle; const ran = call(p, .{ .tag = 3, .op = .open, .node = run }).reply.handle; try testing.expect(logged != 0 and reader != 0 and ran != 0); try testing.expect(logged != reader and reader != ran and logged != ran); // An open that holds nothing takes no record. try testing.expectEqual(@as(u32, 0), call(p, .{ .tag = 4, .op = .open, .node = Node.of(serial, .addr) }).reply.handle); // A handle is refused on any other node, and releasing it there gives // nothing up. try testing.expectEqual(E.INVAL, call(p, .{ .tag = 5, .op = .read, .node = run, .handle = logged, .size = 64 }).errno()); try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .write, .node = log, .handle = ran, .data = "follow\n" }).errno()); _ = call(p, .{ .tag = 7, .op = .release, .node = log, .handle = reader }); try testing.expectEqualStrings(e_in_use, call(p, .{ .tag = 8, .op = .open, .node = event }).reply.ename); for ([_]u64{ log, event, run }, [_]u32{ logged, reader, ran }) |node, h| _ = call(p, .{ .tag = 9, .op = .release, .node = node, .handle = h }); for (p.fs.opens) |o| try testing.expect(o.node == 0); try testing.expectEqual(@as(u16, 0), p.fs.listeners); } test "directories walk to their parents" { const p = try withFile(testing.allocator, "contents\n"); defer p.deinit(); for ([_]struct { node: u64, parent: u64 }{ .{ .node = root, .parent = root }, .{ .node = @intFromEnum(TopFile.pane), .parent = root }, .{ .node = Node.of(serialOf(p), .dir), .parent = @intFromEnum(TopFile.pane) }, .{ .node = fs.os_root, .parent = root }, }) |case| { const result = look_up(p, case.node, ".."); try testing.expectEqual(Status.ok, result.reply.status); try testing.expectEqual(case.parent, result.reply.attr.node); } try testing.expectEqual(E.NOTDIR, look_up(p, @intFromEnum(TopFile.index), "..").errno()); try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serialOf(p), .body), "..").errno()); } test "editor paths resolve to the same nodes the wire serves" { const p = try withFile(testing.allocator, "contents\n"); defer p.deinit(); const serial = serialOf(p); var path: [64]u8 = undefined; try testing.expectEqual(root, resolveSelf(p, "").?); try testing.expectEqual(root, resolveSelf(p, "/./pane/../").?); try testing.expectEqual(@intFromEnum(TopFile.index), resolveSelf(p, "index").?); try testing.expectEqual(@intFromEnum(TopFile.status), resolveSelf(p, "status").?); try testing.expect(resolveSelf(p, "new") == null); try testing.expectEqual(@intFromEnum(TopFile.pane), resolveSelf(p, "pane").?); try testing.expectEqual(Node.of(serial, .dir), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}", .{serial})).?); try testing.expectEqual(Node.of(serial, .sel), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/sel", .{serial})).?); try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/pty/ctl", .{serial})) == null); try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/rdsel", .{serial})) == null); try testing.expect(resolveSelf(p, "self/index") == null); try testing.expect(resolveSelf(p, "status/ctl") == null); try testing.expect(resolveSelf(p, "cons") == null); try testing.expect(resolveSelf(p, "pane/0") == null); // The editor resolves its own paths to inspect them, so `new` names // nothing here; only a client's walk makes a pane. const before = p.next_serial; try testing.expect(resolveSelf(p, "pane/new") == null); try testing.expect(resolveSelf(p, "pane/new/body") == null); try testing.expectEqual(before, p.next_serial); } test "random placements, refused or not, leave no pane under its tag and two rows" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var look_line: [4200]u8 = undefined; const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir}); var seed: u64 = 0; while (seed < 300) : (seed += 1) { var prng = std.Random.DefaultPrng.init(seed); const r = prng.random(); const rows = r.intRangeAtMost(u16, 6, 40); const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows }); defer p.deinit(); while (p.nextEffect()) |_| {} _ = try p.setTestFile("x\n"); while (p.nextEffect()) |_| {} for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n"); if (r.boolean()) p.settings.placement = .pardes; for (0..40) |step| { var serials: [pardes.MAX_PANES]u32 = undefined; var n: usize = 0; for (p.panes) |slot| if (slot) |pn| { serials[n] = pn.serial; n += 1; }; if (n == 0) break; const at = serials[r.uintLessThan(usize, n)]; const action = r.uintLessThan(u8, 8); switch (action) { 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"), 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }), 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"), 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"), 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"), 5 => _ = th.wr(p, Node.of(at, .look), looked), 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"), else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"), } for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c }); for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) }); return error.TestUnexpectedResult; }; } } } test "at the pane cap, pane/new, look and New each say so, and look reads back empty" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); // Tall enough that the column holds every pane at its tag and two rows, // and wide enough that each tag, the session directory's +New, is one. p.update(.{ .resize = .{ .cols = 400, .rows = pardes.MAX_PANES * 4 } }); const new = @intFromEnum(TopFile.new); while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status); var want: [64]u8 = undefined; const said = try std.fmt.bufPrint(&want, "no space for a pane: {d} max", .{pardes.MAX_PANES}); const refused = call(p, .{ .tag = 2, .op = .open, .node = new }); try testing.expectEqual(E.NOSPC, refused.errno()); try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said)); // A refused open is its error alone: no err, and no msg besides. try testing.expect(!th.logHas(p, said)); try testing.expect(!th.logHas(p, "New: no space")); // A look that would open a pane fails its write, and says the same. var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; const look_node = @intFromEnum(TopFile.look); const looked = th.wr(p, look_node, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir})); try testing.expect(looked.errno() != 0); try testing.expect(std.mem.indexOf(u8, looked.reply.ename, said) != null); // Its err record names the file once: `look: no space...`. try testing.expect(th.logHas(p, "look: no space")); try testing.expect(!th.logHas(p, "look: look:")); try testing.expectEqualStrings("", th.rd(p, look_node, 0, 64).bytes); // New from a pane's ctl fails as well. const ctl_node = Node.of(p.panes[0].?.serial, .ctl); const made = th.wr(p, ctl_node, "New\n"); try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null); } test "nothing a mount's close or a no-op truncate sends waits on the editor: a held line runs at its next step" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const exec = @intFromEnum(TopFile.exec); // `> exec` truncates nothing; `> body` does. try testing.expect(!needsQuiet(p, .{ .tag = 1, .op = .setattr, .node = exec, .truncate = true })); try testing.expect(!changesPane(p, .{ .tag = 1, .op = .setattr, .node = exec, .truncate = true })); try testing.expect(needsQuiet(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serialOf(p), .body), .truncate = true })); const h = call(p, .{ .tag = 2, .op = .open, .node = exec, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 3, .op = .write, .node = exec, .handle = h, .data = "Msg at-step" }); const close: Req = .{ .tag = 4, .op = .release, .node = exec, .handle = h, .opened = true }; try testing.expect(!needsQuiet(p, close) and handsOff(p, close)); _ = call(p, close); try testing.expect(!th.logHas(p, " at-step\n")); runClosedLines(p); try testing.expect(th.logHas(p, " at-step\n")); } test "a command line cut across writes runs once whole, and the last runs at release; an Edit block waits for its end" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const exec = @intFromEnum(TopFile.exec); const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle; try testing.expect(h != 0); // Each piece fills its Twrite (a client's one write cut at msize). for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| { p.fs.write_room = @intCast(piece.len); try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status); } p.fs.write_room = 0; try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n")); try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n")); _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true }); runClosedLines(p); try testing.expect(th.logHas(p, " tail\n")); // An Edit block's text lines come in later writes on the same open. const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); const c = call(p, .{ .tag = 4, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; for ([_][]const u8{ "Edit ,a\n", "one\ntw", "o\n", ".\n" }) |piece| try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = piece }).reply.status); _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true }); runClosedLines(p); try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content); } test "a refused open, create or remove says why in words and logs no err; a refused write logs one" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); // pane/new is read, never written: a write is refused, and is no // failure the log tells of. const new = th.wr(p, @intFromEnum(TopFile.new), "x\n"); try testing.expectEqual(Status.err, new.reply.status); try testing.expectEqualStrings("permission denied", new.reply.ename); const made = call(p, .{ .tag = 2, .op = .open, .node = Node.of(serial, .body), .create = true }); try testing.expectEqualStrings("permission denied", made.reply.ename); const gone = th.rmdir(p, @intFromEnum(TopFile.index)); try testing.expectEqual(Status.err, gone.reply.status); try testing.expect(gone.reply.ename.len > 0 and !std.ascii.isUpper(gone.reply.ename[0])); // An OTRUNC open's truncation of data after a failed addr: refused, no err. _ = th.wr(p, Node.of(serial, .addr), "/nothere/"); const errs = th.logCount(p, "\nerr "); try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .setattr, .node = Node.of(serial, .data), .truncate = true }).reply.status); try testing.expectEqual(errs, th.logCount(p, "\nerr ")); try testing.expectEqual(@as(usize, 1), errs); // the addr write's own // A write is the rule's: refused, it logs its one err, in words too. try testing.expectEqual(Status.err, th.wr(p, Node.of(serial, .ctl), "bogus\n").reply.status); try testing.expectEqual(@as(usize, 2), th.logCount(p, "\nerr ")); } test "a write with no newline waits for its newline or the close, whatever its size, and fails there in the log" { const p = try th.withFile(testing.allocator, "abc\n"); defer p.deinit(); const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; // Short of its message or not, a piece with no newline is held: a mount // cuts a long line anywhere. p.fs.write_room = 8192; defer p.fs.write_room = 0; try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,s/a/" }).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 3, .op = .write, .node = ctl_node, .handle = c, .data = "A/\n" }).reply.status); try testing.expectEqualStrings("Abc\n", p.panes[0].?.file.?.content); // An `a` needs its text and `.`: held across writes. try testing.expectEqual(Status.ok, call(p, .{ .tag = 4, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a" }).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = "\nmore\n.\n" }).reply.status); try testing.expectEqualStrings("Abc\nmore\n", p.panes[0].?.file.?.content); // The last line, never ended, runs at the close: its failure is its err. try testing.expectEqual(Status.ok, call(p, .{ .tag = 6, .op = .write, .node = ctl_node, .handle = c, .data = "bogus" }).reply.status); try testing.expect(!th.logHas(p, "bogus")); _ = call(p, .{ .tag = 7, .op = .release, .node = ctl_node, .handle = c, .opened = true }); runClosedLines(p); try testing.expect(th.logHas(p, "unknown control message \"bogus\"")); // A name: a NUL fails its write, not the close after it. const name = Node.of(serial, .name); const n = call(p, .{ .tag = 10, .op = .open, .node = name, .omode = 1 }).reply.handle; const bad = call(p, .{ .tag = 11, .op = .write, .node = name, .handle = n, .data = "del\x00ete" }); try testing.expectEqual(Status.err, bad.reply.status); try testing.expect(std.mem.indexOf(u8, bad.reply.ename, "a NUL") != null); try testing.expectEqual(Status.ok, call(p, .{ .tag = 12, .op = .release, .node = name, .handle = n, .opened = true }).reply.status); runClosedLines(p); } test "look and exec read back what their own open's write touched; a fresh open reads the session's last" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const exec = @intFromEnum(TopFile.exec); const first = p.panes[0].?.serial; const mine = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 2 }).reply.handle; const theirs = call(p, .{ .tag = 2, .op = .open, .node = exec, .omode = 2 }).reply.handle; _ = call(p, .{ .tag = 3, .op = .write, .node = exec, .handle = mine, .data = "Tty\n" }); const made = p.panes[p.active].?.serial; try testing.expect(made != first); _ = call(p, .{ .tag = 4, .op = .write, .node = exec, .handle = theirs, .data = "Msg hi\n" }); var want: [16]u8 = undefined; // Read at the offset the write left, as a shell's `exec 3<>` does. try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), call(p, .{ .tag = 5, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes); try testing.expectEqualStrings("", call(p, .{ .tag = 6, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes); const said = p.panes[p.active].?.serial; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 7, .op = .read, .node = exec, .handle = theirs, .size = 64 }).bytes); const fresh = call(p, .{ .tag = 8, .op = .open, .node = exec }).reply.handle; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 9, .op = .read, .node = exec, .handle = fresh, .size = 64 }).bytes); for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true }); } test "a write cut where a writer's 4096-byte buffer filled holds its partial line for the next write" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const exec_node = Node.of(serialOf(p), .exec); const h = call(p, .{ .tag = 1, .op = .open, .node = exec_node, .omode = 1 }).reply.handle; // 4096 bytes: whole `Msg first` lines, then a line cut after `Msg`. var buf: [4096]u8 = undefined; const line = "Msg first\n"; var n: usize = 0; while (n + line.len <= buf.len - 3) : (n += line.len) @memcpy(buf[n..][0..line.len], line); @memset(buf[n..], ' '); @memcpy(buf[buf.len - 3 ..], "Msg"); try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec_node, .handle = h, .data = &buf }).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 3, .op = .write, .node = exec_node, .handle = h, .data = " last\n" }).reply.status); _ = call(p, .{ .tag = 4, .op = .release, .node = exec_node, .handle = h, .opened = true }); try testing.expect(th.logHas(p, "last")); try testing.expect(!th.logHas(p, "wrong #args")); } test "a rename of a pane's directory or file is refused as not permitted" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); for ([_]u64{ Node.of(serial, .dir), Node.of(serial, .body) }) |node| { const r = call(p, .{ .tag = 1, .op = .setattr, .node = node, .set = .{ .name = true } }); try testing.expectEqual(E.PERM, r.errno()); try testing.expect(std.mem.indexOf(u8, r.reply.ename, "not permitted") != null); } } test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const new = @intFromEnum(TopFile.new); var made: usize = 1; const refused = while (made < pardes.MAX_PANES) : (made += 1) { const a = call(p, .{ .tag = 1, .op = .open, .node = new }); if (a.reply.status == .err) break a; } else return error.NeverRefused; try testing.expectEqual(E.NOSPC, refused.errno()); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "no space for a pane in that column") != null); // The refused pane was never there; its serial is not handed out again // (gaps are fine: a serial once said names one pane for good). const before = p.next_serial; try testing.expectEqual(E.NOSPC, call(p, .{ .tag = 1, .op = .open, .node = new }).errno()); try testing.expectEqual(before + 1, p.next_serial); p.sync(); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); try testing.expectEqual(made, count); // the refused one is gone for (0..p.ncol) |c| for (p.col_panes[c][0..p.col_n[c]]) |k| { if (p.col_n[c] > 1 and !p.panes[k].?.collapsed) try testing.expect(p.rects[k].h >= p.panes[k].?.tag_rows + pardes.layout.min_body_rows); }; } test "an Edit block still open when its open closes fails there with an err and changes nothing" { const p = try th.withFile(testing.allocator, "abc\n"); defer p.deinit(); const ctl_node = Node.of(serialOf(p), .ctl); const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,x/b/{\ni/[/\n" }); _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true }); runClosedLines(p); try testing.expect(th.logHas(p, "unmatched")); try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); } test "an a, c or i text still open when its open closes fails there too and changes nothing" { const p = try th.withFile(testing.allocator, "abc\n"); defer p.deinit(); const ctl_node = Node.of(serialOf(p), .ctl); const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a\nhalf of a block\n" }); _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true }); runClosedLines(p); try testing.expect(th.logHas(p, "not ended by a . line")); try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); } test "an a, c or i text cut short through exec is an err in the log, not a msg" { const p = try th.withFile(testing.allocator, "abc\n"); defer p.deinit(); const exec = Node.of(serialOf(p), .exec); const c = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = c, .data = "Edit $a\nhalf\n" }); _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = c, .opened = true }); runClosedLines(p); var want: [64]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: Edit: a, c or i text not ended", .{serialOf(p)}))); try testing.expect(!th.logHas(p, "msg 1 Edit: a, c or i")); try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); } test "a pane made from pane/new goes last in its column, even when the last pane has no room to halve" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); for (0..3) |_| { const made = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }).reply.handle; p.sync(); try testing.expectEqual(made, p.panes[p.col_panes[0][p.col_n[0] - 1]].?.serial); } }