diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index 7f375affd..9de7b4c3f 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -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 { diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index c08c437fd..2c7d58335 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -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 diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index 90d1c8402..0f3635487 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -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), + ); }