From d572edefe32284db343259b0b7e12c9470366b28 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 18:23:34 -0300 Subject: A followed log, event or pty/data answers a read inside its last answer from it again, so bash's read loses nothing bash's read on a seekable fd takes a chunk, keeps one line and lseeks back to just past it; a stream had already moved on, so the rest of the chunk was lost and a while-read loop over the log dropped records. Each stream open keeps its last answer and the offset it was read at, and a read strictly inside it is answered from it; a read at the same offset, as a client that ignores offsets makes, still gets the next record. Co-Authored-By: Claude Opus 5.5 --- src/ninep/events.zig | 26 ++++++++++++++++++++++++++ src/ninep/tree.zig | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) (limited to 'src') diff --git a/src/ninep/events.zig b/src/ninep/events.zig index ffdea95b..1c66c18f 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -565,6 +565,32 @@ const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; +test "a stream read at an offset inside its last answer gets the rest of it again, as bash's read seeks back" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + p.update(.tick); + while (p.nextEffect()) |_| {} + const log = @intFromEnum(tree.TopFile.log); + const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; + const frozen: u64 = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len; + _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" }); + notePath(p, "dump", "/a"); + notePath(p, "dump", "/b"); + const Read = struct { + fn at(pp: *Pardes, h: u32, off: u64) []const u8 { + return call(pp, .{ .tag = 4, .op = .read, .node = @intFromEnum(tree.TopFile.log), .handle = h, .off = off, .size = 128 }).bytes; + } + }; + try testing.expectEqualStrings("dump /a\n", Read.at(p, f, frozen)); + // bash kept "dump" and seeked back to just past it. + try testing.expectEqualStrings(" /a\n", Read.at(p, f, frozen + 4)); + // At the end of what it was given: the next record. + try testing.expectEqualStrings("dump /b\n", Read.at(p, f, frozen + 8)); + // A client that reads every record at one offset still gets new ones. + notePath(p, "dump", "/c"); + try testing.expectEqualStrings("dump /c\n", Read.at(p, f, frozen + 8)); +} + test "event records are acme's bytes, one per read, and .again when empty" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg fs-ran\n"); diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index f118e4b4..b3f3dde6 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -130,6 +130,13 @@ pub const Open = struct { /// place of the older. The listener fills it in and answers it /// (src/9p_io.zig, `answerHeld`). held: ?Held = null, + /// The last answer a stream open (a followed log, event, pty/data) gave, + /// and the offset it was read at. A read at an offset inside it is + /// answered from it again: bash's `read` takes a chunk, keeps a line and + /// lseeks back to just past it, and a stream has already moved on. + replay: ?Replay = null, + + pub const Replay = struct { off: u64, bytes: []u8 }; /// `asker` is the connection the read came on, which only the listener /// knows. @@ -145,6 +152,7 @@ pub const Open = struct { }, .event, .pty_data, .ctl => {}, } + if (o.replay) |r| gpa.free(r.bytes); o.* = .{}; } }; @@ -692,6 +700,32 @@ fn setattr(p: *Pardes, req: Req, target: Target) Reply { } fn read(p: *Pardes, req: Req, target: Target) Reply { + const stream = if (openOf(p, req)) |o| switch (o.what) { + .log => o, + .event => |reader| if (reader) o else null, + .pty_data => o, + else => null, + } else null; + const s = stream orelse return readFile(p, req, target); + // Strictly inside: a client that reads every record at one offset (the + // offset is ignored on a stream, as in Plan 9) always gets new ones. + if (s.replay) |r| if (req.off > r.off and req.off < r.off + r.bytes.len) { + const from = r.bytes[@intCast(req.off - r.off)..]; + const n = @min(from.len, req.size); + p.fs.stage(p.gpa).appendSlice(p.gpa, from[0..n]) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; + }; + const reply = readFile(p, req, target); + if (reply.status != .ok) return reply; + const bytes = p.fsPayload(reply); + if (bytes.len == 0) return reply; + const kept = p.gpa.dupe(u8, bytes) catch return reply; + if (s.replay) |r| p.gpa.free(r.bytes); + s.replay = .{ .off = req.off, .bytes = kept }; + return reply; +} + +fn readFile(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { .README => help: { -- cgit v1.3