diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index defb9ce24..72c79a999 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -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,