From 554181b73fd53b898841203066382e4287b83e89 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 06:05:08 -0300 Subject: One rule for failing builtins: the write fails, one err, no msg A builtin that failed through a ctl failed the write and logged its err, but through look, exec, tagexec or a column's exec it only said so on the message row, logged as a msg, and the write succeeded. Now every click write runs its builtin as a ctl line does (ctl.captured): a failure fails the write, with its words (EINVAL for malformed input, else EIO or what the words name), logs one err and no msg or announcement. get's Modified and a look miss are shown but logged once, as their err. A control character's refusal names its reason, a failed click reads back nothing, and look never reads back a pane closed since. The special case for a cut-short Edit through exec goes (the rule covers it); fs.md's table of exceptions becomes the one rule, as does the skill. Co-Authored-By: Claude Opus 5.5 --- src/Messages.zig | 5 +++-- src/builtins.zig | 16 +------------- src/fs.zig | 4 ++++ src/look.zig | 3 +++ src/ninep/cols.zig | 4 ++-- src/ninep/ctl.zig | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 73 insertions(+), 22 deletions(-) (limited to 'src') diff --git a/src/Messages.zig b/src/Messages.zig index 7970908e..d8aeea0c 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -487,7 +487,8 @@ pub fn clip(text: []const u8, max: usize) []const u8 { pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void { // A builtin a ctl write runs: its first error is also the write's, cut // between words. - if (p.fs.no_prompt and p.fs.failure_len == 0) { + const failing_write = p.fs.no_prompt or p.fs.capturing; + if (failing_write and p.fs.failure_len == 0) { const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text; @memcpy(p.fs.failure[0..kept.len], kept); var n = kept.len; @@ -500,7 +501,7 @@ pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void { // The write fails with it, and its err record says it: no msg for it, // and no Verbose announcement before that, so the same failure again is // the same record again, counted (events.pushCounting). - if (p.fs.no_prompt) { + if (failing_write) { const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0; ctlfs.events.dropAnnouncement(p, serial, text); p.fs.unlogged = true; diff --git a/src/builtins.zig b/src/builtins.zig index 6ca216e3..dfd1e84c 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -946,21 +946,7 @@ pub const Edit = struct { var why: sam.Why = .{}; var said: [260]u8 = undefined; const res = sam.run(arena, f.content, ninep_pane.dotOf(c.pane), f.path, c.arg orelse "", &why) catch |err| switch (err) { - error.Edit => { - const text = std.fmt.bufPrint(&said, "Edit: {s}", .{why.text()}) catch "Edit: failed"; - // A block cut short (no `.` line, no `}`) that came over - // 9P through an exec, whose write does not fail for what - // a command says, is still the write's fault: an err. - const cut = std.mem.eql(u8, why.text(), "unmatched `{'") or std.mem.startsWith(u8, why.text(), "a, c or i text not ended"); - if (cut and c.p.fs.serving and !c.p.fs.no_prompt) { - var rec: [300]u8 = undefined; - pardes.ctlfs.events.notePath(c.p, "err", std.fmt.bufPrint(&rec, "{d} exec: {s}", .{ c.pane.serial, text }) catch "- exec: Edit failed"); - c.p.fs.unlogged = true; - defer c.p.fs.unlogged = false; - return c.p.reportFailure(c.id, text); - } - return c.p.reportFailure(c.id, text); - }, + error.Edit => return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Edit: {s}", .{why.text()}) catch "Edit: failed"), error.OutOfMemory => return c.p.reportError(c.id, "Edit", err), }; if (res.undo != 0) { diff --git a/src/fs.zig b/src/fs.zig index cb89648c..71e2cbad 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -1274,6 +1274,10 @@ pub const Namespace = struct { /// A ctl write's failure is being said: its err record logs it, so /// no msg does (Messages.reportFailure). unlogged: bool = false, + /// A 9P write of clicks (look, exec, tagexec, a column's exec) is running + /// one: a builtin's failure is the write's, as a ctl line's is (no_prompt, + /// which also refuses prompts, which a click may open). + capturing: bool = false, /// A 9P request is being served (Pardes.serveFs): a prompt it opens is /// a question for the log (`ask`), answered with `answer`. serving: bool = false, diff --git a/src/look.zig b/src/look.zig index d53ac76a..796eb5bb 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1209,7 +1209,10 @@ fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void { var said: [miss_path_cap + 64]u8 = undefined; const why = std.fmt.bufPrint(&said, fmt, args) catch "no match"; var msg: [said.len + 8]u8 = undefined; + // Shown on the pane; logged once, as the err below, not a msg too. + p.fs.unlogged = true; p.setMessage(id, std.fmt.bufPrint(&msg, "Look: {s}", .{why}) catch "Look: no match"); + p.fs.unlogged = false; const serial = if (p.panes[id]) |pane| pane.serial else 0; var rec: [said.len + 32]u8 = undefined; pardes.ctlfs.events.notePath(p, "err", std.fmt.bufPrint(&rec, "{d} look: {s}", .{ serial, why }) catch return); diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index ebe3279a..7745ee5e 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -164,7 +164,7 @@ pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply { while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - for (line) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL); + for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control); if (pardes.ctlfs.ctl.tooLong(req, line)) |refusal| return refusal; const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null; if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); @@ -172,7 +172,7 @@ pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply { defer p.exec_column = null; p.exec_header = true; defer p.exec_header = false; - pardes.ctlfs.ctl.run(p, p.active, true, line, true); + if (pardes.ctlfs.ctl.captured(p, req, p.active, true, line, true)) |refusal| return refusal; } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 112df099..3f7da44c 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -174,13 +174,13 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep while (it.next()) |text| { if (text.len == 0) continue; // Only an Edit block holds newlines (Messages). - for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return Reply.fail(req.tag, E.INVAL); + for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return tree.failText(req.tag, E.INVAL, e_control); if (exec) if (tooLong(req, text)) |refusal| return refusal; if (!apply) continue; const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); p.fs.no_pane_slot = false; - run(p, id, exec, text, in_tag); + if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal; // A pane it would have opened had no slot: the write fails, and // a look reads back nothing. if (p.fs.no_pane_slot) { @@ -206,6 +206,24 @@ pub fn tooLong(req: Req, line: []const u8) ?Reply { return tree.failText(req.tag, E.INVAL, e_too_long); } +pub const e_control = "a command line holds no control character but a tab"; + +/// Runs one click (`run`) as a 9P write's: a builtin that fails there fails +/// the write, with its words and an err record, and no msg, as a ctl line +/// does (runBuiltin). The refusal, or null. +pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, in_tag: bool) ?Reply { + p.fs.capturing = true; + p.fs.failure_len = 0; + defer p.fs.capturing = false; + run(p, id, exec, text, in_tag); + if (p.fs.failure_len == 0) return null; + // A failed click answers nothing: look reads back empty. + p.fs.results_len = 0; + const failure = p.fs.failure[0..p.fs.failure_len]; + // A slot or a place refused keeps its errno words (no space: ENOSPC). + return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); +} + pub fn resultsLen(p: *Pardes) u64 { var n: u64 = 0; for (p.fs.results[0..p.fs.results_len]) |serial| { @@ -221,7 +239,14 @@ pub fn readResults(p: *Pardes, req: Req) Reply { const out = p.fs.stage(p.gpa); const o = tree.openOf(p, req); const kept = if (o) |open| open.results.list[0..open.results.len] else p.fs.results[0..p.fs.results_len]; - for (kept) |serial| + // A look answers panes to go to: never one closed since (an exec may + // answer the pane it closed, which is what it acted on). + const looking = if (tree.Node.target(req.node)) |t| switch (t) { + .top => |f| f == .look, + .pane => |pt| pt.file == .look, + .col => false, + } else false; + for (kept) |serial| if (!looking or p.paneBySerial(serial) != null) out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM); const open = o orelse return tree.stagedReply(p, req); if (!open.results.wrote) return tree.stagedReply(p, req); @@ -792,6 +817,9 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 { if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == .get) return null; pane.discard_warned = .{ .revision = f.revision, .by = .get }; const said = std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)"; + // Shown; the write's err record logs it, not a msg too. + p.fs.unlogged = true; + defer p.fs.unlogged = false; p.setMessage(p.paneBySerial(pane.serial).?, said); return said; } @@ -1452,6 +1480,10 @@ test "look and exec read back what the last command touched, a ctl write's too" // A ctl write that makes nothing names the pane it ran at. try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Verbose off\n").reply.status); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes); + // Closed, it is no pane to go to: a fresh read of look is empty. + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .ctl), "Del\n").reply.status); + try testing.expect(p.paneBySerial(made) == null); + try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes); } test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" { @@ -1820,3 +1852,28 @@ test "get names the path it could not read, logs the edits it threw away, and a try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, try std.fmt.bufPrint(&rec, "changed {d} deleted\n", .{serial}))); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } + +test "a builtin that fails through exec, tagexec or a column's exec fails the write with one err and no msg" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + p.update(.tick); // the pane's `new` is logged first + while (p.nextEffect()) |_| {} + const serial = serialOf(p); + for ([_]u64{ root_exec, Node.of(serial, .exec), @intFromEnum(tree.TopFile.tagexec), Node.ofCol(pardes.layout.columnSerial(p, 0), .exec) }) |node| { + const failed = wr(p, node, "Kill zzz\n"); + try testing.expectEqual(Status.err, failed.reply.status); + try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Kill: no running command has that first word") != null); + } + const log = @intFromEnum(tree.TopFile.log); + const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; + const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; + try testing.expect(std.mem.indexOf(u8, text, "msg ") == null); + try testing.expectEqual(@as(usize, 4), std.mem.count(u8, text, "Kill: no running command has that first word")); + _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); + // A control character is refused with its reason. + try testing.expectEqualStrings(e_control, wr(p, root_exec, "Msg a\x01b\n").reply.ename); + // A look miss: one err, no msg. + _ = wr(p, @intFromEnum(tree.TopFile.look), "zzqqnotthere\n"); + try testing.expect(th.logHas(p, "look: no match for \"zzqqnotthere\"")); + try testing.expect(!th.logHas(p, "msg 1 Look: no match")); +} -- cgit v1.3