diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 22:37:06 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 5dcfade5f102256de787b2157b01293160780411 (patch) | |
| tree | 256416f7a82eacc06233d6a543a1fdfec390daab /src | |
| parent | 297e14cfc36e4613a8c1cb3b995597d0a9c2873b (diff) | |
| download | pardes-5dcfade5f102256de787b2157b01293160780411.tar.gz pardes-5dcfade5f102256de787b2157b01293160780411.zip | |
Make a pane by opening /pane/new, and a Plan 9 idiom pass
The Tcreate that replaced acme's /new was a step away from the idiom dressed
up as a step toward it. A pane is named by a server-assigned serial, so the
create ignored the client's name: `mkdir /pane/foo` succeeded and left you
/pane/12. A mkdir that does not make the directory you named is worse than the
read-with-side-effect it replaced, and it broke in the shell workflow that
motivated the change. `create` is out of the declared features, so Tcreate is
EPERM again; Tremove stays, since `rm` to close a pane is unambiguously right.
/pane/new is now opened, not created: the open makes the pane, the read of
that fid answers its serial, two reads agree, and closing it leaves the pane.
That is /net/tcp/clone's mechanism (kernel/network/ip/devip.c, in ipopen),
not acme's, and the difference is deliberate. acme allocates during the walk
and lands inside the new window, so /dev/new/body works in one step, and it
can afford to list `new` because a Plan 9 directory read carries every entry's
stat and nothing walks. A kernel or FUSE mount walks and stats each name a
listing gave it, so allocate-on-walk would make a pane per `ls -l`. Allocating
on open keeps `new` listed -- a stat is not an open -- at the cost of the
one-step new/body. `new` stays unreachable from an editor path, because that
resolution serves Look hover previews.
The idiom pass behind it, read out of the Plan 9 tree at
~/05-genizah/principia-softwarica rather than recalled:
Rerror carries a string, not an errno (man 5 error: `ename[s]`), and acme
names every refusal. The five refusals pardes shares with acme now say what
they mean; the generic sites keep their bare errno rather than invent strings
acme does not have. body and tag declare DMAPPEND, which they had always
behaved as (acme(4): "always appended; the file offset is ignored"), checked
first against Linux's fs/9p, which never maps the bit. excl stays unset
everywhere, because acme sets DMEXCL on nothing. Blocking reads, per-object
addr scope and the readable pane ctl were already right. Real stat sizes and
qid versions stay: acme reports length 0 and version 0 for everything, and
Linux clients need better.
One bug fell out of it. open reset the addr range, so `echo '#0,#5' >addr;
cat addr` answered `0 0` and `cp addr dot` copied zeros. acme(4) makes the
contract explicit -- "a regular expression may be evaluated by writing it to
addr and reading it back" -- and acme gets away with resetting on the 0-to-1
open only because its clients hold the fid across both. A shell cannot: that
is two opens. The register is cleared by truncating it now.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/fs-help.txt | 10 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 2 | ||||
| -rw-r--r-- | src/ninep/events.zig | 6 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 8 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 2 | ||||
| -rw-r--r-- | src/ninep/testing.zig | 26 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 160 | ||||
| -rw-r--r-- | src/tutor.txt | 2 |
8 files changed, 143 insertions, 73 deletions
diff --git a/src/fs-help.txt b/src/fs-help.txt index 1e5d64c2..3113590a 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -8,16 +8,16 @@ exec write a line: a middle click, an editor command word or a shell line log one line per editor event (new/del/rename/save <serial> <name>); reads wait screen rendered screen as JSON, frozen from open to close listeners the session's dial addresses -pane/ mkdir makes a pane, rmdir <serial> closes it +pane/new open it to make a pane; the read answers that pane's serial pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll - errors event look exec, and pty/ for terminals + errors event look exec, and pty/ for terminals; rmdir closes it os/ the host filesystem src/ the editor's own sources, only in a -Dembed-sources=true build Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too): cat $m/index which panes exist - mkdir $m/pane/x; n=$(awk 'END{print $1}' $m/index) make a pane, take its serial + n=$(cat $m/pane/new) make a pane, take its serial echo /etc/hosts:3 > $m/look; cat $m/look open a file, see the pane it went to printf 'text\n' > $m/pane/$n/body append to a pane (>| truncates first) cat $m/pane/$n/name; echo notes.txt > $m/pane/$n/name read, then rename @@ -31,8 +31,8 @@ Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT Pitfalls, one each: - Only mkdir in pane/ makes a pane; ls, stat, find and every read create nothing. - Panes are named by the serial the editor gives them, not by the name mkdir asked for. + Each open of pane/new makes another pane; two reads of one fid name the same one. + Only that open creates: ls, stat, find and every other read leave the tree alone. rmdir closes a pane even when it is dirty; index and pane/<n>/dirty show the flag. addr, dot and limit read the same pair of offsets they take, so cp between them works. dirty, mark and scroll read "0" or "1" and take "0" or "1"; truncating limit lifts it. diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index c213ece5..7625f501 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -188,7 +188,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - if (!std.mem.eql(u8, line, "get")) return Reply.fail(req.tag, E.INVAL); + if (!std.mem.eql(u8, line, "get")) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl); asked = true; } if (asked) { diff --git a/src/ninep/events.zig b/src/ninep/events.zig index bd53bcd9..d94fb54d 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -317,12 +317,12 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { while (check.next()) |r| { switch (r.action) { .body_look, .tag_look, .body_exec, .tag_exec => {}, - else => return Reply.fail(req.tag, E.INVAL), + else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event), } const n = if (r.action.onTag()) tag.len else body.len; - if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL); + if (r.q0 > r.q1 or r.q1 > n) return tree.failText(req.tag, E.INVAL, tree.e_bad_event); } - if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL); + if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event); } var run: EventReader = .{ .data = req.data }; while (run.next()) |r| { diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 42c0c48e..4b34cf96 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -24,6 +24,12 @@ const Node = tree.Node; /// Filesystem state a pane carries beside its editor state. pub const State = struct { + /// The range `data` and `xdata` read and write through. acme clears it + /// when the first client opens `addr` (editors/acme/xfid.c:105), which + /// suits a client that holds the fid open and leaves a shell reading back + /// `0 0` from the address it just wrote. Here it is the pane's own + /// register, cleared by truncating the file, so that `cp addr dot` and + /// `cat addr` answer what was written. addr: Range = .{}, limit: ?Range = null, readers: u16 = 0, @@ -458,7 +464,7 @@ fn writeRange(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Repl const pf = &p.fs.panes[id]; const text = bodyOf(pane); clampAddr(pf, text.len); - const r = rangeOf(pf, text, req.data) orelse return Reply.fail(req.tag, E.INVAL); + const r = rangeOf(pf, text, req.data) orelse return tree.failText(req.tag, E.INVAL, tree.e_bad_addr); switch (file) { .addr => pf.addr = r, .limit => pf.limit = r, diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index 1b71a8d3..c657d27d 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -42,7 +42,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply { while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - if (!verb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); + if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl); } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig index ad1d84e4..44329eee 100644 --- a/src/ninep/testing.zig +++ b/src/ninep/testing.zig @@ -69,26 +69,14 @@ pub fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); } -/// Creates a pane in /pane, as a client's mkdir does, and returns its serial. +/// Makes a pane the way a client does, by walking to /pane/new, and answers +/// the serial of the pane the walk landed on. +/// The open is what makes the pane; the handle it answers is the serial, which +/// is also what a read of that fid reports. pub fn newPane(p: *Pardes) !u32 { - const made = mkdir(p, "scratch"); - if (made.reply.status != .ok) return error.NoPane; - const target = tree.Node.target(made.reply.attr.node) orelse return error.NoPane; - return switch (target) { - .pane => |t| t.serial, - .top => error.NoPane, - }; -} - -pub fn mkdir(p: *Pardes, name: []const u8) Answer { - return call(p, .{ - .tag = 9, - .op = .open, - .node = @intFromEnum(tree.TopFile.pane), - .data = name, - .create = true, - .perm = 0x8000_0000 | 0o755, - }); + const made = call(p, .{ .tag = 8, .op = .open, .node = @intFromEnum(tree.TopFile.new) }); + if (made.reply.status != .ok or made.reply.handle == 0) return error.NoPane; + return made.reply.handle; } pub fn rmdir(p: *Pardes, node: u64) Answer { diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 46609649..5a0c1e11 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -5,7 +5,8 @@ //! and mounts. //! //! /README /index /status /look /exec /log /screen /listeners -//! /pane/ mkdir makes a pane, rmdir closes it +//! /pane/new an open makes a pane, a read names it +//! /pane/<serial> rmdir closes that pane //! /pane/<serial>/{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) @@ -49,14 +50,28 @@ pub const Payload = union(enum) { pub const Reply = cloud9.fs.ReplyWith(Payload); -/// Tcreate opens a pane and Tremove closes one; nothing else in the tree is -/// created or destroyed by the protocol, and wstat stays a truncation. -pub const features: cloud9.fs.Features = .{ .create = true, .remove = true }; +/// 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"; + +/// 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 }; pub fn changesPane(req: Req) bool { return switch (req.op) { .write, .setattr => true, - .open => req.create, + .open => req.node == @intFromEnum(TopFile.new), .release => req.remove, .lookup, .getattr, .read, .readdir => false, }; @@ -77,6 +92,7 @@ pub const TopFile = enum(u4) { screen, listeners, pane, + new, pub fn fileName(f: TopFile) []const u8 { return if (f == .root) "/" else @tagName(f); @@ -86,7 +102,7 @@ pub const TopFile = enum(u4) { return switch (f) { .root, .pane => 0o755, .look, .exec => 0o666, - .README, .index, .status, .log, .screen, .listeners => 0o444, + .README, .index, .status, .log, .screen, .listeners, .new => 0o444, }; } @@ -187,9 +203,22 @@ fn paneFileNamed(name: []const u8) ?PaneFile { fn topFileNamed(name: []const u8) ?TopFile { const f = std.meta.stringToEnum(TopFile, name) orelse return null; - return if (f == .root) null else f; + // `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; @@ -218,7 +247,12 @@ pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { if (top == .pane or parts.next() != null) return null; return @intFromEnum(top); } - const serial = serialNamed(parts.next() orelse return @intFromEnum(TopFile.pane)) orelse return null; + 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; @@ -258,9 +292,9 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply { 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 /pane is created in and removed from. The host tree and the - // embedded sources say so, rather than quietly doing nothing. - if ((host or archive) and (req.create or req.remove)) return Reply.fail(req.tag, E.PERM); + // 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); @@ -302,6 +336,10 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .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, }; }, } @@ -314,8 +352,9 @@ fn attrReply(p: *Pardes, tag: u64, target: Target) Reply { fn topSize(p: *Pardes, f: TopFile) u64 { return switch (f) { - // ponytail: /screen has no length until an open renders its frame. - .root, .pane, .screen => 0, + // 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), @@ -348,6 +387,7 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply { 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); }, @@ -396,6 +436,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { 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; @@ -426,27 +467,19 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } -/// Tcreate in /pane opens a pane, the way mkdir opens a directory. The name -/// asked for is ignored: a pane is named by the serial the editor gives it, -/// which the reply carries back and /index lists last. -fn create(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| if (f != .pane) return Reply.fail(req.tag, E.PERM), - .pane => return Reply.fail(req.tag, E.PERM), - } - if (req.perm & cloud9.dmdir == 0) return Reply.fail(req.tag, E.PERM); - const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE); +/// 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); - const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); - const attr = attrOf(p, .{ .pane = .{ .serial = made.serial, .file = .dir } }) orelse - return Reply.fail(req.tag, E.NFILE); - return .{ .tag = req.tag, .handle = 1, .attr = attr }; + return (p.panes[slot] orelse return null).serial; } fn open(p: *Pardes, req: Req, target: Target) Reply { - if (req.create) return create(p, req, target); 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 => p.fs.log_readers +|= 1, else => {}, @@ -458,7 +491,6 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { 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), - .addr => pf.addr = .{}, .event => { pf.readers +|= 1; p.fs.listeners +|= 1; @@ -552,6 +584,14 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { .log => events.readQueue(p, req, &p.fs.log), .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| { @@ -655,7 +695,9 @@ test "filesystem inspection preserves pending and displayed Look hover" { test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ - .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.pane), .data = "x", .create = true, .perm = cloud9.dmdir | 0o755 }, + // 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| { @@ -719,6 +761,14 @@ test "readdir lists the root and a pane directory without creating anything" { 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); @@ -780,38 +830,58 @@ test "lookup resolves top files, pane serials and pane files" { try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); } -test "creating in the pane directory opens a pane and removing one closes it" { +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; only a create does. + // 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); - const made = th.mkdir(p, "scratch"); + // 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(Node.of(serial, .dir), made.reply.attr.node); - try testing.expect(made.reply.attr.dir); + try testing.expectEqual(serial, made.reply.handle); var expected: [16]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}", .{serial}), made.reply.attr.name); + 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"); - const second = th.mkdir(p, "another"); + + // 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.attr.node != made.reply.attr.node); + 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); - // A plain file, and a create anywhere else, are refused. - try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = panes_dir, .data = "f", .create = true, .perm = 0o666 }).errno()); - try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = root, .data = "d", .create = true, .perm = cloud9.dmdir | 0o755 }).errno()); - try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = Node.of(serial, .dir), .data = "d", .create = true, .perm = cloud9.dmdir | 0o755 }).errno()); + // 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()); @@ -858,4 +928,10 @@ test "editor paths resolve to the same nodes the wire serves" { 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); } diff --git a/src/tutor.txt b/src/tutor.txt index adf4ae3a..ab73be33 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -396,7 +396,7 @@ typed /pane/<serial>/body /index - /pane mkdir here makes a pane, rmdir closes it + /pane/new open it to make a pane; rmdir closes one /look /exec `FILE:12` and `Save`, as the mouse does A terminal pane also has `pty/`: |
