summaryrefslogtreecommitdiff
path: root/src/ninep/screen.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/screen.zig')
-rw-r--r--src/ninep/screen.zig351
1 files changed, 351 insertions, 0 deletions
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
new file mode 100644
index 00000000..5b68b34c
--- /dev/null
+++ b/src/ninep/screen.zig
@@ -0,0 +1,351 @@
+//! /screen, /listeners and the snapshot handles that freeze a screen or a
+//! terminal history between open and close.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const panes = @import("../panes.zig");
+const tree = @import("tree.zig");
+
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+
+pub const Snapshot = struct { node: u64 = 0, bytes: ?[]const u8 = null };
+pub const snapshot_slots = 32;
+
+pub fn render(p: *Pardes) ![]u8 {
+ var arena: std.heap.ArenaAllocator = .init(p.gpa);
+ defer arena.deinit();
+ const surface = try p.render(arena.allocator());
+ var styles: std.ArrayList(pardes.CellStyle) = .empty;
+ var indices: std.ArrayList(usize) = .empty;
+ for (surface.cells) |cell| {
+ const style: pardes.CellStyle = if (cell.default) .{} else cell.style;
+ const index = for (styles.items, 0..) |previous, i| {
+ if (std.meta.eql(style, previous)) break i;
+ } else new: {
+ try styles.append(arena.allocator(), style);
+ break :new styles.items.len - 1;
+ };
+ try indices.append(arena.allocator(), index);
+ }
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ const writer = &out.writer;
+ try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows });
+ try std.json.Stringify.value(surface.cursor, .{}, writer);
+ try writer.writeAll(",\"styles\":");
+ try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer);
+ try writer.writeAll(",\"cells\":[");
+ for (surface.cells, indices.items, 0..) |cell, index, i| {
+ if (i != 0) try writer.writeByte(',');
+ try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer);
+ }
+ try writer.writeAll("]}\n");
+ return out.toOwnedSlice();
+}
+
+/// A screen freezes at open; a terminal body freezes at its first read.
+pub fn openSnapshot(p: *Pardes, req: Req, of_screen: bool) Reply {
+ for (&p.fs.snapshots, 0..) |*slot, i| {
+ if (slot.node != 0) continue;
+ const bytes = if (of_screen) render(p) catch return Reply.fail(req.tag, E.NOMEM) else null;
+ slot.* = .{ .node = req.node, .bytes = bytes };
+ return .{ .tag = req.tag, .handle = @intCast(i + 1) };
+ }
+ return Reply.fail(req.tag, E.NFILE);
+}
+
+pub fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply {
+ if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL);
+ const snapshot = &p.fs.snapshots[req.handle - 1];
+ if (snapshot.node == 0 or snapshot.node != req.node) return Reply.fail(req.tag, E.INVAL);
+ if (snapshot.bytes == null) {
+ const terminal = pane orelse return Reply.fail(req.tag, E.INVAL);
+ snapshot.bytes = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM);
+ }
+ const bytes = snapshot.bytes.?;
+ const off = @min(req.off, bytes.len);
+ const len = @min(bytes.len - off, req.size);
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } };
+}
+
+/// True when the handle named a snapshot, which is now released.
+pub fn releaseSnapshot(p: *Pardes, req: Req) bool {
+ if (req.handle == 0 or req.handle > p.fs.snapshots.len) return false;
+ const snapshot = &p.fs.snapshots[req.handle - 1];
+ if (snapshot.node != req.node) return false;
+ if (snapshot.bytes) |bytes| p.gpa.free(bytes);
+ snapshot.* = .{};
+ return true;
+}
+
+pub fn readListeners(p: *Pardes, req: Req) Reply {
+ var buf: [512]u8 = undefined;
+ var text = std.Io.Writer.fixed(&buf);
+ if (p.fs.socket_path.len != 0)
+ text.print("unix!{s}\n", .{p.fs.socket_path}) catch return Reply.fail(req.tag, E.IO);
+ for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| {
+ const address = maybe orelse continue;
+ switch (address) {
+ .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch
+ return Reply.fail(req.tag, E.IO),
+ .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch
+ return Reply.fail(req.tag, E.IO),
+ }
+ }
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, text.buffered()) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const fs = @import("../fs.zig");
+const call = th.call;
+const rd = th.rd;
+const wr = th.wr;
+const withFile = th.withFile;
+const withTerm = th.withTerm;
+const serialOf = th.serialOf;
+const Node = tree.Node;
+const Status = tree.Status;
+const screen_node = @intFromEnum(tree.TopFile.screen);
+
+test "listener addresses are readable canonical dials with bounded partial reads" {
+ const gpa = testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ p.fs.socket_path = "/tmp/a socket.sock";
+ p.fs.tcp_address = .{ .ip4 = .loopback(5640) };
+ p.fs.quic_address = .{ .ip6 = .loopback(5641) };
+ const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n";
+ for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| {
+ const bytes = try fs.read(p, path);
+ defer gpa.free(bytes);
+ try testing.expectEqualStrings(expected, bytes);
+ try testing.expectError(error.ReadOnlyFilesystem, fs.write(p, path, ""));
+ }
+ const node = @intFromEnum(tree.TopFile.listeners);
+ var off: usize = 0;
+ while (off < expected.len) {
+ const part = rd(p, node, off, 3);
+ try testing.expectEqual(Status.ok, part.reply.status);
+ try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes);
+ off += part.bytes.len;
+ }
+ try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len);
+}
+
+test "terminal body handles keep one history snapshot across fragmented reads" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .body);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } });
+ while (p.nextEffect()) |_| {}
+ const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa);
+ defer gpa.free(original);
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 });
+ try testing.expectEqualStrings(original[0..3], first.bytes);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } });
+ while (p.nextEffect()) |_| {}
+ var offset: usize = 3;
+ while (offset < original.len) {
+ const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 });
+ try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes);
+ offset += part.bytes.len;
+ }
+ try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes);
+ _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle });
+ const newer = call(p, .{ .tag = 6, .op = .open, .node = node });
+ try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
+ _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle });
+}
+
+test "an empty terminal body snapshot stays empty while output continues" {
+ const p = try withTerm(std.testing.allocator);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .body);
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
+ try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes);
+ try testing.expect(p.fs.snapshots[opened.reply.handle - 1].bytes != null);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle });
+ const newer = call(p, .{ .tag = 5, .op = .open, .node = node });
+ try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
+ _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle });
+}
+
+test "terminal body snapshots are lazy bounded and released after the pane closes" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .body);
+ var handles: [snapshot_slots]u32 = undefined;
+ for (&handles) |*opened_handle| {
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ opened_handle.* = opened.reply.handle;
+ }
+ for (p.fs.snapshots) |snapshot| {
+ try testing.expectEqual(node, snapshot.node);
+ try testing.expect(snapshot.bytes == null);
+ }
+ try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno());
+ const scratch = try th.newPane(p);
+ const scratch_id = p.paneBySerial(scratch).?;
+ try testing.expect(p.panes[scratch_id].?.file != null);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } });
+ while (p.nextEffect()) |_| {}
+ var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ p.gpa = failing.allocator();
+ const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 });
+ p.gpa = gpa;
+ try testing.expectEqual(E.NOMEM, failed.errno());
+ try testing.expect(p.fs.snapshots[handles[0] - 1].bytes == null);
+ const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 });
+ try testing.expectEqualStrings("latest output", first.bytes);
+ const saved = p.fs.snapshots[handles[0] - 1].bytes.?;
+ failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ p.gpa = failing.allocator();
+ const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 });
+ p.gpa = gpa;
+ try testing.expectEqualStrings("output", second.bytes);
+ try testing.expect(!failing.has_induced_failure);
+ try testing.expectEqual(saved.ptr, p.fs.snapshots[handles[0] - 1].bytes.?.ptr);
+ try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen_node, .handle = handles[0], .size = 32 }).errno());
+ _ = call(p, .{ .tag = 7, .op = .release, .node = screen_node, .handle = handles[0] });
+ try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node);
+ try p.removePane(0);
+ for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle });
+ for (p.fs.snapshots) |snapshot| {
+ try testing.expectEqual(@as(u64, 0), snapshot.node);
+ try testing.expect(snapshot.bytes == null);
+ }
+}
+
+test "screen snapshots preserve rendered cells and styles until their handle is released" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "const value = 1;\n");
+ defer p.deinit();
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = screen_node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ const snapshot = p.fs.snapshots[opened.reply.handle - 1].bytes.?;
+ const original = try gpa.dupe(u8, snapshot);
+ defer gpa.free(original);
+ const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{});
+ defer parsed.deinit();
+ const data = parsed.value.object;
+ try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer);
+ try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer);
+ try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len);
+ try testing.expect(data.get("styles").?.array.items.len > 0);
+ _ = wr(p, Node.of(serialOf(p), .body), "changed\n");
+ const newer = call(p, .{ .tag = 2, .op = .open, .node = screen_node });
+ try testing.expectEqual(Status.ok, newer.reply.status);
+ try testing.expect(!std.mem.eql(u8, original, p.fs.snapshots[newer.reply.handle - 1].bytes.?));
+ var off: usize = 0;
+ while (off < original.len) {
+ const result = call(p, .{ .tag = 3, .op = .read, .node = screen_node, .handle = opened.reply.handle, .off = off, .size = 13 });
+ try testing.expectEqual(Status.ok, result.reply.status);
+ try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes);
+ off += result.bytes.len;
+ }
+ _ = call(p, .{ .tag = 4, .op = .release, .node = screen_node, .handle = opened.reply.handle });
+ _ = call(p, .{ .tag = 5, .op = .release, .node = screen_node, .handle = newer.reply.handle });
+ for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0);
+}
+
+test "screen inspection preserves acknowledged presentation and the next real frame" {
+ const gpa = testing.allocator;
+ for (std.enums.values(pardes.layout.Transition)) |transition| {
+ errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)});
+ const control = try withFile(gpa, "const value = 1;\n");
+ defer control.deinit();
+ const inspected = try withFile(gpa, "const value = 1;\n");
+ defer inspected.deinit();
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ for ([_]*Pardes{ control, inspected }) |p| {
+ p.settings.panel_transition = .off;
+ p.update(.tick);
+ p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks());
+ p.settings.panel_transition = transition;
+ }
+ for (0..4) |phase| {
+ for ([_]*Pardes{ control, inspected }) |p| switch (phase) {
+ 0 => {},
+ 1 => p.update(.{ .command = "New" }),
+ 2 => {
+ p.update(.tick);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status);
+ },
+ 3 => {
+ for (0..transition.frames() + 1) |_| p.update(.tick);
+ p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks());
+ p.update(.{ .command = "Del" });
+ },
+ else => unreachable,
+ };
+ for (0..2) |_| {
+ const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen_node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen_node, .handle = opened.reply.handle, .size = 32 });
+ try testing.expectEqual(Status.ok, captured.reply.status);
+ try testing.expect(captured.bytes.len > 0);
+ try testing.expectEqual(Status.ok, call(inspected, .{
+ .tag = 3,
+ .op = .release,
+ .node = screen_node,
+ .handle = opened.reply.handle,
+ }).reply.status);
+ }
+ const a = &control.presentation;
+ const b = &inspected.presentation;
+ try testing.expectEqualDeep(a.shown, b.shown);
+ try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks);
+ try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]);
+ try testing.expectEqual(a.pending, b.pending);
+ try testing.expectEqual(a.acknowledged, b.acknowledged);
+ try testing.expectEqual(a.previous_valid, b.previous_valid);
+ try testing.expectEqual(a.previous_cols, b.previous_cols);
+ try testing.expectEqual(a.previous_rows, b.previous_rows);
+ try testing.expectEqualDeep(a.previous_layout, b.previous_layout);
+ try testing.expectEqual(a.previous_cells.len, b.previous_cells.len);
+ for (a.previous_cells, b.previous_cells) |*expected, *actual|
+ try testing.expect(expected.visuallyEqual(actual));
+
+ const expected = try control.render(arena.allocator());
+ const actual = try inspected.render(arena.allocator());
+ try testing.expectEqual(expected.cols, actual.cols);
+ try testing.expectEqual(expected.rows, actual.rows);
+ try testing.expectEqualDeep(expected.cursor, actual.cursor);
+ try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks());
+ try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs);
+ try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len);
+ try testing.expectEqual(expected.cells.len, actual.cells.len);
+ try testing.expectEqual(@as(usize, 0), expected.nimages);
+ try testing.expectEqual(@as(usize, 0), actual.nimages);
+ for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right));
+ for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right));
+ if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0);
+ if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff());
+ if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0);
+ control.acknowledgePanelPresentation(expected.panelTracks());
+ inspected.acknowledgePanelPresentation(actual.panelTracks());
+ try testing.expectEqualDeep(a.shown, b.shown);
+ try testing.expectEqualDeep(a.previous_layout, b.previous_layout);
+ _ = arena.reset(.retain_capacity);
+ }
+ }
+}