summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 09:54:20 -0300
committerGabriel Schneider <[email protected]>2026-10-01 10:16:45 -0300
commitcaa8ed077e8b9b67d18f18b3ec46891bd8d310a7 (patch)
tree05c09ee65e1a68aacffd90b137690d5b39ed9457
parenteda60572013d046d08e253e5847a0ba588c10339 (diff)
downloadpardes-caa8ed077e8b9b67d18f18b3ec46891bd8d310a7.tar.gz
pardes-caa8ed077e8b9b67d18f18b3ec46891bd8d310a7.zip
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 <[email protected]>
-rw-r--r--docs/typ/building.typ17
-rw-r--r--src/ninep/pane.zig6
-rw-r--r--src/ninep/tree.zig52
-rw-r--r--test/fs.py15
-rw-r--r--test/selfmount.py13
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 <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 <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('<IQI', ctl, 0, len(line)) + line)
- except OSError:
- pass # refused with its write (a line over 1024 bytes), not held
- else:
- raise AssertionError('a whole 20000-byte line waited for a newline')
+ big.rpc(118, struct.pack('<IQI', ctl, 0, len(line)) + line)
+ assert b'ctl: unknown control message' not in client.read('/log')
big.close(ctl)
+ assert b'ctl: unknown control message' in client.read('/log')
frozen = client.open('/screen')
before = bytearray(client.read_fid(frozen, count=31))
diff --git a/test/selfmount.py b/test/selfmount.py
index 6b9d8740..ddfb77dc 100644
--- a/test/selfmount.py
+++ b/test/selfmount.py
@@ -169,6 +169,19 @@ def run(binary):
check(run(f'python3 -c "{maker}" > {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