diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 02:11:15 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | 3db73735e9a8d88878bc9ee343f226c033320913 (patch) | |
| tree | 93c52cd0d4d2008d765c862651464684e15006d6 | |
| parent | b29de1d4e78466a15f5d3dc0916410cba1445c5f (diff) | |
| download | pardes-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.md | 6 | ||||
| -rw-r--r-- | docs/fs.md | 18 | ||||
| -rw-r--r-- | src/9p_io.zig | 6 | ||||
| -rw-r--r-- | src/fs-help.txt | 4 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 36 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 164 | ||||
| -rw-r--r-- | src/pardes.zig | 2 | ||||
| -rw-r--r-- | test/fs.py | 24 | ||||
| -rw-r--r-- | test/selfmount.py | 33 |
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 @@ -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); @@ -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') |
