summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/addr.zig15
-rw-r--r--src/ninep/cols.zig70
-rw-r--r--src/ninep/ctl.zig120
-rw-r--r--src/ninep/events.zig23
-rw-r--r--src/ninep/pane.zig25
-rw-r--r--src/ninep/pty.zig34
-rw-r--r--src/ninep/tree.zig26
7 files changed, 270 insertions, 43 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 9674a323..df2cfaf0 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -384,6 +384,21 @@ test "an address is evaluated from the current one, as acme's are" {
try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
}
+test "a backward search along one long line takes time in its length, not its square" {
+ const gpa = testing.allocator;
+ const line = try gpa.alloc(u8, 7 * 16800);
+ defer gpa.free(line);
+ for (0..16800) |i| @memcpy(line[i * 7 ..][0..7], "answer ");
+ const p = try th.withFile(gpa, line);
+ defer p.deinit();
+ const t0 = std.Io.Timestamp.now(testing.io, .awake);
+ try testing.expectEqual(Status.ok, th.wr(p, Node.of(th.serialOf(p), .addr), "?a?").reply.status);
+ // Quadratic, it took seconds (6.4 s through 9P in Debug); linear, a
+ // few milliseconds.
+ const took = t0.durationTo(std.Io.Timestamp.now(testing.io, .awake)).toNanoseconds();
+ try testing.expect(took < std.time.ns_per_s);
+}
+
test "an alternation anchoring some branches and not others is refused with why, a $ not counting" {
const p = try th.withFile(testing.allocator, "def\nfoo\n");
defer p.deinit();
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index 454cce4a..c4ee83eb 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -163,7 +163,7 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
- if (line.len == 0) continue;
+ if (line.len == 0 or pardes.exec.isComment(line)) continue;
const word = line[0 .. std.mem.indexOfAny(u8, line, " \t") orelse line.len];
const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
if (std.mem.eql(u8, w, word)) break true;
@@ -228,6 +228,69 @@ test "New written to a column's exec makes its scratch in the session's director
try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path);
}
+test "pane/new takes rows from a pane in another column before refusing ENOSPC" {
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 16 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const first = p.panes[p.active].?.serial;
+ // A second column holding one tall pane.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status);
+ const tall = try th.newPane(p);
+ const tall_col = layout.findPane(p, p.paneBySerial(tall).?).?.col;
+ const focus = @intFromEnum(tree.TopFile.focus);
+ var digits: [16]u8 = undefined;
+ // From the first column, until pane/new is refused.
+ var moved = false;
+ for (0..pardes.MAX_PANES) |_| {
+ _ = th.wr(p, focus, try std.fmt.bufPrint(&digits, "{d}\n", .{first}));
+ const made = th.newPane(p) catch break;
+ if (layout.findPane(p, p.paneBySerial(made).?).?.col == tall_col) moved = true;
+ }
+ try testing.expect(moved);
+}
+
+test "a Newcol refused because the tags would not fit logs its err alone and spends no column serial" {
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ // Long tags wrap onto more rows as their column narrows: the boot's
+ // two panes share one, and each keeps its tag and two rows.
+ const made = try th.newPane(p);
+ const pane = p.panes[p.paneBySerial(made).?].?;
+ const col = layout.findPane(p, p.paneBySerial(made).?).?.col;
+ for (p.col_panes[col][0..p.col_n[col]]) |k| {
+ const each = p.panes[k].?;
+ if (each.tag.own) |old| p.gpa.free(old);
+ each.tag.own = try p.gpa.dupe(u8, "word " ** 60);
+ }
+ try testing.expect(p.col_n[col] >= 2);
+ p.sync();
+ const ctl = @intFromEnum(tree.TopFile.ctl);
+ const focus = @intFromEnum(tree.TopFile.focus);
+ var digits: [16]u8 = undefined;
+ const own = try std.fmt.bufPrint(&digits, "{d}\n", .{pane.serial});
+ var refused = false;
+ for (0..pardes.MAX_COLS) |_| {
+ // From the long tag's column, so it is the one split narrower.
+ _ = th.wr(p, focus, own);
+ const serial_before = p.next_column_serial;
+ const log_before = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_before.bytes);
+ const r = th.wr(p, ctl, "Newcol\n");
+ if (r.reply.status == .ok) continue;
+ if (std.mem.indexOf(u8, r.reply.ename, "tags would not fit") == null) continue;
+ refused = true;
+ try testing.expectEqual(serial_before, p.next_column_serial);
+ const log_after = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_after.bytes);
+ const added = log_after.bytes[log_before.bytes.len..];
+ try testing.expect(std.mem.startsWith(u8, added, "err "));
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, added, "\n"));
+ break;
+ }
+ try testing.expect(refused);
+}
+
test "a tagexec word that makes, acts on and focuses no pane reads back nothing" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -458,7 +521,10 @@ test "a refused write after a truncation leaves every kind of tag as it was, and
const h = th.call(p, .{ .tag = 2, .op = .open, .node = node, .omode = 1 }).reply.handle;
const r = th.call(p, .{ .tag = 3, .op = .write, .node = node, .handle = h, .data = big });
try testing.expectEqual(E.NOSPC, r.errno());
- try testing.expectEqualStrings("tag: no space: over 4096 bytes", r.reply.ename);
+ try testing.expectEqualStrings("no space: over 4096 bytes", r.reply.ename);
+ // Its err record names the file once: `tag: no space`, never `tag: tag:`.
+ try testing.expect(th.logHas(p, "tag: no space: over 4096 bytes"));
+ try testing.expect(!th.logHas(p, "tag: tag:"));
_ = th.call(p, .{ .tag = 4, .op = .release, .node = node, .handle = h, .opened = true });
try testing.expectEqualStrings(before, th.rd(p, node, 0, 1 << 16).bytes);
// A control character is refused the same way on every kind.
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 0bbc0319..9a95a10f 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -192,8 +192,30 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep
for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) 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;
+ var id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
+ // No pane at all: the root's look and exec act in the session's
+ // directory. An exec runs as the workspace tag's; a look from a
+ // scratch made as New makes one (and its column), dropped
+ // unannounced when the look went elsewhere.
+ var scratch: ?usize = null;
+ if (p.panes[id] == null and serial == null and p.anyPane() == null) {
+ if (exec) {
+ p.exec_header = true;
+ defer p.exec_header = false;
+ if (captured(p, req, id, exec, text, true)) |refusal| return refusal;
+ continue;
+ }
+ const free = p.freeSlot() orelse return Reply.fail(req.tag, E.NOSPC);
+ p.newScratchFor(id, .scratch);
+ if (p.panes[free] == null) return Reply.fail(req.tag, E.NOSPC);
+ p.active = free;
+ id = free;
+ scratch = free;
+ }
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
+ defer if (scratch) |s| if (p.active != s) if (p.panes[s]) |sp| if (sp.file) |f| {
+ if (f.content.len == 0) p.removePane(s, null) catch {};
+ };
p.fs.ran_at = p.panes[id].?.serial;
p.fs.no_pane_slot = false;
if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal;
@@ -267,7 +289,8 @@ fn failureErrno(failure: []const u8) u16 {
if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT;
// A pattern refused (Edit's too) is malformed input, as 9ns reads it;
// so is a word this build has not (`invalid: acme's Put ...`).
- if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid")) return E.INVAL;
+ if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid") or
+ std.mem.startsWith(u8, failure, "address out of range")) return E.INVAL;
return E.IO;
}
@@ -586,7 +609,7 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
if (apply) p.fs.results_len = 0;
var it = Messages.init(p, req.data);
while (it.next()) |line| {
- if (line.len == 0) continue;
+ if (line.len == 0 or exec_line.isComment(line)) continue;
if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) {
const size = sizeOf(line) orelse return refuse(p, req, e_size, line);
// A frontend's window is the size; only a session none is
@@ -812,7 +835,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
var held = !other and pane.fs.lock != null;
var it = Messages.init(p, req.data);
while (it.next()) |line_in| {
- if (line_in.len == 0) continue;
+ if (line_in.len == 0 or exec_line.isComment(line_in)) continue;
// A line before this one may have closed the pane (`Del`).
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
// acme's ctl words: done by the file that replaces each, or the
@@ -1929,12 +1952,12 @@ test "a builtin that fails a ctl write logs only its err, so the same failure ag
p.update(.tick); // the pane's `new` is logged first
while (p.nextEffect()) |_| {}
const ctl_node = @intFromEnum(tree.TopFile.ctl);
- for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Kill zzz\n").reply.status);
+ for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Unmount zzz\n").reply.status);
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.endsWith(u8, text, "err - ctl: Kill: no running command has that first word (x3)\n"));
- try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null);
+ try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Unmount: no such mount zzz (x3)\n"));
+ try testing.expect(std.mem.indexOf(u8, text, "msg - Unmount") == null);
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
@@ -2445,6 +2468,81 @@ test "a look that misses file:/re/ names the file, and is logged as its pane's"
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} look: {s}/f.txt: no match for regexp", .{ f_serial, dir })));
}
+test "with no pane at all, the root look opens its file and the root exec runs in the session's directory" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "file\n" });
+ var buf: [4096]u8 = undefined;
+ const session = buf[0..try tmp.dir.realPath(testing.io, &buf)];
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = @intCast(session.len);
+ const Empty = struct {
+ fn all(q: *Pardes) !void {
+ while (q.anyPane()) |k| _ = wr(q, Node.of(q.panes[k].?.serial, .ctl), "delete\n");
+ }
+ };
+ try Empty.all(p);
+ const look_node = @intFromEnum(tree.TopFile.look);
+ try testing.expectEqual(Status.ok, wr(p, look_node, "f.txt\n").reply.status);
+ var want: [4200]u8 = undefined;
+ const path = try std.fmt.bufPrint(&want, "{s}/f.txt", .{session});
+ var opened: usize = 0;
+ var scratches: usize = 0;
+ for (p.panes) |slot| {
+ const f = (slot orelse continue).file orelse continue;
+ opened += @intFromBool(std.mem.eql(u8, f.path, path));
+ scratches += @intFromBool(std.mem.endsWith(u8, f.path, "/+New"));
+ }
+ try testing.expectEqual(@as(usize, 1), opened);
+ try testing.expectEqual(@as(usize, 0), scratches);
+
+ try Empty.all(p);
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.exec), "echo hi\n").reply.status);
+ const made = p.panes[p.anyPane().?].?;
+ try testing.expect(made.command != null);
+ try testing.expectEqualStrings(session, made.cwdSlice());
+}
+
+test "a look at file:3:0 is refused as addr 3:0 is, a column counting from 1" {
+ const p = try withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const by_addr = wr(p, Node.of(serial, .addr), "3:0");
+ const by_look = wr(p, Node.of(serial, .look), "/test.txt:3:0\n");
+ try testing.expectEqual(E.INVAL, by_addr.errno());
+ try testing.expectEqual(E.INVAL, by_look.errno());
+ try testing.expectEqualStrings(by_addr.reply.ename, by_look.reply.ename);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "/test.txt:3:1\n").reply.status);
+}
+
+test "Kill with a name and nothing running says so and does not fail, as bare Kill does" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const r = wr(p, @intFromEnum(tree.TopFile.ctl), "Kill zzz\n");
+ try testing.expectEqual(Status.ok, r.reply.status);
+ try testing.expect(th.logHas(p, "Kill: nothing running"));
+}
+
+test "a line starting with # runs as nothing at an exec or a ctl, silently" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ // The setup's own announcements are made by the first write's update.
+ _ = wr(p, @intFromEnum(tree.TopFile.ctl), "# settle\n");
+ const before = p.next_serial;
+ const log_before = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_before.bytes);
+ for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec), @intFromEnum(tree.TopFile.ctl), Node.of(serial, .ctl), Node.ofCol(layout.columnSerial(p, 0), .ctl) }) |node| {
+ try testing.expectEqual(Status.ok, wr(p, node, "# Platform: tty\n # indented too\n").reply.status);
+ }
+ try testing.expectEqual(before, p.next_serial);
+ const log_after = try pardes.ctlfs.events.freezeLog(p);
+ defer p.gpa.free(log_after.bytes);
+ try testing.expectEqualStrings(log_before.bytes, log_after.bytes);
+}
+
test "the ctl word name refuses a leading blank, as the name file does" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -3225,9 +3323,9 @@ test "a builtin that fails through exec, tagexec or a column's exec fails the wr
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");
+ const failed = wr(p, node, "Unmount 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);
+ try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Unmount: no such mount zzz") != null);
}
const log = @intFromEnum(tree.TopFile.log);
const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
@@ -3235,8 +3333,8 @@ test "a builtin that fails through exec, tagexec or a column's exec fails the wr
try testing.expect(std.mem.indexOf(u8, text, "msg ") == null);
// The root's exec ran at the pane, so its err and the pane's exec's are
// the same record, counted.
- try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Kill: no running command has that first word"));
- try testing.expect(std.mem.indexOf(u8, text, "first word (x2)") != null);
+ try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Unmount: no such mount zzz"));
+ try testing.expect(std.mem.indexOf(u8, text, "mount zzz (x2)") != null);
_ = 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);
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index c6505f52..63405781 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -688,8 +688,17 @@ fn scanNumber(data: []const u8, i: *usize) ?u32 {
}
/// Writing a Look or Exec record back performs the action it names.
-pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
- const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT);
+pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply {
+ const pane0 = p.panes[id] orelse return Reply.fail(asked.tag, E.NOENT);
+ // Its last record needs no newline, as every other line file's last
+ // line does not (`printf 'MX0 10' > event`).
+ var req = asked;
+ if (req.data.len > 0 and req.data[req.data.len - 1] != '\n') {
+ const whole = p.scratch.allocator().alloc(u8, req.data.len + 1) catch return Reply.fail(req.tag, E.NOMEM);
+ @memcpy(whole[0..req.data.len], req.data);
+ whole[req.data.len] = '\n';
+ req.data = whole;
+ }
const serial = pane0.serial;
{
const body = pane_files.bodyOf(pane0);
@@ -783,7 +792,7 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
else => unreachable,
}
}
- return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+ return .{ .tag = req.tag, .written = @intCast(asked.data.len) };
}
// ---- tests ----
@@ -984,11 +993,17 @@ test "writing an event record back performs the action it names" {
try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status);
try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
+ // The last record needs no newline, as any line file's last line.
+ pane.msg_len = 0;
+ const bare = wr(p, event, "FX0 10");
+ try testing.expectEqual(Status.ok, bare.reply.status);
+ try testing.expectEqual(@as(u32, 6), bare.reply.written);
+ try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
+
pane.msg_len = 0;
for ([_][]const u8{
"FX0 10\nFQ0 1\n", // unknown type character
"FX0 999\n", // out of range
- "FX0 10", // no newline
"FX5 1\n", // q0 > q1
"FD0 3\n", // a report, not a request
"F\n",
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 6a0fbba5..7526075b 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -639,7 +639,7 @@ pub fn tagFault(data: []const u8) ?[]const u8 {
/// A tag write's limit, the same for a pane's, a column's and the
/// workspace's: whole or not at all.
-pub const e_tag_over = std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail});
+pub const e_tag_over = std.fmt.comptimePrint("no space: over {d} bytes", .{limits.max_tag_tail});
fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
@@ -860,18 +860,14 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
"rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
else
"rename not allowed: an image or PDF is named by the file it shows");
- const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
+ var home_buf: [4096]u8 = undefined;
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), fs.expandHome(name, &home_buf) }) catch
return Reply.fail(req.tag, E.NOMEM);
if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return tree.failText(req.tag, E.INVAL, "invalid file name: longer than a path may be");
nameBuffer(p, id, full, false) catch |err| return Reply.fail(req.tag, switch (err) {
error.OutOfMemory => E.NOMEM,
else => E.INVAL,
});
- // A name into a directory not there yet asks for it, as Config's pane
- // does: its Save makes the directories (`pardes new/dir/f` forwarded).
- if (comptime pardes.hosted) if (fs.localPath(std.fs.path.dirname(full) orelse "/")) |local| {
- if (!exec.isDirectory(local)) fileOf(pane).?.make_dirs = true;
- };
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -1319,7 +1315,7 @@ test "a tag write past the limit is refused whole, naming the limit" {
try testing.expectEqual(E.NOSPC, r.errno());
// `no space` is what 9ns maps to ENOSPC, so a shell through a mount
// sees the errno the tree gives.
- try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over "));
+ try testing.expect(std.mem.startsWith(u8, r.reply.ename, "no space: over "));
try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes);
}
@@ -1392,19 +1388,6 @@ test "a name with a component over 255 bytes is refused, and a long path's faile
try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory"));
}
-test "a name into a directory not there has Save make it; one into a directory there does not" {
- if (comptime !pardes.hosted) return error.SkipZigTest;
- const p = try withFile(testing.allocator, "x\n");
- defer p.deinit();
- const serial = serialOf(p);
- const f = fileOf(p.panes[p.paneBySerial(serial).?].?).?;
- try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-mk/sub/f.txt\n").reply.status);
- try testing.expect(f.make_dirs);
- f.make_dirs = false;
- try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/f.txt\n").reply.status);
- try testing.expect(!f.make_dirs);
-}
-
test "a name cut across writes is one name, applied once at its newline or its close" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 0f5202cc..2245255b 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -33,7 +33,17 @@ pub const pty_rows_min = 2;
/// As the root ctl's `size` (4096 a side).
const winsize_max = 4096;
-const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x1 to {d}x{d}", .{ winsize_max, winsize_max });
+const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x{d} to {d}x{d}", .{ pty_rows_min, winsize_max, winsize_max });
+const e_winsize_rows = std.fmt.comptimePrint("invalid winsize: at least {d} rows", .{pty_rows_min});
+
+/// `winsize C R` whose R is a size but fewer rows than a pty takes.
+fn tooFewRows(line: []const u8) bool {
+ var words = std.mem.tokenizeAny(u8, line, " \t");
+ if (!std.mem.eql(u8, words.next() orelse return false, "winsize")) return false;
+ _ = dimension(words.next() orelse return false) orelse return false;
+ const rows = dimension(words.next() orelse return false) orelse return false;
+ return words.next() == null and rows < pty_rows_min;
+}
/// A winsize whose numbers are numbers but not sizes: said as such.
fn outOfRange(line: []const u8) bool {
@@ -79,7 +89,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
if (@import("../host_io.zig").Shell.refusal(bin[0..@min(bin.len, 200)], &why)) |refused|
return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}", .{refused}) catch "exec: no such shell");
};
- if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
+ if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (tooFewRows(line)) e_winsize_rows else if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -93,8 +103,10 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
.winsize => {
const cols = dimension(words.next() orelse return false) orelse return false;
// Two rows at least, as a pane is laid out: a one-row pty
- // loses its prompt's input mark and reads busy for ever.
- const rows = @max(pty_rows_min, dimension(words.next() orelse return false) orelse return false);
+ // loses its prompt's input mark and reads busy for ever. Fewer
+ // is refused, never quietly raised.
+ const rows = dimension(words.next() orelse return false) orelse return false;
+ if (rows < pty_rows_min) return false;
if (words.next() != null) return false;
if (!apply) return true;
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
@@ -619,6 +631,13 @@ test "every pty/ctl verb, and every refusal" {
try testing.expect(a.winsize == null);
}
+ // One row is a size, but fewer than a pty takes: refused, and said so.
+ const one = wr(p, ctl, "winsize 80 1");
+ try testing.expectEqual(E.INVAL, one.errno());
+ try testing.expectEqualStrings("invalid winsize: at least 2 rows", one.reply.ename);
+ try testing.expect(one.winsize == null);
+ try testing.expectEqualStrings("invalid winsize: out of range, 1x2 to 4096x4096", e_winsize_range);
+
const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
try testing.expectEqual(Status.ok, spaced.reply.status);
try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols);
@@ -960,6 +979,7 @@ test "a run's answer says cut when its output's start is gone, and reads a bound
var got = sh.answered(p, node, h, into);
try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
+
// A resize between its start and end reflows the pins with the text.
h = sh.send(p, node);
sh.put(p, c ++ "one\r\ntwo\r\n");
@@ -1022,6 +1042,12 @@ test "a run's answer says cut when its output's start is gone, and reads a bound
sh.put(p, d ++ prompt);
p.gpa = gpa;
try testing.expectEqualStrings("error out of memory\n", sh.answered(p, node, h, into));
+ // An end drawn above the start (the prompt redrawn over what was
+ // printed, as in a window two rows high): cut, never an empty whole.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "printed\r\nmore\r\n\x1b[4A" ++ d ++ prompt);
+ got = sh.answered(p, node, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
}
test "a terminal's ctl counts its body, and a new directory is a rename in the log" {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 17941e10..322728da 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -1169,7 +1169,12 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
// 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;
+ // So may one cut where a writer's buffer filled: stdio flushes, and a
+ // mount's page cache writes back, in multiples of 4096 bytes. `seq ...
+ // > exec` through a mount arrived in 4096-byte writes, and the line cut
+ // at each end ran as two commands (the half `Msg` refused, failing the
+ // write and the rest of the burst).
+ const cut = (p.fs.write_room != 0 and req.data.len >= p.fs.write_room) or req.data.len % 4096 == 0;
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);
@@ -1826,6 +1831,25 @@ test "look and exec read back what their own open's write touched; a fresh open
for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
}
+test "a write cut where a writer's 4096-byte buffer filled holds its partial line for the next write" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec_node = Node.of(serialOf(p), .exec);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = exec_node, .omode = 1 }).reply.handle;
+ // 4096 bytes: whole `Msg first` lines, then a line cut after `Msg`.
+ var buf: [4096]u8 = undefined;
+ const line = "Msg first\n";
+ var n: usize = 0;
+ while (n + line.len <= buf.len - 3) : (n += line.len) @memcpy(buf[n..][0..line.len], line);
+ @memset(buf[n..], ' ');
+ @memcpy(buf[buf.len - 3 ..], "Msg");
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec_node, .handle = h, .data = &buf }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 3, .op = .write, .node = exec_node, .handle = h, .data = " last\n" }).reply.status);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = exec_node, .handle = h, .opened = true });
+ try testing.expect(th.logHas(p, "last"));
+ try testing.expect(!th.logHas(p, "wrong #args"));
+}
+
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();