terminal/snapshot: pty continuation

Builds on #13544

This adds a new CONTINUATION record type that is sent before READY.
CONTINUATION contains the bytes (if any) that will bring a ground-state
VT state machine up to the same state.

This allows snapshotting a terminal instance that is, for example,
blocked waiting for a caller to complet an in-flight Kitty graphics
protocol send. In practice, I think this will be rare. But in theory, it
avoids a DoS-type attack.

The continuation state must be the MINIMAL set of bytes that will move
the virtual terminal state from a ground to non-ground state. The reason
it must be minimal is because any extra bytes can duplicate work into the 
terminal that might already exist.
This commit is contained in:
Mitchell Hashimoto
2026-08-01 15:13:18 -07:00
parent f5880782fe
commit 70e41e96d3
16 changed files with 1140 additions and 101 deletions

View File

@@ -4,10 +4,11 @@
//! before that checkpoint's record header. This binds record order and detects
//! omitted or duplicated records in ways that independent record CRCs cannot.
//!
//! READY covers the envelope, TERMINAL, and all SCREEN/PAGE sequences. FINISH
//! covers that same prefix plus the complete READY record and all HISTORY/PAGE
//! sequences. Neither digest includes its own record. FINISH terminates one
//! snapshot; bytes after it belong to the containing transport.
//! READY covers the envelope, TERMINAL, all SCREEN/PAGE sequences, and the
//! required CONTINUATION record. FINISH covers that same prefix plus the
//! complete READY record and all HISTORY/PAGE sequences. Neither digest includes
//! its own record. FINISH terminates one snapshot; bytes after it belong to the
//! containing transport.
//!
//! The digest does not replace record framing. Its 32-byte payload is still
//! protected by the record's CRC32C.

View File

@@ -0,0 +1,320 @@
//! Continuation record type.
//!
//! Continuation uses the standard record header. All trailing bytes
//! are payload bytes for the continuation state.
//!
//! A continuation's payload must be the minimal bytes to go from
//! a ground state to a non-ground state in a VT emulator. Any extra
//! bytes are treated as a validation error.
const std = @import("std");
const Allocator = std.mem.Allocator;
const record = @import("record.zig");
const stream_continuation = @import("../stream_continuation.zig");
const test_fixture = @import("fixture.zig");
/// A borrowed continuation value supplied to encode, or an allocator-owned
/// value stored in a decoded complete snapshot.
pub const Value = union(enum) {
/// Neither the VT parser nor UTF-8 decoder has unfinished state.
ground,
/// Canonical replay-safe PTY bytes. Empty is equivalent to `ground`.
bytes: []const u8,
};
pub const ValidateError = stream_continuation.ValidateError || error{
/// The common record's u32 payload length cannot represent these bytes.
Overflow,
};
/// Validate a continuation before any snapshot bytes are emitted.
pub fn validate(value: Value) ValidateError!void {
switch (value) {
.ground => {},
.bytes => |bytes| {
if (std.math.cast(u32, bytes.len) == null) {
return error.Overflow;
}
try stream_continuation.validate(bytes);
},
}
}
pub const EncodeError = ValidateError || record.Writer.FinishError;
/// Encode one complete CONTINUATION record.
pub fn encode(
value: Value,
stream: *record.Writer,
) EncodeError!void {
try validate(value);
const payload = stream.begin(.continuation);
errdefer stream.cancel();
switch (value) {
.ground => {},
.bytes => |bytes| try payload.writeAll(bytes),
}
try stream.finish();
}
pub const DecodeError = Allocator.Error ||
stream_continuation.ValidateError ||
record.Reader.InitError ||
record.Reader.FinishError ||
std.Io.Reader.Error ||
error{
/// The next record is valid but is not CONTINUATION.
UnexpectedRecordTag,
/// The declared payload exceeds caller policy.
ContinuationLimitExceeded,
};
/// Decode and validate one CONTINUATION record.
///
/// A zero-length payload returns `ground` without allocating. A nonempty
/// payload returns allocator-owned bytes; although `Value.bytes` is a const
/// slice, the caller must eventually free it with the same allocator.
pub fn decode(
alloc: Allocator,
source: *std.Io.Reader,
max_bytes: usize,
) DecodeError!Value {
var record_reader: record.Reader = undefined;
try record_reader.init(source);
if (record_reader.header.tag != .continuation) {
return error.UnexpectedRecordTag;
}
// Validate the header payload vs our max size since the header
// enforces it.
const len: usize = record_reader.header.payload_len;
if (len > max_bytes) return error.ContinuationLimitExceeded;
if (len == 0) {
// Empty is the ground representation, but finish is still required:
// its CRC covers the tag and zero length even though there is no body.
try record_reader.finish();
return .ground;
}
// Consume the bytes
const bytes = try alloc.alloc(u8, len);
errdefer alloc.free(bytes);
try record_reader.payloadReader().readSliceAll(bytes);
try record_reader.finish();
// Framing is valid, so it is now safe to classify the bytes as replay
// state. Validation requires a minimal, unfinished, side-effect-free tail.
try stream_continuation.validate(bytes);
// Ownership transfers to the returned Value only after all validation.
return .{ .bytes = bytes };
}
const test_ground_fixture = test_fixture.parse(@embedFile("testdata/continuation-ground-v1.hex"));
const test_utf8_fixture = test_fixture.parse(@embedFile("testdata/continuation-utf8-v1.hex"));
const test_esc_fixture = test_fixture.parse(@embedFile("testdata/continuation-esc-v1.hex"));
const test_csi_fixture = test_fixture.parse(@embedFile("testdata/continuation-csi-v1.hex"));
const test_osc_fixture = test_fixture.parse(@embedFile("testdata/continuation-osc-v1.hex"));
const test_dcs_fixture = test_fixture.parse(@embedFile("testdata/continuation-dcs-v1.hex"));
const test_apc_fixture = test_fixture.parse(@embedFile("testdata/continuation-apc-v1.hex"));
test "continuation golden records" {
const Golden = struct {
path: []const u8,
candidate: []const u8,
value: Value,
expected: []const u8,
};
const values = [_]Golden{
.{
.path = "src/terminal/snapshot/testdata/continuation-ground-v1.hex",
.candidate = "snapshot_fixture-continuation-ground-v1.hex",
.value = .ground,
.expected = &test_ground_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-utf8-v1.hex",
.candidate = "snapshot_fixture-continuation-utf8-v1.hex",
.value = .{ .bytes = "\xF0\x9F\x98" },
.expected = &test_utf8_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-esc-v1.hex",
.candidate = "snapshot_fixture-continuation-esc-v1.hex",
.value = .{ .bytes = "\x1b" },
.expected = &test_esc_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-csi-v1.hex",
.candidate = "snapshot_fixture-continuation-csi-v1.hex",
.value = .{ .bytes = "\x1b[31" },
.expected = &test_csi_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-osc-v1.hex",
.candidate = "snapshot_fixture-continuation-osc-v1.hex",
.value = .{ .bytes = "\x1b]2;title" },
.expected = &test_osc_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-dcs-v1.hex",
.candidate = "snapshot_fixture-continuation-dcs-v1.hex",
.value = .{ .bytes = "\x1bPqdata" },
.expected = &test_dcs_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-apc-v1.hex",
.candidate = "snapshot_fixture-continuation-apc-v1.hex",
.value = .{ .bytes = "\x1b_Gdata" },
.expected = &test_apc_fixture,
},
};
for (values) |golden| {
var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer encoded.deinit();
var stream: record.Writer = .init(
std.testing.allocator,
&encoded.writer,
);
defer stream.deinit();
try encode(golden.value, &stream);
try test_fixture.expectEqual(
.bytes,
golden.path,
golden.candidate,
golden.expected,
encoded.written(),
);
}
}
test "continuation validates supported unfinished states" {
const values = [_][]const u8{
"\x1b",
"\x1b[31",
"\x1b]2;title",
"\x1bPqdata",
"\x1b_Gdata",
"\xF0\x9F\x98",
};
for (values) |bytes| try validate(.{ .bytes = bytes });
}
test "empty continuation bytes encode and decode as ground" {
const testing = std.testing;
try validate(.{ .bytes = "" });
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
var stream: record.Writer = .init(testing.allocator, &encoded.writer);
defer stream.deinit();
try encode(.{ .bytes = "" }, &stream);
try testing.expectEqualStrings(&test_ground_fixture, encoded.written());
var source: std.Io.Reader = .fixed(encoded.written());
const decoded = try decode(testing.allocator, &source, 0);
try testing.expect(decoded == .ground);
}
test "continuation rejects invalid semantic shapes" {
const testing = std.testing;
try testing.expectError(
error.NoPendingState,
validate(.{ .bytes = "\x1b[31m" }),
);
try testing.expectError(
error.ReplayWouldCommit,
validate(.{ .bytes = "\x1b[31\x07" }),
);
try testing.expectError(
error.NonCanonicalContinuation,
validate(.{ .bytes = "prefix\x1b[31" }),
);
try testing.expectError(
error.NonCanonicalContinuation,
validate(.{ .bytes = "\x1b[31\x1b[4" }),
);
}
test "continuation record round trip and cap" {
const testing = std.testing;
const values = [_]Value{
.ground,
.{ .bytes = "\x1b[31" },
.{ .bytes = "\xF0\x9F" },
};
for (values) |value| {
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
var stream: record.Writer = .init(testing.allocator, &encoded.writer);
defer stream.deinit();
try encode(value, &stream);
var source: std.Io.Reader = .fixed(encoded.written());
const decoded = try decode(testing.allocator, &source, 1024);
defer switch (decoded) {
.ground => {},
.bytes => |bytes| testing.allocator.free(bytes),
};
switch (value) {
.ground => try testing.expect(decoded == .ground),
.bytes => |expected| try testing.expectEqualStrings(
expected,
decoded.bytes,
),
}
}
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
var stream: record.Writer = .init(testing.allocator, &encoded.writer);
defer stream.deinit();
try encode(.{ .bytes = "\x1b[31" }, &stream);
var capped_source: std.Io.Reader = .fixed(encoded.written());
var failing = testing.FailingAllocator.init(testing.allocator, .{
.fail_index = 0,
});
try testing.expectError(
error.ContinuationLimitExceeded,
decode(failing.allocator(), &capped_source, 3),
);
try testing.expectEqual(
@as(usize, record.Header.len),
capped_source.seek,
);
}
test "continuation record rejects truncation, checksum, and tag" {
const testing = std.testing;
for (0..test_csi_fixture.len) |len| {
var source: std.Io.Reader = .fixed(test_csi_fixture[0..len]);
try testing.expectError(
error.EndOfStream,
decode(testing.allocator, &source, 1024),
);
}
var invalid_checksum = test_csi_fixture;
invalid_checksum[6] ^= 1;
var checksum_source: std.Io.Reader = .fixed(&invalid_checksum);
try testing.expectError(
error.InvalidChecksum,
decode(testing.allocator, &checksum_source, 1024),
);
var wrong_tag = test_ground_fixture;
std.mem.writeInt(u16, wrong_tag[0..2], @intFromEnum(record.Tag.ready), .little);
var tag_source: std.Io.Reader = .fixed(&wrong_tag);
try testing.expectError(
error.UnexpectedRecordTag,
decode(testing.allocator, &tag_source, 1024),
);
}

