summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 15:47:52 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commitc2e58c827bd8d2497c86b2eca23ac096cb972296 (patch)
treeb9e46bee4978e97952d377b6e21f20338dc08d2c
parent9babe3a63ca06b4b0248d1d21ccb74687c5b188d (diff)
downloadpardes-c2e58c827bd8d2497c86b2eca23ac096cb972296.tar.gz
pardes-c2e58c827bd8d2497c86b2eca23ac096cb972296.zip
A ctl or exec line with no newline runs with its write and fails it, unless it needs more
A last line with no newline was held to the close, so printf bogus > ctl answered 0 and failed only in the log, Edit ,s/zzz/y/ too. A write shorter than its Twrite is the whole of what was written (acme takes each write whole): its tail runs then and a failure is the write's. It is held only when the parser says it needs more -- an open { block, an a/c/i text -- or when the write filled its Twrite and may go on (the listener tells the core the room, msize less its header). Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--.agents/skills/pardes-9p/SKILL.md7
-rw-r--r--docs/fs.md13
-rw-r--r--src/9p_io.zig2
-rw-r--r--src/fs.zig4
-rw-r--r--src/ninep/pane.zig10
-rw-r--r--src/ninep/tree.zig41
6 files changed, 64 insertions, 13 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 16e90dc9..0de8820a 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -164,9 +164,10 @@ an open that never wrote, the session's last. With other clients about,
write and read one open (`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`).
A read is a stream: once read, a second read on the same fd gives EOF (on
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 `.`
+it again. Each command line runs once whole, however a mount cuts a big write; a
+write's last line with no newline runs with it (and fails it), unless the
+write filled its 9P message and may go on; an Edit block still open when the
+file closes fails there (an `err`: ``unmatched `{'``, or an a/c/i text with no `.`
line), changing nothing. One rule: a builtin that fails, whether through a
ctl (a pane's pty/ctl `exec` too), look, exec, tagexec or a column's exec,
fails the write (EINVAL for a malformed line, ENOENT for what is not there --
diff --git a/docs/fs.md b/docs/fs.md
index 83906d0a..7b1e47bf 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -518,12 +518,13 @@ A write of command lines -- to `look`, `exec`, `tagexec`, a `ctl` of the
root, a pane or a column, or a column's `exec` -- runs each line once it is
whole: a mount cuts a big write at its message size (4 KiB through the
kernel's, 8 KiB from a client that asks), anywhere, and each piece comes as
-a write of its own, so the open keeps a last line with no newline yet, or
-an Edit block whose text has not ended, until its next write, and runs it
-then; what is left when it closes runs at the close -- so a line written
-with no newline (`printf Save > exec`) runs only as the file closes, after
-the write has been answered, and its failure is in the log alone: end each
-line with its newline to have the write itself fail -- where an Edit block
+a write of its own. A write that fills its piece may go on in the next, so
+the open keeps its last line with no newline until then; a write shorter
+than a piece is the whole of what was written, as acme takes each write, so
+its last line runs with it even with no newline (`printf Save > exec`), and
+a failure is that write's. Only what needs more is held: an Edit block
+whose `{` or `a`/`c`/`i` text has not ended waits for its next write, and
+what is left when the file closes runs at the close -- where an Edit block
whose `{` or `a`/`c`/`i` text never ended fails and changes nothing
(``unmatched `{'``, or `a, c or i text not ended by a . line`, logged as an
`err`): sam takes the end of input for a `.`, but a block that reaches Edit
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 91240b57..7f490f81 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -261,6 +261,7 @@ pub const Listener = struct {
return conn.reply(&later, "");
}
const core = l.core;
+ core.fs.write_room = if (conn.engine.protocol.msize > 24) conn.engine.protocol.msize - 24 else 0;
const epoch = pardes.turn.epoch;
const restores = pardes.turn.restores;
// Asked first: a release that runs a held line has none after.
@@ -506,6 +507,7 @@ pub const Listener = struct {
/// Answers one QUIC request, on the editor's thread.
fn step(l: *Listener, srv: *Srv, req: pardes.ctlfs.Req) void {
+ l.core.fs.write_room = if (srv.protocol.msize > 24) srv.protocol.msize - 24 else 0;
const reply = l.core.serveFs(req);
srv.reply(&reply, l.core.fsPayload(reply));
if (req.op == .release) l.collectOs();
diff --git a/src/fs.zig b/src/fs.zig
index 753a4446..845e6424 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1326,6 +1326,10 @@ pub const Namespace = struct {
/// A 9P write asked a language server something: the answer count it
/// waits to change (`turn.lsp_answers`), so its failure is the write's.
lsp_answer_at: ?u64 = null,
+ /// The most a Twrite of the request being served can carry (msize less
+ /// its header), 0 when unknown: a write shorter than it is the whole of
+ /// what its client wrote, one that fills it may be cut and go on.
+ write_room: u32 = 0,
/// The serial of the pane the root's exec or look last ran a line at
/// (the keyboard's), 0 before one: its err record names that pane.
ran_at: u32 = 0,
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 9f85a75f..d760bebd 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -1153,13 +1153,19 @@ test "a name cut across writes is one name, applied once at its newline or its c
const node = Node.of(serial, .name);
const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle;
try testing.expect(h != 0);
+ // A piece that fills its Twrite goes on in the next.
+ p.fs.write_room = "/tmp/pardes-na".len;
_ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-na" });
+ p.fs.write_room = 0;
try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
_ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" });
try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
- // No newline: the close applies it.
- _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-closed.txt" });
_ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true });
+ // No newline, the whole write: applied at once, on its own open.
+ const h3 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" });
+ try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true });
try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index d6f4211b..0960f00b 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -1073,7 +1073,18 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
// to fail unseen at the close.
if (resultsFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control);
o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM);
- const end = ctl.completeEnd(p, o.pending.items);
+ var end = ctl.completeEnd(p, o.pending.items);
+ // A tail with no newline is a whole line when the write is the whole of
+ // what its client wrote (shorter than a Twrite can carry): it runs now,
+ // and its failure is this write's, as acme takes each write whole. Held
+ // only when it is not whole yet -- an open `{` block, an `a`/`c`/`i`
+ // text awaiting its `.` -- or the write filled its Twrite and may go on.
+ const cut = p.fs.write_room != 0 and req.data.len >= p.fs.write_room;
+ if (end < o.pending.items.len and !cut) {
+ o.pending.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM);
+ const whole = ctl.completeEnd(p, o.pending.items);
+ if (whole == o.pending.items.len) end = whole else o.pending.shrinkRetainingCapacity(o.pending.items.len - 1);
+ }
if (end == 0) {
if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
o.pending.clearRetainingCapacity();
@@ -1580,8 +1591,12 @@ test "a command line cut across writes runs once whole, and the last runs at rel
const exec = @intFromEnum(TopFile.exec);
const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
try testing.expect(h != 0);
- for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece|
+ // Each piece fills its Twrite (a client's one write cut at msize).
+ for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| {
+ p.fs.write_room = @intCast(piece.len);
try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status);
+ }
+ p.fs.write_room = 0;
try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n"));
try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n"));
_ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true });
@@ -1596,6 +1611,28 @@ test "a command line cut across writes runs once whole, and the last runs at rel
try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content);
}
+test "a write with no newline, whole in its Twrite, runs then and fails the write; one needing more waits" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ p.fs.write_room = 8192;
+ defer p.fs.write_room = 0;
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "bogus" }).reply.status);
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,s/zzz/y/" }).reply.status);
+ // An `a` needs its text and `.`: held, not run, not failed.
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 4, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a" }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = "\nmore\n.\n" }).reply.status);
+ try testing.expectEqualStrings("abc\nmore\n", p.panes[0].?.file.?.content);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ // The root's ctl too.
+ const root_ctl = @intFromEnum(TopFile.ctl);
+ const r = call(p, .{ .tag = 7, .op = .open, .node = root_ctl, .omode = 1 }).reply.handle;
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = root_ctl, .handle = r, .data = "bogus" }).reply.status);
+ _ = call(p, .{ .tag = 9, .op = .release, .node = root_ctl, .handle = r, .opened = true });
+}
+
test "look and exec read back what their own open's write touched; a fresh open reads the session's last" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();