summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 06:05:08 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit554181b73fd53b898841203066382e4287b83e89 (patch)
tree09447d6a6ee1f3cdb3f70fbb6f136fb90a289f08
parent81ec44ca3b103bdc755e9f5d73a270c05c88617b (diff)
downloadpardes-554181b73fd53b898841203066382e4287b83e89.tar.gz
pardes-554181b73fd53b898841203066382e4287b83e89.zip
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 <[email protected]>
-rw-r--r--.agents/skills/pardes-9p/SKILL.md9
-rw-r--r--docs/fs.md28
-rw-r--r--src/Messages.zig5
-rw-r--r--src/builtins.zig16
-rw-r--r--src/fs.zig4
-rw-r--r--src/look.zig3
-rw-r--r--src/ninep/cols.zig4
-rw-r--r--src/ninep/ctl.zig63
-rw-r--r--test/fs.py17
9 files changed, 108 insertions, 41 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 4f903d5d..9e540de8 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -154,9 +154,12 @@ an open that wrote, until its next write); open again, or seek to 0, to read
it again. Each command line runs once whole, however a mount cuts a big write; a last
line with no newline runs when the open closes, and an Edit block still open
then fails there (an `err`: ``unmatched `{'``, or an a/c/i text with no `.`
-line), changing nothing. A command that fails is reported in
-the editor, not as a write error, so inspect the resulting pane, index, message
-or screen; only a malformed line fails the write itself. A word no builtin
+line), changing nothing. One rule: a builtin that fails, whether through a
+ctl, look, exec, tagexec or a column's exec, fails the write (EINVAL for a
+malformed line, else EIO or an errno that fits) and logs one `err` with the
+reason, no `msg`. A look that finds nothing is no failure: it answers
+nothing and logs one `err`. A command line run in a command pane is judged
+by its `exit` record. A word no builtin
knows (a typo included) is a command line: written at a terminal at its
prompt it is typed into that shell; from anywhere else it runs as a command
pane, a terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set)
diff --git a/docs/fs.md b/docs/fs.md
index 0a7cee7d..07b380aa 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -410,8 +410,8 @@ only its pane's; a command they run starts in the session's directory,
where pardes started, not the focused pane's), and what the word says is logged as the session's,
`msg -`. Blank lines are skipped, and every other line
is checked before any of them runs, so a control character fails the whole
-write with EINVAL; a command that fails inside the editor is reported on the
-message row, not as a write error. Reading any of these files answers the
+write with EINVAL; a builtin that fails there fails the write as well
+(below: one rule). Reading any of these files answers the
serials of the panes the last command created, or, when it created none, the
pane a look focused or the pane an exec acted on (even one it closed), one
per line; a look that found text answers the pane the text is selected in,
@@ -631,17 +631,19 @@ pane. Every `data`, `xdata` or `body` write is an undo step of its own
while `mark` is 1; to make a loop's writes one step, write `1` (an undo
point here), then `0`, the writes, then `1` again.
-Which writes fail and which only say so:
-
-| file | a write fails (the errno, an `err` record) | said on the message row and logged as `msg`, the write succeeding |
-|---|---|---|
-| `ctl` (root, pane, column) | a malformed line, an unknown word, a builtin's failure, a refusal (Modified...) | what a builtin says when it works |
-| `look`, `exec`, `tagexec`, a column's `exec` | a control character, a line over 1024 bytes, no pane slot or room | a look that finds nothing, a command's own failure, a builtin's failure |
-| `addr`, `dot`, `limit` | an address that does not evaluate | -- |
-| `data`, `xdata` | no address (the last one failed) | -- |
-| `body`, `tag`, `name`, `sel`, the flag files | a bad value, a pane gone | -- |
-| `event` | a malformed record, a range past the text | what the action it runs says |
-| `pty/ctl`, `pty/data`, `pty/run` | a malformed verb, a size out of range, a pane that is no terminal | -- |
+One rule for what fails: a write fails whenever what it asked for fails,
+whether it came to a `ctl` (the root's, a pane's, a column's), `look`,
+`exec`, `tagexec` or a column's `exec`, or to any other file -- with an
+errno that fits, EINVAL for malformed input (an unknown word, a control
+character, a command line over 1024 bytes, a `size` or `winsize` out of
+range, a bad address or event record), else EIO or the errno the words
+name (ENOENT for a pane or file gone, ENOSPC for no room or slot, EBUSY
+for a held lock) -- and logs its reason exactly once, as `err <serial|->
+<file>: <why>`, with no `msg` for it. A builtin a click runs (Save, get's
+`Modified`, Tty with no room, Edit) is no exception. What is not a
+failure: a look that finds nothing answers nothing and logs one `err`
+(`look: no match for ...`), the write succeeding; and a command line run in
+a command pane ends in its own time, told by its `exit` record.
`tag` reads the whole tag as the pane shows it: the computed path or PDF
page (no mark for unsaved text: the grip shows that, and `dirty` says it),
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"));
+}
diff --git a/test/fs.py b/test/fs.py
index b7e8adcc..8d16d718 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -133,6 +133,16 @@ def execute(client, serial, command):
client.write(f'/pane/{serial}/exec', f'{command}\n'.encode())
+def execute_refused(client, serial, command, words):
+ """A middle click on a builtin that fails: the write fails, saying why."""
+ try:
+ execute(client, serial, command)
+ except OSError as why:
+ assert words in str(why), (command, why)
+ return
+ raise AssertionError(f'{command!r} was taken')
+
+
def walk_tree(client, path='/', skip=('/log', '/os', '/screen')):
"""Stat every entry below `path`, as `find` does, without opening anything."""
seen = []
@@ -725,8 +735,9 @@ def test(binary, quic=False):
execute(remote, control, 'Mount peer ' + str(address))
opened = look(remote, '/n/peer/pane/1/body', source=control)
assert remote.read(f'/pane/{opened}/body') == client.read('/pane/1/body')
- execute(remote, control, 'Mount peer ' + str(own_address))
- execute(remote, control, 'Unmount peer')
+ # A builtin that fails through exec fails the write (one rule).
+ execute_refused(remote, control, 'Mount peer ' + str(own_address), 'already mounted')
+ execute_refused(remote, control, 'Unmount peer', 'mount in use') # a pane is open on it
remote.write(f'/pane/{opened}/body', b'runtime mounted Save\n', truncate=True)
remote.write(f'/pane/{opened}/exec', b'Save\n')
assert client.read('/pane/1/body') == b'runtime mounted Save\n'
@@ -753,7 +764,7 @@ def test(binary, quic=False):
with session(binary, root, name, tty=tty) as (old, address):
old.write('/pane/1/body', b'dumped state\n', truncate=True)
control = new_pane(old, b'')
- execute(old, control, 'Restore ' + str(root / 'missing dump.zon'))
+ execute_refused(old, control, 'Restore ' + str(root / 'missing dump.zon'), 'no such file')
assert old.read('/pane/1/body') == b'dumped state\n'
execute(old, control, 'Mount own ' + str(address))
execute(old, control, 'Dump')