View File

@@ -10,11 +10,11 @@
//! To do that, it sends the active terminal state followed by a READY record,
//! then complete history.
//!
//! READY denotes that enough of the terminal state is down that it can
//! be fully rendered at that point. This is also the point where live
//! terminals can also start accepting pty bytes, typically. But the current
//! snapshot format lacks some of the information necessary to synchronize
//! pty byte state with an authoritative server.
//! READY denotes that enough authenticated state is present to render the
//! terminal and reconstruct its unfinished standard Stream state. The current
//! synchronous decoder still returns only after FINISH. A caller moves the
//! Terminal into final storage, replays CONTINUATION once, and only then applies
//! PTY bytes belonging after the snapshot cut.
//!
//! After READY, we send history pages (scrollback).
//!
@@ -53,6 +53,8 @@
//! | SCREEN * terminal.screen_count |
//! | PAGE * each screen.page_count |
//! +----------------------------------------+
//! | CONTINUATION |
//! +----------------------------------------+
//! | READY |
//! +----------------------------------------+
//! | HISTORY * terminal.screen_count |
@@ -71,12 +73,16 @@
//! is zero. FINISH terminates the snapshot. Bytes after FINISH belong to the
//! containing transport and are not consumed by snapshot decoding.
//!
//! CONTINUATION contains the bytes required to bring the terminal's
//! VT parser/stream up to the same state, or no bytes if it should be
//! in the ground state.
//!
//! READY and FINISH contain BLAKE3-256 digests of all preceding snapshot bytes.
//! READY therefore validates the renderable active-state prefix. FINISH covers
//! READY and all history as well, validating the complete snapshot and its
//! record ordering. Each SCREEN declares its complete logical history extent,
//! allowing a client to size its scrollbar at READY even though older PAGE
//! records arrive afterward.
//! READY therefore validates the renderable active state and continuation.
//! FINISH covers READY and all history as well, validating the complete snapshot
//! and its record ordering. Each SCREEN declares its complete logical history
//! extent, allowing a client to size its scrollbar at READY even though older
//! PAGE records arrive afterward.
//!
//! ## Encoding
//!
@@ -86,7 +92,9 @@
//! var output: std.Io.Writer.Allocating = .init(alloc);
//! defer output.deinit();
//!
//! try snapshot.encode(alloc, &output.writer, &terminal);
//! try snapshot.encode(alloc, &output.writer, &terminal, .{
//! .continuation = .ground,
//! });
//!
//! const bytes = output.written();
//! ```
@@ -113,7 +121,11 @@
//! outside this ordered snapshot record sequence.
//!
//! ```zig
//! var terminal = try snapshot.decode(alloc, io, &reader);
//! var decoded = try snapshot.decode(alloc, io, &reader, .{
//! .max_continuation_bytes = 1024 * 1024,
//! });
//! defer decoded.deinit(alloc);
//! var terminal = decoded.toOwned();
//! defer terminal.deinit(alloc);
//! ```
//!
@@ -122,6 +134,7 @@
//! block when used with a live stream.
pub const checkpoint = @import("checkpoint.zig");
pub const continuation = @import("continuation.zig");
pub const envelope = @import("envelope.zig");
pub const grid = @import("grid.zig");
pub const history = @import("history.zig");
@@ -136,6 +149,10 @@ const codec = @import("snapshot.zig");
pub const EncodeError = codec.EncodeError;
pub const DecodeError = codec.DecodeError;
pub const DecodeExactError = codec.DecodeExactError;
pub const Continuation = codec.Continuation;
pub const EncodeOptions = codec.EncodeOptions;
pub const DecodeOptions = codec.DecodeOptions;
pub const Decoded = codec.Decoded;
pub const encode = codec.encode;
pub const decode = codec.decode;
pub const decodeExact = codec.decodeExact;

