summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md38
-rw-r--r--docs/fs.md40
-rw-r--r--docs/v9fs.md9
-rw-r--r--features.txt5
-rw-r--r--src/fs-help.txt10
-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
-rw-r--r--src/tutor.txt2
-rw-r--r--test/fs.py27
-rw-r--r--test/v9fs.py27
14 files changed, 237 insertions, 125 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 2cea4d4a..91a8e864 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -49,7 +49,7 @@ $m/exec write a line = a middle click: an editor command word, or a shell
$m/log one record per read: new|del|rename|save <serial> <name>; reads park
$m/screen the rendered screen as JSON, frozen per open
$m/listeners this session's dial addresses
-$m/pane/ mkdir opens a pane; rmdir <serial> closes it
+$m/pane/new open it to make a pane, read names it; rmdir $m/pane/<n> closes it
$m/os/ the host filesystem
```
@@ -60,7 +60,7 @@ reuse or close a pane.
```sh
cat "$m/index" # which panes exist
-mkdir "$m/pane/x"; n=$(awk 'END{print $1}' "$m/index") # open one, take its serial
+n=$(cat "$m/pane/new") # make one, take its serial
printf 'text\n' > "$m/pane/$n/body" # append
cat "$m/pane/$n/tag" # what its tagline offers
echo notes.txt > "$m/pane/$n/name" # rename the buffer
@@ -69,10 +69,20 @@ echo "/etc/hosts:3" > "$m/look"; cat "$m/look" # open a file, see where it land
rmdir "$m/pane/$n" # close it, dirty or not
```
-The name `mkdir` asks for is ignored: a pane is named by the serial the editor
-gives it, and because `/index` is ordered by serial its last row is the pane
-just made. Nothing else in the tree can be created or removed, and no read
-creates anything, so `ls`, `stat` and `find` over the whole tree are inert.
+**Opening** `$m/pane/new` is what makes a pane, and reading the open file
+answers its serial — `/net/tcp/clone`'s mechanism. Each open makes another one,
+two reads of the same open file answer the same serial, and closing it leaves
+the pane. A pane is named by the serial the editor gives it, never by a name
+you choose.
+
+A *stat* makes nothing, which is the whole reason the allocation sits on open:
+`new` is listed in `$m/pane`, so `ls` shows it, and `ls -l`, `find` and
+anything else that stats every name a listing handed it stay inert. acme
+allocates on the walk instead and lets it land inside the new window, so
+`/dev/new/body` works in one step — it can afford that because a Plan 9
+directory read carries every entry's stat and nothing walks. Under a kernel or
+FUSE mount that would be a pane per `ls -l`. Nothing else in the tree can be
+created or removed, and no read creates anything.
`look` and `exec` are the editor's two clicks, one per line of a write, at the
active pane from the root and at that pane from `$m/pane/<n>/look` and
@@ -107,11 +117,12 @@ search and reads empty until set; truncate it to lift it.
buffer differs from its file, whether a write pushes an undo point, and whether
a write scrolls. Truncating `tag` clears the part of the tag you may edit.
-Address state belongs to the pane, not to a client: opening `addr` resets it,
-so two clients addressing the same pane will interfere. `$pane/ctl` reads
-acme's window status line — serial, tag length, body length, a reserved zero,
-the dirty flag, the width in cells, the font and the tab width — and takes the
-one verb `get`.
+Address state belongs to the pane, not to a client: it keeps the last range
+written until someone writes or truncates it, so writing an address and reading
+it back evaluates it, and two clients addressing the same pane will interfere.
+`$pane/ctl` reads acme's window status line — serial, tag length, body length,
+a reserved zero, the dirty flag, the width in cells, the font and the tab
+width — and takes the one verb `get`.
Terminal panes have no file: writing their `body` sends child input, and
truncation does not erase terminal history.
@@ -209,8 +220,9 @@ with tempfile.TemporaryDirectory(prefix='pardes-9p-skill-') as directory:
PY
```
-`new_pane` is `mkdir` plus a read of the index; `execute` writes one line to a
-pane's `exec`. Both are in `test/fs.py`. For terminal tests, use
+`new_pane` walks to `/pane/new` and reads the serial off the directory it
+lands on; `execute` writes one line to a pane's `exec`. Both are in
+`test/fs.py`. For terminal tests, use
`session(..., tty=True)` and read
[test/agent_session.py](../../../test/agent_session.py) for bounded interactive
driving; its readiness text and history threshold are application-specific, and
diff --git a/docs/fs.md b/docs/fs.md
index 0bcc0313..8607a26f 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -79,19 +79,32 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
/log one line per editor event: new|del|rename|save <serial> <name>; reads park
/screen rendered screen JSON; frozen per open handle
/listeners the session's dial addresses
-/pane/ create a directory here to open a pane; remove one to close 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, plus pty/{ctl,status,data} on terminals
/os/ the host filesystem
/src/ the editor's embedded sources, only when built with -Dembed-sources=true
```
-Nothing in the tree is created by list, stat, walk or read. A pane is opened
-by Tcreate in `/pane` (`mkdir`) and closed by Tremove on `/pane/<n>` (`rmdir`),
-which is the only create and the only remove the tree serves. The name a
-create asks for is ignored, since a pane is named by the serial the editor
-gives it: the Rcreate qid names the new directory, and because `/index` is
-ordered by serial its last line is the pane just made.
+A pane is made by **opening** `/pane/new`, and closed by Tremove on
+`/pane/<n>` (`rmdir`), which is the only remove the tree serves; Tcreate is
+refused everywhere, as it is in acme. Reading the open fid answers the serial
+of the pane that open made, so `n=$(cat /pane/new)` makes one and names it in
+a line. Each open makes another pane, and two reads of one fid answer the same
+serial: the open acted, the read only observes. Closing the fid leaves the
+pane.
+
+This is `/net/tcp/clone`'s mechanism, not acme's `new`, and the difference is
+deliberate. acme allocates during the *walk* and lets the walk land inside the
+new window, so `/dev/new/body` works in one step (acme(4): "accessing any file
+in `new` creates a new window"). acme can also afford to list `new`, because a
+Plan 9 directory read carries the stat of every entry and nothing walks. A
+kernel or FUSE mount is not so lucky: it walks and stats each name a listing
+gave it, so an allocate-on-walk name would make a pane per `ls -l`. Allocating
+on open instead keeps `new` listed and `ls` honest — a stat is not an open —
+at the cost of acme's one-step `new/body`. Nothing in the tree is created by
+list, stat, walk or read; only that one open. Every other name in `/pane` is a
+serial.
`/look` and `/exec` are the editor's two clicks, one per line of a write:
@@ -130,6 +143,9 @@ address expression (`#0,#5`, `/pattern/`, `2+1`); `addr` selects what `data`
and `xdata` read or replace, `dot` is the editor's own selection and moving it
scrolls the pane into view, and `limit` bounds a search and reads empty until
it is set. Truncating a range file empties it; truncating `limit` lifts it.
+`addr` belongs to the pane rather than to a client and keeps what was written
+until someone writes or truncates it, so writing an address and reading it
+back evaluates it, which is what acme(4) promises of its own `addr`.
The three flag files `dirty`, `mark` and `scroll` read `0` or `1` and take
`0` or `1`: whether the buffer differs from its file, whether a write pushes
@@ -164,8 +180,8 @@ enables the option by default, so the device can serve its own source.
This is a control filesystem, not a complete POSIX export. Native filenames
may contain up to 255 bytes. Existing regular OS files support read, write,
and truncation to zero; under `/os` protocol create, remove, rename and other
-metadata changes are refused, as is every create and remove in the control
-tree but the pane directories. Ownership and permissions under `/os` are
+metadata changes are refused, as is every create in the control tree and every
+remove in it but a pane directory's. Ownership and permissions under `/os` are
synthetic.
Zero-length truncation accepts the accompanying `mtime` hint sent by Linux
v9fs; the hint is not stored. Standalone timestamp changes remain refused.
@@ -180,9 +196,9 @@ for Unix and TCP, a poll loop for QUIC, and the 9P client for mounts);
`src/fs.zig` keeps host access, mounts, resolution, find and grep.
`zig build fs-test` drives real sessions using the independent Python client
-in `test/ninep.py`; `zig build fs-discovery-test` checks that browsing creates
-nothing, that a pane create and remove work, and that `look`, `exec`, `name`,
-`sel` and `log` behave. `zig build
+in `test/ninep.py`; `zig build fs-discovery-test` checks that browsing
+creates nothing, that the walk to `/pane/new` and a remove work, and that
+`look`, `exec`, `name`, `sel` and `log` behave. `zig build
9p-test` checks the two engine configurations' budgets (the engine's own
tests are cloud9's `zig build test`); `zig build fs-bench` measures
filesystem transactions in the core.
diff --git a/docs/v9fs.md b/docs/v9fs.md
index 8cc97ff9..1b14c21f 100644
--- a/docs/v9fs.md
+++ b/docs/v9fs.md
@@ -13,8 +13,17 @@ cat "$PARDES_MOUNT/index"
cat "$PARDES_MOUNT/README"
cat "$PARDES_MOUNT/pane/$PARDES_PANE/body"
echo 'Msg hello' > "$PARDES_MOUNT/exec"
+awk '{print $1}' "$PARDES_MOUNT/pane/new/ctl"
```
+Walking to `pane/new` opens a pane, and the walk lands on that pane's own
+directory, so its `ctl` answers the serial to use afterwards. The kernel keeps
+the name it walked rather than the one the server answers back, so
+`pane/new` stays in the dentry cache as a name of its own; address the pane as
+`pane/<serial>` once you have it, and expect a fresh path resolution of
+`pane/new` to open another pane. It is not listed in `pane/`, so `ls -l` and
+`find` over the mount create nothing.
+
The mount belongs to that pane's subprocess tree. Other panes and the editor
core keep their original mount namespace. It works in native Linux TTY and SDL
sessions, including detached sessions. A frontend attaching from elsewhere does
diff --git a/features.txt b/features.txt
index bee079a7..a09607ea 100644
--- a/features.txt
+++ b/features.txt
@@ -68,3 +68,8 @@ bias, so only a definite refusal reaps.
After all of the above, do an optimization pass on startup time for the gui and tty platforms: measure first, then optimize. build.zig already has a `perf` step
(gesture latency and bounded terminal stress, with --json and a --base baseline to compare against), so a startup measurement belongs there rather than in a new
harness, and the baseline files are how a regression gets caught later.
+
+9p create semantics: doing an action on read is not how plan9 frames it. The canonical pattern is the clone file -- /net/tcp/clone -- where *opening* allocates the
+object and reading the fid only tells you which one you got; acme's new/ctl is the same shape, and pardes's old /new already keyed its side effect on open. The
+Tcreate that replaced it has a real wart: a pane is named by a server-assigned serial, so the create ignores the client's name and `mkdir /pane/foo` leaves you
+/pane/12. Replace it with /pane/clone (open allocates, read answers the serial) and keep Tremove, which is unambiguously right.
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/`:
diff --git a/test/fs.py b/test/fs.py
index c3ffa440..b20dc8ee 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -106,9 +106,8 @@ def newest(client):
def new_pane(client, contents):
- """mkdir in /pane; the editor names the directory after the new serial."""
- client.mkdir('/pane', 'scratch')
- serial = newest(client)
+ """An open of /pane/new makes a pane; the read names it."""
+ serial = int(client.read('/pane/new'))
if contents:
client.write(f'/pane/{serial}/body', contents)
return serial
@@ -151,7 +150,7 @@ def discovery(binary, embedded=False):
assert ('src' in top) == embedded, (top, embedded)
before = client.read('/index')
guide = client.read('/README')
- assert guide.count(b'\n') <= 45 and b'Msg hello' in guide and b'mkdir' in guide, guide
+ assert guide.count(b'\n') <= 45 and b'Msg hello' in guide and b'pane/new' in guide, guide
assert client.stat('/README')['length'] == len(guide)
# ls/stat/find over the whole tree, without opening, creates nothing.
seen = walk_tree(client)
@@ -183,17 +182,20 @@ def discovery(binary, embedded=False):
status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines())
assert int(status['pid']) > 0 and status['version'] and int(status['panes']) >= 1, status
assert client.read('/exec') == b''
- # mkdir makes a pane, named by the serial the editor gives it.
+ # An open of /pane/new makes a pane and the read names it; each
+ # open makes another. A stat makes none, which is why new can be
+ # listed at all: ls -l stats every name a listing gave it.
log = client.open('/log')
- client.mkdir('/pane', 'one')
- first = newest(client)
- client.mkdir('/pane', 'two')
- second = newest(client)
+ first = int(client.read('/pane/new'))
+ assert first == newest(client)
+ second = int(client.read('/pane/new'))
assert first != second and first != fixture, (first, second)
- assert 'one' not in client.list('/pane') and 'two' not in client.list('/pane')
+ assert 'new' in client.list('/pane'), 'new should be visible to ls'
+ client.stat('/pane/new')
+ assert newest(client) == second, 'a stat of new made a pane'
assert client.read_fid(log) == f'new {first} {root}/+New\n'.encode()
assert client.read_fid(log) == f'new {second} {root}/+New\n'.encode()
- assert set(client.list('/pane')) == {str(fixture), str(first), str(second)}
+ assert set(client.list('/pane')) == {'new', str(fixture), str(first), str(second)}
client.write(f'/pane/{first}/body', b'first pane', truncate=True)
assert client.read(f'/pane/{first}/body') == b'first pane'
assert client.read(f'/pane/{second}/body') == b''
@@ -247,6 +249,7 @@ def discovery(binary, embedded=False):
refused = [lambda: client.write('/exec', b'Msg a\x00b'),
lambda: client.write('/status', b'anything\n'),
lambda: client.mkdir('/', 'x'),
+ lambda: client.mkdir('/pane', 'one'),
lambda: client.create('/pane', 'plain-file'),
lambda: client.remove('/pane/1/body'),
lambda: client.remove('/index')]
@@ -262,7 +265,7 @@ def discovery(binary, embedded=False):
assert client.read('/src/pardes.zig').startswith(b'const std')
assert client.stat('/src/pardes.zig')['mode'] == 0o444
assert b'pub const Pardes' in client.read(f'/pane/{look(client, "/virtual/src/pardes.zig")}/body')
- print('9P discovery: listing/stat/find are inert; create, remove, look, exec, name, sel and log behave')
+ print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel and log behave')
def test(binary, quic=False):
diff --git a/test/v9fs.py b/test/v9fs.py
index fa35388f..30efe9fb 100644
--- a/test/v9fs.py
+++ b/test/v9fs.py
@@ -45,6 +45,7 @@ def worker(mountpoint, socket, uid, gid, original_namespace):
tree = mountpoint
assert {'os', 'index', 'pane', 'status', 'look', 'exec', 'log', 'screen', 'README'} <= set(os.listdir(tree))
assert 'self' not in os.listdir(tree) and 'new' not in os.listdir(tree)
+ assert 'new' not in os.listdir(tree / 'pane'), 'a listing would make a pane per stat'
# A direct connection provides independent evidence for VFS reads/writes.
with Client(socket) as client:
assert (tree / 'index').read_bytes() == client.read('/index')
@@ -55,26 +56,22 @@ def worker(mountpoint, socket, uid, gid, original_namespace):
subprocess.run(['find', str(tree / 'pane'), '-ls'], check=True, capture_output=True, timeout=5)
assert (tree / 'README').read_bytes() == client.read('/README')
assert client.read('/index') == before, 'browsing created panes'
- # mkdir through the kernel mount opens a pane. The editor names it
- # after its serial, not after the name asked for, so the kernel's own
- # revalidation of that name may fail; the index is the answer.
+ # Opening pane/new makes a pane and the read names it, so no trip
+ # through the index. Whether a repeated path reaches the server at all
+ # is the kernel's dentry cache's business, so the second pane comes
+ # over the wire, where the open is exact.
def serials():
return {int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()}
- def mkpane(name):
+ def mkpane():
known = serials()
- try:
- (tree / 'pane' / name).mkdir()
- except FileNotFoundError:
- pass
- made = serials() - known
- assert len(made) == 1, (name, made)
- return made.pop()
+ made = int((tree / 'pane' / 'new').read_bytes())
+ assert made not in known, 'the open of new made no pane'
+ return made
- serial = mkpane('kernel-made')
- another = mkpane('kernel-made-again')
- assert serial != another, 'a second mkdir reused a pane'
- assert not (tree / 'pane' / 'kernel-made').exists()
+ serial = mkpane()
+ another = int(client.read('/pane/new'))
+ assert serial != another, 'a second open of new reused a pane'
(tree / 'pane' / str(another)).rmdir()
assert another not in serials()
pane = tree / 'pane' / str(serial)