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/ninep/tree.zig | |
| 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/ninep/tree.zig')
| -rw-r--r-- | src/ninep/tree.zig | 160 |
1 files changed, 118 insertions, 42 deletions
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); } |
