mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 15:18:40 +00:00
terminal/snapshot: encode screen pages safely
SCREEN encoding assumed every page from the active boundary onward was resident. A debug assertion guarded that PageList policy invariant, but release builds immediately used pageAssumeResident. If compression policy ever allowed a SCREEN suffix page to remain compressed, the encoder would read an inactive union field, causing undefined behavior and potentially a crash or corrupt snapshot. Use pagePreservingState for every SCREEN suffix page, as HISTORY already does, and include allocation failure in EncodeError. Resident pages remain a zero-allocation borrow while compressed pages decode into temporary read-only storage without changing the source representation. Exercise the path with an explicitly compressed active suffix page.
This commit is contained in:
@@ -233,7 +233,7 @@ const PayloadEncodeError = hyperlink.EncodeError || error{
|
||||
};
|
||||
|
||||
/// Errors possible while encoding a SCREEN and its complete PAGE sequence.
|
||||
pub const EncodeError = PayloadEncodeError || page.EncodeError || error{
|
||||
pub const EncodeError = Allocator.Error || PayloadEncodeError || page.EncodeError || error{
|
||||
/// The active area spans more pages than the SCREEN header can declare.
|
||||
PageCountOverflow,
|
||||
};
|
||||
@@ -273,13 +273,14 @@ pub fn encode(
|
||||
try destination.finish();
|
||||
}
|
||||
|
||||
// PageList never compresses the active-boundary page or any later page.
|
||||
// Encoding this resident suffix therefore does not restore cold history or
|
||||
// otherwise mutate the source screen.
|
||||
// Active pages are resident today, but use the representation-safe access
|
||||
// path so a future PageList compression-policy change cannot turn this
|
||||
// wire encoder's optimization invariant into undefined behavior.
|
||||
node = first;
|
||||
while (node) |current| : (node = current.next) {
|
||||
std.debug.assert(current.pageIfResident() != null);
|
||||
try page.encode(current.pageAssumeResident(), destination);
|
||||
var preserved = try current.pagePreservingState(screen.alloc);
|
||||
defer preserved.deinit();
|
||||
try page.encode(preserved.page(), destination);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1963,6 +1964,62 @@ test "SCREEN encodes the minimal complete-page active suffix" {
|
||||
try std.testing.expectError(error.EndOfStream, restore_source.takeByte());
|
||||
}
|
||||
|
||||
test "SCREEN encoding preserves a compressed suffix page" {
|
||||
const testing = std.testing;
|
||||
const compression = @import("../compress.zig");
|
||||
|
||||
var screen = try TerminalScreen.init(
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 8, .rows = 2, .max_scrollback_bytes = 0 },
|
||||
);
|
||||
defer screen.deinit();
|
||||
screen.pages.getCell(.{ .active = .{} }).?.cell.* = .init('A');
|
||||
|
||||
// Force the active suffix into the representation that current PageList
|
||||
// policy normally reserves for history. This models a future policy change
|
||||
// and makes pageAssumeResident an invalid tagged-union access.
|
||||
const node = screen.pages.getTopLeft(.active).node;
|
||||
const resident = node.pageAssumeResident();
|
||||
const scratch = try testing.allocator.alloc(
|
||||
u8,
|
||||
try compression.Page.requiredScratch(resident.memory.len),
|
||||
);
|
||||
defer testing.allocator.free(scratch);
|
||||
var table: compression.lz4.HashTable = undefined;
|
||||
const compressed = (try compression.Page.init(
|
||||
testing.allocator,
|
||||
resident,
|
||||
scratch,
|
||||
&table,
|
||||
)).?;
|
||||
node.data = .{ .compressed = compressed };
|
||||
try testing.expectEqual(.compressed, node.storage());
|
||||
|
||||
var destination: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer destination.deinit();
|
||||
var stream: record.Writer = .init(testing.allocator, &destination.writer);
|
||||
defer stream.deinit();
|
||||
try encode(&screen, .primary, &stream);
|
||||
|
||||
// Encoding borrows or clones through PreservedPage and never changes the
|
||||
// source node's storage representation.
|
||||
try testing.expectEqual(.compressed, node.storage());
|
||||
|
||||
var source: std.Io.Reader = .fixed(destination.written());
|
||||
var decoded = try decode(
|
||||
&source,
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
.{ .cols = 8, .rows = 2, .max_scrollback_bytes = 0 },
|
||||
);
|
||||
defer decoded.deinit();
|
||||
try testing.expectEqual(
|
||||
@as(u21, 'A'),
|
||||
decoded.screen.pages.getCell(.{ .active = .{} }).?.cell.codepoint(),
|
||||
);
|
||||
}
|
||||
|
||||
test "SCREEN restoration normalizes invalid cursor positions" {
|
||||
var screen = try TerminalScreen.init(
|
||||
std.testing.io,
|
||||
|
||||
Reference in New Issue
Block a user