terminal/snapshot: full encode/decode

This commit is contained in:
Mitchell Hashimoto
2026-07-30 10:38:00 -07:00
parent 83e482700b
commit 86ec146334
6 changed files with 476 additions and 70 deletions

View File

@@ -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(

View File

@@ -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(

View File

@@ -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");

View File

@@ -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 },
),
);

View 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),
);
}

View File

@@ -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.