From caa8ed077e8b9b67d18f18b3ec46891bd8d310a7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 09:54:20 -0300 Subject: A command line runs when its newline comes, or at its open's close, never because a write was short: a long line cut by a mount is one line A write shorter than its message, and not a multiple of 4096, was taken as whole, its tail a line. 9ns cuts a FUSE write of up to 512 KiB into 65512-byte Twrites, the last one short, so a 600 KiB `Edit ,c/.../` arrived cut and its tail ran as an unknown control message; the 1 MiB refusal failed the same way. Each open now holds a partial line until its newline, or its release, which ends the last line; an Edit block is whole when it closes, as before. A name write with a NUL still fails its write at once. selfmount writes a 600 KiB Edit line and a 1.1 MiB line through a real 9ns mount; building.typ says how lines are cut. Co-Authored-By: Claude Opus 5.5 --- src/ninep/pane.zig | 6 +++--- src/ninep/tree.zig | 52 +++++++++++++++++++++++----------------------------- 2 files changed, 26 insertions(+), 32 deletions(-) (limited to 'src') diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 48c2888e..e2ec31d1 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -914,7 +914,7 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { /// (editors/acme/xfid.c:650-652). A blank inside a name is taken here, as /// pardes names files with spaces; one at either end is refused, not /// quietly cut off, so the name a script wrote is the name it gets. -const e_name_char = "bad character in file name"; +pub const e_name_char = "bad character in file name"; /// Why `name` is not one file name, with the reason, or null: a newline, a /// control byte, DEL or a C1 control (U+0080-U+009F), a blank at either @@ -1566,10 +1566,10 @@ test "a name cut across writes is one name, applied once at its newline or its c _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" }); try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); - // No newline, the whole write: applied at once, on its own open. + // No newline: held, whatever the write's size, and applied at the close. 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, p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p, 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, p.panes[p.paneBySerial(serial).?].?)); } diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 95e46ab3..e6c139c1 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -1177,6 +1177,9 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { // A command line holds no control character but a tab: the whole write // is refused at once, not held to fail unseen at the close. if (resultsFile(target) or ctlFile(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); + // A NUL no name may hold fails its write at once, not the close. + if (target == .pane and target.pane.file == .name and std.mem.indexOfScalar(u8, req.data, 0) != null) + return failText(req.tag, E.INVAL, pane.e_name_char ++ ": a NUL"); 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); // The rest of a line refused for its length goes through its newline @@ -1189,23 +1192,12 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { if (data.len == 0) return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } o.pending.appendSlice(p.gpa, data) catch return Reply.fail(req.tag, E.NOMEM); - 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. - // 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); - if (whole == o.pending.items.len) end = whole else o.pending.shrinkRetainingCapacity(o.pending.items.len - 1); - } + // A line runs when its newline comes, or, the last one, when its open + // is let go (release). Nothing is inferred from a write's size: a mount + // cuts a long write anywhere (9ns sends 512 KiB as 65512-byte Twrites, + // stdio and the page cache at 4096 multiples), and a piece shorter than + // its message is no whole line. An Edit block is whole when it closes. + 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(); @@ -1819,27 +1811,29 @@ test "a refused open, create or remove says why in words and logs no err; a refu 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" { +test "a write with no newline waits for its newline or the close, whatever its size, and fails there in the log" { 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; + // Short of its message or not, a piece with no newline is held: a mount + // cuts a long line anywhere. 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 = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,s/a/" }).reply.status); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 3, .op = .write, .node = ctl_node, .handle = c, .data = "A/\n" }).reply.status); + try testing.expectEqualStrings("Abc\n", p.panes[0].?.file.?.content); + // An `a` needs its text and `.`: held across writes. 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. + try testing.expectEqualStrings("Abc\nmore\n", p.panes[0].?.file.?.content); + // The last line, never ended, runs at the close: its failure is its err. + try testing.expectEqual(Status.ok, call(p, .{ .tag = 6, .op = .write, .node = ctl_node, .handle = c, .data = "bogus" }).reply.status); + try testing.expect(!th.logHas(p, "bogus")); + _ = call(p, .{ .tag = 7, .op = .release, .node = ctl_node, .handle = c, .opened = true }); + try testing.expect(th.logHas(p, "unknown control message \"bogus\"")); + // A name: a NUL 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 = "del\x00ete" }); -- cgit v1.3