summaryrefslogtreecommitdiff
path: root/src/ninep/tree.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 12:54:24 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit7ec97214a7a6ee905167b7f94a800a8c1d70b100 (patch)
treee765f9343ad1b6f7b87ec6aa33b98e0ea4b83f94 /src/ninep/tree.zig
parentd622163fa4c7ecb5f9ab47afb995199edcfb3d7f (diff)
downloadpardes-7ec97214a7a6ee905167b7f94a800a8c1d70b100.tar.gz
pardes-7ec97214a7a6ee905167b7f94a800a8c1d70b100.zip
A listing freezes at its open, so a read in several chunks never splices two moments
/index and the other listings were rendered afresh for each read, so a client reading one in chunks while panes changed got rows from two moments spliced together (the fs-test flake). /index, /layout, /recent, /commands, /status, /listeners and a ctl opened only to read now freeze at the open, as /screen does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/ninep/tree.zig')
-rw-r--r--src/ninep/tree.zig54
1 files changed, 52 insertions, 2 deletions
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 66f73e9c..60778b9c 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -134,6 +134,10 @@ pub const Open = struct {
/// An open writing a file pane's body, data or xdata: its writes are
/// one undo step (pane.zig `joined_by`), another open's its own.
writes,
+ /// A listing (`/index`, `/layout`, `/recent`, `/commands`, `/status`,
+ /// `/listeners`, a ctl opened to read) frozen at the open, as
+ /// `/screen` is: reads in several chunks never splice two moments.
+ frozen: []const u8,
} = .{ .snapshot = null },
/// What a write to a file of command lines left unfinished: a last line
/// with no newline yet, or an Edit block still open. A write through a
@@ -183,6 +187,7 @@ pub const Open = struct {
gpa.free(run.output);
gpa.free(run.line);
},
+ .frozen => |bytes| gpa.free(bytes),
.event, .pty_data, .ctl, .lines, .writes => {},
}
o.pending.deinit(gpa);
@@ -744,11 +749,14 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
},
.screen => .{ .snapshot = null },
.log => .{ .log = .{} },
+ .index, .layout, .recent, .commands, .status, .listeners => .{ .frozen = &.{} },
+ // The root's ctl opened only to read: its settings, frozen.
+ .ctl => if (linesOpen(target, req)) .lines else .{ .frozen = &.{} },
else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
},
.col => |c| if (layout.columnBySerial(p, c.serial) == null)
return Reply.fail(req.tag, E.NOENT)
- else if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
+ else if (linesOpen(target, req)) .lines else if (c.file == .ctl) .{ .frozen = &.{} } else return .{ .tag = req.tag },
.pane => |t| what: {
const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
@@ -791,6 +799,13 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
o.* = .{};
return Reply.fail(req.tag, E.NOMEM);
},
+ .frozen => |*bytes| {
+ const whole = readFile(p, .{ .tag = req.tag, .op = .read, .node = req.node, .size = 1 << 22 }, target);
+ bytes.* = if (whole.status == .ok) p.gpa.dupe(u8, p.fsPayload(whole)) catch {
+ o.* = .{};
+ return Reply.fail(req.tag, E.NOMEM);
+ } else &.{};
+ },
.event => |reader| {
const pn = pane_of.?;
pn.fs.readers +|= 1;
@@ -877,7 +892,7 @@ fn releaseHandle(p: *Pardes, req: Req) void {
.ctl => if (pn.fs.lock == req.handle) {
pn.fs.lock = null;
},
- .snapshot, .log, .lines, .writes => {},
+ .snapshot, .log, .lines, .writes, .frozen => {},
};
o.deinit(p.gpa);
}
@@ -897,6 +912,14 @@ fn setattr(p: *Pardes, req: Req, target: Target) Reply {
}
fn read(p: *Pardes, req: Req, target: Target) Reply {
+ // A listing reads what it was at the open.
+ if (openOf(p, req)) |o| if (o.what == .frozen) {
+ const bytes = o.what.frozen;
+ const off = @min(req.off, bytes.len);
+ const n = @min(bytes.len - off, req.size);
+ p.fs.stage(p.gpa).appendSlice(p.gpa, bytes[off..][0..n]) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } };
+ };
const stream = if (openOf(p, req)) |o| switch (o.what) {
.log => o,
.event => |reader| if (reader) o else null,
@@ -1113,6 +1136,33 @@ const look_up = th.look_up;
const withFile = th.withFile;
const serialOf = th.serialOf;
+test "a listing read in chunks is what it was at the open, however the panes change between reads" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for ([_]TopFile{ .index, .layout, .recent, .ctl }) |file| {
+ const node = @intFromEnum(file);
+ const whole = try testing.allocator.dupe(u8, th.rd(p, node, 0, 1 << 16).bytes);
+ defer testing.allocator.free(whole);
+ const h = th.call(p, .{ .tag = 1, .op = .open, .node = node }).reply.handle;
+ try testing.expect(h != 0);
+ var got: std.ArrayList(u8) = .empty;
+ defer got.deinit(testing.allocator);
+ try got.appendSlice(testing.allocator, th.call(p, .{ .tag = 2, .op = .read, .node = node, .handle = h, .size = 7 }).bytes);
+ // Something changes between the chunks.
+ const made = try th.newPane(p);
+ _ = th.wr(p, Node.of(made, .name), "/tmp/pardes-frozen-listing.txt\n");
+ _ = th.wr(p, @intFromEnum(TopFile.ctl), "Verbose off\n");
+ while (true) {
+ const more = th.call(p, .{ .tag = 2, .op = .read, .node = node, .handle = h, .off = got.items.len, .size = 7 }).bytes;
+ if (more.len == 0) break;
+ try got.appendSlice(testing.allocator, more);
+ }
+ _ = th.call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true });
+ try testing.expectEqualStrings(whole, got.items);
+ _ = th.wr(p, @intFromEnum(TopFile.ctl), "Verbose on\n");
+ }
+}
+
test "filesystem inspection preserves pending and displayed Look hover" {
const delay = config.look_preview_delay_frames orelse return;
const gpa = testing.allocator;