summaryrefslogtreecommitdiff
path: root/src/ninep
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 /src/ninep
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]>
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/events.zig190
-rw-r--r--src/ninep/screen.zig8
-rw-r--r--src/ninep/tree.zig46
3 files changed, 207 insertions, 37 deletions
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");