//! 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"; pub const e_shut_down = "window shut down"; /// 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(req: Req) bool { return switch (req.op) { .write, .setattr => true, .release => req.remove, else => false, }; } /// `needsQuiet`, plus the open that makes a pane: what costs a frame and a /// wake of the editor. pub fn changesPane(req: Req) bool { return switch (req.op) { .write, .setattr => true, .open => req.node == @intFromEnum(TopFile.new), .release => req.remove, .lookup, .getattr, .read, .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, } = .{ .snapshot = null }, /// 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, pub const Replay = struct { off: u64, bytes: []u8 }; /// `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); }, .event, .pty_data, .ctl => {}, } 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, 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 => 0o666, .README, .index, .status, .screen, .listeners, .new, .commands, .layout => 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, 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 }; 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 { 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 .{ .name = if (c.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{c.serial}) catch unreachable) else "tag", .node = Node.ofCol(c.serial, c.file), .dir = c.file == .dir, .mode = if (c.file == .dir) 0o755 else 0o666, .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 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 .{ .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) { // ponytail: /screen has no length until an open renders its frame, // and /pane/new none until an open has a pane to name. .root, .pane, .col, .screen, .new => 0, .layout => cols.layoutLen(p), .tag => cols.headerText(p, null).?.len + 1, .index => pane.indexLen(p), .README => fs.help.len, .status => ctl.statusLen(p), .look, .exec => ctl.resultsLen(p), .log => events.logLen(&p.fs.log), .listeners => listeners: { var buf: [screen.listeners_cap]u8 = undefined; break :listeners screen.listenersText(p, &buf).len; }, .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; 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); if (!std.mem.eql(u8, name, "tag")) return Reply.fail(req.tag, E.NOENT); break :col Node.ofCol(c.serial, .tag); }, .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.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, 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; 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); 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); if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, Node.ofCol(c.serial, .tag), false, "tag"); }, .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.NFILE, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no pane slots"); p.newScratchFor(p.active, .fs); const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); return .{ .tag = req.tag, .handle = made.serial }; }, .screen => .{ .snapshot = null }, .log => .{ .log = .{} }, else => return .{ .tag = req.tag }, }, .col => |c| return if (layout.columnBySerial(p, c.serial) != null) .{ .tag = req.tag } else Reply.fail(req.tag, E.NOENT), .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 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 => 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 }; 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); }, .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 => {}, } 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, .col => return Reply.fail(req.tag, E.PERM), }; 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| { p.panes[id].?.fs.tag_rewrite = false; p.panes[id].?.fs.tag_held_newline = 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; 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 => {}, }; o.deinit(p.gpa); } fn setattr(p: *Pardes, req: Req, target: Target) Reply { 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 { 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].?; out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), pane.nameOf(pn), pane.columnOf(p, id) }) catch {}; } break :index stagedReply(p, req); }, .status => ctl.readStatus(p, req), .look, .exec => 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), .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 or pty/data read the pane closed under: acme's // answer to the same (editors/acme/xfid.c:1005). if (openOf(p, req)) |o| if (o.what == .event or o.what == .pty_data) return failText(req.tag, E.IO, 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 if (c.file == .tag) cols.read(p, req, c.serial) else Reply.fail(req.tag, E.PERM), } } fn write(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { .look, .exec => ctl.command(p, req, null, f == .exec), .log => events.writeLog(p, req), .focus => ctl.writeFocus(p, req), .ctl => ctl.writeRoot(p, req), .tag => cols.write(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 if (c.file == .tag) cols.write(p, req, c.serial) else 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 "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 "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", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); try testing.expectEqual(i >= 13, top[i].dir); } try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node); try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node); try testing.expectEqual(fs.os_root, top[15].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, 17), files.len); for ([_][]const u8{ "name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata", "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", }, 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); _ = 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 "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(); 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 pane slots: {d} max", .{pardes.MAX_PANES}); const refused = call(p, .{ .tag = 2, .op = .open, .node = new }); try testing.expectEqual(E.NFILE, refused.errno()); try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said)); try testing.expect(th.logHas(p, said)); // 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); 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); }