From 8598a5fbe6ffecad46891957356d47a37649e547 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:41:52 -0300 Subject: A write of a multiple of 4096 bytes holds the partial line it ends with, as one that fills its Twrite does: a burst through a mount no longer runs its cut lines as two commands selfmount's 1000-line burst reported 943 lines, on main too. The log ring was not losing records: a follower opened before the burst heard exactly 943 and no `lost`, and the burst itself exited 1 with `err 1 exec: wrong #args in control message "Msg"`. `seq ... > exec` through the mount arrives as stdio's 4096-byte writes, each shorter than a Twrite. The rule that such a write is whole ran each one's cut last line at once, so both halves of the line ran as commands (hence `exit 127` records). At 12288 bytes the cut left a bare `Msg`, which was refused and failed the write and the rest of the burst. A write of a multiple of 4096 bytes is where a writer's buffer (stdio, a page cache) filled, so it may go on: its unended tail now waits for the next write or the close, like one that fills its Twrite. `printf Save > exec` stays whole at once. fs.md says so, the 9P fuzzer's model of served lines follows it, and a unit test cuts a `Msg` at 4096 bytes. A follower does not lose records silently: a ring that outruns one already reads it `lost N` first (documented, tested). selfmount now follows the log from before the burst, so it checks every line heard once, in order, or a loss said, not a fresh open of a 64 KiB ring. It passed 3 in 3 and joins the gates with the new 9ns. Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 5 ++++- src/ninep/tree.zig | 26 +++++++++++++++++++++++++- test/monkey9p.py | 7 ++++--- test/selfmount.py | 39 +++++++++++++++++++++++++++++++++++---- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index 146ff17b..34664343 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -156,7 +156,10 @@ effect, as acme's ctl does. Blank lines are skipped. A mount cuts a big write into pieces of at most one message (msize 64 KiB, 65536 negotiated, less the header), and each 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`). An `Edit` whose `{` or +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. An `Edit` whose `{` or `a`/`c`/`i` text is still open waits for the next write on that open, and fails at the close if it never ends (``unmatched `{'``). A line held past 1 MiB is refused. 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(); diff --git a/test/monkey9p.py b/test/monkey9p.py index 4d190bbc..fd8c80fa 100644 --- a/test/monkey9p.py +++ b/test/monkey9p.py @@ -674,8 +674,9 @@ def served_lines(data, sizes, room): (fs.md, tree.zig write): each ended line, and the unended tail of a piece shorter than a Twrite holds (`room`, msize less 24), which is whole in itself, as acme takes each write; a piece that fills its Twrite may go - on, and its tail waits for the next. A line written a byte a Twrite is - so as many lines as bytes.""" + on, and its tail waits for the next, and so does one of a multiple of + 4096 bytes, where a writer's buffer filled. A line written a byte a + Twrite is so as many lines as bytes.""" lines, pending, at = [], b'', 0 for size in sizes: piece = data[at:at + size] @@ -683,7 +684,7 @@ def served_lines(data, sizes, room): pending += piece *done, pending = pending.split(b'\n') lines += done - if pending and len(piece) < room: + if pending and len(piece) < room and len(piece) % 4096 != 0: lines.append(pending) pending = b'' if pending: diff --git a/test/selfmount.py b/test/selfmount.py index 6534aa25..86c32fa8 100644 --- a/test/selfmount.py +++ b/test/selfmount.py @@ -20,6 +20,7 @@ import signal import struct import sys import termios +import threading import time sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -127,12 +128,42 @@ def run(binary): return exits[-1].split()[2] time.sleep(.1) return 'timeout' + # Followed from before the burst: a fresh open of the log reads + # only what its 64 KiB ring still holds, which a burst this size + # outgrows, and a follower the ring outruns reads `lost N`, so + # every line is either heard once, in order, or a loss is said. + heard = [] + + def follow(): + with Client(sock) as follower: + fid = follower.open('/log', 2) + start = len(follower.read_fid(fid)) + follower.rpc(118, struct.pack(' {mount}/exec\n".encode()) check(got == '0', f'a 1000-line burst written through the mount ({got!r})') - log = client.read('/log').decode() - said = [line.split(' ', 2)[2] for line in log.splitlines() if line.startswith('msg ') and 'mnt-' in line] - check(said == ['mnt-%04d' % i for i in range(1000)], f'each line of it ran once, whole ({len(said)} said)') - check('exit 127' not in log, 'no piece of a line ran as a command') + reader.join(60) + for r in heard: + if r.startswith(('err ', 'exit ')): + print(' heard: ' + r.rstrip()) + said = [r.rstrip('\n').split(' ', 2)[2] for r in heard if r.startswith('msg ') and 'mnt-' in r] + lost = [r for r in heard if r.startswith('lost ')] + numbers = [int(s[4:]) for s in said] + check(numbers == list(range(numbers[0] if numbers else 0, 1000)), + f'each line it heard ran once, whole, in order, through the last ({len(said)} heard)') + check(len(said) == 1000 or (lost and heard.index(lost[0]) < heard.index(next(r for r in heard if 'mnt-' in r))), + f'no line was lost unsaid ({len(said)} heard, {lost!r})') + check(not any('exit 127' in r for r in heard), 'no piece of a line ran as a command') scratch = int(client.read('/pane/new')) maker = "import sys; sys.stdout.write('Edit ,a\\n' + ''.join('line %05d of the block\\n' % i for i in range(2500)) + '.\\n')" check(run(f'python3 -c "{maker}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 50 KB Edit block written through the mount') -- cgit v1.3