View File

@@ -53,6 +53,9 @@ pub const Tag = enum(u16) {
/// Digest validating the complete snapshot blob.
finish = 6,
/// Canonical unfinished standard TerminalStream input.
continuation = 7,
};
/// The fixed framing that precedes every record payload.
@@ -408,7 +411,7 @@ test "golden PAGE record header and checksum" {
}
test "reject invalid tags" {
for ([_]u16{ 0, 7, std.math.maxInt(u16) }) |tag| {
for ([_]u16{ 0, 8, std.math.maxInt(u16) }) |tag| {
var fixture = [_]u8{0} ** Header.len;
std.mem.writeInt(u16, fixture[0..2], tag, .little);
var reader: std.Io.Reader = .fixed(&fixture);

View File

@@ -8,11 +8,12 @@ doc: |
Ghostty terminal snapshot format version 1.
A complete snapshot contains an envelope, terminal-wide state, one or two
renderable screen sequences, a READY checkpoint, matching history sequences,
and a FINISH checkpoint. SCREEN pages are oldest-to-newest. HISTORY pages are
newest-to-oldest. FINISH terminates the snapshot; bytes that follow belong to
the containing transport and are outside this schema. Each SCREEN declares
its complete logical history extent before READY.
renderable screen sequences, one raw standard-Stream CONTINUATION, a READY
checkpoint, matching history sequences, and a FINISH checkpoint. SCREEN pages
are oldest-to-newest. HISTORY pages are newest-to-oldest. FINISH terminates the
snapshot; bytes that follow belong to the containing transport and are outside
this schema. Each SCREEN declares its complete logical history extent before
READY.
Record CRC32C values and checkpoint BLAKE3-256 digests are represented here
but cannot be calculated by portable Kaitai Struct expressions. The adjacent
@@ -26,6 +27,8 @@ seq:
type: screen_sequence
repeat: expr
repeat-expr: terminal.payload.header.screen_count
- id: continuation
type: continuation_record
- id: ready
type: checkpoint_record(5)
- id: histories
@@ -42,6 +45,7 @@ enums:
4: history
5: ready
6: finish
7: continuation
screen_key:
0: primary
1: alternate
@@ -202,6 +206,16 @@ types:
type: history_payload
size: header.payload_length
continuation_record:
doc: |
Raw canonical standard TerminalStream continuation bytes. An empty
payload explicitly represents ground state.
seq:
- id: header
type: record_header(7)
- id: payload
size: header.payload_length
checkpoint_record:
params:
- id: expected_tag

View File

@@ -4,6 +4,7 @@ const std = @import("std");
const build_options = @import("terminal_options");
const Allocator = std.mem.Allocator;
const checkpoint = @import("checkpoint.zig");
const continuation = @import("continuation.zig");
const envelope = @import("envelope.zig");
const test_fixture = @import("fixture.zig");
const history = @import("history.zig");
@@ -11,20 +12,25 @@ const record = @import("record.zig");
const screen = @import("screen.zig");
const terminal = @import("terminal.zig");
const Terminal = @import("../Terminal.zig");
const TerminalStream = @import("../stream_terminal.zig").Stream;
const terminal_kitty = @import("../kitty.zig");
const TerminalPageList = @import("../PageList.zig");
const TerminalScreen = @import("../Screen.zig");
const TerminalScreenKey = @import("../ScreenSet.zig").Key;
const test_complete_fixture = test_fixture.parse(
@embedFile("testdata/complete-v1.hex"),
);
/// Re-export continuation to make it a bit more ergonomic to reference.
pub const Continuation = continuation.Value;
/// Errors possible while encoding one complete terminal snapshot.
pub const EncodeError = terminal.EncodeError ||
screen.EncodeError ||
history.EncodeError ||
checkpoint.EncodeError;
checkpoint.EncodeError ||
continuation.EncodeError;
pub const EncodeOptions = struct {
continuation: Continuation,
};
/// Encode one complete terminal snapshot.
///
@@ -36,7 +42,11 @@ pub fn encode(
alloc: Allocator,
destination: *std.Io.Writer,
t: *const Terminal,
options: EncodeOptions,
) EncodeError!void {
// Continuation errors must not emit even the snapshot envelope.
try continuation.validate(options.continuation);
var stream: record.Writer = .init(alloc, destination);
defer stream.deinit();
@@ -58,11 +68,13 @@ pub fn encode(
&stream,
);
// 4. Ready checkpoint. In the future we'll put our continuation
// state before this so pty bytes can also flow.
// 4. Standard Stream continuation.
try continuation.encode(options.continuation, &stream);
// 5. Ready checkpoint.
try checkpoint.encode(.ready, &stream);
// 5. History
// 6. History
try history.encode(
t.screens.get(.primary).?,
.primary,
@@ -74,7 +86,7 @@ pub fn encode(
&stream,
);
// 6. Finish
// 7. Finish
try checkpoint.encode(.finish, &stream);
}
@@ -84,6 +96,7 @@ pub const DecodeError = envelope.DecodeError ||
screen.DecodeError ||
history.DecodeError ||
checkpoint.DecodeError ||
continuation.DecodeError ||
error{
/// A SCREEN names a key not declared by TERMINAL.
UnexpectedScreenKey,
@@ -98,11 +111,69 @@ pub const DecodeError = envelope.DecodeError ||
DuplicateHistory,
};
/// Restore one complete snapshot into a native terminal.
pub const DecodeOptions = struct {
/// Largest non-ground continuation the decoder may allocate and return.
/// Set this to zero when only ground-state snapshots are acceptable.
max_continuation_bytes: usize,
};
/// One complete decoded Terminal and the bytes needed to resume its Stream.
///
/// A successful decode owns both values. Keep this result alive until the
/// Terminal's Stream has been restored, and always finish by calling `deinit`.
/// The usual restoration sequence is:
///
/// 1. Inspect `continuation` and use its length to size continuation tracking.
/// 2. Call `toOwned` once and store the returned Terminal at its final address.
/// 3. Create the persistent, read-only standard TerminalStream for that
/// address. If `continuation` contains bytes, feed them exactly once and
/// verify that re-exporting the continuation returns the same bytes.
/// 4. Call `deinit` to release the decoded continuation. The transferred
/// Terminal and its Stream remain owned by the caller.
/// 5. Process PTY bytes that came after the snapshot cut.
///
/// Do not create a Stream against the address of `terminal` in this struct.
/// `toOwned` moves the Terminal, which would leave such a Stream pointing at
/// its old address.
pub const Decoded = struct {
/// Present until `toOwned` transfers the Terminal to the caller. Callers
/// may inspect it, but must transfer it before attaching a persistent
/// TerminalStream.
terminal: ?Terminal,
/// For decoded results, nonempty bytes are allocator-owned and remain
/// valid until `deinit`. Ground needs no replay. Bytes must be replayed
/// exactly once after the Terminal has reached its final address.
continuation: Continuation,
/// Destroy an untransferred Terminal and always free continuation bytes.
/// After `toOwned`, this leaves the caller-owned Terminal untouched.
pub fn deinit(self: *Decoded, alloc: Allocator) void {
if (self.terminal) |*value| value.deinit(alloc);
switch (self.continuation) {
.ground => {},
.bytes => |bytes| alloc.free(bytes),
}
self.* = undefined;
}
/// Transfer the Terminal while retaining the continuation for replay.
///
/// This may be called exactly once. Store the returned value directly at
/// its final address before creating the TerminalStream that will replay
/// `continuation`.
pub fn toOwned(self: *Decoded) Terminal {
const result = self.terminal.?;
self.terminal = null;
return result;
}
};
/// Restore one complete snapshot into a native Terminal and continuation.
///
/// This consumes one snapshot through FINISH and leaves any following bytes in
/// the reader for the containing transport. Restoration is transactional: the
/// returned terminal is either complete and ready or not (error return).
/// returned result is either complete and ready or not (error return).
/// Individual record codecs normalize optional semantic state, while framing,
/// checkpoints, declared sequence counts, and unique cross-record screen
/// routing remain strict.
@@ -110,7 +181,8 @@ pub fn decode(
alloc: Allocator,
io_: std.Io,
source: *std.Io.Reader,
) DecodeError!Terminal {
options: DecodeOptions,
) DecodeError!Decoded {
// StreamReader owns a zero-buffer hashing adapter, making checkpoint
// boundaries part of its API rather than a caller-maintained invariant.
var stream: record.StreamReader = .init(source);
@@ -128,7 +200,7 @@ pub fn decode(
// 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 screen_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;
@@ -151,7 +223,7 @@ pub fn decode(
reader,
io_,
alloc,
options,
screen_options,
);
errdefer decoded.deinit();
@@ -174,8 +246,20 @@ pub fn decode(
);
}
// READY covers the exact envelope-through-SCREEN prefix. Finalizing does
// not consume the hasher, so the same stream continues toward FINISH.
// CONTINUATION is required after every active screen. Nonempty bytes are
// owned locally until the complete snapshot validates.
const decoded_continuation = try continuation.decode(
alloc,
reader,
options.max_continuation_bytes,
);
errdefer switch (decoded_continuation) {
.ground => {},
.bytes => |bytes| alloc.free(bytes),
};
// READY covers the exact envelope-through-CONTINUATION prefix. Finalizing
// does not consume the hasher, so the stream continues toward FINISH.
try checkpoint.decode(.ready, &stream);
// HISTORY keys make this sequence order-independent just like SCREEN.
@@ -216,7 +300,10 @@ pub fn decode(
// groups. The completed terminal has not escaped yet, so reset them to the
// same initial state as any newly constructed ScreenSet.
for (keys) |key| result.screens.generations.put(key, 0);
return result;
return .{
.terminal = result,
.continuation = decoded_continuation,
};
}
/// Errors possible while restoring a snapshot that must end at end-of-file.
@@ -233,8 +320,9 @@ pub fn decodeExact(
alloc: Allocator,
io_: std.Io,
source: *std.Io.Reader,
) DecodeExactError!Terminal {
var result = try decode(alloc, io_, source);
options: DecodeOptions,
) DecodeExactError!Decoded {
var result = try decode(alloc, io_, source, options);
errdefer result.deinit(alloc);
_ = source.peekByte() catch |err| switch (err) {
@@ -244,6 +332,10 @@ pub fn decodeExact(
return error.TrailingData;
}
const test_encode_options: EncodeOptions = .{ .continuation = .ground };
const test_decode_options: DecodeOptions = .{ .max_continuation_bytes = 1024 };
const test_complete_fixture = test_fixture.parse(@embedFile("testdata/complete-v1.hex"));
test "complete snapshot round trip with history and alternate screen" {
const testing = std.testing;
@@ -330,7 +422,7 @@ test "complete snapshot round trip with history and alternate screen" {
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
try encode(testing.allocator, &encoded.writer, &t);
try encode(testing.allocator, &encoded.writer, &t, test_encode_options);
try testing.expectEqualDeep(source_memory, primary.pages.memoryStats());
try test_fixture.expectEqual(
.snapshot,
@@ -348,7 +440,7 @@ test "complete snapshot round trip with history and alternate screen" {
std.crypto.hash.Blake3.init(.{}),
&.{},
);
try encode(testing.allocator, &hashing.writer, &t);
try encode(testing.allocator, &hashing.writer, &t, test_encode_options);
try testing.expectEqual(
@as(u64, test_complete_fixture.len),
discard.fullCount(),
@@ -371,32 +463,42 @@ test "complete snapshot round trip with history and alternate screen" {
testing.allocator,
testing.io,
&limited.interface,
test_decode_options,
);
defer restored.deinit(testing.allocator);
const restored_terminal = &restored.terminal.?;
try testing.expectEqual(TerminalScreenKey.alternate, restored.screens.active_key);
try testing.expectEqual(
restored.screens.get(.alternate).?,
restored.screens.active,
TerminalScreenKey.alternate,
restored_terminal.screens.active_key,
);
try testing.expectEqual(
restored_terminal.screens.get(.alternate).?,
restored_terminal.screens.active,
);
try testing.expectEqualStrings(
"file:///tmp/snapshot",
restored.getPwd().?,
restored_terminal.getPwd().?,
);
try testing.expectEqualStrings(
"complete snapshot",
restored.getTitle().?,
restored_terminal.getTitle().?,
);
try testing.expectEqual(
primary.pages.scrollbar().total,
restored.screens.get(.primary).?.pages.scrollbar().total,
restored_terminal.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(testing.allocator, &reencoded.writer, &restored);
try encode(
testing.allocator,
&reencoded.writer,
restored_terminal,
test_encode_options,
);
try testing.expectEqualStrings(
&test_complete_fixture,
reencoded.written(),
@@ -418,6 +520,7 @@ test "complete snapshot round trip with history and alternate screen" {
&reversed_stream,
);
try screen.encode(primary, .primary, &reversed_stream);
try continuation.encode(.ground, &reversed_stream);
try checkpoint.encode(.ready, &reversed_stream);
try history.encode(
t.screens.get(.alternate).?,
@@ -432,22 +535,302 @@ test "complete snapshot round trip with history and alternate screen" {
testing.allocator,
testing.io,
&reversed_source,
test_decode_options,
);
defer reversed_restored.deinit(testing.allocator);
const reversed_terminal = &reversed_restored.terminal.?;
try testing.expectEqual(
TerminalScreenKey.alternate,
reversed_restored.screens.active_key,
reversed_terminal.screens.active_key,
);
try testing.expectEqual(
@as(usize, 0),
reversed_restored.screens.generation(.primary),
reversed_terminal.screens.generation(.primary),
);
try testing.expectEqual(
@as(usize, 0),
reversed_restored.screens.generation(.alternate),
reversed_terminal.screens.generation(.alternate),
);
}
test "complete snapshot restores a canonical Stream continuation" {
const testing = std.testing;
var source_terminal = try Terminal.init(
testing.io,
testing.allocator,
.{ .cols = 8, .rows = 2 },
);
defer source_terminal.deinit(testing.allocator);
var source_stream = TerminalStream.init(.{
.allocator = testing.allocator,
.handler = .init(&source_terminal),
.continuation_max_bytes = 1024,
});
defer source_stream.deinit();
source_stream.nextSlice("A\x1b[31");
var exported_bytes: [1024]u8 = undefined;
var exported: std.Io.Writer = .fixed(&exported_bytes);
try source_stream.writeContinuation(&exported);
try testing.expectEqualStrings("\x1b[31", exported.buffered());
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
try encode(testing.allocator, &encoded.writer, &source_terminal, .{
.continuation = .{ .bytes = exported.buffered() },
});
var encoded_source: std.Io.Reader = .fixed(encoded.written());
var decoded = try decode(
testing.allocator,
testing.io,
&encoded_source,
.{ .max_continuation_bytes = 1024 },
);
defer decoded.deinit(testing.allocator);
try testing.expectEqualStrings(
exported.buffered(),
decoded.continuation.bytes,
);
var restored_terminal = decoded.toOwned();
defer restored_terminal.deinit(testing.allocator);
try testing.expect(decoded.terminal == null);
var restored_stream = TerminalStream.init(.{
.allocator = testing.allocator,
.handler = .init(&restored_terminal),
.continuation_max_bytes = 1024,
});
defer restored_stream.deinit();
restored_stream.nextSlice(decoded.continuation.bytes);
var reexported_bytes: [1024]u8 = undefined;
var reexported: std.Io.Writer = .fixed(&reexported_bytes);
try restored_stream.writeContinuation(&reexported);
try testing.expectEqualStrings(exported.buffered(), reexported.buffered());
// The identical post-cut bytes may use different feed chunking without
// changing terminal semantics.
source_stream.nextSlice("mB");
restored_stream.next('m');
restored_stream.next('B');
const source_text = try source_terminal.plainString(testing.allocator);
defer testing.allocator.free(source_text);
const restored_text = try restored_terminal.plainString(testing.allocator);
defer testing.allocator.free(restored_text);
try testing.expectEqualStrings(source_text, restored_text);
try testing.expectEqual(
source_terminal.screens.active.cursor.style_id,
restored_terminal.screens.active.cursor.style_id,
);
}
test "complete snapshot validates continuation before writing" {
const testing = std.testing;
var t = try Terminal.init(
testing.io,
testing.allocator,
.{ .cols = 2, .rows = 1 },
);
defer t.deinit(testing.allocator);
var destination: std.Io.Writer.Allocating = .init(testing.allocator);
defer destination.deinit();
try destination.writer.writeAll("prefix");
try testing.expectError(
error.NoPendingState,
encode(testing.allocator, &destination.writer, &t, .{
.continuation = .{ .bytes = "\x1b[31m" },
}),
);
try testing.expectEqualStrings("prefix", destination.written());
var tail = [_]u8{ 0x1b, '[', '3', '1' };
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
try encode(testing.allocator, &encoded.writer, &t, .{
.continuation = .{ .bytes = &tail },
});
tail[2] = '4';
var source: std.Io.Reader = .fixed(encoded.written());
var decoded = try decode(
testing.allocator,
testing.io,
&source,
test_decode_options,
);
defer decoded.deinit(testing.allocator);
try testing.expectEqualStrings("\x1b[31", decoded.continuation.bytes);
}
test "complete snapshot waits for continuation tracking recovery" {
const testing = std.testing;
var t = try Terminal.init(
testing.io,
testing.allocator,
.{ .cols = 2, .rows = 1 },
);
defer t.deinit(testing.allocator);
var stream = TerminalStream.init(.{
.allocator = testing.allocator,
.handler = .init(&t),
.continuation_max_bytes = 4,
});
defer stream.deinit();
stream.nextSlice("\x1b[123");
var unavailable_bytes: [4]u8 = undefined;
var unavailable: std.Io.Writer = .fixed(&unavailable_bytes);
try testing.expectError(
error.ContinuationUnavailable,
stream.writeContinuation(&unavailable),
);
// A new replay start replaces the lost suffix and makes a later cut
// publishable without affecting normal terminal parsing.
stream.nextSlice("\x1b[");
var recovered_bytes: [4]u8 = undefined;
var recovered: std.Io.Writer = .fixed(&recovered_bytes);
try stream.writeContinuation(&recovered);
try testing.expectEqualStrings("\x1b[", recovered.buffered());
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
try encode(testing.allocator, &encoded.writer, &t, .{
.continuation = .{ .bytes = recovered.buffered() },
});
}
test "complete snapshot preserves every supported continuation cut" {
const testing = std.testing;
const corpora = [_][]const u8{
"plain \xF0\x9F\x98\x84 utf8",
"bad \xE0\xA0\xF0\x9F\x98\x84 utf8",
"\x1b[1\x07;2mstyled\x1b[0m",
"\x1b]2;window title\x1b\\text",
"\x1bP$qm\x1b\\text",
"\x1b_Ga=q;payload\x1b\\text",
"\x1b_25a1;s\x1b\\text",
"\x1b]2;first\x1b\\\x1b_Gsecond",
"\x1b[12\x9D2;title\x1b\\text",
"\x1b[12\x18text\x1b[1\x1Atext",
};
for (corpora) |corpus| for (0..corpus.len + 1) |cut| {
var source_terminal = try Terminal.init(
testing.io,
testing.allocator,
.{ .cols = 20, .rows = 4 },
);
defer source_terminal.deinit(testing.allocator);
var source_stream = TerminalStream.init(.{
.allocator = testing.allocator,
.handler = .init(&source_terminal),
.continuation_max_bytes = 4096,
});
defer source_stream.deinit();
source_stream.nextSlice(corpus[0..cut]);
var cut_bytes: [4096]u8 = undefined;
var cut_writer: std.Io.Writer = .fixed(&cut_bytes);
try source_stream.writeContinuation(&cut_writer);
const cut_continuation: Continuation = if (cut_writer.end == 0)
.ground
else
.{ .bytes = cut_writer.buffered() };
var snapshot_bytes: std.Io.Writer.Allocating = .init(testing.allocator);
defer snapshot_bytes.deinit();
try encode(
testing.allocator,
&snapshot_bytes.writer,
&source_terminal,
.{ .continuation = cut_continuation },
);
var snapshot_source: std.Io.Reader = .fixed(snapshot_bytes.written());
var decoded = try decode(
testing.allocator,
testing.io,
&snapshot_source,
.{ .max_continuation_bytes = 4096 },
);
defer decoded.deinit(testing.allocator);
var restored_terminal = decoded.toOwned();
defer restored_terminal.deinit(testing.allocator);
var restored_stream = TerminalStream.init(.{
.allocator = testing.allocator,
.handler = .init(&restored_terminal),
.continuation_max_bytes = 4096,
});
defer restored_stream.deinit();
switch (decoded.continuation) {
.ground => {},
.bytes => |bytes| restored_stream.nextSlice(bytes),
}
var reexport_bytes: [4096]u8 = undefined;
var reexport_writer: std.Io.Writer = .fixed(&reexport_bytes);
try restored_stream.writeContinuation(&reexport_writer);
try testing.expectEqualStrings(
cut_writer.buffered(),
reexport_writer.buffered(),
);
source_stream.nextSlice(corpus[cut..]);
var offset = cut;
var partition = cut +% corpus.len +% 1;
while (offset < corpus.len) {
partition = partition *% 1664525 +% 1013904223;
const len = @min(1 + partition % 7, corpus.len - offset);
restored_stream.nextSlice(corpus[offset..][0..len]);
offset += len;
}
var source_final_bytes: [4096]u8 = undefined;
var source_final_writer: std.Io.Writer = .fixed(&source_final_bytes);
try source_stream.writeContinuation(&source_final_writer);
var restored_final_bytes: [4096]u8 = undefined;
var restored_final_writer: std.Io.Writer = .fixed(&restored_final_bytes);
try restored_stream.writeContinuation(&restored_final_writer);
try testing.expectEqualStrings(
source_final_writer.buffered(),
restored_final_writer.buffered(),
);
const final_continuation: Continuation = if (source_final_writer.end == 0)
.ground
else
.{ .bytes = source_final_writer.buffered() };
var source_final_snapshot: std.Io.Writer.Allocating = .init(
testing.allocator,
);
defer source_final_snapshot.deinit();
try encode(
testing.allocator,
&source_final_snapshot.writer,
&source_terminal,
.{ .continuation = final_continuation },
);
var restored_final_snapshot: std.Io.Writer.Allocating = .init(
testing.allocator,
);
defer restored_final_snapshot.deinit();
try encode(
testing.allocator,
&restored_final_snapshot.writer,
&restored_terminal,
.{ .continuation = final_continuation },
);
try testing.expectEqualStrings(
source_final_snapshot.written(),
restored_final_snapshot.written(),
);
};
}
test "complete snapshot preserves Kitty virtual placeholders" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
@@ -491,17 +874,19 @@ test "complete snapshot preserves Kitty virtual placeholders" {
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
try encode(testing.allocator, &encoded.writer, &t);
try encode(testing.allocator, &encoded.writer, &t, test_encode_options);
var encoded_source: std.Io.Reader = .fixed(encoded.written());
var restored = try decode(
testing.allocator,
testing.io,
&encoded_source,
test_decode_options,
);
defer restored.deinit(testing.allocator);
const restored_terminal = &restored.terminal.?;
const restored_cell = restored.screens.active.pages.getCell(.{
const restored_cell = restored_terminal.screens.active.pages.getCell(.{
.screen = .{},
}).?;
try testing.expectEqual(
@@ -512,11 +897,11 @@ test "complete snapshot preserves Kitty virtual placeholders" {
try testing.expect(restored_cell.row.kitty_virtual_placeholder);
try testing.expectEqual(
@as(usize, 0),
restored.screens.active.kitty_images.images.count(),
restored_terminal.screens.active.kitty_images.images.count(),
);
try testing.expectEqual(
@as(usize, 0),
restored.screens.active.kitty_images.placements.count(),
restored_terminal.screens.active.kitty_images.placements.count(),
);
}
@@ -535,7 +920,7 @@ test "complete snapshot encoding streams from the current writer position" {
defer nonempty.deinit();
try nonempty.writer.writeAll("prefix");
const snapshot_offset = nonempty.written().len;
try encode(testing.allocator, &nonempty.writer, &t);
try encode(testing.allocator, &nonempty.writer, &t, test_encode_options);
try testing.expectEqualStrings(
"prefix",
nonempty.written()[0..snapshot_offset],
@@ -547,6 +932,7 @@ test "complete snapshot encoding streams from the current writer position" {
testing.allocator,
testing.io,
&appended_source,
test_decode_options,
);
appended.deinit(testing.allocator);
@@ -558,7 +944,12 @@ test "complete snapshot encoding streams from the current writer position" {
try destination.writer.writeAll("prefix");
try testing.expectError(
error.InvalidPalette,
encode(testing.allocator, &destination.writer, &t),
encode(
testing.allocator,
&destination.writer,
&t,
test_encode_options,
),
);
var expected_envelope: [envelope.encoded_len]u8 = undefined;
var envelope_writer: std.Io.Writer = .fixed(&expected_envelope);
@@ -579,6 +970,58 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
defer t.deinit(testing.allocator);
const primary = t.screens.get(.primary).?;
// Old version-1 snapshots proceeded directly from SCREEN to READY. READY
// is individually valid here, but CONTINUATION is now required first.
var old_order: std.Io.Writer.Allocating = .init(testing.allocator);
defer old_order.deinit();
var old_order_stream: record.Writer = .init(
testing.allocator,
&old_order.writer,
);
defer old_order_stream.deinit();
try envelope.encode(old_order_stream.writer());
try terminal.encode(&t, &old_order_stream);
try screen.encode(primary, .primary, &old_order_stream);
try checkpoint.encode(.ready, &old_order_stream);
var old_order_source: std.Io.Reader = .fixed(old_order.written());
try testing.expectError(
error.UnexpectedRecordTag,
decode(
testing.allocator,
testing.io,
&old_order_source,
test_decode_options,
),
);
// A second CONTINUATION is rejected where READY is required.
var duplicate_continuation: std.Io.Writer.Allocating = .init(
testing.allocator,
);
defer duplicate_continuation.deinit();
var duplicate_continuation_stream: record.Writer = .init(
testing.allocator,
&duplicate_continuation.writer,
);
defer duplicate_continuation_stream.deinit();
try envelope.encode(duplicate_continuation_stream.writer());
try terminal.encode(&t, &duplicate_continuation_stream);
try screen.encode(primary, .primary, &duplicate_continuation_stream);
try continuation.encode(.ground, &duplicate_continuation_stream);
try continuation.encode(.ground, &duplicate_continuation_stream);
var duplicate_continuation_source: std.Io.Reader = .fixed(
duplicate_continuation.written(),
);
try testing.expectError(
error.UnexpectedRecordTag,
decode(
testing.allocator,
testing.io,
&duplicate_continuation_source,
test_decode_options,
),
);
// HISTORY is individually valid here, but the full decoder requires the
// primary SCREEN before READY.
var reordered: std.Io.Writer.Allocating = .init(testing.allocator);
@@ -594,7 +1037,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
var reordered_source: std.Io.Reader = .fixed(reordered.written());
try testing.expectError(
error.UnexpectedRecordTag,
decode(testing.allocator, testing.io, &reordered_source),
decode(
testing.allocator,
testing.io,
&reordered_source,
test_decode_options,
),
);
// Construct a correctly framed READY with an intentionally unrelated
@@ -609,6 +1057,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
try envelope.encode(invalid_ready_stream.writer());
try terminal.encode(&t, &invalid_ready_stream);
try screen.encode(primary, .primary, &invalid_ready_stream);
try continuation.encode(.ground, &invalid_ready_stream);
const ready_payload = invalid_ready_stream.begin(.ready);
errdefer invalid_ready_stream.cancel();
try ready_payload.splatByteAll(
@@ -621,7 +1070,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
);
try testing.expectError(
error.InvalidDigest,
decode(testing.allocator, testing.io, &invalid_ready_source),
decode(
testing.allocator,
testing.io,
&invalid_ready_source,
test_decode_options,
),
);
// A SCREEN key must name one of the slots declared by TERMINAL.
@@ -638,7 +1092,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
var undeclared_source: std.Io.Reader = .fixed(undeclared.written());
try testing.expectError(
error.UnexpectedScreenKey,
decode(testing.allocator, testing.io, &undeclared_source),
decode(
testing.allocator,
testing.io,
&undeclared_source,
test_decode_options,
),
);
// HISTORY sequences are also routed by key, which must name a declared
@@ -653,6 +1112,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
try envelope.encode(undeclared_history_stream.writer());
try terminal.encode(&t, &undeclared_history_stream);
try screen.encode(primary, .primary, &undeclared_history_stream);
try continuation.encode(.ground, &undeclared_history_stream);
try checkpoint.encode(.ready, &undeclared_history_stream);
try history.encode(primary, .alternate, &undeclared_history_stream);
var undeclared_history_source: std.Io.Reader = .fixed(
@@ -660,7 +1120,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
);
try testing.expectError(
error.UnexpectedHistoryKey,
decode(testing.allocator, testing.io, &undeclared_history_source),
decode(
testing.allocator,
testing.io,
&undeclared_history_source,
test_decode_options,
),
);
// The declared count cannot be satisfied by repeating the same key.
@@ -679,7 +1144,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
var duplicate_source: std.Io.Reader = .fixed(duplicate.written());
try testing.expectError(
error.DuplicateScreen,
decode(testing.allocator, testing.io, &duplicate_source),
decode(
testing.allocator,
testing.io,
&duplicate_source,
test_decode_options,
),
);
// The declared count cannot be satisfied by repeating one HISTORY key.
@@ -698,6 +1168,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
.alternate,
&duplicate_history_stream,
);
try continuation.encode(.ground, &duplicate_history_stream);
try checkpoint.encode(.ready, &duplicate_history_stream);
try history.encode(primary, .primary, &duplicate_history_stream);
try history.encode(primary, .primary, &duplicate_history_stream);
@@ -706,7 +1177,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" {
);
try testing.expectError(
error.DuplicateHistory,
decode(testing.allocator, testing.io, &duplicate_history_source),
decode(
testing.allocator,
testing.io,
&duplicate_history_source,
test_decode_options,
),
);
}
@@ -721,22 +1197,32 @@ test "complete snapshot leaves continuation bytes unread" {
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
try encode(testing.allocator, &encoded.writer, &t);
try encode(testing.allocator, &encoded.writer, &t, test_encode_options);
const snapshot_len = encoded.written().len;
try encoded.writer.writeAll("pty");
var source: std.Io.Reader = .fixed(encoded.written());
var restored = try decode(testing.allocator, testing.io, &source);
var restored = try decode(
testing.allocator,
testing.io,
&source,
test_decode_options,
);
defer restored.deinit(testing.allocator);
var continuation: [3]u8 = undefined;
try source.readSliceAll(&continuation);
try testing.expectEqualStrings("pty", &continuation);
var trailing: [3]u8 = undefined;
try source.readSliceAll(&trailing);
try testing.expectEqualStrings("pty", &trailing);
var exact_source: std.Io.Reader = .fixed(encoded.written());
try testing.expectError(
error.TrailingData,
decodeExact(testing.allocator, testing.io, &exact_source),
decodeExact(
testing.allocator,
testing.io,
&exact_source,
test_decode_options,
),
);
var bounded_source: std.Io.Reader = .fixed(
@@ -746,6 +1232,7 @@ test "complete snapshot leaves continuation bytes unread" {
testing.allocator,
testing.io,
&bounded_source,
test_decode_options,
);
defer bounded.deinit(testing.allocator);
}
@@ -761,13 +1248,84 @@ test "complete snapshots decode sequentially from one reader" {
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
try encode(testing.allocator, &encoded.writer, &t);
try encode(testing.allocator, &encoded.writer, &t);
try encode(testing.allocator, &encoded.writer, &t, test_encode_options);
try encode(testing.allocator, &encoded.writer, &t, test_encode_options);
var source: std.Io.Reader = .fixed(encoded.written());
var first = try decode(testing.allocator, testing.io, &source);
var first = try decode(
testing.allocator,
testing.io,
&source,
test_decode_options,
);
defer first.deinit(testing.allocator);
var second = try decode(testing.allocator, testing.io, &source);
var second = try decode(
testing.allocator,
testing.io,
&source,
test_decode_options,
);
defer second.deinit(testing.allocator);
try testing.expectError(error.EndOfStream, source.takeByte());
}
test "complete snapshot decode allocation failures are transactional" {
const testing = std.testing;
const S = struct {
fn exercise(bytes: []const u8) !void {
var baseline = testing.FailingAllocator.init(testing.allocator, .{
.fail_index = std.math.maxInt(usize),
});
var baseline_source: std.Io.Reader = .fixed(bytes);
var baseline_decoded = try decode(
baseline.allocator(),
testing.io,
&baseline_source,
test_decode_options,
);
baseline_decoded.deinit(baseline.allocator());
const allocation_count = baseline.alloc_index;
try testing.expect(allocation_count > 0);
var saw_out_of_memory = false;
for (0..allocation_count) |fail_index| {
var failing = testing.FailingAllocator.init(
testing.allocator,
.{ .fail_index = fail_index },
);
var source: std.Io.Reader = .fixed(bytes);
var decoded = decode(
failing.allocator(),
testing.io,
&source,
test_decode_options,
) catch |err| switch (err) {
error.OutOfMemory => {
saw_out_of_memory = true;
continue;
},
else => return err,
};
decoded.deinit(failing.allocator());
}
try testing.expect(saw_out_of_memory);
}
};
// The complete golden exercises Terminal, both screens, and history.
try S.exercise(&test_complete_fixture);
// A non-ground component additionally exercises owned continuation bytes.
var t = try Terminal.init(
testing.io,
testing.allocator,
.{ .cols = 2, .rows = 1 },
);
defer t.deinit(testing.allocator);
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
try encode(testing.allocator, &encoded.writer, &t, .{
.continuation = .{ .bytes = "\x1b[31" },
});
try S.exercise(encoded.written());
}

View File

@@ -106,34 +106,37 @@ ee ee 80 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000037a
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000053d
00 # 0x0000054d
# offset 0x0000054e: ready record, payload 32 bytes
05 00 20 00 00 00 43 0d 7b 3a 84 06 56 ba 02 59 # 0x0000054e
04 24 20 59 96 92 14 ba 76 53 80 40 09 12 85 a7 # 0x0000055e
f2 b5 b8 f9 63 98 e8 dc 9a 39 # 0x0000056e
# offset 0x0000054e: continuation record, payload 0 bytes
07 00 00 00 00 00 27 80 63 d1 # 0x0000054e
# offset 0x00000578: history record, payload 6 bytes
04 00 06 00 00 00 20 32 ed e1 00 00 02 00 00 00 # 0x00000578
# offset 0x00000558: ready record, payload 32 bytes
05 00 20 00 00 00 d4 d1 fc d0 8d 69 76 c1 6e c6 # 0x00000558
88 21 bd 10 97 41 97 35 63 2d ce 9e 3c b8 fa cf # 0x00000568
57 04 f5 4b e0 70 66 5e 7e b6 # 0x00000578
# offset 0x00000588: page record, payload 86 bytes
03 00 56 00 00 00 52 3b 6e 8d 02 00 02 00 00 00 # 0x00000588
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000598
00 00 00 00 00 00 00 42 00 00 00 00 00 00 00 00 # 0x000005a8
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005b8
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005c8
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005d8
# offset 0x00000582: history record, payload 6 bytes
04 00 06 00 00 00 20 32 ed e1 00 00 02 00 00 00 # 0x00000582
# offset 0x000005e8: page record, payload 86 bytes
03 00 56 00 00 00 fb bc 15 06 02 00 02 00 00 00 # 0x000005e8
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x000005f8
00 00 00 00 00 00 00 41 00 00 00 00 00 00 00 00 # 0x00000608
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000618
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000628
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000638
# offset 0x00000592: page record, payload 86 bytes
03 00 56 00 00 00 52 3b 6e 8d 02 00 02 00 00 00 # 0x00000592
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x000005a2
00 00 00 00 00 00 00 42 00 00 00 00 00 00 00 00 # 0x000005b2
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005c2
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005d2
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005e2
# offset 0x00000648: history record, payload 6 bytes
04 00 06 00 00 00 b8 7a ba b1 01 00 00 00 00 00 # 0x00000648
# offset 0x000005f2: page record, payload 86 bytes
03 00 56 00 00 00 fb bc 15 06 02 00 02 00 00 00 # 0x000005f2
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000602
00 00 00 00 00 00 00 41 00 00 00 00 00 00 00 00 # 0x00000612
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000622
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000632
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000642
# offset 0x00000658: finish record, payload 32 bytes
06 00 20 00 00 00 8d d6 46 7a ac 02 c8 09 9d 1a # 0x00000658
5a 00 ce 47 5e d7 b0 ae 54 10 bb 0d f1 35 7c ab # 0x00000668
92 fd ae d3 5e 1a 77 1e 40 7f # 0x00000678
# offset 0x00000652: history record, payload 6 bytes
04 00 06 00 00 00 b8 7a ba b1 01 00 00 00 00 00 # 0x00000652
# offset 0x00000662: finish record, payload 32 bytes
06 00 20 00 00 00 f3 a4 cf b6 f2 2b 17 88 1a 28 # 0x00000662
a7 27 6e f7 de bc 7a 52 f9 a4 f9 c3 32 5b e0 09 # 0x00000672
8a 1a 7d a0 9f 8b 32 31 67 41 # 0x00000682

View File

@@ -0,0 +1,8 @@
# Ghostty snapshot fixture
# Kaitai type: continuation_record
# Kaitai params:
# Kaitai offset: 0
# Wire version: 1
# Incomplete Kitty APC payload.
07 00 07 00 00 00 bd ff a8 ff 1b 5f 47 64 61 74 61

View File

@@ -0,0 +1,8 @@
# Ghostty snapshot fixture
# Kaitai type: continuation_record
# Kaitai params:
# Kaitai offset: 0
# Wire version: 1
# Incomplete SGR CSI.
07 00 04 00 00 00 30 d5 20 17 1b 5b 33 31

View File

@@ -0,0 +1,8 @@
# Ghostty snapshot fixture
# Kaitai type: continuation_record
# Kaitai params:
# Kaitai offset: 0
# Wire version: 1
# Incomplete supported DCS payload.
07 00 07 00 00 00 63 ae 0a 1d 1b 50 71 64 61 74 61

View File

@@ -0,0 +1,8 @@
# Ghostty snapshot fixture
# Kaitai type: continuation_record
# Kaitai params:
# Kaitai offset: 0
# Wire version: 1
# Bare ESC leaves the VT parser unfinished.
07 00 01 00 00 00 1c d9 1d 17 1b

View File

@@ -0,0 +1,8 @@
# Ghostty snapshot fixture
# Kaitai type: continuation_record
# Kaitai params:
# Kaitai offset: 0
# Wire version: 1
# Empty payload is the explicit ground-state assertion.
07 00 00 00 00 00 27 80 63 d1

View File

@@ -0,0 +1,8 @@
# Ghostty snapshot fixture
# Kaitai type: continuation_record
# Kaitai params:
# Kaitai offset: 0
# Wire version: 1
# Incomplete title OSC.
07 00 09 00 00 00 39 7d 6e 08 1b 5d 32 3b 74 69 74 6c 65

View File

@@ -0,0 +1,8 @@
# Ghostty snapshot fixture
# Kaitai type: continuation_record
# Kaitai params:
# Kaitai offset: 0
# Wire version: 1
# Partial four-byte UTF-8 codepoint.
07 00 03 00 00 00 b8 07 2a 22 f0 9f 98

View File

@@ -159,7 +159,7 @@ def crc32c(data: bytes) -> int:
def validate_record(record: Any, data: bytes, offset: int) -> int:
"""Validate one parsed record's source bytes and CRC32C."""
payload = record._raw_payload
payload = getattr(record, "_raw_payload", record.payload)
header = record.header
if header.payload_length != len(payload):
raise ValueError(
@@ -194,6 +194,7 @@ def all_snapshot_records(snapshot: Any) -> list[Any]:
for sequence in snapshot.screens:
records.append(sequence.screen)
records.extend(sequence.pages)
records.append(snapshot.continuation)
records.append(snapshot.ready)
for sequence in snapshot.histories:
records.append(sequence.history)

View File

@@ -4,6 +4,72 @@ const Allocator = std.mem.Allocator;
const Parser = @import("Parser.zig");
const UTF8Decoder = @import("UTF8Decoder.zig");
/// Errors possible while validating a snapshot continuation.
pub const ValidateError = error{
/// Nonempty input left both the VT parser and UTF-8 decoder at ground.
///
/// A continuation exists only to reconstruct state that was unfinished at
/// the snapshot cut. Input that returns to ground is a complete PTY
/// fragment, not a continuation. Replaying it would repeat work already
/// represented by the Terminal snapshot. For example, `ESC [ 3 1 m`
/// completes an SGR command and must not be stored as a continuation.
NoPendingState,
/// The input does not begin at its effective replay start.
///
/// A later ESC supersedes earlier VT parser state, and a pending UTF-8
/// codepoint begins at its lead byte. Any prefix before that effective
/// start is unnecessary and would disappear when the restored Stream
/// exports its continuation again. Rejecting the prefix preserves the
/// byte-identical re-export invariant and prevents unrelated prior input
/// from being replayed.
NonCanonicalContinuation,
/// Replaying the input would perform handler-visible work.
///
/// The Terminal snapshot already contains every mutation committed before
/// its capture cut. A continuation may rebuild unfinished parser or
/// builder state, but it must not mutate the Terminal or repeat an external
/// effect while doing so. For example, BEL inside an unfinished CSI would
/// ring again even though the CSI itself remains pending.
ReplayWouldCommit,
};
/// Validate continuation bytes.
///
/// Ensure that:
///
/// - replay ends with either VT or UTF-8 state unfinished
/// - byte zero is the effective start needed to reconstruct that state
/// - replay commits no Terminal mutation or external handler effect
pub fn validate(bytes: []const u8) ValidateError!void {
// Empty explicitly requests ground state and needs no replay.
if (bytes.len == 0) return;
// We need the final parser and decoder states to classify the input, so a
// committed byte cannot return early. Remember it while scanning the rest.
var scanner: BoundaryScanner = .init();
var committed_work = false;
for (bytes) |byte| {
if (scanner.next(byte) != .uncommitted) committed_work = true;
}
// Nonempty continuation bytes must leave state that future input needs.
if (scanner.ground()) return error.NoPendingState;
// The tracker exports the minimal suffix beginning at the effective replay
// start. A different prefix would not survive byte-identical re-export.
const replay_start = if (scanner.parser.state != .ground)
findVTReplayStart(bytes)
else
findUtf8ReplayStart(bytes);
if (replay_start != 0) return error.NonCanonicalContinuation;
// At this point the input is unfinished and minimal, but replay must also
// be inert with respect to the already-restored Terminal and its effects.
if (committed_work) return error.ReplayWouldCommit;
}
/// Retains the input needed to reconstruct unfinished Stream parser state.
///
/// A feed is one chunk of bytes given to a Stream. It can end in the middle of