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 --- docs/typ/building.typ | 17 ++++++++--------- src/ninep/pane.zig | 6 +++--- src/ninep/tree.zig | 52 +++++++++++++++++++++++---------------------------- test/fs.py | 15 ++++++--------- test/selfmount.py | 13 +++++++++++++ 5 files changed, 53 insertions(+), 50 deletions(-) diff --git a/docs/typ/building.typ b/docs/typ/building.typ index 85f2dfbb..6a2758ad 100644 --- a/docs/typ/building.typ +++ b/docs/typ/building.typ @@ -229,15 +229,14 @@ use `.cloud9 = .{ .path = "../cloud9" }` while editing both. cloud9's own == Writes through a mount A mount cuts a big write into pieces of at most one message (msize 65536, -less the header), and each command line runs once it is whole. A write -that does not fill its message is whole, so its last line runs even -without a newline (`printf Save > exec`), unless it is a multiple of 4096 -bytes: that is where a writer's buffer (stdio, a mount's page cache) -filled and cut a line, so its tail waits for the next write or the close. -A line held to the close (such a tail, or an `Edit` block never ended) -runs there, and its failure is only in the log, as its `err` record: the -close reports no error, and the write that sent it had already succeeded. -So a script that needs a line's result ends the write with a newline. +less the header), anywhere, and each command line runs once its newline +comes; nothing is read into a write's size. A last line with no newline +(`printf Save > exec`) runs when the file is closed, as does an `Edit` +block never ended, and its failure is then only in the log, as its `err` +record: the close reports no error, and the write that sent it had +already succeeded. So a script that needs a line's result ends it with a +newline. A line over 1 MiB is refused once, and the rest of it, through +its newline, is dropped. == Listeners 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" }); diff --git a/test/fs.py b/test/fs.py index 52ac61c8..0e1e44df 100644 --- a/test/fs.py +++ b/test/fs.py @@ -1034,20 +1034,17 @@ def test(binary, quic=False): with Client(address, msize=256) as small: assert set(small.list('/os' + str(listing))) == { f'entry-{number:02}' for number in range(24)} - # 64 KiB frames: a ctl line with no newline, whole in its one - # Twrite, runs with it however long -- the cutoff for a write - # that may go on is the frame the client asked for, less 24. + # 64 KiB frames: a ctl line with no newline is held whatever the + # size of its write -- a mount cuts lines anywhere -- and runs at + # the close, its failure then in the log. with Client(address, msize=65536) as big: assert big.msize == 65536 ctl = big.open('/pane/1/ctl', 1) line = b'bogus' + b'x' * 20000 - try: - big.rpc(118, struct.pack(' {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 50 KB Edit block written through the mount') body = client.read(f'/pane/{scratch}/body').decode().splitlines() check(body == ['line %05d of the block' % i for i in range(2500)], f'it ran once, whole ({len(body)} lines)') + # One 600 KiB line: 9ns sends it as 65512-byte Twrites, the last + # one short, and none is taken for a whole line by its size. + errs = client.read('/log').decode().count('\nerr ') + long_edit = "import sys; sys.stdout.write('Edit ,c/' + 'q' * 600000 + '/\\n')" + check(run(f'python3 -c "{long_edit}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 600 KiB Edit line written through the mount') + check(client.read(f'/pane/{scratch}/body') == b'q' * 600000, 'it ran once, whole') + check(client.read('/log').decode().count('\nerr ') == errs, 'no piece of it was refused as a line of its own') + # Over 1 MiB: refused once, its tail dropped through its newline. + too_long = "import sys; sys.stdout.write('Msg ' + 'z' * 1150000 + '\\nMsg after-long\\n')" + run(f'python3 -c "{too_long}" > {mount}/exec\n'.encode()) + log = client.read('/log').decode() + check(log.count('over 1 MiB') == 1 and 'unknown control message' not in log and 'after-long' in log, + 'a 1.1 MiB line is refused once, its tail never a line, the next line runs') # Forty event reads held through the mount, a follower each, and # the mount still answers the rest: 9ns keeps workers past what # the editor holds (128), so `cat layout` is not queued behind -- cgit v1.3