//! 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 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"; /// Handles that mark an open as the one reading `event` or `pty/data`, so the /// release knows to give the file up; other opens get `open_handle`. const open_handle: u32 = 1; // ponytail: EBUSY spelled locally; use E.BUSY once cloud9 names it and pardes bumps the pin. const e_busy: u16 = 16; const reader_handle: u32 = 2; fn reads(omode: u8) bool { return omode & 3 != 1; // OWRITE is the one access mode that never reads } /// 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, which needs no feature of the engine's, so Tcreate is refused /// everywhere, as it is in acme (editors/acme/fsys.c, fsyscreate). pub const features: cloud9.fs.Features = .{ .remove = 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; // ---- nodes ---- pub const TopFile = enum(u4) { root = 1, README, index, status, look, exec, log, screen, listeners, pane, new, 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 => 0o755, .look, .exec, .log => 0o666, .README, .index, .status, .screen, .listeners, .new => 0o444, }; } pub fn dir(f: TopFile) bool { return f == .root or f == .pane; } }; 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, pub fn fileName(f: PaneFile) []const u8 { return switch (f) { .dir => ".", .pty_ctl => "ctl", .pty_status => "status", .pty_data => "data", 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 => true, else => false, }; } }; 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 target(node: u64) ?Target { const n: Node = @bitCast(node); if (n.serial == 0) { return .{ .top = std.enums.fromInt(TopFile, 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 }, }; fn paneFileNamed(name: []const u8) ?PaneFile { const f = std.meta.stringToEnum(PaneFile, name) orelse return null; if (f == .dir) return null; return if (f.inPty() and f != .pty) null else f; } fn topFileNamed(name: []const u8) ?TopFile { const f = std.meta.stringToEnum(TopFile, name) orelse return null; // `new` is reached in /pane, where the panes it makes are. return if (f == .root or f == .new) null else f; } /// 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"; fn serialNamed(name: []const u8) ?u32 { if (name.len == 0 or name.len > 10) return null; for (name) |c| if (c < '0' or c > '9') return null; const serial = std.fmt.parseInt(u32, name, 10) catch return null; return if (serial == 0) null else serial; } 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")) { const top = topFileNamed(first) orelse return sources.node(normalized); if (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; const serial = serialNamed(next) orelse return null; const id = p.paneBySerial(serial) orelse return null; const file = paneFileNamed(parts.next() orelse return Node.of(serial, .dir)) orelse 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 { 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 => 'F', }; return switch (req.op) { .lookup => lookup(p, req, target), .getattr => attrReply(p, req.tag, target), .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), }; } 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, }, .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 = pane.mtimeOf(p, pn), .version = pane.versionOf(pn, t.file), // 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 attrReply(p: *Pardes, tag: u64, target: Target) Reply { const a = attrOf(p, target) orelse return Reply.fail(tag, E.NOENT); return .{ .tag = tag, .attr = a }; } 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, .screen, .new => 0, .index => pane.indexLen(p), .README => fs.help.len, .status => ctl.statusLen(p), .look, .exec => ctl.resultsLen(p), .log => events.logLen(&p.fs.log), .listeners => screen.listenersLen(p), }; } 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), }, }; 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 }); if (topFileNamed(name)) |t| 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); const serial = serialNamed(name) orelse return Reply.fail(req.tag, E.NOENT); break :pane Node.of(serial, .dir); }, else => return Reply.fail(req.tag, E.NOTDIR), }, .pane => |t| pane: { _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const f = switch (t.file) { .dir => paneFileNamed(name), .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); }, }; return attrReply(p, req.tag, Node.target(node) 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 => { inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |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); } }, else => return Reply.fail(req.tag, E.NOTDIR), }, .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) } }; } /// Opens a pane for an open of /pane/new, whose serial becomes the handle: /// the fid remembers which pane it made, so reading it twice answers the /// same one and closing it leaves the pane alone. fn makePane(p: *Pardes) ?u32 { const slot = p.freeSlot() orelse return null; p.newScratchBelow(p.active); return (p.panes[slot] orelse return null).serial; } fn open(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| switch (f) { .new => return .{ .tag = req.tag, .handle = makePane(p) orelse return Reply.fail(req.tag, E.NFILE) }, .screen => return screen.openSnapshot(p, req, true), .log => return events.openLog(p, req), else => {}, }, .pane => |t| { const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const pn = p.panes[id].?; const pf = &p.fs.panes[id]; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); switch (t.file) { .body => if (pn.isTerminal()) return screen.openSnapshot(p, req, false), // Reads of these consume, so two readers would each see half // the stream; the second is refused rather than robbed. .event => { const reader = reads(req.omode); if (reader and pf.event_reader) return failText(req.tag, e_busy, e_in_use); pf.readers +|= 1; p.fs.listeners +|= 1; pf.event_reader = pf.event_reader or reader; if (reader) return .{ .tag = req.tag, .handle = reader_handle }; }, .pty_data => if (reads(req.omode)) { if (pf.pty_readers > 0) return failText(req.tag, e_busy, e_in_use); pf.pty_readers = 1; return .{ .tag = req.tag, .handle = reader_handle }; }, else => {}, } }, } return .{ .tag = req.tag, .handle = open_handle }; } /// 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), }; 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. const done = releaseHandle(p, req); return if (req.remove) remove(p, req) else done; } fn releaseHandle(p: *Pardes, req: Req) Reply { if (screen.releaseSnapshot(p, req)) return .{ .tag = req.tag }; const target = Node.target(req.node) orelse return .{ .tag = req.tag }; switch (target) { .top => {}, .pane => |t| { if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; const pf = &p.fs.panes[id]; if (t.file == .pty_data) { if (req.handle != reader_handle or pf.pty_readers == 0) return .{ .tag = req.tag }; pf.pty_readers -= 1; if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa); return .{ .tag = req.tag }; } if (pf.readers == 0) return .{ .tag = req.tag }; if (req.handle == reader_handle) pf.event_reader = false; pf.readers -= 1; p.fs.listeners -|= 1; if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); }, } return .{ .tag = req.tag }; } 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 (pane.truncate(p, id, p.panes[id].?, t.file) != .ok) return Reply.fail(req.tag, E.NOMEM); }, .top => {}, }; return attrReply(p, req.tag, target); } fn read(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; pane.stageIndexLine(p, out, p.panes[p.paneBySerial(serial).?].?); } 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), // 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 => 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.read(p, req, id, pn, t.file); }, } } 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), 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); }, } } // ---- tests ---- test { _ = events; _ = pane; _ = addr; _ = ctl; _ = pty; _ = 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", "look", "exec", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); try testing.expectEqual(i >= 8, top[i].dir); } try testing.expectEqual(@intFromEnum(TopFile.pane), top[8].node); try testing.expectEqual(fs.os_root, top[9].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); // The tree declares no create, so the engine refuses every Tcreate // (cloud9 fs.zig: `.tcreate => if (features.create) ... else fail(e_perm)`), // and `new` is the only name in /pane that is not a serial. try testing.expect(!features.create); 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 "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); }