From 5dcfade5f102256de787b2157b01293160780411 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 22:37:06 -0300 Subject: 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) --- src/ninep/ctl.zig | 2 +- src/ninep/events.zig | 6 +- src/ninep/pane.zig | 8 ++- src/ninep/pty.zig | 2 +- src/ninep/testing.zig | 26 +++----- src/ninep/tree.zig | 162 ++++++++++++++++++++++++++++++++++++-------------- 6 files changed, 138 insertions(+), 68 deletions(-) (limited to 'src/ninep') 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/ 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) @@ -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,8 +203,21 @@ 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; @@ -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); } -- cgit v1.3