mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 20:59:02 +00:00
terminal/snapshot: full encode/decode
This commit is contained in:
@@ -30,9 +30,9 @@ active: *Screen,
|
||||
/// All screens that are initialized.
|
||||
all: std.EnumMap(Key, *Screen),
|
||||
|
||||
/// Monotonic generation counter for each screen key. This changes whenever a
|
||||
/// screen is removed so external handles can distinguish a newly initialized
|
||||
/// screen from stale references into destroyed screen storage.
|
||||
/// Monotonic generation counter for each screen key. This changes whenever
|
||||
/// screen storage is removed or replaced so external handles can distinguish a
|
||||
/// newly initialized screen from stale references into destroyed storage.
|
||||
generations: std.EnumMap(Key, usize),
|
||||
|
||||
pub fn init(
|
||||
|
||||
@@ -472,24 +472,28 @@ test "HISTORY encodes newest first and restores complete history" {
|
||||
|
||||
// Restore SCREEN first, then prepend HISTORY directly into that PageList.
|
||||
var restore_source: std.Io.Reader = .fixed(destination.written());
|
||||
var restored = try screen.decode(
|
||||
var decoded_screen = try screen.decode(
|
||||
&restore_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = screen_rows,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer restored.deinit();
|
||||
defer decoded_screen.deinit();
|
||||
try std.testing.expectEqual(
|
||||
TerminalScreenKey.primary,
|
||||
decoded_screen.key,
|
||||
);
|
||||
const restored = &decoded_screen.screen;
|
||||
try std.testing.expect(!restored.semantic_prompt.seen);
|
||||
try decode(
|
||||
&restore_source,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
&restored,
|
||||
restored,
|
||||
);
|
||||
|
||||
try std.testing.expectEqual(
|
||||
@@ -530,18 +534,18 @@ test "HISTORY encodes newest first and restores complete history" {
|
||||
var truncated_source: std.Io.Reader = .fixed(
|
||||
encoded[0 .. second_page_offset - 1],
|
||||
);
|
||||
var truncated = try screen.decode(
|
||||
var decoded_truncated = try screen.decode(
|
||||
&truncated_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = screen_rows,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer truncated.deinit();
|
||||
defer decoded_truncated.deinit();
|
||||
const truncated = &decoded_truncated.screen;
|
||||
const truncated_screen_first = truncated.pages.getTopLeft(.screen).node;
|
||||
const truncated_screen_page_count = truncated.pages.totalPages();
|
||||
try std.testing.expectError(
|
||||
@@ -550,7 +554,7 @@ test "HISTORY encodes newest first and restores complete history" {
|
||||
&truncated_source,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
&truncated,
|
||||
truncated,
|
||||
),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
@@ -574,18 +578,18 @@ test "HISTORY encodes newest first and restores complete history" {
|
||||
);
|
||||
|
||||
var partial_source: std.Io.Reader = .fixed(encoded);
|
||||
var partial = try screen.decode(
|
||||
var decoded_partial = try screen.decode(
|
||||
&partial_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = screen_rows,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer partial.deinit();
|
||||
defer decoded_partial.deinit();
|
||||
const partial = &decoded_partial.screen;
|
||||
const screen_page_count = partial.pages.totalPages();
|
||||
try std.testing.expectError(
|
||||
error.UnexpectedRecordTag,
|
||||
@@ -593,7 +597,7 @@ test "HISTORY encodes newest first and restores complete history" {
|
||||
&partial_source,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
&partial,
|
||||
partial,
|
||||
),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
|
||||
@@ -44,17 +44,14 @@
|
||||
//! +------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! Records have a strict order:
|
||||
//! Record groups have a strict order:
|
||||
//!
|
||||
//! ```text
|
||||
//! +----------------------------------------+
|
||||
//! | TERMINAL |
|
||||
//! +----------------------------------------+
|
||||
//! | SCREEN (primary) |
|
||||
//! | PAGE * screen.page_count |
|
||||
//! +----------------------------------------+
|
||||
//! | SCREEN (alternate, when present) |
|
||||
//! | PAGE * screen.page_count |
|
||||
//! | SCREEN * terminal.screen_count |
|
||||
//! | PAGE * each screen.page_count |
|
||||
//! +----------------------------------------+
|
||||
//! | READY |
|
||||
//! +----------------------------------------+
|
||||
@@ -68,11 +65,13 @@
|
||||
//! +----------------------------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! The SCREEN sequences contain the complete pages needed to restore each
|
||||
//! active area. A HISTORY sequence contains the older complete pages for its
|
||||
//! screen in newest-to-oldest order so they can be prepended as they arrive.
|
||||
//! Every SCREEN has one corresponding HISTORY, even when its history page count
|
||||
//! is zero. FINISH is followed by end-of-file.
|
||||
//! The SCREEN sequences may appear in any key order. Each key must be unique
|
||||
//! and must identify one of the screens declared by TERMINAL. They contain the
|
||||
//! complete pages needed to restore each active area. A HISTORY sequence
|
||||
//! contains the older complete pages for its screen in newest-to-oldest order
|
||||
//! so they can be prepended as they arrive. Every SCREEN has one corresponding
|
||||
//! HISTORY, even when its history page count is zero. FINISH is followed by
|
||||
//! end-of-file.
|
||||
//!
|
||||
//! READY and FINISH contain BLAKE3-256 digests of all preceding snapshot bytes.
|
||||
//! READY therefore validates the renderable active-state prefix. FINISH covers
|
||||
@@ -81,16 +80,15 @@
|
||||
//!
|
||||
//! ## Encoding
|
||||
//!
|
||||
//! Encode the envelope once, then append records in the required order:
|
||||
//! Encode a complete snapshot into an empty allocating writer:
|
||||
//!
|
||||
//! ```zig
|
||||
//! var output: std.Io.Writer.Allocating = .init(alloc);
|
||||
//! defer output.deinit();
|
||||
//!
|
||||
//! try envelope.encode(&output.writer);
|
||||
//! try screen.encode(&terminal_screen, .primary, &output);
|
||||
//! try snapshot.encode(&terminal, &output);
|
||||
//!
|
||||
//! const snapshot = output.written();
|
||||
//! const bytes = output.written();
|
||||
//! ```
|
||||
//!
|
||||
//! We have to use an allocating writer because record formats require
|
||||
@@ -108,6 +106,7 @@ pub const hyperlink = @import("hyperlink.zig");
|
||||
pub const page = @import("page.zig");
|
||||
pub const record = @import("record.zig");
|
||||
pub const screen = @import("screen.zig");
|
||||
pub const snapshot = @import("snapshot.zig");
|
||||
pub const style = @import("style.zig");
|
||||
pub const terminal = @import("terminal.zig");
|
||||
|
||||
|
||||
@@ -294,9 +294,6 @@ pub const DecodeError = PayloadDecodeError ||
|
||||
/// The next record is valid but is not a SCREEN.
|
||||
UnexpectedRecordTag,
|
||||
|
||||
/// The SCREEN key does not match the caller-selected native screen.
|
||||
UnexpectedScreenKey,
|
||||
|
||||
/// A SCREEN must declare at least one PAGE.
|
||||
InvalidPageCount,
|
||||
|
||||
@@ -304,18 +301,30 @@ pub const DecodeError = PayloadDecodeError ||
|
||||
InvalidCursorPosition,
|
||||
};
|
||||
|
||||
/// One decoded SCREEN sequence and the native screen identified by its header.
|
||||
pub const Decoded = struct {
|
||||
key: TerminalScreenKey,
|
||||
screen: TerminalScreen,
|
||||
|
||||
/// Release the decoded native screen before ownership is transferred.
|
||||
pub fn deinit(self: *Decoded) void {
|
||||
self.screen.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// Restore one SCREEN and its declared PAGE records into native terminal state.
|
||||
///
|
||||
/// The caller supplies terminal-wide dimensions and policy. The record sequence
|
||||
/// is decoded transactionally: on failure, all page, pin, hyperlink, and
|
||||
/// temporary allocations are released and no partially restored Screen escapes.
|
||||
/// The returned key selects the ScreenSet slot that owns the decoded screen.
|
||||
pub fn decode(
|
||||
source: *std.Io.Reader,
|
||||
io_: std.Io,
|
||||
alloc: Allocator,
|
||||
expected_key: TerminalScreenKey,
|
||||
options: TerminalScreen.Options,
|
||||
) DecodeError!TerminalScreen {
|
||||
) DecodeError!Decoded {
|
||||
// Decode and finish the self-contained SCREEN record before consuming any
|
||||
// of the PAGE records which follow it.
|
||||
var record_reader: record.Reader = undefined;
|
||||
@@ -340,11 +349,7 @@ pub fn decode(
|
||||
defer if (cursor_hyperlink) |*value| value.deinit(alloc);
|
||||
try record_reader.finish();
|
||||
|
||||
// Reject a structurally valid SCREEN which does not describe the native
|
||||
// screen selected by the caller.
|
||||
if (header.key.terminal() != expected_key) {
|
||||
return error.UnexpectedScreenKey;
|
||||
}
|
||||
const key = header.key.terminal();
|
||||
if (header.page_count == 0) return error.InvalidPageCount;
|
||||
|
||||
// Dimensions are terminal-wide state supplied by the enclosing snapshot.
|
||||
@@ -367,11 +372,11 @@ pub fn decode(
|
||||
var builder = try TerminalPageList.Builder.init(alloc, .{
|
||||
.cols = options.cols,
|
||||
.rows = options.rows,
|
||||
.max_size = if (expected_key == .alternate)
|
||||
.max_size = if (key == .alternate)
|
||||
0
|
||||
else
|
||||
options.max_scrollback_bytes,
|
||||
.max_lines = if (expected_key == .alternate)
|
||||
.max_lines = if (key == .alternate)
|
||||
0
|
||||
else
|
||||
options.max_scrollback_lines,
|
||||
@@ -407,7 +412,7 @@ pub fn decode(
|
||||
.io = io_,
|
||||
.alloc = alloc,
|
||||
.pages = pages,
|
||||
.no_scrollback = expected_key == .alternate or
|
||||
.no_scrollback = key == .alternate or
|
||||
options.max_scrollback_bytes == 0,
|
||||
.cursor = .{
|
||||
.x = header.cursor_x,
|
||||
@@ -504,7 +509,7 @@ pub fn decode(
|
||||
// before transferring ownership to the caller.
|
||||
result.pages.assertIntegrity();
|
||||
result.assertIntegrity();
|
||||
return result;
|
||||
return .{ .key = key, .screen = result };
|
||||
}
|
||||
|
||||
/// Errors possible while decoding fixed SCREEN payload fields.
|
||||
@@ -2036,14 +2041,15 @@ test "framed native SCREEN and PAGE sequence" {
|
||||
// The complete sequence restores directly into native Screen/PageList
|
||||
// state, including page-local cursor references.
|
||||
var restore_source: std.Io.Reader = .fixed(destination.written()[6..]);
|
||||
var restored = try decode(
|
||||
var decoded = try decode(
|
||||
&restore_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.alternate,
|
||||
.{ .cols = 8, .rows = 8, .max_scrollback_bytes = 0 },
|
||||
);
|
||||
defer restored.deinit();
|
||||
defer decoded.deinit();
|
||||
try std.testing.expectEqual(TerminalScreenKey.alternate, decoded.key);
|
||||
const restored = &decoded.screen;
|
||||
|
||||
try std.testing.expect(restored.no_scrollback);
|
||||
try std.testing.expectEqual(@as(u16, 7), restored.cursor.x);
|
||||
@@ -2197,18 +2203,19 @@ test "SCREEN encodes the minimal complete-page active suffix" {
|
||||
// Native restoration keeps the incidental history prefix and positions
|
||||
// the active top within the first decoded page.
|
||||
var restore_source: std.Io.Reader = .fixed(destination.written());
|
||||
var restored = try decode(
|
||||
var decoded = try decode(
|
||||
&restore_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = screen_rows,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer restored.deinit();
|
||||
defer decoded.deinit();
|
||||
try std.testing.expectEqual(TerminalScreenKey.primary, decoded.key);
|
||||
const restored = &decoded.screen;
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), restored.pages.totalPages());
|
||||
const restored_top = restored.pages.getTopLeft(.active);
|
||||
@@ -2245,21 +2252,6 @@ test "SCREEN restoration rejects invalid and incomplete sequences" {
|
||||
defer destination.deinit();
|
||||
try encode(&screen, .primary, &destination);
|
||||
|
||||
// The caller selects which native screen receives the record.
|
||||
{
|
||||
var source: std.Io.Reader = .fixed(destination.written());
|
||||
try std.testing.expectError(
|
||||
error.UnexpectedScreenKey,
|
||||
decode(
|
||||
&source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.alternate,
|
||||
.{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Every truncation must fail without leaking a partially restored
|
||||
// PageList, cursor pin, or cursor-owned state.
|
||||
for (0..destination.written().len) |fixture_len| {
|
||||
@@ -2270,7 +2262,6 @@ test "SCREEN restoration rejects invalid and incomplete sequences" {
|
||||
&source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 },
|
||||
) catch continue;
|
||||
restored.deinit();
|
||||
@@ -2290,7 +2281,6 @@ test "SCREEN restoration rejects invalid and incomplete sequences" {
|
||||
&source,
|
||||
std.testing.io,
|
||||
failing.allocator(),
|
||||
.primary,
|
||||
.{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 },
|
||||
),
|
||||
);
|
||||
@@ -2319,7 +2309,6 @@ test "SCREEN restoration rejects invalid and incomplete sequences" {
|
||||
&empty_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 },
|
||||
),
|
||||
);
|
||||
|
||||
413
src/terminal/snapshot/snapshot.zig
Normal file
413
src/terminal/snapshot/snapshot.zig
Normal file
@@ -0,0 +1,413 @@
|
||||
//! Complete terminal snapshot encoding and restoration.
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("terminal_options");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const checkpoint = @import("checkpoint.zig");
|
||||
const envelope = @import("envelope.zig");
|
||||
const history = @import("history.zig");
|
||||
const record = @import("record.zig");
|
||||
const screen = @import("screen.zig");
|
||||
const terminal = @import("terminal.zig");
|
||||
const Terminal = @import("../Terminal.zig");
|
||||
const TerminalScreen = @import("../Screen.zig");
|
||||
const TerminalScreenKey = @import("../ScreenSet.zig").Key;
|
||||
|
||||
/// Errors possible while encoding one complete terminal snapshot.
|
||||
pub const EncodeError = terminal.EncodeError ||
|
||||
screen.EncodeError ||
|
||||
history.EncodeError ||
|
||||
checkpoint.EncodeError ||
|
||||
error{
|
||||
/// A snapshot envelope must begin at byte zero.
|
||||
DestinationNotEmpty,
|
||||
};
|
||||
|
||||
/// Encode one complete terminal snapshot.
|
||||
///
|
||||
/// `destination` must be empty because checkpoint digests cover every byte
|
||||
/// from the snapshot envelope onward. The operation is transactional: any
|
||||
/// failure restores the destination to empty.
|
||||
pub fn encode(
|
||||
t: *const Terminal,
|
||||
destination: *std.Io.Writer.Allocating,
|
||||
) EncodeError!void {
|
||||
// We require empty for checkpoint digests
|
||||
if (destination.written().len != 0) return error.DestinationNotEmpty;
|
||||
errdefer destination.shrinkRetainingCapacity(0);
|
||||
|
||||
// 1. Envelope
|
||||
try envelope.encode(&destination.writer);
|
||||
|
||||
// 2. Terminal
|
||||
try terminal.encode(t, destination);
|
||||
|
||||
// 3. Primary and alt screen
|
||||
try screen.encode(
|
||||
t.screens.get(.primary).?,
|
||||
.primary,
|
||||
destination,
|
||||
);
|
||||
if (t.screens.get(.alternate)) |alternate| try screen.encode(
|
||||
alternate,
|
||||
.alternate,
|
||||
destination,
|
||||
);
|
||||
|
||||
// 4. Ready checkpoint. In the future we'll put our continuation
|
||||
// state before this so pty bytes can also flow.
|
||||
try checkpoint.encode(.ready, destination);
|
||||
|
||||
// 5. History
|
||||
try history.encode(
|
||||
t.screens.get(.primary).?,
|
||||
.primary,
|
||||
destination,
|
||||
);
|
||||
if (t.screens.get(.alternate)) |alternate| try history.encode(
|
||||
alternate,
|
||||
.alternate,
|
||||
destination,
|
||||
);
|
||||
|
||||
// 6. Finish
|
||||
try checkpoint.encode(.finish, destination);
|
||||
}
|
||||
|
||||
/// Errors possible while restoring one complete terminal snapshot.
|
||||
pub const DecodeError = envelope.DecodeError ||
|
||||
terminal.DecodeError ||
|
||||
screen.DecodeError ||
|
||||
history.DecodeError ||
|
||||
checkpoint.DecodeError ||
|
||||
error{
|
||||
/// A SCREEN names a key not declared by TERMINAL.
|
||||
UnexpectedScreenKey,
|
||||
|
||||
/// More than one SCREEN names the same key.
|
||||
DuplicateScreen,
|
||||
};
|
||||
|
||||
/// Restore one complete snapshot into a native terminal.
|
||||
///
|
||||
/// The reader must contain exactly one snapshot through FINISH. Restoration is
|
||||
/// transactional: the returned terminal is either complete and ready or
|
||||
/// not (error return).
|
||||
pub fn decode(
|
||||
source: *std.Io.Reader,
|
||||
io_: std.Io,
|
||||
alloc: Allocator,
|
||||
) DecodeError!Terminal {
|
||||
// Keep this reader unbuffered so the hasher never reads past a checkpoint
|
||||
// boundary. Record readers provide their own bounded buffers.
|
||||
const Blake3 = std.crypto.hash.Blake3;
|
||||
var hashing = source.hashed(Blake3.init(.{}), &.{});
|
||||
const reader = &hashing.reader;
|
||||
|
||||
// Read the envelope, which is currently just a verification step.
|
||||
try envelope.decode(reader);
|
||||
|
||||
// TERMINAL establishes terminal-wide state and allocates empty screen
|
||||
// slots with their final routing. SCREEN values replace those slots in
|
||||
// place so ScreenSet pointers, including the active pointer, stay valid.
|
||||
var result = try terminal.decode(reader, io_, alloc);
|
||||
errdefer result.deinit(alloc);
|
||||
|
||||
// TERMINAL initializes exactly the number of screen slots it declared.
|
||||
// Decode that many SCREEN sequences and route each one by its encoded key.
|
||||
const screen_count = result.screens.all.count();
|
||||
const options: TerminalScreen.Options = options: {
|
||||
const primary = result.screens.get(.primary).?;
|
||||
const explicit_bytes = primary.pages.limits.bytes.explicit;
|
||||
const explicit_lines = primary.pages.limits.lines.explicit;
|
||||
break :options .{
|
||||
.cols = result.cols,
|
||||
.rows = result.rows,
|
||||
.max_scrollback_bytes = if (explicit_bytes == std.math.maxInt(usize))
|
||||
null
|
||||
else
|
||||
explicit_bytes,
|
||||
.max_scrollback_lines = if (explicit_lines == std.math.maxInt(usize))
|
||||
null
|
||||
else
|
||||
explicit_lines,
|
||||
};
|
||||
};
|
||||
|
||||
for (0..screen_count) |_| {
|
||||
var decoded = try screen.decode(
|
||||
reader,
|
||||
io_,
|
||||
alloc,
|
||||
options,
|
||||
);
|
||||
errdefer decoded.deinit();
|
||||
|
||||
const slot = result.screens.get(decoded.key) orelse
|
||||
return error.UnexpectedScreenKey;
|
||||
|
||||
// The fresh ScreenSet starts every declared slot at generation zero.
|
||||
// Replacing a slot advances its generation, so a nonzero value means
|
||||
// an earlier SCREEN in this snapshot already supplied the same key.
|
||||
if (result.screens.generation(decoded.key) != 0) return error.DuplicateScreen;
|
||||
|
||||
slot.deinit();
|
||||
slot.* = decoded.screen;
|
||||
decoded.screen = undefined;
|
||||
|
||||
// We put an artificial generation in just so we can detect duplicates.
|
||||
result.screens.generations.put(
|
||||
decoded.key,
|
||||
result.screens.generation(decoded.key) +% 1,
|
||||
);
|
||||
}
|
||||
|
||||
// READY covers the exact envelope-through-SCREEN prefix. Finalizing does
|
||||
// not consume the hasher, so the same stream continues toward FINISH.
|
||||
var digest: checkpoint.Digest = undefined;
|
||||
hashing.hasher.final(&digest);
|
||||
try checkpoint.decode(.ready, digest, reader);
|
||||
|
||||
// HISTORY mutates only the restored terminal owned by this function.
|
||||
// Although an individual history decoder may publish recent pages as they
|
||||
// validate, any later full-snapshot failure deinitializes the whole result.
|
||||
const keys = [_]TerminalScreenKey{ .primary, .alternate };
|
||||
for (keys) |key| {
|
||||
const restored = result.screens.get(key) orelse continue;
|
||||
try history.decode(reader, alloc, key, restored);
|
||||
}
|
||||
|
||||
// FINISH authenticates READY and all history. Decode it directly from the
|
||||
// underlying reader so the digest does not include FINISH itself.
|
||||
hashing.hasher.final(&digest);
|
||||
try checkpoint.decode(.finish, digest, source);
|
||||
|
||||
if (comptime build_options.slow_runtime_safety) {
|
||||
for (keys) |key| {
|
||||
const restored = result.screens.get(key) orelse continue;
|
||||
restored.pages.assertIntegrity();
|
||||
restored.assertIntegrity();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
test "complete snapshot round trip with history and alternate screen" {
|
||||
const testing = std.testing;
|
||||
|
||||
var t = try Terminal.init(testing.io, testing.allocator, .{
|
||||
.cols = 80,
|
||||
.rows = 3,
|
||||
.max_scrollback_bytes = null,
|
||||
.max_scrollback_lines = null,
|
||||
});
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
// Exercise terminal-wide state and grow the primary screen until its
|
||||
// active area is preceded by multiple complete history pages.
|
||||
t.width_px = 800;
|
||||
t.height_px = 600;
|
||||
t.colors.palette.set(7, .{ .r = 1, .g = 2, .b = 3 });
|
||||
t.modes.values.bracketed_paste = true;
|
||||
try t.setPwd("file:///tmp/snapshot");
|
||||
try t.setTitle("complete snapshot");
|
||||
|
||||
const primary = t.screens.get(.primary).?;
|
||||
while (primary.pages.totalPages() < 4) {
|
||||
try t.printString("primary history\n");
|
||||
}
|
||||
try testing.expect(primary.pages.scrollbar().total > t.rows);
|
||||
|
||||
// Compression is an internal source representation and must remain
|
||||
// unchanged while the complete history is inspected for encoding.
|
||||
_ = primary.pages.compress(.full);
|
||||
const source_memory = primary.pages.memoryStats();
|
||||
|
||||
// The optional alternate screen participates in both phases and remains
|
||||
// the active screen after restoration.
|
||||
_ = try t.switchScreen(.alternate);
|
||||
try t.printString("alternate");
|
||||
|
||||
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer encoded.deinit();
|
||||
try encode(&t, &encoded);
|
||||
try testing.expectEqualDeep(source_memory, primary.pages.memoryStats());
|
||||
|
||||
var encoded_source: std.Io.Reader = .fixed(encoded.written());
|
||||
var source_buffer: [1]u8 = undefined;
|
||||
var limited = encoded_source.limited(.unlimited, &source_buffer);
|
||||
var restored = try decode(
|
||||
&limited.interface,
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
);
|
||||
defer restored.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(TerminalScreenKey.alternate, restored.screens.active_key);
|
||||
try testing.expectEqual(
|
||||
restored.screens.get(.alternate).?,
|
||||
restored.screens.active,
|
||||
);
|
||||
try testing.expectEqualStrings(
|
||||
"file:///tmp/snapshot",
|
||||
restored.getPwd().?,
|
||||
);
|
||||
try testing.expectEqualStrings(
|
||||
"complete snapshot",
|
||||
restored.getTitle().?,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
primary.pages.scrollbar().total,
|
||||
restored.screens.get(.primary).?.pages.scrollbar().total,
|
||||
);
|
||||
|
||||
// Re-encoding is a compact semantic equality check over all TERMINAL,
|
||||
// SCREEN, PAGE, and HISTORY fields and both checkpoint boundaries.
|
||||
var reencoded: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer reencoded.deinit();
|
||||
try encode(&restored, &reencoded);
|
||||
try testing.expectEqualStrings(encoded.written(), reencoded.written());
|
||||
|
||||
// SCREEN keys make their order independent. Keep HISTORY canonical here;
|
||||
// only the active-state screen sequences are intentionally reversed.
|
||||
var reversed: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer reversed.deinit();
|
||||
try envelope.encode(&reversed.writer);
|
||||
try terminal.encode(&t, &reversed);
|
||||
try screen.encode(t.screens.get(.alternate).?, .alternate, &reversed);
|
||||
try screen.encode(primary, .primary, &reversed);
|
||||
try checkpoint.encode(.ready, &reversed);
|
||||
try history.encode(primary, .primary, &reversed);
|
||||
try history.encode(
|
||||
t.screens.get(.alternate).?,
|
||||
.alternate,
|
||||
&reversed,
|
||||
);
|
||||
try checkpoint.encode(.finish, &reversed);
|
||||
|
||||
var reversed_source: std.Io.Reader = .fixed(reversed.written());
|
||||
var reversed_restored = try decode(
|
||||
&reversed_source,
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
);
|
||||
defer reversed_restored.deinit(testing.allocator);
|
||||
try testing.expectEqual(
|
||||
TerminalScreenKey.alternate,
|
||||
reversed_restored.screens.active_key,
|
||||
);
|
||||
}
|
||||
|
||||
test "complete snapshot encoding is transactional" {
|
||||
const testing = std.testing;
|
||||
|
||||
var t = try Terminal.init(testing.io, testing.allocator, .{
|
||||
.cols = 2,
|
||||
.rows = 1,
|
||||
});
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
// A complete snapshot cannot be appended after unrelated bytes because
|
||||
// its envelope and checkpoint coverage both begin at byte zero.
|
||||
var nonempty: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer nonempty.deinit();
|
||||
try nonempty.writer.writeAll("prefix");
|
||||
try testing.expectError(
|
||||
error.DestinationNotEmpty,
|
||||
encode(&t, &nonempty),
|
||||
);
|
||||
try testing.expectEqualStrings("prefix", nonempty.written());
|
||||
|
||||
// A failure after validation enters a record codec still rolls back every
|
||||
// preceding record in this complete snapshot operation.
|
||||
t.colors.palette.current[7] = .{ .r = 1, .g = 2, .b = 3 };
|
||||
var destination: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer destination.deinit();
|
||||
try testing.expectError(
|
||||
error.InvalidPalette,
|
||||
encode(&t, &destination),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), destination.written().len);
|
||||
}
|
||||
|
||||
test "complete snapshot rejects ordering checkpoints and trailing data" {
|
||||
const testing = std.testing;
|
||||
|
||||
var t = try Terminal.init(testing.io, testing.allocator, .{
|
||||
.cols = 2,
|
||||
.rows = 1,
|
||||
});
|
||||
defer t.deinit(testing.allocator);
|
||||
const primary = t.screens.get(.primary).?;
|
||||
|
||||
// HISTORY is individually valid here, but the full decoder requires the
|
||||
// primary SCREEN before READY.
|
||||
var reordered: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer reordered.deinit();
|
||||
try envelope.encode(&reordered.writer);
|
||||
try terminal.encode(&t, &reordered);
|
||||
try history.encode(primary, .primary, &reordered);
|
||||
var reordered_source: std.Io.Reader = .fixed(reordered.written());
|
||||
try testing.expectError(
|
||||
error.UnexpectedRecordTag,
|
||||
decode(&reordered_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
// Construct a correctly framed READY with an intentionally unrelated
|
||||
// digest so the full driver, rather than record CRC validation, rejects it.
|
||||
var invalid_ready: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer invalid_ready.deinit();
|
||||
try envelope.encode(&invalid_ready.writer);
|
||||
try terminal.encode(&t, &invalid_ready);
|
||||
try screen.encode(primary, .primary, &invalid_ready);
|
||||
var record_writer = try record.Writer.init(&invalid_ready, .ready);
|
||||
try record_writer.payloadWriter().splatByteAll(
|
||||
0,
|
||||
@sizeOf(checkpoint.Digest),
|
||||
);
|
||||
try record_writer.finish();
|
||||
var invalid_ready_source: std.Io.Reader = .fixed(
|
||||
invalid_ready.written(),
|
||||
);
|
||||
try testing.expectError(
|
||||
error.InvalidDigest,
|
||||
decode(&invalid_ready_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
// FINISH is the exact end of a complete snapshot.
|
||||
var trailing: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer trailing.deinit();
|
||||
try encode(&t, &trailing);
|
||||
try trailing.writer.writeByte(0);
|
||||
var trailing_source: std.Io.Reader = .fixed(trailing.written());
|
||||
try testing.expectError(
|
||||
error.TrailingData,
|
||||
decode(&trailing_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
// A SCREEN key must name one of the slots declared by TERMINAL.
|
||||
var undeclared: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer undeclared.deinit();
|
||||
try envelope.encode(&undeclared.writer);
|
||||
try terminal.encode(&t, &undeclared);
|
||||
try screen.encode(primary, .alternate, &undeclared);
|
||||
var undeclared_source: std.Io.Reader = .fixed(undeclared.written());
|
||||
try testing.expectError(
|
||||
error.UnexpectedScreenKey,
|
||||
decode(&undeclared_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
// The declared count cannot be satisfied by repeating the same key.
|
||||
_ = try t.switchScreen(.alternate);
|
||||
var duplicate: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer duplicate.deinit();
|
||||
try envelope.encode(&duplicate.writer);
|
||||
try terminal.encode(&t, &duplicate);
|
||||
try screen.encode(primary, .primary, &duplicate);
|
||||
try screen.encode(primary, .primary, &duplicate);
|
||||
var duplicate_source: std.Io.Reader = .fixed(duplicate.written());
|
||||
try testing.expectError(
|
||||
error.DuplicateScreen,
|
||||
decode(&duplicate_source, testing.io, testing.allocator),
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@
|
||||
//! `history.zig`.
|
||||
//!
|
||||
//! Snapshot version 1 supports the primary screen and an optional alternate
|
||||
//! screen. `screen_count` is therefore one or two. SCREEN records are ordered by
|
||||
//! key: primary first, then alternate when present. `active_screen_key` must
|
||||
//! screen. `screen_count` is therefore one or two. SCREEN records identify
|
||||
//! their destination by key and may appear in either order. Canonical encoders
|
||||
//! write primary first, then alternate when present. `active_screen_key` must
|
||||
//! name one of those declared screens.
|
||||
//!
|
||||
//! All integers are unsigned and little-endian.
|
||||
|
||||
Reference in New Issue
Block a user