summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 22:37:06 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit5dcfade5f102256de787b2157b01293160780411 (patch)
tree256416f7a82eacc06233d6a543a1fdfec390daab /src/ninep
parent297e14cfc36e4613a8c1cb3b995597d0a9c2873b (diff)
downloadpardes-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/ninep')
-rw-r--r--src/ninep/ctl.zig2
-rw-r--r--src/ninep/events.zig6
-rw-r--r--src/ninep/pane.zig8
-rw-r--r--src/ninep/pty.zig2
-rw-r--r--src/ninep/testing.zig26
-rw-r--r--src/ninep/tree.zig160
6 files changed, 137 insertions, 67 deletions
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);
}