diff options
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 3 | ||||
| -rw-r--r-- | docs/fs.md | 6 | ||||
| -rw-r--r-- | src/ninep/events.zig | 26 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 34 |
4 files changed, 67 insertions, 2 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 756a9785..8abd9fc4 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -60,7 +60,8 @@ $m/pane/<n>/pty/run write one line, read `exit N` + its output, or `busy` / `e $m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg <serial|-> <text>, run <serial> <line>, exit <serial> <N|?>, send <from> <to> <repl-id>, dump|restore <path>, err <serial|-> <file>: <why> - (exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not) + (exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not; + while read -r line <&3; do ...; done loses nothing) $m/screen the rendered screen as JSON, frozen per open $m/listeners this session's dial addresses $m/focus the serial of the pane with the keyboard (empty while a column/workspace tag has it); @@ -541,7 +541,11 @@ line on an open whose command still runs fails the write. `exec zsh` or a continuation prompt never reports an end; cancel the read. A read of `run` waits in pardes, so through 9ns it needs 9ns's concurrent requests or it holds up the rest of the mount. A record -longer than a read comes in pieces, so a shell's `read` loop works. `tail -f` +longer than a read comes in pieces, so a shell's `read` loop works: `exec +3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done`. bash's +`read` takes a chunk, keeps one line and seeks back to just past it; a +followed log, `event` and `pty/data` answer a read at an offset inside +their last answer from that answer again, so no record is lost. `tail -f` never writes `follow`, so it sees nothing new: use the follow open instead. `/screen` returns JSON with `cols`, `rows`, `cursor`, a `styles` table, and row-major `cells` of `[grapheme, style_index]`. Each open freezes one frame until close. A 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: { |
