diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 10:55:10 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 2a71bff5f06ba3e785a5eb96a8377e2cab8e2cdd (patch) | |
| tree | 05ba873c6696b4e88261e8fe20482c498ed52eb6 | |
| parent | 68a74ec034a24c9b016497880e375f291498ae5f (diff) | |
| download | pardes-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.md | 8 | ||||
| -rw-r--r-- | src/ninep/events.zig | 61 | ||||
| -rw-r--r-- | src/pardes.zig | 1 | ||||
| -rw-r--r-- | test/fs.py | 4 |
4 files changed, 72 insertions, 2 deletions
@@ -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 @@ -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') |
