diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-30 14:41:52 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | 8598a5fbe6ffecad46891957356d47a37649e547 (patch) | |
| tree | 777230263f4214720a4e1dcbb2f9ea4a8a0bb4fd | |
| parent | b9eefda75ab9a63fc510de781d02b92fc04ad31d (diff) | |
| download | pardes-8598a5fbe6ffecad46891957356d47a37649e547.tar.gz pardes-8598a5fbe6ffecad46891957356d47a37649e547.zip | |
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 <[email protected]>
| -rw-r--r-- | docs/fs.md | 5 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 26 | ||||
| -rw-r--r-- | test/monkey9p.py | 7 | ||||
| -rw-r--r-- | test/selfmount.py | 39 |
4 files changed, 68 insertions, 9 deletions
@@ -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('<IQI', fid, 0, len(b'follow new\n')) + b'follow new\n') + ready.set() + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + record = follower.read_fid(fid, start).decode() + heard.append(record) + if 'mnt-0999' in record: + return + ready = threading.Event() + reader = threading.Thread(target=follow, daemon=True) + reader.start() + check(ready.wait(10), 'a follower of the log is waiting') got = run(f"seq -f 'Msg mnt-%04g' 0 999 > {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') |
