diff options
Diffstat (limited to 'src/ninep/screen.zig')
| -rw-r--r-- | src/ninep/screen.zig | 351 |
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); + } + } +} |
