summaryrefslogtreecommitdiff
path: root/src/ninep/tree.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/tree.zig')
-rw-r--r--src/ninep/tree.zig309
1 files changed, 278 insertions, 31 deletions
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index baf8c772..ed0e0352 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -66,7 +66,10 @@ pub const e_bad_event = "bad event syntax";
/// A second open reading a file whose reads consume: rio's word for it
/// (windows/rio/xfid.c:25), which 9ns turns into EBUSY.
pub const e_in_use = "file in use";
-pub const e_shut_down = "window shut down";
+/// A read held on a pane's event or pty/data that the pane closed under.
+/// acme says "window shut down" (xfid.c:1005), which 9ns reads as EIO;
+/// these words read as ENOENT, what every other file of a gone pane gives.
+pub const e_shut_down = "no such pane: its window shut";
/// Tremove closes a pane; nothing else in the tree is created or destroyed by
/// the protocol, and wstat stays a truncation. Making a pane is an open of
@@ -82,21 +85,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 +128,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 +158,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 +180,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.* = .{};
}
@@ -191,6 +218,7 @@ pub const TopFile = enum(u5) {
layout,
tag,
col,
+ tagexec,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -199,7 +227,7 @@ pub const TopFile = enum(u5) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
.root, .pane, .col => 0o755,
- .look, .exec, .log, .focus, .ctl, .tag => 0o666,
+ .look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666,
.README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444,
};
}
@@ -231,6 +259,9 @@ pub const PaneFile = enum(u5) {
event,
look,
exec,
+ /// A word run as a click in the pane's tag, as /tagexec is in the
+ /// workspace's and col/<n>/exec in a column's.
+ tagexec,
pty,
pty_ctl,
pty_status,
@@ -468,10 +499,14 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
.dir = c.file == .dir,
.mode = switch (c.file) {
.dir => 0o755,
- .ctl, .exec => 0o222,
- .tag => 0o666,
+ .ctl => 0o222,
+ .tag, .exec => 0o666,
+ },
+ .size = switch (c.file) {
+ .tag => cols.headerText(p, c.serial).?.len + 1,
+ .exec => ctl.resultsLen(p),
+ else => 0,
},
- .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 else 0,
.mtime = p.fs.started,
.append = c.file == .tag,
};
@@ -513,7 +548,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.index => pane.indexLen(p),
.README => fs.help.len,
.status => ctl.statusLen(p),
- .look, .exec => ctl.resultsLen(p),
+ .look, .exec, .tagexec => ctl.resultsLen(p),
.log => events.logLen(&p.fs.log),
.listeners => listeners: {
var buf: [screen.listeners_cap]u8 = undefined;
@@ -618,7 +653,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
.root => {
// The guide, then what describes the session, then the
// files that act, then the directories.
- inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.pane, TopFile.col }) |t| {
+ inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| {
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
}
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os");
@@ -680,16 +715,26 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// pane it made, so reading it twice answers the same one and
// closing it leaves the pane alone.
.new => {
- const slot = p.freeSlot() orelse return failText(req.tag, E.NFILE, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no pane slots");
+ const slot = p.freeSlot() orelse return failText(req.tag, E.NOSPC, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no space for a pane");
+ p.fs.no_pane_slot = false;
+ // Refused, the open's err is the one record of it (the
+ // one rule): no msg besides.
+ p.fs.capturing = true;
p.newScratchFor(p.active, .fs);
+ p.fs.capturing = false;
+ p.fs.failure_len = 0;
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
+ // No room in its column: it closes at the step's end.
+ if (p.unplaced.isSet(slot)) return failText(req.tag, E.NOSPC, p.fs.ename[0..p.fs.no_pane_slot_len]);
return .{ .tag = req.tag, .handle = made.serial };
},
.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);
@@ -706,7 +751,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 },
};
},
};
@@ -715,6 +760,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) {
@@ -734,7 +783,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) };
}
@@ -771,9 +820,21 @@ fn releaseHandle(p: *Pardes, req: Req) void {
p.panes[id].?.fs.tag_rewrite = false;
p.panes[id].?.fs.tag_held_newline = false;
};
+ // `: > limit` clears it for good once its open goes.
+ if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| {
+ p.panes[id].?.fs.limit_truncated = null;
+ };
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| {
@@ -795,7 +856,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);
}
@@ -861,7 +922,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
break :index stagedReply(p, req);
},
.status => ctl.readStatus(p, req),
- .look, .exec => ctl.readResults(p, req),
+ .look, .exec, .tagexec => ctl.readResults(p, req),
.log => events.readLog(p, req),
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
@@ -892,25 +953,92 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
// An event or pty/data read the pane closed under: acme's
// answer to the same (editors/acme/xfid.c:1005).
if (openOf(p, req)) |o| if (o.what == .event or o.what == .pty_data)
- return failText(req.tag, E.IO, e_shut_down);
+ return failText(req.tag, E.NOENT, e_shut_down);
return Reply.fail(req.tag, E.NOENT);
};
const pn = p.panes[id].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
return pane.read(p, req, id, pn, t.file);
},
- .col => |c| return if (c.file == .tag) cols.read(p, req, c.serial) else Reply.fail(req.tag, E.PERM),
+ .col => |c| return switch (c.file) {
+ .tag => cols.read(p, req, c.serial),
+ .exec => ctl.readResults(p, req),
+ else => Reply.fail(req.tag, E.PERM),
+ },
}
}
+/// 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 or t.file == .tagexec,
+ };
+}
+
+/// 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 or t.file == .tagexec,
+ };
+}
+
+/// 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),
+ .look, .exec => ctl.command(p, req, null, f == .exec, false),
.log => events.writeLog(p, req),
.focus => ctl.writeFocus(p, req),
.ctl => ctl.writeRoot(p, req),
.tag => cols.write(p, req, null),
+ .tagexec => cols.writeExec(p, req, null),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
@@ -1069,13 +1197,13 @@ test "readdir lists the root and a pane directory without creating anything" {
@memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes);
const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf);
try testing.expect(top.len >= 10);
- for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| {
+ for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| {
try testing.expectEqualStrings(name, top[i].name);
- try testing.expectEqual(i >= 13, top[i].dir);
+ try testing.expectEqual(i >= 14, top[i].dir);
}
- try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node);
- try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node);
- try testing.expectEqual(fs.os_root, top[15].node);
+ try testing.expectEqual(@intFromEnum(TopFile.pane), top[14].node);
+ try testing.expectEqual(@intFromEnum(TopFile.col), top[15].node);
+ try testing.expectEqual(fs.os_root, top[16].node);
var idbuf: [16]u8 = undefined;
try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null);
try testing.expect(th.nameAt(top, "self") == null);
@@ -1114,10 +1242,10 @@ test "readdir lists the root and a pane directory without creating anything" {
const dir = rdir(p, Node.of(serial, .dir), 0);
const files = th.dirents(dir.bytes, &buf);
- try testing.expectEqual(@as(usize, 17), files.len);
+ try testing.expectEqual(@as(usize, 18), files.len);
for ([_][]const u8{
"name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata",
- "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec",
+ "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", "tagexec",
}, 0..) |name, i|
try testing.expectEqualStrings(name, files[i].name);
try testing.expect(th.nameAt(files, ".") == null);
@@ -1203,6 +1331,10 @@ test "opening /pane/new makes a pane and removing one closes it" {
const id = p.paneBySerial(serial).?;
try testing.expectEqualStrings("", p.panes[id].?.file.?.content);
+ // Its directory is the session's, not that of /test.txt, the pane
+ // that happened to have the keyboard.
+ var named: [4096]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&named, "{s}/+New", .{p.sessionDir()}), p.panes[id].?.file.?.path);
_ = wr(p, Node.of(serial, .body), "hi");
// A second open is a second pane, and releasing a clone fid leaves the
@@ -1310,14 +1442,19 @@ test "editor paths resolve to the same nodes the wire serves" {
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
+ // Tall enough that the column holds every pane at its tag and two rows,
+ // and wide enough that each tag, the session directory's +New, is one.
+ p.update(.{ .resize = .{ .cols = 400, .rows = pardes.MAX_PANES * 4 } });
const new = @intFromEnum(TopFile.new);
while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
var want: [64]u8 = undefined;
- const said = try std.fmt.bufPrint(&want, "no pane slots: {d} max", .{pardes.MAX_PANES});
+ const said = try std.fmt.bufPrint(&want, "no space for a pane: {d} max", .{pardes.MAX_PANES});
const refused = call(p, .{ .tag = 2, .op = .open, .node = new });
- try testing.expectEqual(E.NFILE, refused.errno());
+ try testing.expectEqual(E.NOSPC, refused.errno());
try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said));
try testing.expect(th.logHas(p, said));
+ // Its err alone says it: no msg besides (the one rule).
+ try testing.expect(!th.logHas(p, "New: no space"));
// A look that would open a pane fails its write, and says the same.
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
@@ -1335,3 +1472,113 @@ 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 });
+}
+
+test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const new = @intFromEnum(TopFile.new);
+ var made: usize = 1;
+ const refused = while (made < pardes.MAX_PANES) : (made += 1) {
+ const a = call(p, .{ .tag = 1, .op = .open, .node = new });
+ if (a.reply.status == .err) break a;
+ } else return error.NeverRefused;
+ try testing.expectEqual(E.NOSPC, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "no space for a pane in that column") != null);
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(made, count); // the refused one is gone
+ for (0..p.ncol) |c| for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (p.col_n[c] > 1 and !p.panes[k].?.collapsed)
+ try testing.expect(p.rects[k].h >= p.panes[k].?.tag_rows + pardes.layout.min_body_rows);
+ };
+}
+
+test "an Edit block still open when its open closes fails there with an err and changes nothing" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,x/b/{\ni/[/\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expect(th.logHas(p, "unmatched"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "an a, c or i text still open when its open closes fails there too and changes nothing" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a\nhalf of a block\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expect(th.logHas(p, "not ended by a . line"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "an a, c or i text cut short through exec is an err in the log, not a msg" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const exec = Node.of(serialOf(p), .exec);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = c, .data = "Edit $a\nhalf\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = c, .opened = true });
+ var want: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: Edit: a, c or i text not ended", .{serialOf(p)})));
+ try testing.expect(!th.logHas(p, "msg 1 Edit: a, c or i"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "a pane made from pane/new goes last in its column, even when the last pane has no room to halve" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for (0..3) |_| {
+ const made = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }).reply.handle;
+ p.sync();
+ try testing.expectEqual(made, p.panes[p.col_panes[0][p.col_n[0] - 1]].?.serial);
+ }
+}