summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 14:41:52 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commit8598a5fbe6ffecad46891957356d47a37649e547 (patch)
tree777230263f4214720a4e1dcbb2f9ea4a8a0bb4fd
parentb9eefda75ab9a63fc510de781d02b92fc04ad31d (diff)
downloadpardes-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.md5
-rw-r--r--src/ninep/tree.zig26
-rw-r--r--test/monkey9p.py7
-rw-r--r--test/selfmount.py39
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('<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')