summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 02:11:15 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit3db73735e9a8d88878bc9ee343f226c033320913 (patch)
tree93c52cd0d4d2008d765c862651464684e15006d6
parentb29de1d4e78466a15f5d3dc0916410cba1445c5f (diff)
downloadpardes-3db73735e9a8d88878bc9ee343f226c033320913.tar.gz
pardes-3db73735e9a8d88878bc9ee343f226c033320913.zip
A command line cut across writes runs once whole, and look/exec answer per open
A mount cuts a write at its message size, and each piece ran on its own: a line cut at the boundary ran as shell commands and a 50 KB Edit block broke. An open of look, exec, tagexec, any ctl or a column's exec now keeps an unfinished last line, or an Edit block still open, until its next write or its release, and never runs a fragment. The same open record holds what its last write touched, which a read on it answers (as /net/tcp/clone does); an open that never wrote reads the session's last as of its open. A release that runs a held line waits for quiet and counts as a change; a clicked line with a control character is still refused at its write. Tested in unit tests, fs.py (python client, 8 KiB) and selfmount.py (the kernel mount, a 1000-line seq burst and a 50 KB Edit block). Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--.agents/skills/pardes-9p/SKILL.md6
-rw-r--r--docs/fs.md18
-rw-r--r--src/9p_io.zig6
-rw-r--r--src/fs-help.txt4
-rw-r--r--src/ninep/ctl.zig36
-rw-r--r--src/ninep/tree.zig164
-rw-r--r--src/pardes.zig2
-rw-r--r--test/fs.py24
-rw-r--r--test/selfmount.py33
9 files changed, 275 insertions, 18 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 9ec46084..1142efa9 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -140,7 +140,11 @@ created or removed, and no read creates anything.
`look` and `exec` are the editor's two clicks, one per line of a write, at the
active pane from the root and at that pane from `$m/pane/<n>/look` and
`$m/pane/<n>/exec`. Reading any of them answers the serials the last command
-made, or the pane it focused or acted on. A command that fails is reported in
+made, or the pane it focused or acted on: an open's own last write's, or, on
+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<&-`).
+Each command line runs once whole, however a mount cuts a big write; a last
+line with no newline runs when the open closes. 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
knows (a typo included) is a command line: written at a terminal at its
diff --git a/docs/fs.md b/docs/fs.md
index 8aa9d6bf..d1be9f6a 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -378,6 +378,24 @@ 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,
not the hits buffer it opened.
+The answer belongs to the open, as /net/tcp/clone's does: an open that
+wrote reads what its own last write touched, from the start after each
+write, whatever offset the read comes at (a shell's `exec 3<>look` shares
+one offset between its write and its read, as with `pty/run`); an open
+that never wrote reads the session's last answer as it stood when it was
+opened. So `echo x > look; cat look` works for one client, but two
+clients doing it at once may read each other's; for that, write and read
+on one open: `exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`.
+
+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. A fragment never runs
+on its own. A line or Edit block held past 1 MiB is refused (EINVAL).
+
A look takes acme's addresses after a colon (editors/acme/look.c:450): a
line written to `look` as `file:/re/`, `file:#n`, `file:$` or any address
opens (or finds) the file and selects what the address names. **The
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 0f5e0a77..72963e08 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -255,7 +255,7 @@ pub const Listener = struct {
const refused = pardes.ctlfs.Reply.fail(req.tag, pardes.ctlfs.E.IO);
return conn.reply(&refused, "");
}
- if (!quiet and pardes.ctlfs.needsQuiet(req)) {
+ if (!quiet and pardes.ctlfs.needsQuiet(l.core, req)) {
pardes.turn.parked = true;
const later: pardes.ctlfs.Reply = .{ .tag = req.tag, .status = .again };
return conn.reply(&later, "");
@@ -263,6 +263,8 @@ pub const Listener = struct {
const core = l.core;
const epoch = pardes.turn.epoch;
const restores = pardes.turn.restores;
+ // Asked first: a release that runs a held line has none after.
+ const changes = pardes.ctlfs.changesPane(core, req);
const reply = core.serveFs(req);
if (req.op == .release and quiet) l.collectOs();
// A read with nothing yet stays parked in the engine, and the core
@@ -289,7 +291,7 @@ pub const Listener = struct {
o.held = .{ .asker = conn, .req = req, .ticket = ticket };
return;
}
- if (!pardes.ctlfs.changesPane(req)) return conn.reply(&reply, core.fsPayload(reply));
+ if (!changes) return conn.reply(&reply, core.fsPayload(reply));
// The editor draws the change and performs what it asked for; when
// it asked for something -- a save, a shell, a watch -- the answer
// waits until that is done, so `echo Save > exec` returns with the
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 3d84fca2..5a3be01b 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -3,7 +3,7 @@ Pardes control filesystem
README this guide
index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (+New if unnamed), column serial
status pid, version and pane count
-look write a line: a right click on it at the active pane; read: the serials it touched
+look write a line: a right click on it at the active pane; read: the serials it touched (this open's)
exec write a line: a middle click, an editor command word or a shell line; read the same (every exec file)
log events: new del rename save newcol delcol run exit send ask answer dump restore restored restoredcol msg err
screen rendered screen as JSON, frozen from open to close
@@ -39,7 +39,7 @@ Pitfalls, one each:
data: > replaces the addr range, : > deletes it, a 2nd > inserts (addr moved); body: > empties all.
tag reads the path, then its own text; > replaces that text (default words too), >> appends.
A terminal's body is a history snapshot frozen per open, wrapped rows joined into lines; pty/data is the live stream.
- exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in.
+ exec: a non-builtin line runs as a command pane (ctl's Shell -c), a shell's typed in; a line runs once whole.
Through a mount a malformed write is EINVAL, a failed one EIO or an errno that fits (ENOENT): log's err says why.
lock/unlock need a held ctl fd (exec 3>ctl); a held lock fails: retry. fs.md has the rest.
Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands.
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index c3c70667..0f22614c 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -122,6 +122,28 @@ const Messages = struct {
}
};
+/// How much of `data` is whole messages, as `Messages` reads them: lines
+/// ended by a newline, an Edit block only once it closes. The rest waits
+/// for more (tree.Open.pending).
+pub fn completeEnd(p: *Pardes, data: []const u8) usize {
+ const sam = @import("../sam_edit.zig");
+ const edit = "Edit";
+ var whole: usize = 0;
+ var at: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, data, at, '\n')) |nl| {
+ const start = at;
+ var end = nl;
+ const first = std.mem.trim(u8, data[start..end], " \t\r");
+ if (std.mem.startsWith(u8, first, edit) and (first.len == edit.len or first[edit.len] == ' ' or first[edit.len] == '\t')) {
+ while (sam.needsMore(p.scratch.allocator(), std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]))
+ end = std.mem.indexOfScalarPos(u8, data, end + 1, '\n') orelse return whole;
+ }
+ at = end + 1;
+ whole = at;
+ }
+ return whole;
+}
+
// ---- look and exec ----
/// One command per line, checked whole before any line runs: a line is the
@@ -161,11 +183,21 @@ pub fn resultsLen(p: *Pardes) u64 {
return n;
}
+/// The panes the open's last write touched (tree.Open.results); with no
+/// open, the session's last.
pub fn readResults(p: *Pardes, req: Req) Reply {
const out = p.fs.stage(p.gpa);
- for (p.fs.results[0..p.fs.results_len]) |serial|
+ 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|
out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM);
- return tree.stagedReply(p, req);
+ const open = o orelse return tree.stagedReply(p, req);
+ if (!open.results.wrote) return tree.stagedReply(p, req);
+ var at = req;
+ at.off = open.results.read;
+ const reply = tree.stagedReply(p, at);
+ open.results.read += reply.payload.staged;
+ return reply;
}
// ---- /status ----
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 085d03a3..da9c37f0 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -82,21 +82,21 @@ pub const features: cloud9.fs.Features = .{ .remove = true, .create = true };
/// the mutation of its own step, so the layout and the surface are whole
/// under it -- and a step out there is never waiting on a write of its own,
/// so what parks here is never the syscall's own request.
-pub fn needsQuiet(req: Req) bool {
+pub fn needsQuiet(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
- .release => req.remove,
+ .release => req.remove or holdsLines(p, req),
else => false,
};
}
/// `needsQuiet`, plus the open that makes a pane: what costs a frame and a
/// wake of the editor.
-pub fn changesPane(req: Req) bool {
+pub fn changesPane(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
.open => req.node == @intFromEnum(TopFile.new),
- .release => req.remove,
+ .release => req.remove or holdsLines(p, req),
.lookup, .getattr, .read, .readdir => false,
};
}
@@ -125,7 +125,20 @@ pub const Open = struct {
pty_data,
/// An open that may write a pane's ctl, and so hold its lock.
ctl,
+ /// An open of a file that takes lines of commands (look, exec, a
+ /// ctl, tagexec): its `pending` and `results`.
+ lines,
} = .{ .snapshot = null },
+ /// What a write to a file of command lines left unfinished: a last line
+ /// with no newline yet, or an Edit block still open. A write through a
+ /// mount is cut at the 9P message size, so one `echo` of many lines may
+ /// come as several writes, cut anywhere; the rest waits for the open's
+ /// next write, or its release, and never runs as a fragment.
+ pending: std.ArrayList(u8) = .empty,
+ /// The panes this open's last write touched, which a read of look or
+ /// exec on it answers, as /net/tcp/clone answers on the open that
+ /// asked; a fresh open holds the session's last, from before it opened.
+ results: Results = .{},
/// A read that found nothing yet (`.again`), kept to be answered when
/// this open's file has something, the way factotum keeps its log's
/// waiting reads and answers them on append (security/auth/factotum/
@@ -142,6 +155,16 @@ pub const Open = struct {
pub const Replay = struct { off: u64, bytes: []u8 };
+ pub const Results = struct {
+ list: [pardes.MAX_PANES]u32 = undefined,
+ len: u8 = 0,
+ /// Written on: reads are a stream from the write on, whatever
+ /// offset they come at, since a shell's `exec 3<>look` shares one
+ /// offset between the write and the read (as pty/run does).
+ wrote: bool = false,
+ read: usize = 0,
+ };
+
/// `asker` is the connection the read came on, which only the listener
/// knows.
pub const Held = struct { asker: *anyopaque, req: Req, ticket: cloud9.fs.Ticket };
@@ -154,8 +177,9 @@ pub const Open = struct {
gpa.free(run.output);
gpa.free(run.line);
},
- .event, .pty_data, .ctl => {},
+ .event, .pty_data, .ctl, .lines => {},
}
+ o.pending.deinit(gpa);
if (o.replay) |r| gpa.free(r.bytes);
o.* = .{};
}
@@ -692,9 +716,11 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
},
.screen => .{ .snapshot = null },
.log => .{ .log = .{} },
- else => return .{ .tag = req.tag },
+ else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
},
- .col => |c| return if (layout.columnBySerial(p, c.serial) != null) .{ .tag = req.tag } else Reply.fail(req.tag, E.NOENT),
+ .col => |c| if (layout.columnBySerial(p, c.serial) == null)
+ return Reply.fail(req.tag, E.NOENT)
+ else if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
.pane => |t| what: {
const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
@@ -711,7 +737,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
1, 2 => .ctl, // OWRITE, ORDWR
else => return .{ .tag = req.tag },
},
- else => return .{ .tag = req.tag },
+ else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
};
},
};
@@ -720,6 +746,10 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
} else return Reply.fail(req.tag, E.NFILE);
const o = &p.fs.opens[i];
o.* = .{ .node = req.node, .what = what };
+ if (resultsFile(target)) {
+ @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]);
+ o.results.len = p.fs.results_len;
+ }
switch (o.what) {
// A screen freezes at open; a terminal body at its first read.
.snapshot => |*bytes| if (pane_of == null) {
@@ -739,7 +769,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
pn.fs.event_reader = pn.fs.event_reader or reader;
},
.pty_data => pane_of.?.fs.pty_reader = true,
- .run, .ctl => {},
+ .run, .ctl, .lines => {},
}
return .{ .tag = req.tag, .handle = @intCast(i + 1) };
}
@@ -779,6 +809,14 @@ fn releaseHandle(p: *Pardes, req: Req) void {
if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial);
if (t == .top and t.top == .tag) cols.released(p, null);
const o = openOf(p, req) orelse return;
+ // The last line an open wrote with no newline after it runs now.
+ if (o.pending.items.len > 0) {
+ const rest = p.scratch.allocator().dupe(u8, o.pending.items) catch "";
+ o.pending.clearRetainingCapacity();
+ const w: Req = .{ .tag = req.tag, .op = .write, .node = req.node, .handle = req.handle, .data = rest };
+ const reply = writeNow(p, w, t);
+ if (reply.status == .err) events.noteError(p, w, reply);
+ }
const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null;
if (of) |pn| switch (o.what) {
.event => |reader| {
@@ -800,7 +838,7 @@ fn releaseHandle(p: *Pardes, req: Req) void {
.ctl => if (pn.fs.lock == req.handle) {
pn.fs.lock = null;
},
- .snapshot, .log => {},
+ .snapshot, .log, .lines => {},
};
o.deinit(p.gpa);
}
@@ -912,7 +950,69 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
}
}
+/// The files whose writes are lines of commands, run as each line is whole.
+fn linesFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec,
+ .col => |c| c.file == .ctl or c.file == .exec,
+ .pane => |t| t.file == .look or t.file == .exec or t.file == .ctl,
+ };
+}
+
+/// The ones of those a read answers with the panes a write touched.
+fn resultsFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .look or f == .exec or f == .tagexec,
+ .col => |c| c.file == .exec,
+ .pane => |t| t.file == .look or t.file == .exec,
+ };
+}
+
+/// An open that keeps lines or results: any that may write one of those
+/// files, and any that may read a look or exec.
+fn linesOpen(target: Target, req: Req) bool {
+ if (!linesFile(target)) return false;
+ return req.omode & 3 != 0 or resultsFile(target);
+}
+
+/// A release that will run a line its open held back.
+fn holdsLines(p: *Pardes, req: Req) bool {
+ const o = openOf(p, req) orelse return false;
+ return o.pending.items.len > 0;
+}
+
+/// A line held back longer than this is refused rather than kept growing.
+const pending_cap = 1 << 20;
+
fn write(p: *Pardes, req: Req, target: Target) Reply {
+ const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target);
+ if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target);
+ // A clicked line holds no control character: refused at once, not held
+ // to fail unseen at the close.
+ if (resultsFile(target)) for (req.data) |c| if (c < ' ' and c != '\t' and c != '\n' and c != '\r') return Reply.fail(req.tag, E.INVAL);
+ o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM);
+ const end = ctl.completeEnd(p, o.pending.items);
+ if (end == 0) {
+ if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+ o.pending.clearRetainingCapacity();
+ return failText(req.tag, E.INVAL, "a line or Edit block over 1 MiB");
+ }
+ const whole = p.scratch.allocator().dupe(u8, o.pending.items[0..end]) catch return Reply.fail(req.tag, E.NOMEM);
+ const rest = o.pending.items.len - end;
+ std.mem.copyForwards(u8, o.pending.items[0..rest], o.pending.items[end..]);
+ o.pending.shrinkRetainingCapacity(rest);
+ var w = req;
+ w.data = whole;
+ var reply = writeNow(p, w, target);
+ if (resultsFile(target)) {
+ @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]);
+ o.results = .{ .list = o.results.list, .len = p.fs.results_len, .wrote = true };
+ }
+ if (reply.status == .ok) reply.written = @intCast(req.data.len);
+ return reply;
+}
+
+fn writeNow(p: *Pardes, req: Req, target: Target) Reply {
switch (target) {
.top => |f| return switch (f) {
.look, .exec => ctl.command(p, req, null, f == .exec),
@@ -1345,3 +1445,47 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e
const made = th.wr(p, ctl_node, "New\n");
try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null);
}
+
+test "a command line cut across writes runs once whole, and the last runs at release; an Edit block waits for its end" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ 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|
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status);
+ 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 });
+ try testing.expect(th.logHas(p, " tail\n"));
+ // An Edit block's text lines come in later writes on the same open.
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const c = call(p, .{ .tag = 4, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ for ([_][]const u8{ "Edit ,a\n", "one\ntw", "o\n", ".\n" }) |piece|
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = piece }).reply.status);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content);
+}
+
+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();
+ const exec = @intFromEnum(TopFile.exec);
+ const first = p.panes[0].?.serial;
+ const mine = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 2 }).reply.handle;
+ const theirs = call(p, .{ .tag = 2, .op = .open, .node = exec, .omode = 2 }).reply.handle;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = exec, .handle = mine, .data = "Tty\n" });
+ const made = p.panes[p.active].?.serial;
+ try testing.expect(made != first);
+ _ = call(p, .{ .tag = 4, .op = .write, .node = exec, .handle = theirs, .data = "Msg hi\n" });
+ var want: [16]u8 = undefined;
+ // Read at the offset the write left, as a shell's `exec 3<>` does.
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), call(p, .{ .tag = 5, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes);
+ try testing.expectEqualStrings("", call(p, .{ .tag = 6, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes);
+ const said = p.panes[p.active].?.serial;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 7, .op = .read, .node = exec, .handle = theirs, .size = 64 }).bytes);
+ const fresh = call(p, .{ .tag = 8, .op = .open, .node = exec }).reply.handle;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 9, .op = .read, .node = exec, .handle = fresh, .size = 64 }).bytes);
+ for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 706e9ef3..da9889ff 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5311,7 +5311,7 @@ pub const Pardes = struct {
/// that changes a pane costs a frame, which is why a round trip on a
/// local socket costs microseconds and not a vsync.
pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply {
- if (!ctlfs.changesPane(req)) return ctlfs.handle(p, req);
+ if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req);
p.needs_frame = true;
p.raw_hover_intent = false;
look.cancelLookHover(p);
diff --git a/test/fs.py b/test/fs.py
index 0e38f950..bdfc3fcb 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -531,8 +531,32 @@ def signal_cleans_up(binary):
assert not address.exists() and not detached.exists(), (sig, list(root.iterdir()))
+def cut_writes(binary):
+ """A write bigger than a 9P message comes as several, cut anywhere: each
+ command line runs once, whole, an Edit block once it ends, and a last
+ line with no newline when the open closes. Never a fragment."""
+ with tempfile.TemporaryDirectory(prefix='pardes-cut-') as directory:
+ with session(binary, Path(directory), 'cut') as (client, _):
+ burst = b''.join(b'Msg burst-%04d\n' % i for i in range(1000))
+ assert len(burst) > client.msize
+ client.write('/exec', burst)
+ log = client.read('/log').decode()
+ said = [line.split(' ', 2)[2] for line in log.splitlines() if line.startswith('msg ') and 'burst-' in line]
+ assert said == ['burst-%04d' % i for i in range(1000)], said[:3] + said[-3:]
+ assert '\nrun ' not in log and 'exit 127' not in log, log[-400:]
+ client.write('/exec', b'Msg no-newline-at-end')
+ assert 'msg ' in client.read('/log').decode().split('no-newline-at-end')[0][-40:]
+ serial = new_pane(client, b'')
+ block = b'Edit ,a\n' + b''.join(b'line %05d of the block\n' % i for i in range(2500)) + b'.\n'
+ assert len(block) > 50000
+ client.write(f'/pane/{serial}/ctl', block)
+ body = client.read(f'/pane/{serial}/body').decode().splitlines()
+ assert body == ['line %05d of the block' % i for i in range(2500)], (len(body), body[:2])
+
+
def test(binary, quic=False):
started = time.monotonic()
+ cut_writes(binary)
run_file(binary)
exit_answers(binary)
unattended_messages_expire(binary)
diff --git a/test/selfmount.py b/test/selfmount.py
index 90960bed..55809740 100644
--- a/test/selfmount.py
+++ b/test/selfmount.py
@@ -105,6 +105,39 @@ def run(binary):
check(client.read(f'/pane/{first}/body') == b'HELLO initial\n', f'Save wrote back through the mount into pane {first}')
text = ''.join(cell[0] for cell in client.screen()['cells'])
check('saved ' + mount in text, 'the editor reported the save')
+ # Through the kernel mount a write is cut at its msize: a
+ # 1000-line burst and a 50 KB Edit block each still run whole,
+ # line by line, and no piece of a line runs as a shell command.
+ # sh: bash's and fish's prompt files are not reachable in 9ns's
+ # namespace here, and a command pane needs no prompt marks.
+ client.write('/ctl', b'Shell sh\n')
+ # From the fixture's directory: a pane on the mount has one a
+ # shell cannot enter.
+ client.write('/focus', f'{first}\n'.encode())
+
+ def run(line):
+ """A command pane runs the line; answers its exit status."""
+ seen = client.read('/log').decode().count('\nexit ')
+ client.write('/exec', line)
+ deadline = time.monotonic() + 20
+ while time.monotonic() < deadline:
+ drain()
+ exits = [row for row in client.read('/log').decode().splitlines() if row.startswith('exit ')]
+ if len(exits) > seen:
+ return exits[-1].split()[2]
+ time.sleep(.1)
+ return 'timeout'
+ got = run(f"seq -f 'Msg mnt-%04g' 0 999 > {mount}/exec\n".encode())
+ check(got == '0', f'a 1000-line burst written through the mount ({got!r})')
+ log = client.read('/log').decode()
+ said = [line.split(' ', 2)[2] for line in log.splitlines() if line.startswith('msg ') and 'mnt-' in line]
+ check(said == ['mnt-%04d' % i for i in range(1000)], f'each line of it ran once, whole ({len(said)} said)')
+ check('exit 127' not in log, 'no piece of a line ran as a command')
+ scratch = int(client.read('/pane/new'))
+ maker = "import sys; sys.stdout.write('Edit ,a\\n' + ''.join('line %05d of the block\\n' % i for i in range(2500)) + '.\\n')"
+ check(run(f'python3 -c "{maker}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 50 KB Edit block written through the mount')
+ body = client.read(f'/pane/{scratch}/body').decode().splitlines()
+ check(body == ['line %05d of the block' % i for i in range(2500)], f'it ran once, whole ({len(body)} lines)')
client.write('/exec', b'Msg still here\n')
text = ''.join(cell[0] for cell in client.screen()['cells'])
check('still here' in text, 'the editor answers after all of that')