From f453e3c3014ec578bbe6601a141134ce034185ff Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 01:03:41 -0300 Subject: An event record is delivered in pieces to reads shorter than it, so a bash read loop on event works A read shorter than the record waiting was refused EINVAL, and bash's read, a byte at a time on a file it cannot seek in, failed on the first record. A short read now takes the record's front and the next read the rest, as log and pty/data already did and acme allows. A unit test reads two records a byte at a time; selfmount runs a bash read on event through the mount. Co-Authored-By: Claude Opus 5.5 --- src/ninep/events.zig | 35 ++++++++++++++++++++++++++++++----- test/selfmount.py | 10 ++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/ninep/events.zig b/src/ninep/events.zig index c0b52d03..a8c5022f 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -102,10 +102,13 @@ pub const Queue = struct { /// One record per read; `.again` holds the read until a record arrives. pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; - if (req.size < record.len) return tree.failText(req.tag, E.INVAL, "invalid read: shorter than the record waiting"); + // A read shorter than the record takes its front, and the next read the + // rest, as acme's event file and pty/data allow: a shell's `read` + // takes a byte at a time. + const n = @min(record.len, req.size); const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); - q.pop(); + out.appendSlice(p.gpa, record[0..n]) catch return Reply.fail(req.tag, E.NOMEM); + q.popFront(n); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } @@ -905,8 +908,9 @@ test "event records are acme's bytes, one per read, and .again when empty" { try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); - try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno()); - try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes); + // A short read takes the record's front; the next, the rest. + try testing.expectEqualStrings("ML0 ", rd(p, event, 0, 4).bytes); + try testing.expectEqualStrings("3 4 3 one\n", rd(p, event, 0, 4096).bytes); const big = "z" ** max_record_text; _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); @@ -994,6 +998,27 @@ test "one event reader at a time, and a writer still holds the pane" { _ = call(p, .{ .tag = 19, .op = .release, .node = event, .handle = next.reply.handle }); } +test "an event record read a byte at a time arrives whole, in order, as a shell's read takes it" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + defer _ = call(p, .{ .tag = 9, .op = .release, .node = event, .handle = h }); + _ = wr(p, Node.of(serial, .exec), "Msg piecewise\n"); + _ = wr(p, Node.of(serial, .exec), "Msg second\n"); + var got: [128]u8 = undefined; + var n: usize = 0; + while (n < got.len) { + const piece = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .off = n, .size = 1 }); + if (piece.reply.status != .ok or piece.bytes.len == 0) break; + got[n] = piece.bytes[0]; + n += 1; + if (std.mem.count(u8, got[0..n], "\n") == 2) break; + } + try testing.expectEqualStrings("FX0 0 1 13 Msg piecewise\nFX0 0 1 10 Msg second\n", got[0..n]); +} + test "a pane deleted while its event file is open leaves no suppression behind" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); diff --git a/test/selfmount.py b/test/selfmount.py index dd59083c..503ce1e0 100644 --- a/test/selfmount.py +++ b/test/selfmount.py @@ -186,6 +186,16 @@ def run(binary): followed = recipe.read_text() if recipe.exists() else '' recipe.unlink(missing_ok=True) check(ran == '0' and 'recipe-heard' in followed, f'the log follow recipe with cat hears a new record ({followed[-120:]!r})') + # A bash read loop on a pane's event: bash reads it a piece at a + # time, and each record still arrives whole, in pieces. + looped = Path(f'/tmp/{name}.events') + ran = run((f"bash -c 'exec 3<{mount}/pane/{first}/event; " + f"(sleep 1; echo Msg ev-loop > {mount}/pane/{first}/exec) & " + f"read -r line <&3; echo \"$line\" > {looped}'\n").encode()) + got_line = looped.read_text() if looped.exists() else '' + looped.unlink(missing_ok=True) + check(ran == '0' and got_line.startswith('FX') and got_line.rstrip().endswith('Msg ev-loop'), + f'a bash read loop on event reads a whole record ({got_line!r})') client.write('/exec', b'Msg still here\n') text = ''.join(cell[0] for cell in client.screen()['cells']) check('still here' in text, 'the editor answers after all of that') -- cgit v1.3