mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 20:59:02 +00:00
terminal/snapshot: snapshot robustness
Route HISTORY sequences by their encoded screen key so both keyed sequence groups can arrive in either order. Keep undeclared and duplicate routing strict, separate HISTORY manifest parsing from page restoration, and clear decoder-only generation state before returning the terminal.
This commit is contained in:
@@ -223,23 +223,88 @@ pub fn encode(
|
||||
}
|
||||
|
||||
/// Errors possible while restoring one HISTORY and its PAGE sequence.
|
||||
pub const DecodeError = Allocator.Error ||
|
||||
Header.DecodeError ||
|
||||
page.DecodeError ||
|
||||
record.Reader.InitError ||
|
||||
record.Reader.FinishError ||
|
||||
TerminalPageList.PageAllocation.FinalizeError ||
|
||||
pub const DecodeError = Decoder.InitError ||
|
||||
Decoder.RestoreError ||
|
||||
error{
|
||||
/// The next record is valid but is not a HISTORY.
|
||||
UnexpectedRecordTag,
|
||||
|
||||
/// The HISTORY key does not match the caller-selected screen.
|
||||
UnexpectedScreenKey,
|
||||
|
||||
/// The Screen already contains complete pages before its active page.
|
||||
ExistingHistory,
|
||||
};
|
||||
|
||||
/// A decoded HISTORY manifest ready to restore its following PAGE records.
|
||||
///
|
||||
/// Keeping manifest decoding separate lets the full snapshot wrapper route a
|
||||
/// HISTORY sequence by its encoded key before any pages mutate a Screen.
|
||||
pub const Decoder = struct {
|
||||
source: *std.Io.Reader,
|
||||
header: Header,
|
||||
|
||||
pub const InitError = Header.DecodeError ||
|
||||
record.Reader.InitError ||
|
||||
record.Reader.FinishError ||
|
||||
error{
|
||||
/// The next record is valid but is not a HISTORY.
|
||||
UnexpectedRecordTag,
|
||||
};
|
||||
|
||||
pub const RestoreError = Allocator.Error ||
|
||||
page.DecodeError ||
|
||||
TerminalPageList.PageAllocation.FinalizeError ||
|
||||
error{
|
||||
/// The Screen already contains complete pages before its active page.
|
||||
ExistingHistory,
|
||||
};
|
||||
|
||||
/// Decode and finish the self-contained HISTORY manifest.
|
||||
pub fn init(self: *Decoder, source: *std.Io.Reader) InitError!void {
|
||||
var record_reader: record.Reader = undefined;
|
||||
try record_reader.init(source);
|
||||
if (record_reader.header.tag != .history) {
|
||||
return error.UnexpectedRecordTag;
|
||||
}
|
||||
const header = try Header.decode(record_reader.payloadReader());
|
||||
try record_reader.finish();
|
||||
self.* = .{ .source = source, .header = header };
|
||||
}
|
||||
|
||||
/// Restore the declared PAGE records into the selected native Screen.
|
||||
pub fn decode(
|
||||
self: *Decoder,
|
||||
alloc: Allocator,
|
||||
terminal_screen: *TerminalScreen,
|
||||
) RestoreError!void {
|
||||
// A freshly restored SCREEN may carry overlap inside its first page,
|
||||
// but cannot already contain a complete historical page before that
|
||||
// boundary.
|
||||
const active_top = terminal_screen.pages.getTopLeft(.active);
|
||||
if (terminal_screen.pages.getTopLeft(.screen).node != active_top.node) {
|
||||
return error.ExistingHistory;
|
||||
}
|
||||
|
||||
// Row metadata is redundant with the restored SCREEN and PAGE
|
||||
// topology. Consume the declared PAGE records, but derive all native
|
||||
// row totals from their actual dimensions.
|
||||
for (0..self.header.page_count) |_| {
|
||||
// PAGE exposes its exact capacity before decoding the payload,
|
||||
// allowing the destination PageList to allocate the final backing
|
||||
// memory once.
|
||||
var decoder: page.Decoder = undefined;
|
||||
try decoder.init(self.source);
|
||||
var allocation = try terminal_screen.pages.allocatePage(
|
||||
decoder.capacity(),
|
||||
);
|
||||
defer allocation.deinit();
|
||||
try decoder.decode(allocation.page(), alloc);
|
||||
|
||||
const contains_prompt = hasSemanticPrompt(allocation.page());
|
||||
try allocation.finalize(.prepend);
|
||||
if (contains_prompt) terminal_screen.semantic_prompt.seen = true;
|
||||
}
|
||||
|
||||
terminal_screen.pages.assertIntegrity();
|
||||
terminal_screen.assertIntegrity();
|
||||
}
|
||||
};
|
||||
|
||||
/// Restore one HISTORY and its declared PAGE records into a native Screen.
|
||||
///
|
||||
/// Each PAGE is decoded directly into a detached PageList-pooled allocation and
|
||||
@@ -252,46 +317,10 @@ pub fn decode(
|
||||
expected_key: TerminalScreenKey,
|
||||
terminal_screen: *TerminalScreen,
|
||||
) DecodeError!void {
|
||||
// Decode and finish the self-contained HISTORY manifest before consuming
|
||||
// any of its following PAGE records.
|
||||
const header: Header = header: {
|
||||
var record_reader: record.Reader = undefined;
|
||||
try record_reader.init(source);
|
||||
if (record_reader.header.tag != .history) return error.UnexpectedRecordTag;
|
||||
const header = try Header.decode(record_reader.payloadReader());
|
||||
try record_reader.finish();
|
||||
break :header header;
|
||||
};
|
||||
|
||||
if (header.key != expected_key) return error.UnexpectedScreenKey;
|
||||
|
||||
// A freshly restored SCREEN may carry overlap inside its first page, but
|
||||
// cannot already contain a complete historical page before that boundary.
|
||||
const active_top = terminal_screen.pages.getTopLeft(.active);
|
||||
if (terminal_screen.pages.getTopLeft(.screen).node != active_top.node) {
|
||||
return error.ExistingHistory;
|
||||
}
|
||||
|
||||
// Row metadata is redundant with the restored SCREEN and PAGE topology.
|
||||
// Consume the declared number of PAGE records, but derive all native row
|
||||
// totals from their actual dimensions instead of rejecting inconsistent
|
||||
// metadata from another implementation.
|
||||
for (0..header.page_count) |_| {
|
||||
// PAGE exposes its exact capacity before decoding the payload, allowing
|
||||
// the destination PageList to allocate the final backing memory once.
|
||||
var decoder: page.Decoder = undefined;
|
||||
try decoder.init(source);
|
||||
var allocation = try terminal_screen.pages.allocatePage(decoder.capacity());
|
||||
defer allocation.deinit();
|
||||
try decoder.decode(allocation.page(), alloc);
|
||||
|
||||
const contains_prompt = hasSemanticPrompt(allocation.page());
|
||||
try allocation.finalize(.prepend);
|
||||
if (contains_prompt) terminal_screen.semantic_prompt.seen = true;
|
||||
}
|
||||
|
||||
terminal_screen.pages.assertIntegrity();
|
||||
terminal_screen.assertIntegrity();
|
||||
var decoder: Decoder = undefined;
|
||||
try decoder.init(source);
|
||||
if (decoder.header.key != expected_key) return error.UnexpectedScreenKey;
|
||||
try decoder.decode(alloc, terminal_screen);
|
||||
}
|
||||
|
||||
fn hasSemanticPrompt(terminal_page: *const TerminalPage) bool {
|
||||
|
||||
@@ -55,23 +55,20 @@
|
||||
//! +----------------------------------------+
|
||||
//! | READY |
|
||||
//! +----------------------------------------+
|
||||
//! | HISTORY (primary) |
|
||||
//! | PAGE * history.page_count |
|
||||
//! +----------------------------------------+
|
||||
//! | HISTORY (alternate, when present) |
|
||||
//! | PAGE * history.page_count |
|
||||
//! | HISTORY * terminal.screen_count |
|
||||
//! | PAGE * each history.page_count |
|
||||
//! +----------------------------------------+
|
||||
//! | FINISH |
|
||||
//! +----------------------------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! 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.
|
||||
//! The SCREEN and HISTORY sequence groups may each appear in any key order.
|
||||
//! Each key must occur exactly once in each group and must identify one of the
|
||||
//! screens declared by TERMINAL. SCREEN contains the complete pages needed to
|
||||
//! restore each active area. HISTORY 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
|
||||
|
||||
@@ -93,13 +93,21 @@ pub const DecodeError = envelope.DecodeError ||
|
||||
|
||||
/// More than one SCREEN names the same key.
|
||||
DuplicateScreen,
|
||||
|
||||
/// A HISTORY names a key not declared by TERMINAL.
|
||||
UnexpectedHistoryKey,
|
||||
|
||||
/// More than one HISTORY names the same key.
|
||||
DuplicateHistory,
|
||||
};
|
||||
|
||||
/// 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).
|
||||
/// not (error return). Individual record codecs normalize optional semantic
|
||||
/// state, while framing, checkpoints, declared sequence counts, and unique
|
||||
/// cross-record screen routing remain strict.
|
||||
pub fn decode(
|
||||
source: *std.Io.Reader,
|
||||
io_: std.Io,
|
||||
@@ -175,13 +183,25 @@ pub fn decode(
|
||||
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);
|
||||
// HISTORY keys make this sequence order-independent just like SCREEN.
|
||||
// Although a decoder may publish recent pages as they validate, any later
|
||||
// full-snapshot failure deinitializes the whole result.
|
||||
for (0..screen_count) |_| {
|
||||
var decoder: history.Decoder = undefined;
|
||||
try decoder.init(reader);
|
||||
|
||||
const key = decoder.header.key;
|
||||
const restored = result.screens.get(key) orelse
|
||||
return error.UnexpectedHistoryKey;
|
||||
|
||||
// SCREEN routing advanced every decoded slot from generation zero to
|
||||
// one. A value other than one means this key already received HISTORY.
|
||||
if (result.screens.generation(key) != 1) {
|
||||
return error.DuplicateHistory;
|
||||
}
|
||||
|
||||
try decoder.decode(alloc, restored);
|
||||
result.screens.generations.put(key, 2);
|
||||
}
|
||||
|
||||
// FINISH authenticates READY and all history. Decode it directly from the
|
||||
@@ -189,6 +209,7 @@ pub fn decode(
|
||||
hashing.hasher.final(&digest);
|
||||
try checkpoint.decode(.finish, digest, source);
|
||||
|
||||
const keys = [_]TerminalScreenKey{ .primary, .alternate };
|
||||
if (comptime build_options.slow_runtime_safety) {
|
||||
for (keys) |key| {
|
||||
const restored = result.screens.get(key) orelse continue;
|
||||
@@ -196,6 +217,11 @@ pub fn decode(
|
||||
restored.assertIntegrity();
|
||||
}
|
||||
}
|
||||
|
||||
// Generations are only scratch state while routing the two keyed sequence
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -334,8 +360,7 @@ test "complete snapshot round trip with history and alternate screen" {
|
||||
reencoded.written(),
|
||||
);
|
||||
|
||||
// SCREEN keys make their order independent. Keep HISTORY canonical here;
|
||||
// only the active-state screen sequences are intentionally reversed.
|
||||
// SCREEN and HISTORY keys make both sequence groups order independent.
|
||||
var reversed: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer reversed.deinit();
|
||||
try envelope.encode(&reversed.writer);
|
||||
@@ -343,12 +368,12 @@ test "complete snapshot round trip with history and alternate screen" {
|
||||
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 history.encode(primary, .primary, &reversed);
|
||||
try checkpoint.encode(.finish, &reversed);
|
||||
|
||||
var reversed_source: std.Io.Reader = .fixed(reversed.written());
|
||||
@@ -362,6 +387,14 @@ test "complete snapshot round trip with history and alternate screen" {
|
||||
TerminalScreenKey.alternate,
|
||||
reversed_restored.screens.active_key,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(usize, 0),
|
||||
reversed_restored.screens.generation(.primary),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(usize, 0),
|
||||
reversed_restored.screens.generation(.alternate),
|
||||
);
|
||||
}
|
||||
|
||||
test "complete snapshot preserves Kitty virtual placeholders" {
|
||||
@@ -535,6 +568,23 @@ test "complete snapshot rejects ordering checkpoints and trailing data" {
|
||||
decode(&undeclared_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
// HISTORY sequences are also routed by key, which must name a declared
|
||||
// screen even when the sequence contains no PAGE records.
|
||||
var undeclared_history: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer undeclared_history.deinit();
|
||||
try envelope.encode(&undeclared_history.writer);
|
||||
try terminal.encode(&t, &undeclared_history);
|
||||
try screen.encode(primary, .primary, &undeclared_history);
|
||||
try checkpoint.encode(.ready, &undeclared_history);
|
||||
try history.encode(primary, .alternate, &undeclared_history);
|
||||
var undeclared_history_source: std.Io.Reader = .fixed(
|
||||
undeclared_history.written(),
|
||||
);
|
||||
try testing.expectError(
|
||||
error.UnexpectedHistoryKey,
|
||||
decode(&undeclared_history_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);
|
||||
@@ -548,4 +598,26 @@ test "complete snapshot rejects ordering checkpoints and trailing data" {
|
||||
error.DuplicateScreen,
|
||||
decode(&duplicate_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
// The declared count cannot be satisfied by repeating one HISTORY key.
|
||||
var duplicate_history: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer duplicate_history.deinit();
|
||||
try envelope.encode(&duplicate_history.writer);
|
||||
try terminal.encode(&t, &duplicate_history);
|
||||
try screen.encode(primary, .primary, &duplicate_history);
|
||||
try screen.encode(
|
||||
t.screens.get(.alternate).?,
|
||||
.alternate,
|
||||
&duplicate_history,
|
||||
);
|
||||
try checkpoint.encode(.ready, &duplicate_history);
|
||||
try history.encode(primary, .primary, &duplicate_history);
|
||||
try history.encode(primary, .primary, &duplicate_history);
|
||||
var duplicate_history_source: std.Io.Reader = .fixed(
|
||||
duplicate_history.written(),
|
||||
);
|
||||
try testing.expectError(
|
||||
error.DuplicateHistory,
|
||||
decode(&duplicate_history_source, testing.io, testing.allocator),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user