summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 17:41:55 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit542dd489149e33915d19878a277e23b3a9c0b070 (patch)
treeaeb557581446621c366a2d3cd5888d25b37c5916
parentc11ffe24f022d61cdd479e1ee946033c42e9a08c (diff)
downloadpardes-542dd489149e33915d19878a277e23b3a9c0b070.tar.gz
pardes-542dd489149e33915d19878a277e23b3a9c0b070.zip
Record /log whether or not anyone reads it, with the editor's messages in it
/log is now one ring (64 KiB, 4 KiB on the board) that records new, del, rename and save, and a msg line for everything the editor says. An open freezes it, so cat log shows what happened lately and ends; writing follow to that same open makes reads past it wait for newer records, and a follower the ring outran reads lost N first. The message log keeps the pane's serial, not its reusable slot. A test now fails when the tree serves a file /README never mentions; it caught pty/status, now documented along with typing through pty/data. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--.agents/skills/pardes-9p/SKILL.md3
-rw-r--r--docs/fs.md18
-rw-r--r--src/fs-help.txt9
-rw-r--r--src/fs.zig10
-rw-r--r--src/memory.zig2
-rw-r--r--src/ninep/events.zig190
-rw-r--r--src/ninep/screen.zig8
-rw-r--r--src/ninep/tree.zig46
-rw-r--r--src/panes.zig2
-rw-r--r--src/pardes.zig19
-rw-r--r--test/fs.py24
-rw-r--r--test/panes.zig1
12 files changed, 266 insertions, 66 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 2e065819..2867ed18 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -46,7 +46,8 @@ $m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag,
$m/status pid, version, panes
$m/look write a line = a right click on it at the active pane
$m/exec write a line = a middle click: an editor command word, or a shell line
-$m/log one record per read: new|del|rename|save <serial> <name>; reads park
+$m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg <serial|-> <text>
+ (exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones)
$m/screen the rendered screen as JSON, frozen per open
$m/listeners this session's dial addresses
$m/pane/new open it to make a pane, read names it; rmdir $m/pane/<n> closes it
diff --git a/docs/fs.md b/docs/fs.md
index 335ee4e6..a0dd86fa 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -76,7 +76,8 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
/status pid, version and pane count
/look write a line: a right click on it at the active pane; read: the serials it touched
/exec write a line: a middle click; read the same serials
-/log one line per editor event: new|del|rename|save <serial> <name>; reads park
+/log recent events, one a line: new|del|rename|save <serial> <name>, msg <serial|-> <text>;
+ write follow to that open to wait for more
/screen rendered screen JSON; frozen per open handle
/listeners the session's dial addresses
/pane/new open it to make a pane; the read answers that pane's serial
@@ -173,8 +174,9 @@ pane. Truncating `tag` clears the part of the tag you may edit.
Stats report real lengths for `index`, `status`, `look`, `exec`, `listeners`,
`name`, `body`, `tag`, `sel`, `ctl`, the range files and the flag files, and
-for `log`, `event` and `pty/data` the length of the record a read would
-answer, which is zero when nothing is waiting. Modes are 0644/0666 (0444 for
+for `event` and `pty/data` the length of the record a read would
+answer, which is zero when nothing is waiting; for `log`, the text an open
+would freeze now. Modes are 0644/0666 (0444 for
read-only files, 0222 for write-only); mtime is the pane's last edit or the
process start. The qid version of `body`, `data` and `xdata` is the pane's
revision, so a stat sees an edit land without reading the text; every other
@@ -182,8 +184,14 @@ file leaves it zero rather than promise a version it cannot keep. Directory
entries carry no sizes, and neither does `/screen`, which has no length until
an open renders its frame; stat the entry.
-`/log` records `new`, `del`, `rename` and `save` while at least one client
-holds it open; a read parks until a record arrives. `/screen` returns JSON with
+`/log` is one ring (64 KiB) that records whether or not anyone reads it:
+`new`, `del`, `rename` and `save <serial> <name>`, and `msg <serial|-> <text>`
+for every line the editor says, builtins announcing themselves included.
+Control characters in a record become spaces, so a record is one line. An
+open freezes the ring's text the way `/screen` freezes a frame: reads walk it
+and end. Writing `follow` to that same open makes reads past it park for the
+next record, one per read; a follower the ring outran reads `lost N` first.
+Closing the open is the only way back, as with rio's `consctl`. `/screen` returns JSON with
`cols`, `rows`, `cursor`, a `styles` table, and row-major `cells` of
`[grapheme, style_index]`. Each open freezes one frame until close. A
terminal `body` freezes its history on the first read of each open handle;
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 7f55b3af..dca2c14e 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -5,7 +5,8 @@ index one line per pane: serial, kind (text|term|pdf|image), dirty flag, n
status pid, version and pane count
look write a line: a right click on it at the active pane; read: the serials it touched
exec write a line: a middle click, an editor command word or a shell line; read the same
-log one line per editor event (new/del/rename/save <serial> <name>); reads wait
+log recent events, one a line: new/del/rename/save <serial> <name>, and
+ msg <serial|-> <text> for what the editor said; write follow to wait
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
pane/new open it to make a pane; the read answers that pane's serial
@@ -26,9 +27,10 @@ Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too
echo '#0,#5' > $m/pane/$n/addr; printf NEW >> $m/pane/$n/data replace bytes 0..5
cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it
cat $m/pane/$n/dirty; echo 0 > $m/pane/$n/dirty is it modified? say it is not
- cat $m/log block until a pane is made, renamed, saved or closed
+ exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more
cat $m/pane/$n/event hold it open to own that pane's clicks
echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT
+ printf 'ls\r' > $m/pane/$n/pty/data type into a terminal; pty/status: cols rows busy
Pitfalls, one each:
Each open of pane/new makes another pane; two reads of one fid name the same one.
@@ -39,6 +41,5 @@ Pitfalls, one each:
Writing body appends; open it with truncation to replace; writes are whole UTF-8 sequences.
Truncating tag clears the part you may edit; truncating dot or addr empties it.
A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
- screen is frozen per open handle as well; reopen it for a fresh frame.
- A failing command is reported in the editor, not as a write error; a bad line fails the write.
+ A failing command is reported in the editor and in log, not as a write error; a bad line fails the write.
pane/<n>/ctl reads acme's window status line and takes one verb, get, which reloads from disk.
diff --git a/src/fs.zig b/src/fs.zig
index 84ab3b95..fef4ffe1 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -308,8 +308,10 @@ test "virtual writes enforce permissions even when contents are empty" {
const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
for ([_][]const u8{ "", "bytes" }) |bytes| {
- for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/README", "/n/self/log" }) |path|
+ for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/README" }) |path|
try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes));
+ // /log takes one word, `follow`, and only on the open it changes.
+ if (bytes.len > 0) if (write(p, "/n/self/log", bytes)) |_| return error.TestUnexpectedResult else |_| {};
try std.testing.expectError(error.IsDirectory, write(p, "/virtual/pane", bytes));
try std.testing.expectError(error.FileNotFound, write(p, "/virtual/missing", bytes));
try std.testing.expectError(error.FileNotFound, write(p, "/virtual/cons", bytes));
@@ -1240,9 +1242,9 @@ pub const Namespace = struct {
panes: [MAX_PANES]tree.pane.State = @splat(.{}),
listeners: u16 = 0,
origin: u8 = 'K',
- /// The editor-wide event stream, recorded only while /log is held open.
- log: tree.events.Queue = .{},
- log_readers: u16 = 0,
+ /// The editor-wide event ring: panes made, renamed, saved and closed, and
+ /// what the editor said. Recorded whether or not anyone reads /log.
+ log: tree.events.Queue = .{ .cap = limits.log_bytes },
/// What the last look or exec created or touched, answered by reading
/// either of those files.
results: [MAX_PANES]u32 = undefined,
diff --git a/src/memory.zig b/src/memory.zig
index b8a64a1a..7b6ee2c0 100644
--- a/src/memory.zig
+++ b/src/memory.zig
@@ -17,6 +17,8 @@ pub const limits = struct {
pub const wrap_rows = if (board) 128 else 256;
pub const undo_max = if (board) 16 else 256;
pub const message_log = if (board) 16 else 128;
+ /// /log's ring, which records whether or not anyone reads it.
+ pub const log_bytes: usize = if (board) 4 * KiB else 64 * KiB;
pub const max_tag_tail: usize = if (board) 512 else 4096;
pub const host_path_cap: usize = if (board) 0 else 4095;
/// -Dembed-sources: the editor serves its own sources under /src.
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index d94fb54d..472a114f 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -25,6 +25,10 @@ pub fn now() u32 {
pub const Queue = struct {
buf: std.ArrayList(u8) = .empty,
head: usize = 0,
+ cap: usize = queue_cap,
+ /// Records pushed out of the front to make room: the sequence number of
+ /// the oldest one still held.
+ dropped: u64 = 0,
pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void {
q.buf.deinit(gpa);
@@ -33,9 +37,10 @@ pub const Queue = struct {
pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void {
if (record.len > std.math.maxInt(u32)) return;
- while (q.buf.items.len - q.head + record.len + 4 > queue_cap) {
+ while (q.buf.items.len - q.head + record.len + 4 > q.cap) {
if (q.peek() == null) return;
q.pop();
+ q.dropped += 1;
}
q.compact();
var head: [4]u8 = undefined;
@@ -136,15 +141,114 @@ pub fn announce(p: *Pardes) void {
}
}
-/// Records `<kind> <serial> <name>` while a reader holds /log open.
+/// Records `<kind> <serial> <name>`.
pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
- if (p.fs.log_readers == 0) return;
var buf: [4096 + 64]u8 = undefined;
const name = pane_files.nameOf(pane);
- const record = std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name[0..@min(name.len, 4096)] }) catch return;
+ pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name[0..@min(name.len, 4096)] }) catch return);
+}
+
+/// Records `msg <serial> <text>` for what the editor said, `-` for no pane.
+pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void {
+ var buf: [pardes.LoggedMessage.cap + 32]u8 = undefined;
+ pushLog(p, (if (serial == 0)
+ std.fmt.bufPrint(&buf, "msg - {s}\n", .{text})
+ else
+ std.fmt.bufPrint(&buf, "msg {d} {s}\n", .{ serial, text })) catch return);
+}
+
+/// The log is one ring that records whether or not anyone reads it. A record
+/// is one line: a newline in a message or a name would read as two records.
+fn pushLog(p: *Pardes, record: []u8) void {
+ for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') {
+ c.* = ' ';
+ };
p.fs.log.push(p.gpa, record);
}
+/// An open freezes the ring's text, so `cat log` answers what happened lately
+/// and ends; `next` is where a follower picks up after it.
+pub fn openLog(p: *Pardes, req: Req) Reply {
+ for (&p.fs.snapshots, 0..) |*slot, i| {
+ if (slot.node != 0) continue;
+ var text: std.ArrayList(u8) = .empty;
+ var at = p.fs.log.head;
+ const items = p.fs.log.buf.items;
+ var n: u64 = 0;
+ while (at + 4 <= items.len) : (n += 1) {
+ const len = std.mem.readInt(u32, items[at..][0..4], .little);
+ text.appendSlice(p.gpa, items[at + 4 ..][0..len]) catch {
+ text.deinit(p.gpa);
+ return Reply.fail(req.tag, E.NOMEM);
+ };
+ at += 4 + len;
+ }
+ const bytes = text.toOwnedSlice(p.gpa) catch return Reply.fail(req.tag, E.NOMEM);
+ slot.* = .{ .node = req.node, .bytes = bytes, .next = p.fs.log.dropped + n };
+ return .{ .tag = req.tag, .handle = @intCast(i + 1) };
+ }
+ return Reply.fail(req.tag, E.NFILE);
+}
+
+/// Past the frozen text, a following open reads newer records one per read,
+/// parked until one arrives; one that fell behind the ring hears `lost N`.
+pub fn readLog(p: *Pardes, req: Req) Reply {
+ if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL);
+ const slot = &p.fs.snapshots[req.handle - 1];
+ if (slot.node != req.node) return Reply.fail(req.tag, E.INVAL);
+ const frozen = slot.bytes orelse &.{};
+ const out = p.fs.stage(p.gpa);
+ if (!slot.follow or req.off < frozen.len) {
+ const off = @min(req.off, frozen.len);
+ const len = @min(frozen.len - off, req.size);
+ out.appendSlice(p.gpa, frozen[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } };
+ }
+ const q = &p.fs.log;
+ if (slot.next < q.dropped) {
+ out.print(p.gpa, "lost {d}\n", .{q.dropped - slot.next}) catch return Reply.fail(req.tag, E.NOMEM);
+ slot.next = q.dropped;
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
+ }
+ // ponytail: walks the ring from its oldest record; it holds at most log_bytes.
+ var at = q.head;
+ var seq = q.dropped;
+ while (at + 4 <= q.buf.items.len) : (seq += 1) {
+ const len = std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
+ if (seq == slot.next) {
+ if (req.size < len) return Reply.fail(req.tag, E.INVAL);
+ out.appendSlice(p.gpa, q.buf.items[at + 4 ..][0..len]) catch return Reply.fail(req.tag, E.NOMEM);
+ slot.next += 1;
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } };
+ }
+ at += 4 + len;
+ }
+ return .{ .tag = req.tag, .status = .again };
+}
+
+/// `follow`, written to an open of the log, makes that open wait for newer
+/// records instead of ending; closing it is the way back.
+pub fn writeLog(p: *Pardes, req: Req) Reply {
+ if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL);
+ const slot = &p.fs.snapshots[req.handle - 1];
+ if (slot.node != req.node) return Reply.fail(req.tag, E.INVAL);
+ if (!std.mem.eql(u8, std.mem.trim(u8, req.data, " \t\r\n"), "follow")) return Reply.fail(req.tag, E.INVAL);
+ slot.follow = true;
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// The length a stat reports: the text an open would freeze now.
+pub fn logLen(q: *const Queue) u64 {
+ var n: u64 = 0;
+ var at = q.head;
+ while (at + 4 <= q.buf.items.len) {
+ const len = std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
+ n += len;
+ at += 4 + len;
+ }
+ return n;
+}
+
// ---- per-pane event records ----
pub const max_record_text = 256;
@@ -517,49 +621,81 @@ test "writing an event record back performs the action it names" {
try testing.expectEqual(@as(u8, 'F'), p.fs.origin);
}
-test "the log parks until a pane is created, renamed, saved or deleted" {
+test "the log records whether or not anyone reads, and an open that follows waits" {
const gpa = testing.allocator;
const p = try withFile(gpa, "logged\n");
defer p.deinit();
const log = @intFromEnum(tree.TopFile.log);
- _ = try th.newPane(p);
- try testing.expect(p.fs.log.empty());
+ var expected: [4200]u8 = undefined;
+
+ // Nobody holds /log open, and the pane is recorded anyway.
+ const early = try th.newPane(p);
+ const early_line = try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ early, pane_files.nameOf(p.panes[p.paneBySerial(early).?].?) });
+ const early_owned = try gpa.dupe(u8, early_line);
+ defer gpa.free(early_owned);
- const opened = call(p, .{ .tag = 1, .op = .open, .node = log });
- try testing.expectEqual(Status.ok, opened.reply.status);
- try testing.expectEqual(@as(u16, 1), p.fs.log_readers);
- try testing.expectEqual(Status.again, rd(p, log, 0, 4096).reply.status);
+ // A plain open answers what happened so far, and ends: `cat log` never hangs.
+ const plain = call(p, .{ .tag = 1, .op = .open, .node = log });
+ try testing.expectEqual(Status.ok, plain.reply.status);
+ const first = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = plain.reply.handle, .size = 8192 });
+ try testing.expect(std.mem.endsWith(u8, first.bytes, early_owned));
+ const past = call(p, .{ .tag = 3, .op = .read, .node = log, .handle = plain.reply.handle, .off = first.bytes.len, .size = 8192 });
+ try testing.expectEqual(Status.ok, past.reply.status);
+ try testing.expectEqual(@as(usize, 0), past.bytes.len);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = log, .handle = plain.reply.handle });
+
+ // `follow` on an open makes it wait past what it froze, one record a read.
+ const f = call(p, .{ .tag = 5, .op = .open, .node = log });
+ const fh = f.reply.handle;
+ const frozen = call(p, .{ .tag = 6, .op = .read, .node = log, .handle = fh, .size = 8192 }).bytes.len;
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 7, .op = .write, .node = log, .handle = fh, .data = "tail\n" }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 8, .op = .write, .node = log, .handle = fh, .data = "follow\n" }).reply.status);
+ const rdf = struct {
+ fn next(pp: *Pardes, node: u64, h: u32, off: u64) th.Answer {
+ return call(pp, .{ .tag = 9, .op = .read, .node = node, .handle = h, .off = off, .size = 8192 });
+ }
+ };
+ try testing.expectEqual(Status.again, rdf.next(p, log, fh, frozen).reply.status);
const serial = try th.newPane(p);
const id = p.paneBySerial(serial).?;
- var expected: [4200]u8 = undefined;
try testing.expectEqualStrings(
try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p.panes[id].?) }),
- rd(p, log, 0, 4096).bytes,
+ rdf.next(p, log, fh, frozen).bytes,
);
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/logged.txt\n").reply.status);
try testing.expectEqualStrings(
try std.fmt.bufPrint(&expected, "rename {d} /tmp/logged.txt\n", .{serial}),
- rd(p, log, 0, 4096).bytes,
+ rdf.next(p, log, fh, frozen).bytes,
);
- const saving = wr(p, Node.of(serial, .exec), "Save\n");
- try testing.expectEqual(Status.ok, saving.reply.status);
- try testing.expect(saving.saved);
- p.perform(.{ .save_file = .{ .pane = @intCast(id) } });
+ // What the editor says is in the same stream, one line even when the
+ // message had several.
+ p.setMessage(id, "save: two\nlines");
try testing.expectEqualStrings(
- try std.fmt.bufPrint(&expected, "save {d} /tmp/logged.txt\n", .{serial}),
- rd(p, log, 0, 4096).bytes,
+ try std.fmt.bufPrint(&expected, "msg {d} save: two lines\n", .{serial}),
+ rdf.next(p, log, fh, frozen).bytes,
);
+ // A builtin announces itself on the message row, so the log says which ran.
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status);
try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&expected, "msg {d} Del\n", .{serial}),
+ rdf.next(p, log, fh, frozen).bytes,
+ );
+ try testing.expectEqualStrings(
try std.fmt.bufPrint(&expected, "del {d} /tmp/logged.txt\n", .{serial}),
- rd(p, log, 0, 4096).bytes,
+ rdf.next(p, log, fh, frozen).bytes,
);
- try testing.expectEqual(Status.again, rd(p, log, 0, 4096).reply.status);
+ try testing.expectEqual(Status.again, rdf.next(p, log, fh, frozen).reply.status);
- _ = call(p, .{ .tag = 2, .op = .release, .node = log, .handle = opened.reply.handle });
- try testing.expectEqual(@as(u16, 0), p.fs.log_readers);
- _ = try th.newPane(p);
- try testing.expect(p.fs.log.empty());
- try testing.expectEqual(@as(usize, 0), p.fs.log.buf.capacity);
+ // A follower the ring outran hears how much it missed, then carries on.
+ var filler: [200]u8 = @splat('x');
+ for (0..p.fs.log.cap / filler.len + 8) |i| {
+ _ = std.fmt.bufPrint(&filler, "{d:0>6}", .{i}) catch unreachable; // a repeat would collapse
+ p.setMessage(0, &filler);
+ }
+ const lost = rdf.next(p, log, fh, frozen).bytes;
+ try testing.expect(std.mem.startsWith(u8, lost, "lost "));
+ try testing.expect(std.mem.startsWith(u8, rdf.next(p, log, fh, frozen).bytes, "msg "));
+ _ = call(p, .{ .tag = 10, .op = .release, .node = log, .handle = fh });
+ for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0);
}
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
index 60fb5951..245a31ef 100644
--- a/src/ninep/screen.zig
+++ b/src/ninep/screen.zig
@@ -11,7 +11,13 @@ const Req = tree.Req;
const Reply = tree.Reply;
const E = tree.E;
-pub const Snapshot = struct { node: u64 = 0, bytes: ?[]const u8 = null };
+pub const Snapshot = struct {
+ node: u64 = 0,
+ bytes: ?[]const u8 = null,
+ /// /log only: this open waits for records after `bytes`, from `next`.
+ follow: bool = false,
+ next: u64 = 0,
+};
pub const snapshot_slots = 32;
pub fn render(p: *Pardes) ![]u8 {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index aca8d109..f98429ab 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -117,8 +117,8 @@ pub const TopFile = enum(u4) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
.root, .pane => 0o755,
- .look, .exec => 0o666,
- .README, .index, .status, .log, .screen, .listeners, .new => 0o444,
+ .look, .exec, .log => 0o666,
+ .README, .index, .status, .screen, .listeners, .new => 0o444,
};
}
@@ -375,7 +375,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.README => fs.help.len,
.status => ctl.statusLen(p),
.look, .exec => ctl.resultsLen(p),
- .log => events.pending(&p.fs.log),
+ .log => events.logLen(&p.fs.log),
.listeners => screen.listenersLen(p),
};
}
@@ -497,7 +497,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
.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,
+ .log => return events.openLog(p, req),
else => {},
},
.pane => |t| {
@@ -543,10 +543,7 @@ fn releaseHandle(p: *Pardes, req: Req) Reply {
if (screen.releaseSnapshot(p, req)) return .{ .tag = req.tag };
const target = Node.target(req.node) orelse return .{ .tag = req.tag };
switch (target) {
- .top => |f| if (f == .log and p.fs.log_readers > 0) {
- p.fs.log_readers -= 1;
- if (p.fs.log_readers == 0) p.fs.log.clearAndFree(p.gpa);
- },
+ .top => {},
.pane => |t| {
if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag };
const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag };
@@ -597,7 +594,7 @@ fn read(p: *Pardes, req: Req, target: Target) Reply {
},
.status => ctl.readStatus(p, req),
.look, .exec => ctl.readResults(p, req),
- .log => events.readQueue(p, req, &p.fs.log),
+ .log => events.readLog(p, req),
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
// The serial the open handed this fid, so that two reads of one
@@ -623,6 +620,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
switch (target) {
.top => |f| return switch (f) {
.look, .exec => ctl.command(p, req, null, f == .exec),
+ .log => events.writeLog(p, req),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
@@ -730,6 +728,36 @@ test "filesystem pane creation and truncation cancel Look hover" {
}
}
+test "README names every file the tree serves" {
+ // /README is the whole manual a client gets; a file it never mentions
+ // is one nobody can find without reading this source.
+ const words = struct {
+ fn has(name: []const u8) bool {
+ var it = std.mem.tokenizeAny(u8, fs.help, " \t\n,.:;()'\"$<>|=/");
+ while (it.next()) |w| if (std.mem.eql(u8, w, name)) return true;
+ return false;
+ }
+ };
+ inline for (@typeInfo(TopFile).@"enum".fields) |f| {
+ const t: TopFile = @enumFromInt(f.value);
+ if (t != .root and !words.has(t.fileName())) {
+ std.debug.print("README does not mention /{s}\n", .{t.fileName()});
+ return error.Undocumented;
+ }
+ }
+ inline for (@typeInfo(PaneFile).@"enum".fields) |f| {
+ const t: PaneFile = @enumFromInt(f.value);
+ const ok = if (t == .dir) true else if (t.inPty() and t != .pty)
+ std.mem.indexOf(u8, fs.help, "pty/" ++ comptime @as(PaneFile, @enumFromInt(f.value)).fileName()) != null
+ else
+ words.has(t.fileName());
+ if (!ok) {
+ std.debug.print("README does not mention pane file {s}\n", .{@tagName(t)});
+ return error.Undocumented;
+ }
+ }
+}
+
test "readdir lists the root and a pane directory without creating anything" {
const gpa = testing.allocator;
const p = try withFile(gpa, "hello\n");
diff --git a/src/panes.zig b/src/panes.zig
index 0b4204aa..156d443a 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -3072,7 +3072,7 @@ pub const Output = struct {
errdefer out.deinit();
var i: usize = 0;
while (p.messageLog(i)) |m| : (i += 1) {
- if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane});
+ if (m.serial != 0) try out.writer.print("{d}: ", .{m.serial});
try out.writer.writeAll(m.slice());
if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats});
try out.writer.writeByte('\n');
diff --git a/src/pardes.zig b/src/pardes.zig
index ca09caa1..6d294230 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -672,9 +672,11 @@ test "the message log keeps what the row forgets, and collapses repeats" {
// The same text from a DIFFERENT pane is a different event: one pane's
// failure must not be recorded as another's.
- p.setMessage(1, "save: AccessDenied");
+ p.newScratchBelow(0);
+ const other = p.active;
+ p.setMessage(other, "save: AccessDenied");
try std.testing.expectEqual(@as(usize, 3), p.messages_len);
- try std.testing.expectEqual(@as(u8, 1), p.messageLog(2).?.pane);
+ try std.testing.expectEqual(p.panes[other].?.serial, p.messageLog(2).?.serial);
// Progress is NOT logged: it arrives several times a second for a whole
// index and would push everything else out (`setStatus`).
@@ -6254,8 +6256,10 @@ pub const LoggedMessage = struct {
pub const cap = 256;
text: [cap]u8 = undefined,
len: u16 = 0,
- /// Which pane said it, or 0xff for one said to no pane in particular.
- pane: u8 = 0xff,
+ /// The serial of the pane that said it, or 0 for none in particular. A
+ /// serial, not a slot: slots are reused, so an old line would name
+ /// whichever pane took the slot since.
+ serial: u32 = 0,
/// How many times in a row. Saturating: a key held down against the same
/// refusal is one event, not four hundred.
repeats: u16 = 0,
@@ -7432,11 +7436,11 @@ pub const Pardes = struct {
fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
if (text.len == 0) return;
- const pane: u8 = if (id < MAX_PANES) @intCast(id) else 0xff;
+ const serial: u32 = if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
const kept = text[0..@min(text.len, LoggedMessage.cap)];
if (p.messages_len > 0) {
const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log];
- if (last.pane == pane and
+ if (last.serial == serial and
std.mem.eql(u8, Message.body(last.slice()), Message.body(kept)))
{
// The NEWEST wording wins, so the row carries the latest clock
@@ -7447,10 +7451,11 @@ pub const Pardes = struct {
return;
}
}
+ ctlfs.events.noteMessage(p, serial, kept);
const slot = &p.messages[p.messages_head];
slot.len = @intCast(kept.len);
@memcpy(slot.text[0..slot.len], kept);
- slot.pane = pane;
+ slot.serial = serial;
slot.repeats = 1;
p.messages_head = (p.messages_head + 1) % limits.message_log;
p.messages_len = @min(p.messages_len + 1, limits.message_log);
diff --git a/test/fs.py b/test/fs.py
index 33bcdfa9..48104e3f 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -185,7 +185,11 @@ def discovery(binary, embedded=False):
# 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')
+ # /log answers the recent past and ends; `follow` written to the
+ # same open makes its reads past that wait for new records.
+ log = client.open('/log', 2)
+ frozen = len(client.read_fid(log))
+ client.rpc(118, struct.pack('<IQI', log, 0, 7) + b'follow\n')
first = int(client.read('/pane/new'))
assert first == newest(client)
second = int(client.read('/pane/new'))
@@ -193,8 +197,14 @@ def discovery(binary, embedded=False):
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()
+ # What the editor said is in the stream too, and the fixture's own
+ # `new` lands on whichever side of the open its first update did.
+ def pane_event():
+ while (record := client.read_fid(log, frozen)).startswith((b'msg ', f'new {fixture} '.encode())):
+ pass
+ return record
+ assert pane_event() == f'new {first} {root}/+New\n'.encode()
+ assert pane_event() == f'new {second} {root}/+New\n'.encode()
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'
@@ -206,7 +216,7 @@ def discovery(binary, embedded=False):
assert client.read(f'/pane/{first}/name') == f'{root}/+New\n'.encode()
client.write(f'/pane/{first}/name', b'notes.txt\n')
assert client.read(f'/pane/{first}/name') == f'{root}/notes.txt\n'.encode()
- assert client.read_fid(log) == f'rename {first} {root}/notes.txt\n'.encode()
+ assert pane_event() == f'rename {first} {root}/notes.txt\n'.encode()
assert client.stat(f'/pane/{first}/name')['length'] == len(f'{root}/notes.txt\n')
# Copying addr onto dot selects that range; sel reads and replaces it.
client.write(f'/pane/{first}/addr', b'#0,#5')
@@ -223,11 +233,11 @@ def discovery(binary, embedded=False):
assert client.read(f'/pane/{first}/dirty') == b'1\n'
client.write(f'/pane/{first}/exec', b'Save\n')
assert (root / 'notes.txt').read_bytes() == b'FIRST pane'
- assert client.read_fid(log) == f'save {first} {root}/notes.txt\n'.encode()
+ assert pane_event() == f'save {first} {root}/notes.txt\n'.encode()
assert client.read('/exec') == f'{first}\n'.encode()
assert client.read(f'/pane/{first}/dirty') == b'0\n'
client.remove(f'/pane/{second}')
- assert client.read_fid(log) == f'del {second} {root}/+New\n'.encode()
+ assert pane_event() == f'del {second} {root}/+New\n'.encode()
assert str(second) not in client.list('/pane')
# look opens a file; the serial reads back.
(root / 'opened.txt').write_bytes(b'one\ntwo\nthree\n')
@@ -236,7 +246,7 @@ def discovery(binary, embedded=False):
assert opened not in (fixture, first, second), opened
assert client.read(f'/pane/{opened}/body') == b'one\ntwo\nthree\n'
assert client.read(f'/pane/{opened}/name') == f'{root}/opened.txt\n'.encode()
- assert client.read_fid(log) == f'new {opened} {root}/opened.txt\n'.encode()
+ assert pane_event() == f'new {opened} {root}/opened.txt\n'.encode()
client.write(f'/pane/{first}/look', f'{root}/opened.txt:3\n'.encode())
assert client.read('/look') == f'{opened}\n'.encode(), 'a repeated look targets the open pane'
assert {str(fixture), str(first), str(opened)} <= set(client.list('/pane'))
diff --git a/test/panes.zig b/test/panes.zig
index 2d3aed43..2f307cb5 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -2193,6 +2193,7 @@ test "document panes ignore terminal output without allocating terminal state" {
const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("document body\n");
+ p.update(.tick); // the step that installed it records it in /log
while (p.nextEffect()) |_| {}
const revision = pane.file.?.revision;
const allocations = allocator.alloc_index;