summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 10:55:10 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit2a71bff5f06ba3e785a5eb96a8377e2cab8e2cdd (patch)
tree05ba873c6696b4e88261e8fe20482c498ed52eb6
parent68a74ec034a24c9b016497880e375f291498ae5f (diff)
downloadpardes-2a71bff5f06ba3e785a5eb96a8377e2cab8e2cdd.tar.gz
pardes-2a71bff5f06ba3e785a5eb96a8377e2cab8e2cdd.zip
A refused or failed write is an err record in the log, naming the file and the reason
Through a mount every refusal reads as Invalid argument, so a shell user could not tell a missing regexp match from a bad ctl word. Every write or truncation the tree refuses or that fails now adds err <serial|-> <file>: <why> to /log. No per-pane readable error file: acme's errors only takes text, and one stream is simpler to watch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/fs.md8
-rw-r--r--src/ninep/events.zig61
-rw-r--r--src/pardes.zig1
-rw-r--r--test/fs.py4
4 files changed, 72 insertions, 2 deletions
diff --git a/docs/fs.md b/docs/fs.md
index 714a8d2c..6eb56537 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -297,7 +297,13 @@ an open renders its frame; stat the entry.
`new`, `del`, `rename` (a terminal's too, as its shell changes directory,
since a terminal is named by its directory) and `save <serial> <name>`, and `msg <serial|-> <text>`
for every line the editor says, repeats included (with `verbose` on, that
-includes each builtin announcing itself as it runs). A `msg` said while a
+includes each builtin announcing itself as it runs), and `err <serial|->
+<file>: <why>` for every write or truncation the tree refused or that
+failed -- through a mount a shell sees only the errno its kernel mapped the
+reply to, usually `Invalid argument`, and this is the reason (`err 3 addr:
+no match for regexp`). There is no per-pane error file to read instead:
+acme's `errors` only takes text, and one record stream is simpler to watch
+than a file per pane. A `msg` said while a
pane is being made can precede that pane's `new`; panes present at boot are
recorded before anything else.
Control characters in a record become spaces, so a record is one line. An
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 172f5c00..8ee25b74 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -4,6 +4,7 @@ const std = @import("std");
const pardes = @import("../pardes.zig");
const exec = @import("../exec.zig");
const look = @import("../look.zig");
+const cloud9 = @import("cloud9");
const tree = @import("tree.zig");
const pane_files = @import("pane.zig");
@@ -149,6 +150,36 @@ pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void {
std.fmt.bufPrint(&buf, "msg {d} {s}\n", .{ serial, text })) catch return);
}
+/// Records `err <serial|-> <file>: <why>` for a write or truncation that was
+/// refused or failed: through a mount a shell sees only the errno the
+/// kernel mapped the reply to (`Invalid argument`), and here is the reason.
+/// The log is the one place for it, as acme's `errors` file takes text and
+/// answers nothing: a per-pane readable error file would be a second.
+pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
+ const why = if (reply.ename.len > 0) reply.ename else cloud9.fs.errString(reply.errno);
+ var name: [16]u8 = undefined;
+ var serial: []const u8 = "-";
+ var file: []const u8 = "os";
+ if (req.node & tree.sources.archive_node != 0) file = "src";
+ if (req.node != pardes.filesystem.os_root and req.node & pardes.filesystem.os_node == 0 and req.node & tree.sources.archive_node == 0) {
+ if (tree.Node.target(req.node)) |t| switch (t) {
+ .top => |f| file = f.fileName(),
+ .pane => |pt| {
+ serial = std.fmt.bufPrint(&name, "{d}", .{pt.serial}) catch "-";
+ file = switch (pt.file) {
+ .pty_ctl => "pty/ctl",
+ .pty_status => "pty/status",
+ .pty_data => "pty/data",
+ .pty_run => "pty/run",
+ else => pt.file.fileName(),
+ };
+ },
+ };
+ }
+ var buf: [256]u8 = undefined;
+ pushLog(p, std.fmt.bufPrint(&buf, "err {s} {s}: {s}\n", .{ serial, file, why[0..@min(why.len, 200)] }) 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 {
@@ -690,6 +721,8 @@ test "the log records whether or not anyone reads, and an open that follows wait
return call(pp, .{ .tag = 9, .op = .read, .node = node, .handle = h, .off = off, .size = 8192 });
}
};
+ // The word it refused is on record, and then there is nothing new.
+ try testing.expect(std.mem.startsWith(u8, rdf.next(p, log, fh, frozen).bytes, "err - log: "));
try testing.expectEqual(Status.again, rdf.next(p, log, fh, frozen).reply.status);
const serial = try th.newPane(p);
@@ -761,6 +794,34 @@ test "the log records whether or not anyone reads, and an open that follows wait
_ = call(p, .{ .tag = 13, .op = .release, .node = log, .handle = cut.reply.handle });
}
+test "a refused or failed write is an err record in the log, saying which file and why" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged before the log is opened
+ while (p.nextEffect()) |_| {}
+ const serial = serialOf(p);
+ const log = @intFromEnum(tree.TopFile.log);
+ const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 1 << 16 }).bytes.len;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
+ var want: [128]u8 = undefined;
+ const next = struct {
+ fn record(pp: *Pardes, h: u32, off: usize) []const u8 {
+ return call(pp, .{ .tag = 4, .op = .read, .node = @intFromEnum(tree.TopFile.log), .handle = h, .off = off, .size = 4096 }).bytes;
+ }
+ };
+ _ = wr(p, Node.of(serial, .addr), "/nowhere/");
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen));
+ _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus");
+ try testing.expectEqualStrings("err - ctl: unknown control message \"Bogus\"\n", next.record(p, f, frozen));
+ _ = wr(p, @intFromEnum(tree.TopFile.focus), "99999");
+ try testing.expectEqualStrings("err - focus: no such window\n", next.record(p, f, frozen));
+ // A write that succeeds says nothing.
+ _ = wr(p, Node.of(serial, .addr), "#0");
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).reply.status);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = log, .handle = f });
+}
+
test "opens of the log share the open records, and a closed one frees its record" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
diff --git a/src/pardes.zig b/src/pardes.zig
index 084b2216..9d336db3 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4642,6 +4642,7 @@ pub const Pardes = struct {
p.raw_hover_intent = false;
look.cancelLookHover(p);
const reply = ctlfs.handle(p, req);
+ if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply);
// The request was a whole step of its own, so it settles the way a
// step does: the cursor and scroll reconciled, the scripted panes
// told, and the panes it made announced to /log now rather than at
diff --git a/test/fs.py b/test/fs.py
index 816f416c..c56c6298 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -200,7 +200,7 @@ def discovery(binary, embedded=False):
assert newest(client) == second, 'a stat of new made a pane'
# What the editor said is in the stream too.
def pane_event():
- while (record := client.read_fid(log, frozen)).startswith(b'msg '):
+ while (record := client.read_fid(log, frozen)).startswith((b'msg ', b'err ')):
pass
return record
assert pane_event() == f'new {first} {root}/+New\n'.encode()
@@ -356,6 +356,8 @@ def discovery(binary, embedded=False):
except OSError as refused:
assert why in str(refused), refused
assert client.read(f'/pane/{scratch}/body') == b'HOWDY world\nsecond line\n'
+ # Through a mount both read as EINVAL; the log says which and why.
+ assert f'err {scratch} addr: no match for regexp\n'.encode() in client.read('/log')
client.remove(f'/pane/{scratch}')
print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel, log, ctl lock, focus, the ctl split and commands behave')