diff options
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/addr.zig | 10 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 5 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 344 | ||||
| -rw-r--r-- | src/ninep/events.zig | 37 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 141 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 39 | ||||
| -rw-r--r-- | src/ninep/testing.zig | 7 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 208 |
8 files changed, 741 insertions, 50 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index f60c07b1..33a3f646 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -17,7 +17,7 @@ pub const e_col_zero = "address out of range: a column counts from 1"; pub const e_regexp = "bad regular expression"; /// Not "took too long": 9ns reads errors by their words, and that would be /// ENAMETOOLONG. -pub const e_slow = "regular expression search gave up, backtracking past its step budget"; +pub const e_slow = "regular expression search took too much time, gave up"; pub const e_syntax = "bad address syntax"; pub const e_order = "addresses out of order"; @@ -299,7 +299,13 @@ pub const Addr = struct { return null; } var rx = regexp_.Regex.compile(pat) catch |err| { - a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp; + a.err = switch (err) { + error.Anchor => regexp_.Regex.e_anchor, + error.TooLong => regexp_.Regex.e_long, + error.WideRange => regexp_.Regex.e_wide, + error.NegatedRunes => regexp_.Regex.e_negated, + error.Bad => e_regexp, + }; return null; }; // sam's nextmatch (editors/sam/address.c:97-119): an empty match diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index 6b59f9e5..cc583c93 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -368,10 +368,11 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno()); var rec: [64]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}/ctl: unknown control message \"Exit\"", .{empty}))); - // rmdir refuses a column with a pane, and says why. + // rmdir refuses a column with a pane, and says why, in its error alone. const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true }); try testing.expectEqual(E.NOTEMPTY, full.errno()); - try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty}))); + try testing.expect(std.mem.startsWith(u8, full.reply.ename, "column not empty")); + try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty}))); // exec runs a word as a click in that column's tag: Delcol closes it. try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status); try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty)); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 397f3339..5bf354eb 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -251,8 +251,9 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i /// EIO. 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. - if (std.mem.indexOf(u8, failure, "bad regular expression") != null) return E.INVAL; + // 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; return E.IO; } @@ -330,13 +331,13 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { try w.writeByte('\''); } -pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48; +pub const pane_text_cap = 9 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48; -/// acme's winctlprint: serial, tag length, body length, a reserved zero, the -/// dirty flag, then the width in cells, the font and the tab width. Then -/// rio's word for whether the window has the keyboard, `current` or -/// `notcurrent` (rio(4), wctl), which acme's line has no room for because -/// acme has one window for input at a time only by the mouse. +/// acme's winctlprint (plan9port wind.c), field for field: serial, tag +/// length, body length, isdir (0), the dirty flag, the width in cells, the +/// font, the tab width, whether Undo has a step, whether Redo has one. Then +/// pardes's own: rio's word for whether the window has the keyboard, +/// `current` or `notcurrent` (rio(4), wctl), and a REPL's id. pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { var w = std.Io.Writer.fixed(buf); w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ @@ -352,7 +353,10 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { writeQuoted(&w, if (font.len == 0) "default" else font) catch return w.buffered(); // While a column's or the workspace's tag has the keyboard, no pane does. const current = p.panes[p.active] == pane and !p.header_focus; - w.print(" {d:>11} {s} ", .{ config.tab_width, if (current) "current" else "notcurrent" }) catch {}; + const f = pane_files.fileOf(pane); + const undo = if (f) |file| file.history.undo_len > 0 else false; + const redo = if (f) |file| file.history.redo_len > 0 else false; + w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_width, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {}; // A terminal bound as a REPL ends with its id, `python-a`. var idbuf: [48]u8 = undefined; const id = exec_line.replId(&idbuf, pane); @@ -387,7 +391,13 @@ pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: [] /// as on a tag; acme's lowercase verbs are the pane ctl's and alias none. fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?Reply { const sp = std.mem.indexOfAny(u8, line, " \t"); - const word = line[0 .. sp orelse line.len]; + const typed = line[0 .. sp orelse line.len]; + // acme's words: checked as the pardes word they run as, or refused. + const word = for (pardes.exec.acme_words) |w| { + if (!std.mem.eql(u8, typed, w.acme)) continue; + const now = w.pardes orelse return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "invalid: acme's {s} is not a pardes builtin{s}", .{ w.acme, w.hint }) catch "invalid: an acme word pardes has not"); + break now; + } else typed; const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else ""; const b = std.meta.stringToEnum(Builtin, word) orelse { // A setting this build's frontend cannot show (Lift, GripWidth on a @@ -777,10 +787,19 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { if (apply) p.fs.results_len = 0; var held = !other and pane.fs.lock != null; var it = Messages.init(p, req.data); - while (it.next()) |line| { - if (line.len == 0) continue; + while (it.next()) |line_in| { + if (line_in.len == 0) 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 + // builtin that does it, checked with the rest before any runs. + var line = line_in; + switch (acmeCtl(p, req, serial, line_in, apply)) { + .no => {}, + .done => continue, + .refused => |r| return r, + .builtin => |word| line = word, + } if (std.mem.eql(u8, line, "get")) { if (!apply) continue; // Quoting the line, as a builtin's refusal (Save's) does. @@ -854,6 +873,72 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } +/// acme's pane ctl vocabulary (editors/acme/xfid.c xfidctlwrite): each word +/// done here by the file that replaces it, or run as the builtin that does +/// it; those with no place here refused with why. +const AcmeCtl = union(enum) { no, done, refused: Reply, builtin: []const u8 }; + +fn acmeCtl(p: *Pardes, req: Req, serial: u32, line: []const u8, apply: bool) AcmeCtl { + const id = p.paneBySerial(serial) orelse return .no; + const pane = p.panes[id].?; + const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ') orelse line.len]; + const rest = std.mem.trim(u8, line[word.len..], " "); + const File = tree.PaneFile; + // A word written to one of the pane's own files, as acme's is to its. + const Into = struct { file: File, data: []const u8 }; + const into: ?Into = if (std.mem.eql(u8, word, "name") and rest.len > 0) + .{ .file = .name, .data = rest } + else if (std.mem.eql(u8, line, "clean")) + .{ .file = .dirty, .data = "0" } + else if (std.mem.eql(u8, line, "dirty")) + .{ .file = .dirty, .data = "1" } + else if (std.mem.eql(u8, line, "mark")) + .{ .file = .mark, .data = "1" } + else if (std.mem.eql(u8, line, "nomark")) + .{ .file = .mark, .data = "0" } + else + null; + if (into) |w| { + if (!apply) return .done; + const r = pane_files.write(p, .{ .tag = req.tag, .op = .write, .node = tree.Node.of(serial, w.file), .data = w.data }, id, pane, w.file); + return if (r.status == .err) .{ .refused = r } else .done; + } + if (std.mem.eql(u8, line, "put")) return .{ .builtin = "Save" }; + if (std.mem.eql(u8, line, "del")) return .{ .builtin = "Del" }; + if (std.mem.eql(u8, line, "delete")) return .{ .builtin = "Delete" }; + const pf = &pane.fs; + if (std.mem.eql(u8, line, "dot=addr")) { + if (apply) pane_files.setDot(pane, pf.addr); + return .done; + } + if (std.mem.eql(u8, line, "addr=dot")) { + if (apply) pf.addr = pane_files.dotOf(pane); + return .done; + } + if (std.mem.eql(u8, line, "limit=addr")) { + if (apply) pf.limit = pf.addr; + return .done; + } + if (std.mem.eql(u8, line, "show")) { + if (apply) pane_files.showOffset(pane, pane_files.dotOf(pane).q0); + return .done; + } + if (std.mem.eql(u8, line, "cleartag")) { + if (apply) _ = pane_files.truncate(p, pane, .tag); + return .done; + } + const none: ?[]const u8 = if (std.mem.eql(u8, word, "dump") or std.mem.eql(u8, word, "dumpdir")) + "a pane is dumped with the session: Dump and DumpDir on /ctl" + else if (std.mem.eql(u8, word, "font")) + "the font is the session's: Font on /ctl" + else if (std.mem.eql(u8, line, "menu") or std.mem.eql(u8, line, "nomenu")) + "pardes has no menu" + else + null; + if (none) |why| return .{ .refused = tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "invalid: acme's {s}: {s}", .{ word, why }) catch "invalid: acme's ctl word") }; + return .no; +} + const e_not_locked = "pane not locked by this open"; /// Who holds it is another open of this ctl, whose client pardes cannot name. const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock"; @@ -922,7 +1007,7 @@ const root_status = @intFromEnum(tree.TopFile.status); const root_look = @intFromEnum(tree.TopFile.look); const root_exec = @intFromEnum(tree.TopFile.exec); -test "pane ctl read is index's five fields plus width in cells, font, tab width and whether it is current" { +test "pane ctl read is acme's fields -- index's five, width in cells, font, tab width, undo, redo -- then whether it is current" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); @@ -932,8 +1017,8 @@ test "pane ctl read is index's five fields plus width in cells, font, tab width try testing.expectEqual(Status.ok, a.reply.status); var want: std.ArrayList(u8) = .empty; defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} current ", .{ - pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, + try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} {d:>11} {d:>11} current ", .{ + pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, 0, 0, }); try testing.expectEqualStrings(want.items, a.bytes); try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size); @@ -1034,13 +1119,9 @@ test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the r defer p.deinit(); const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); - // acme's other ctl words have files of their own here, and a builtin is - // its own capitalised word: `Get`, `DEL` and `del` are none of them. - for ([_][]const u8{ - "menu", "nomenu", "dump echo hi", "font Go Mono", "lock x", "bogus", "DEL", - "put", "del", "delete", "clean", "dirty", "show", "mark", - "Get", "limit=addr", "get x", "look /tmp", "exec Del", - }) |bad| { + // A builtin is its own capitalised word: `GET` and `DEL` are none; + // acme's own words are done or refused as acme's (the test below). + for ([_][]const u8{ "lock x", "bogus", "DEL", "GET", "get x", "look /tmp", "exec Del" }) |bad| { const refused = wr(p, ctl_node, bad); try testing.expectEqual(E.INVAL, refused.errno()); var want: [64]u8 = undefined; @@ -1671,6 +1752,59 @@ test "a look after Newcol fills the empty column the keyboard is on" { try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]); } +test "a look at file:addr whose file is not there is one miss, quoting what was written, in a terminal too" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const looked = Node.of(serialOf(p), .look); + for ([_][]const u8{ "zzq:#3", "zzq:2", "zzq:/re/" }) |written| { + var line: [64]u8 = undefined; + _ = wr(p, looked, try std.fmt.bufPrint(&line, "{s}\n", .{written})); + var want: [64]u8 = undefined; + try testing.expectEqual(@as(usize, 1), th.logCount(p, try std.fmt.bufPrint(&want, "look: no match for \"{s}\"\n", .{written}))); + } + try testing.expectEqual(@as(usize, 3), th.logCount(p, "\nerr ")); +} + +test "a look whose pane is refused a place fails with that alone, not a missing line too" { + const gpa = testing.allocator; + // A screen with rows for one pane only. + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 5 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile("x\n"); + while (p.nextEffect()) |_| {} + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" }); + var dir: [4096]u8 = undefined; + const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; + var line: [4200]u8 = undefined; + const r = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/f.txt:2\n", .{at})); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "no space for a pane") != null); + try testing.expectEqual(@as(usize, 1), th.logCount(p, "\nerr ")); +} + +test "a look under pardes's placement, from a pane pane/new made after Delcol, opens its file" { + const gpa = testing.allocator; + const p = try withFile(gpa, "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 dir: [4096]u8 = undefined; + const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; + var line: [4200]u8 = undefined; + const first = serialOf(p); + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(first, .exec), "Placement\n").reply.status); + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(first, .exec), "Delcol\n").reply.status); + const made = try th.newPane(p); + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .look), try std.fmt.bufPrint(&line, "{s}/f.txt:#4\n", .{at})).reply.status); + // The pane pane/new made is placed, and so is the one its look opened. + for (p.panes, 0..) |slot, i| if (slot != null) try testing.expect(layout.findPane(p, i) != null); + try testing.expect(p.panes[p.active].?.serial != made); +} + test "a Restore of a file that is no dump fails the write before any warning, and a failed Dump fails its write" { if (comptime !pardes.hosted) return error.SkipZigTest; const p = try withFile(testing.allocator, "x\n"); @@ -1924,10 +2058,10 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir for (p.panes) |slot| count += @intFromBool(slot != null); const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, missed.errno()); - try testing.expectEqualStrings("grep: pattern not found", missed.reply.ename); + try testing.expectEqualStrings("Grep: pattern not found", missed.reply.ename); const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, unfound.errno()); - try testing.expectEqualStrings("find: name not found", unfound.reply.ename); + try testing.expectEqualStrings("Find: name not found", unfound.reply.ename); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); @@ -2234,6 +2368,133 @@ test "a buffer whose file was deleted on disk is dirty, and Del asks first" { try testing.expect(p.paneBySerial(serial) == null); } +test "a failure said while a 9P write waits on it posts no msg, only the write's err" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + p.fs.write_waits = true; + p.saveFailed(@intCast(p.active), "/nowhere/zz.txt", error.FileNotFound); + p.fs.write_waits = false; + try testing.expect(!th.logHas(p, "msg ")); + try testing.expect(p.fs.late_failure_len > 0); +} + +test "Mini with no argument is wrong #args, EINVAL, as every word that needs one" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const r = wr(p, Node.of(serialOf(p), .ctl), "Mini\n"); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings("wrong #args in control message \"Mini\"", r.reply.ename); +} + +test "a look that misses file:/re/ names the file, and is logged as its pane's" { + 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 = "alpha\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir})); + p.sync(); + const f_serial = p.panes[p.active].?.serial; + _ = wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "{s}/f.txt:/zzqq/\n", .{dir})); + var want: [4300]u8 = undefined; + 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 "acme's words run as pardes's (Put is Save, Delete a Del that does not ask) or are refused, never run as commands" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n" }) |line| { + for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| { + const r = wr(p, node, line); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "is not a pardes builtin") != null); + } + } + // Put saves, as Save does. + _ = wr(p, Node.of(serial, .name), "/tmp/pardes-put.txt\n"); + const put = wr(p, Node.of(serial, .exec), "Put\n"); + try testing.expectEqual(Status.ok, put.reply.status); + try testing.expect(put.saved); + // Delete closes an edited pane without asking. + const made = try th.newPane(p); + _ = wr(p, Node.of(made, .name), "/tmp/pardes-delete.txt\n"); + _ = wr(p, Node.of(made, .body), "edited\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(made, .ctl), "Delete\n").reply.status); + p.sync(); + try testing.expect(p.paneBySerial(made) == null); +} + +test "a pane's ctl takes acme's words: done by the files that replace them, or refused with why" { + const p = try withFile(testing.allocator, "one two\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const pane = p.panes[p.paneBySerial(serial).?].?; + try testing.expectEqual(Status.ok, wr(p, ctl_node, "name /tmp/pardes-acme-ctl.txt\n").reply.status); + try testing.expectEqualStrings("/tmp/pardes-acme-ctl.txt", pane.file.?.path); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "dirty\n").reply.status); + try testing.expectEqualStrings("1", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "clean\n").reply.status); + try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); + _ = wr(p, Node.of(serial, .addr), "/two/"); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "dot=addr\nshow\nnomark\nmark\nlimit=addr\n").reply.status); + try testing.expectEqual(@as(u32, 4), pane_files.dotOf(pane).q0); + try testing.expect(pane.fs.limit != null); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "cleartag\n").reply.status); + for ([_][]const u8{ "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono" }) |line| { + const r = wr(p, ctl_node, line); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expect(std.mem.startsWith(u8, r.reply.ename, "invalid: acme's ")); + } + // Checked whole: a refused word anywhere and nothing ran. + try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nmenu\n").errno()); + try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); +} + +test "ctl reads acme's fields in acme's order, undo and redo flags included, then pardes's own" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const fields = struct { + fn of(text: []const u8, buf: *[16][]const u8) [][]const u8 { + var it = std.mem.tokenizeScalar(u8, text, ' '); + var n: usize = 0; + while (it.next()) |t| : (n += 1) buf[n] = t; + return buf[0..n]; + } + }; + var buf: [16][]const u8 = undefined; + var f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); + try testing.expectEqualStrings("0", f[8]); // nothing to undo + try testing.expectEqualStrings("0", f[9]); // nothing to redo + try testing.expect(std.mem.eql(u8, f[10], "current") or std.mem.eql(u8, f[10], "notcurrent")); + _ = wr(p, Node.of(serial, .body), "two\n"); + f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); + try testing.expectEqualStrings("1", f[8]); + _ = wr(p, ctl_node, "Undo\n"); + f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); + try testing.expectEqualStrings("1", f[9]); +} + +test "a write to addr, limit or mark asks for no frame; one to body does" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + for ([_]tree.PaneFile{ .addr, .limit, .mark }) |file| { + p.needs_frame = false; + _ = p.serveFs(.{ .tag = 1, .op = .write, .node = Node.of(serial, file), .data = if (file == .mark) "1" else "#0" }); + try testing.expect(!p.needs_frame); + } + p.needs_frame = false; + _ = p.serveFs(.{ .tag = 1, .op = .write, .node = Node.of(serial, .body), .data = "two\n" }); + try testing.expect(p.needs_frame); +} + test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -2960,7 +3221,7 @@ test "@p<serial>:<address> takes any sam address; a miss and an unknown serial a } var line: [64]u8 = undefined; _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial})); - try testing.expect(th.logHas(p, "look: no match for regexp")); + try testing.expect(th.logHas(p, ": no match for regexp")); try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); _ = wr(p, root, "@p999:/foo/\n"); try testing.expect(th.logHas(p, "look: no pane @p999")); @@ -3020,3 +3281,36 @@ test "@p<terminal>:<address> takes any address over the terminal's lines, select try testing.expectEqual(row_before, pane.body.cur_row); try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); } + +test "a bad command inside an Edit block written whole is named, not the } after it" { + const p = try withFile(testing.allocator, "abc\n"); + defer p.deinit(); + const r = wr(p, Node.of(serialOf(p), .ctl), "Edit {\n,s/a/b/\nzq\n}\n"); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "\"}\"") == null); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "z") != null); + try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); +} + +test "a refused Delcol changes nothing: no pane, no focus" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n"); + const made = try th.newPane(p); + _ = wr(p, Node.of(made, .name), "/tmp/pardes-delcol-refused.txt\n"); + _ = wr(p, Node.of(made, .body), "edit\n"); + p.sync(); + const col = layout.findPane(p, p.paneBySerial(made).?).?.col; + const active = p.active; + const active_column = p.active_column; + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + try testing.expectEqual(Status.err, wr(p, Node.ofCol(layout.columnSerial(p, col), .ctl), "Delcol\n").reply.status); + p.sync(); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count, after); + try testing.expectEqual(active, p.active); + try testing.expectEqual(active_column, p.active_column); + try testing.expect(th.logHas(p, "unsaved ")); +} diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 570cae78..fe7af77e 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -171,11 +171,11 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void { pushCounting(p, (if (serial == 0) std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut }) else - std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return); + std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return, said); } -/// Records `err <serial|-> <file>: <why>` for a write or truncation that was -/// refused or failed: through a mount a shell sees only the errno the +/// Records `err <serial|-> <file>: <why>` for a write that was refused or +/// failed: through a mount a shell sees only the errno the /// kernel mapped the reply to (`Invalid argument`), and here is the reason. /// The log is the one place for it, as acme's `errors` file takes text and /// answers nothing: a per-pane readable error file would be a second. @@ -186,7 +186,7 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void { pub fn noteErrCounted(p: *Pardes, text: []const u8) void { bootFirst(p); var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined; - pushCounting(p, std.fmt.bufPrint(&buf, "err {s}\n", .{text}) catch return); + pushCounting(p, std.fmt.bufPrint(&buf, "err {s}\n", .{text}) catch return, text); } pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void { @@ -239,7 +239,7 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void { for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') { c.* = ' '; }; - pushCounting(p, record); + pushCounting(p, record, reason); } /// Drops the newest record when it is a msg saying `text` (of any pane) @@ -256,7 +256,11 @@ pub fn dropMessage(p: *Pardes, text: []const u8) void { /// counts that one instead: `<record> (x2)`, the count being every time it /// was said. One a follower has already read is not rewritten: the repeat /// is a new line carrying the running count, `(x3)`. -fn pushCounting(p: *Pardes, raw: []u8) void { +/// `full` is the text before any cut: two records that agree only in what +/// was cut to fit are no repeat (`fs.log_full_hash`). +fn pushCounting(p: *Pardes, raw: []u8, full: []const u8) void { + const full_hash = std.hash.Wyhash.hash(0, full); + defer p.fs.log_full_hash = full_hash; // A client retrying a write that fails the same way would fill the ring // with one line, so a repeat of the newest record is that record counted, // `(x3)`, as +Messages counts its repeats; unless a follower has read it @@ -272,7 +276,7 @@ fn pushCounting(p: *Pardes, raw: []u8) void { said = said[0..x]; } else |_| {} }; - if (!std.mem.eql(u8, said, record[0 .. record.len - 1])) return pushLog(p, record); + if (!std.mem.eql(u8, said, record[0 .. record.len - 1]) or p.fs.log_full_hash != full_hash) return pushLog(p, record); var counted: [pardes.Messages.LoggedMessage.cap + 64]u8 = undefined; const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return; if (!followerRead(p, last.seq)) p.fs.log.buf.shrinkRetainingCapacity(last.at); @@ -373,6 +377,8 @@ fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { } fn pushLog(p: *Pardes, raw: []u8) void { + // A record not counted: the next one is no repeat of a counted one. + p.fs.log_full_hash = 0; var clean: [4 * 4096 + 256]u8 = undefined; const record = sanitize(raw, &clean); // One record larger than the ring would push every other out and then @@ -1283,7 +1289,7 @@ test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out)); } -test "a long err record is cut between words, with an ellipsis" { +test "a long err record keeps its end, its middle given up to an ellipsis" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n"); @@ -1292,6 +1298,19 @@ test "a long err record is cut between words, with an ellipsis" { const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?; const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1]; - try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n")); + try testing.expect(std.mem.indexOf(u8, record, "…") != null); + try testing.expect(std.mem.startsWith(u8, record, "err - ctl: ")); + try testing.expect(std.mem.endsWith(u8, record, "abcdefgh\"\n") or std.mem.endsWith(u8, record, "abcdefgh \"\n")); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } + +test "two failures that agree only in what their records cut are two records, not one counted" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const long = "why " ** 80; + p.setMessage(0, long ++ "first"); + p.setMessage(0, long ++ "second"); + try testing.expect(!th.logHas(p, "(x2)")); + p.setMessage(0, long ++ "second"); + try testing.expect(th.logHas(p, "(x2)")); +} diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 9f85a75f..3c1e181e 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -475,6 +475,55 @@ fn joinOnly(pane: *Pane, handle: u32) void { if (pane.fs.joined_by != 0 and pane.fs.joined_by != handle) pane.fs.joined = null; } +/// How long writes in a row may pause before the editor's own step puts +/// them in: long enough that a stream of 8 KB writes is one edit. +pub const batch_pause_ns: u64 = 20 * std.time.ns_per_ms; + +/// The editor's step: the held writes go in once they have paused. +pub fn flushPausedBatch(p: *Pardes) void { + const b = &p.fs.batch; + if (b.bytes.items.len == 0) return; + if (p.now_ns != 0 and p.now_ns -| b.last_ns < batch_pause_ns) return; + flushBatch(p); +} + +/// Puts the held writes (`fs.batch`) in as one edit: before any other +/// request, the open's release, or the editor's own step. +pub fn flushBatch(p: *Pardes) void { + const b = &p.fs.batch; + if (b.bytes.items.len == 0) return; + defer b.bytes.clearRetainingCapacity(); + const id = p.paneBySerial(b.serial) orelse return; + const pane = p.panes[id].?; + if (fileOf(pane) == null) return; + const before = dotOf(pane); + joinOnly(pane, b.handle); + const take = spliceBody(p, pane, b.at, b.at, b.bytes.items) orelse return; + pane.fs.joined = fileOf(pane).?.revision; + pane.fs.joined_by = b.handle; + setDot(pane, .{ + .q0 = shiftOne(before.q0, clip(b.at), 0, clip(take)), + .q1 = shiftOne(before.q1, clip(b.at), 0, clip(take)), + }); + if (!pane.fs.noscroll) showOffset(pane, b.at + take); +} + +/// The held bytes a write at `at` on this open can go on from: its own +/// batch, when it ends right at `at`; any other is put in first. +fn batchFor(p: *Pardes, req: Req, pane: *Pane, at: usize) ?*@TypeOf(p.fs.batch) { + const b = &p.fs.batch; + const mine = b.serial == pane.serial and b.node == req.node and b.handle == req.handle; + if (b.bytes.items.len > 0 and !(mine and b.at + b.bytes.items.len == at)) flushBatch(p); + // Only an open's writes are held: one with no record (a test's, handle + // 0) is put in at once. A hold past 64 MiB is put in first. + if (req.handle == 0 or b.bytes.items.len > 64 << 20) { + flushBatch(p); + return null; + } + if (b.bytes.items.len == 0) b.* = .{ .serial = pane.serial, .node = req.node, .handle = req.handle, .at = at, .bytes = b.bytes }; + return b; +} + fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; if (pane.file == null) { @@ -484,7 +533,14 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { p.emitWrite(id, req.data[0..take]); return .{ .tag = req.tag, .written = @intCast(take) }; } - const at = bodyOf(pane).len; + const held = if (p.fs.batch.serial == pane.serial and p.fs.batch.node == req.node and p.fs.batch.handle == req.handle) p.fs.batch.bytes.items.len else 0; + const at = bodyOf(pane).len + held; + if (batchFor(p, req, pane, at)) |b| { + const take = wholeUtf8(req.data); + b.bytes.appendSlice(p.gpa, req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM); + b.last_ns = p.now_ns; + return .{ .tag = req.tag, .written = @intCast(take) }; + } joinOnly(pane, req.handle); const take = spliceBody(p, pane, at, at, req.data) orelse return Reply.fail(req.tag, E.NOMEM); @@ -514,8 +570,9 @@ fn writeTag(req: Req, pane: *Pane) Reply { const pf = &pane.fs; const had = tagline.curTail(pane).len; const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline); - if (room == 0) return Reply.fail(req.tag, E.NOSPC); - const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); + // Whole or not at all: a write that would pass the limit changes nothing. + if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail})); + const take = wholeUtf8(req.data); // A truncating write drops ONE trailing newline, its whole text's: a // newline held from a write before goes in once more text follows it. if (pf.tag_held_newline) { @@ -538,9 +595,19 @@ fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply { if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text); const pf = &pane.fs; if (pf.addr_failed) return tree.failText(req.tag, E.INVAL, e_addr_failed); - clampAddr(pf, bodyOf(pane).len); + const held = if (p.fs.batch.serial == pane.serial and p.fs.batch.node == req.node and p.fs.batch.handle == req.handle) p.fs.batch.bytes.items.len else 0; + clampAddr(pf, bodyOf(pane).len + held); const q0: usize = pf.addr.q0; const q1: usize = @max(q0, @as(usize, pf.addr.q1)); + // An insert (an empty address) goes on from the open's held bytes. + if (q1 == q0) if (batchFor(p, req, pane, q0)) |b| { + const take = wholeUtf8(req.data); + b.bytes.appendSlice(p.gpa, req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM); + b.last_ns = p.now_ns; + pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; + return .{ .tag = req.tag, .written = @intCast(take) }; + }; + flushBatch(p); const before = dotOf(pane); joinOnly(pane, req.handle); const take = spliceBody(p, pane, q0, q1, req.data) orelse @@ -1097,6 +1164,62 @@ test "the writes of one open are one undo step, as bash's line-buffered printf m } } +test "an open's writes in a row are held and go in as one edit, seen by the next request" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + const body = Node.of(serial, .body); + const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; + const revision = p.panes[0].?.file.?.revision; + for (0..100) |_| _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = h, .data = "0123456789\n" }); + // Held: nothing edited yet. + try testing.expectEqual(revision, p.panes[0].?.file.?.revision); + // Any other request puts them in, once. + try testing.expectEqual(@as(usize, 4 + 1100), rd(p, body, 0, 4096).bytes.len); + try testing.expectEqual(revision + 1, p.panes[0].?.file.?.revision); + _ = call(p, .{ .tag = 3, .op = .release, .node = body, .handle = h, .opened = true }); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); + try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes); + // data inserts at its address go on the same way. + const data = Node.of(serial, .data); + _ = wr(p, Node.of(serial, .addr), "#0"); + const d = call(p, .{ .tag = 4, .op = .open, .node = data, .omode = 1 }).reply.handle; + _ = call(p, .{ .tag = 5, .op = .write, .node = data, .handle = d, .data = "a" }); + _ = call(p, .{ .tag = 5, .op = .write, .node = data, .handle = d, .data = "b" }); + _ = call(p, .{ .tag = 6, .op = .release, .node = data, .handle = d, .opened = true }); + try testing.expectEqualStrings("abone\n", rd(p, body, 0, 64).bytes); +} + +test "writes held before the shell's clock is first seen go in at its first step" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const body = Node.of(serialOf(p), .body); + try testing.expect(!p.clock_started); + const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; + const revision = p.panes[0].?.file.?.revision; + _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = h, .data = "x\n" }); + try testing.expectEqual(@as(u64, 0), p.fs.batch.last_ns); + // A detached host's first step: its clock is days into the machine's. + p.advance(700_000 * std.time.ns_per_s); + flushPausedBatch(p); + try testing.expectEqual(revision + 1, p.panes[0].?.file.?.revision); +} + +test "a tag write past the limit is refused whole, naming the limit" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const tag = Node.of(serialOf(p), .tag); + const before = try testing.allocator.dupe(u8, rd(p, tag, 0, 1 << 16).bytes); + defer testing.allocator.free(before); + const big = "w" ** (limits.max_tag_tail + 10); + const r = wr(p, tag, big); + 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.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes); +} + test "two opens writing one body are two undo steps, however their writes interleave" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); @@ -1153,13 +1276,19 @@ test "a name cut across writes is one name, applied once at its newline or its c const node = Node.of(serial, .name); const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expect(h != 0); + // A piece that fills its Twrite goes on in the next. + p.fs.write_room = "/tmp/pardes-na".len; _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-na" }); + p.fs.write_room = 0; try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" }); try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); - // No newline: the close applies it. - _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-closed.txt" }); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); + // No newline, the whole write: applied at once, on its own open. + const h3 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; + _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" }); + try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true }); try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); } diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index d32c35fd..8ba9f4df 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -61,6 +61,10 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply { while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; + // A command pane's child is its command, done or running: exec + // would run its line again with no header, footer or records. + if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pane.command != null) + return tree.failText(req.tag, E.INVAL, "invalid exec: a command pane does not restart"); // A shell restarted where the directory is gone would not start: // refused before anything runs, as Tty there is. if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pardes.exec.missingDir(p, pane.cwdSlice())) { @@ -204,6 +208,12 @@ fn answer(p: *Pardes, slot: *Run, comptime fmt: []const u8, args: anytype) void pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.run; if (slot.phase == .sent or slot.phase == .waiting) return tree.failText(req.tag, E.BUSY, tree.e_in_use); + // One line a run, its answer read before the next: a second line (bash + // writes printf 'a\nb\n' a line at a time) would run and lose the + // first's answer. + const one = std.mem.trimEnd(u8, req.data, "\r\n"); + if (std.mem.indexOfScalar(u8, one, '\n') != null or (slot.phase == .done and slot.read == 0)) + return tree.failText(req.tag, E.INVAL, e_one_line); // A new line on this open: a new answer. p.gpa.free(slot.output); p.gpa.free(slot.line); @@ -421,6 +431,7 @@ fn waitingRun(p: *Pardes, pane: *Pane) ?*Run { } const e_bad_line = "bad command line"; +const e_one_line = "invalid run: one line a run, its answer read before the next"; const e_bad_pty_ctl = tree.e_bad_ctl ++ "; takes winsize COLS ROWS, sig INT|TERM|HUP|QUIT|KILL, or exec"; pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply { @@ -1087,3 +1098,31 @@ test "a run's answer longer than its room is cut, its newline kept" { try testing.expectEqual(slot.answer.len, slot.len); try testing.expectEqual(@as(u8, '\n'), slot.answer[slot.len - 1]); } + +test "exec on a command pane is refused: a command pane does not restart" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const serial = th.serialOf(p); + const pane = p.panes[p.paneBySerial(serial).?].?; + pane.command = try p.gpa.dupe(u8, "echo hi"); + const r = th.wr(p, tree.Node.of(serial, .pty_ctl), "exec\n"); + try testing.expectEqual(tree.E.INVAL, r.errno()); + try testing.expectEqualStrings("invalid exec: a command pane does not restart", r.reply.ename); +} + +test "pty/run takes one line a run: two in one write, or a second before the first's answer is read, are refused" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const serial = th.serialOf(p); + const run = tree.Node.of(serial, .pty_run); + const h = th.call(p, .{ .tag = 1, .op = .open, .node = run, .omode = 2 }).reply.handle; + const two = th.call(p, .{ .tag = 2, .op = .write, .node = run, .handle = h, .data = "true\nfalse\n" }); + try testing.expectEqual(tree.E.INVAL, two.errno()); + try testing.expectEqualStrings(e_one_line, two.reply.ename); + // A finished run whose answer nobody read takes no next line. + const slot = &tree.openOf(p, .{ .tag = 0, .op = .read, .node = run, .handle = h }).?.what.run; + slot.phase = .done; + slot.len = 7; + try testing.expectEqual(tree.E.INVAL, th.call(p, .{ .tag = 3, .op = .write, .node = run, .handle = h, .data = "true\n" }).errno()); + _ = th.call(p, .{ .tag = 4, .op = .release, .node = run, .handle = h, .opened = true }); +} diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig index 927552e2..b8eaa02b 100644 --- a/src/ninep/testing.zig +++ b/src/ninep/testing.zig @@ -58,11 +58,16 @@ pub fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { /// Whether the log, read whole from a fresh open, holds `needle`. pub fn logHas(p: *Pardes, needle: []const u8) bool { + return logCount(p, needle) > 0; +} + +/// How many times the log, read whole from a fresh open, holds `needle`. +pub fn logCount(p: *Pardes, needle: []const u8) usize { const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; defer _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; - return std.mem.indexOf(u8, text, needle) != null; + return std.mem.count(u8, text, needle); } pub fn wr(p: *Pardes, node: u64, data: []const u8) Answer { diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index d6f4211b..5f3ffc9c 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -454,6 +454,86 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply { // ---- dispatch ---- pub fn handle(p: *Pardes, req: Req) Reply { + var reply = serve(p, req); + if (reply.status == .err and reply.ename.len == 0) reply.ename = errWords(reply.errno); + if (reply.status == .err and reply.ename.len > cloud9.fs.errmax) + if (p.scratch.allocator().alloc(u8, cloud9.fs.errmax)) |room| { + reply.ename = fitErr(reply.ename, room); + } else |_| {}; + return reply; +} + +/// A reason longer than an Rerror carries (Plan 9's ERRMAX, 128 bytes) +/// keeps its end, which says why: the longest path in it -- else the whole +/// text -- gives up its middle to `…`. +pub fn fitErr(text: []const u8, out: []u8) []const u8 { + const ell = "…"; + if (text.len <= out.len) return text; + const over = text.len - out.len + ell.len; + var from: usize = 0; + var to: usize = text.len; + var words = std.mem.tokenizeAny(u8, text, " \t"); + var longest: usize = 0; + while (words.next()) |w| if (std.mem.indexOfScalar(u8, w, '/') != null and w.len > longest and w.len > over + 2) { + longest = w.len; + from = @intFromPtr(w.ptr) - @intFromPtr(text.ptr); + to = from + w.len; + }; + var a = from + (to - from - over) / 2; + var b = a + over; + while (a > from and text[a] & 0xC0 == 0x80) a -= 1; + while (b < to and text[b] & 0xC0 == 0x80) b += 1; + return std.fmt.bufPrint(out, "{s}" ++ ell ++ "{s}", .{ text[0..a], text[b..] }) catch text[0..out.len]; +} + +test "a reason past 128 bytes keeps its end: the path in it gives up its middle" { + var out: [128]u8 = undefined; + const long = "Save /home/someone/projects/" ++ "deep/" ** 30 ++ "file.txt: no such directory"; + const fit = fitErr(long, &out); + try testing.expect(fit.len <= 128); + try testing.expect(std.mem.startsWith(u8, fit, "Save /home/someone/")); + try testing.expect(std.mem.endsWith(u8, fit, "file.txt: no such directory")); + try testing.expect(std.mem.indexOf(u8, fit, "…") != null); + try testing.expect(std.unicode.utf8ValidateSlice(fit)); + // No path: the whole text's middle goes, the end stays. + const words = "why " ** 40 ++ "the reason"; + try testing.expect(std.mem.endsWith(u8, fitErr(words, &out), "the reason")); + try testing.expectEqualStrings("short", fitErr("short", &out)); + // Through the tree: a look at a long ./ name not there says why at the end. + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + var line: [512]u8 = undefined; + const r = th.wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "./{s}x.txt\n", .{"no-such-dir/" ** 20})); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(r.reply.ename.len <= 128); + try testing.expect(std.mem.endsWith(u8, r.reply.ename, "no such file") or std.mem.endsWith(u8, r.reply.ename, "no such directory")); +} + +/// A refusal with no reason of its own said in Plan 9's words, not the C +/// library's (`Operation not permitted`); each maps back to its errno in +/// 9ns (enameToErrno), EPERM's to EACCES as Plan 9's does. +pub fn errWords(errno: u16) []const u8 { + return switch (errno) { + E.PERM => "permission denied", + E.NOENT => "file does not exist", + E.NOMEM => "out of memory", + E.BUSY => "file in use", + E.EXIST => "file already exists", + E.NOTDIR => "not a directory", + E.ISDIR => "is a directory", + E.INVAL => "bad argument", + E.NFILE => "too many open files", + E.NOSPC => "no space left", + E.NOSYS => "not supported", + E.NOTEMPTY => "directory not empty", + else => "i/o error", + }; +} + +fn serve(p: *Pardes, req: Req) Reply { + // Held writes go in before anything but the next write of their open. + if (p.fs.batch.bytes.items.len > 0 and !(req.op == .write and req.node == p.fs.batch.node and req.handle == p.fs.batch.handle)) + pane.flushBatch(p); if (req.op == .open and req.create) { if (Node.target(req.node)) |t| if (t == .pane and p.paneBySerial(t.pane.serial) == null) return Reply.fail(req.tag, E.NOENT); @@ -1036,7 +1116,7 @@ fn linesFile(target: Target) bool { .top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec, .col => |c| c.file == .ctl or c.file == .exec, // `name` too: one name, however its writes cut it, applied once - // its newline or its close comes. + // its newline comes, or with its write when that is whole. .pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec or t.file == .name, }; } @@ -1073,7 +1153,18 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { // to fail unseen at the close. if (resultsFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control); o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM); - const end = ctl.completeEnd(p, o.pending.items); + var end = ctl.completeEnd(p, o.pending.items); + // A tail with no newline is a whole line when the write is the whole of + // what its client wrote (shorter than a Twrite can carry): it runs now, + // and its failure is this write's, as acme takes each write whole. Held + // only when it is not whole yet -- an open `{` block, an `a`/`c`/`i` + // text awaiting its `.` -- or the write filled its Twrite and may go on. + const cut = p.fs.write_room != 0 and req.data.len >= p.fs.write_room; + if (end < o.pending.items.len and !cut) { + o.pending.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM); + const whole = ctl.completeEnd(p, o.pending.items); + if (whole == o.pending.items.len) end = whole else o.pending.shrinkRetainingCapacity(o.pending.items.len - 1); + } if (end == 0) { if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) }; o.pending.clearRetainingCapacity(); @@ -1537,6 +1628,55 @@ test "editor paths resolve to the same nodes the wire serves" { try testing.expectEqual(before, p.next_serial); } +test "random placements, refused or not, leave no pane under its tag and two rows" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var look_line: [4200]u8 = undefined; + const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir}); + var seed: u64 = 0; + while (seed < 300) : (seed += 1) { + var prng = std.Random.DefaultPrng.init(seed); + const r = prng.random(); + const rows = r.intRangeAtMost(u16, 6, 40); + const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile("x\n"); + while (p.nextEffect()) |_| {} + for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n"); + if (r.boolean()) p.settings.placement = .pardes; + for (0..40) |step| { + var serials: [pardes.MAX_PANES]u32 = undefined; + var n: usize = 0; + for (p.panes) |slot| if (slot) |pn| { + serials[n] = pn.serial; + n += 1; + }; + if (n == 0) break; + const at = serials[r.uintLessThan(usize, n)]; + const action = r.uintLessThan(u8, 8); + switch (action) { + 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"), + 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }), + 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"), + 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"), + 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"), + 5 => _ = th.wr(p, Node.of(at, .look), looked), + 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"), + else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"), + } + for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { + std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c }); + for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) }); + return error.TestUnexpectedResult; + }; + } + } +} + 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(); @@ -1550,8 +1690,8 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e const refused = call(p, .{ .tag = 2, .op = .open, .node = new }); 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). + // A refused open is its error alone: no err, and no msg besides. + try testing.expect(!th.logHas(p, said)); 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(.{}); @@ -1580,8 +1720,12 @@ test "a command line cut across writes runs once whole, and the last runs at rel const exec = @intFromEnum(TopFile.exec); const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle; try testing.expect(h != 0); - for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| + // Each piece fills its Twrite (a client's one write cut at msize). + for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| { + p.fs.write_room = @intCast(piece.len); try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status); + } + p.fs.write_room = 0; try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n")); try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n")); _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true }); @@ -1596,6 +1740,60 @@ test "a command line cut across writes runs once whole, and the last runs at rel try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content); } +test "a refused open, create or remove says why in words and logs no err; a refused write logs one" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + // pane/new is read, never written: a write is refused, and is no + // failure the log tells of. + const new = th.wr(p, @intFromEnum(TopFile.new), "x\n"); + try testing.expectEqual(Status.err, new.reply.status); + try testing.expectEqualStrings("permission denied", new.reply.ename); + const made = call(p, .{ .tag = 2, .op = .open, .node = Node.of(serial, .body), .create = true }); + try testing.expectEqualStrings("permission denied", made.reply.ename); + const gone = th.rmdir(p, @intFromEnum(TopFile.index)); + try testing.expectEqual(Status.err, gone.reply.status); + try testing.expect(gone.reply.ename.len > 0 and !std.ascii.isUpper(gone.reply.ename[0])); + // An OTRUNC open's truncation of data after a failed addr: refused, no err. + _ = th.wr(p, Node.of(serial, .addr), "/nothere/"); + const errs = th.logCount(p, "\nerr "); + try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .setattr, .node = Node.of(serial, .data), .truncate = true }).reply.status); + try testing.expectEqual(errs, th.logCount(p, "\nerr ")); + try testing.expectEqual(@as(usize, 1), errs); // the addr write's own + // A write is the rule's: refused, it logs its one err, in words too. + try testing.expectEqual(Status.err, th.wr(p, Node.of(serial, .ctl), "bogus\n").reply.status); + try testing.expectEqual(@as(usize, 2), th.logCount(p, "\nerr ")); +} + +test "a write with no newline, whole in its Twrite, runs then and fails the write; one needing more waits" { + const p = try th.withFile(testing.allocator, "abc\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; + p.fs.write_room = 8192; + defer p.fs.write_room = 0; + try testing.expectEqual(Status.err, call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "bogus" }).reply.status); + try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,s/zzz/y/" }).reply.status); + // An `a` needs its text and `.`: held, not run, not failed. + try testing.expectEqual(Status.ok, call(p, .{ .tag = 4, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a" }).reply.status); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = "\nmore\n.\n" }).reply.status); + try testing.expectEqualStrings("abc\nmore\n", p.panes[0].?.file.?.content); + _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true }); + // The root's ctl too. + const root_ctl = @intFromEnum(TopFile.ctl); + const r = call(p, .{ .tag = 7, .op = .open, .node = root_ctl, .omode = 1 }).reply.handle; + try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = root_ctl, .handle = r, .data = "bogus" }).reply.status); + _ = call(p, .{ .tag = 9, .op = .release, .node = root_ctl, .handle = r, .opened = true }); + // And a name: a bad one fails its write, not the close after it. + const name = Node.of(serial, .name); + const n = call(p, .{ .tag = 10, .op = .open, .node = name, .omode = 1 }).reply.handle; + const bad = call(p, .{ .tag = 11, .op = .write, .node = name, .handle = n, .data = "delete " }); + try testing.expectEqual(Status.err, bad.reply.status); + try testing.expect(std.mem.indexOf(u8, bad.reply.ename, "a blank at its end") != null); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 12, .op = .release, .node = name, .handle = n, .opened = true }).reply.status); +} + 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(); |
