From e7969ed43658b1330e282254cc1f0fe3805379f7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 21:32:21 -0700 Subject: [PATCH] terminal: make PageList compression self-contained Incremental compression previously exposed its traversal state to callers, requiring them to coordinate the cursor with PageList lifetime and topology changes. Read-only consumers also had to restore compressed nodes to inspect their contents. Move the continuation state into PageList and expose a single mode-based compression entry point. Incremental passes restart safely across mutations and verify a no-work pass before becoming idle, while full passes leave incremental state fresh. Add preserved page reads which decode compressed nodes into caller-owned storage without changing their representation, and migrate the scrollback compression benchmark to the new API. --- src/benchmark/ScrollbackCompression.zig | 32 +- src/terminal/PageList.zig | 613 +++++++++++++++++------- src/terminal/compress/Page.zig | 64 ++- 3 files changed, 504 insertions(+), 205 deletions(-) diff --git a/src/benchmark/ScrollbackCompression.zig b/src/benchmark/ScrollbackCompression.zig index d541f25fd..3684a91b3 100644 --- a/src/benchmark/ScrollbackCompression.zig +++ b/src/benchmark/ScrollbackCompression.zig @@ -25,10 +25,10 @@ //! * `noop` parses the corpus but performs no timed PageList operation. This is //! the common process and setup baseline for the other modes. //! * `compress` times one complete `compress` invocation. -//! * `incremental` performs the same complete pass through -//! `compressIncremental`. It drains the candidate-bounded steps in the timed -//! region, making the cursor and repeated traversal overhead directly -//! comparable with `compress`. +//! * `incremental` reaches the same final representation through +//! `compress(.incremental)`. It drains the candidate-bounded steps and final +//! no-work verification pass in the timed region, making cursor and repeated +//! traversal overhead directly comparable with `compress`. //! * `restore` compresses cold history during setup, outside the timed region, //! then visits every fully historical node through `Node.page`. That public //! content-access boundary transparently restores compressed nodes. @@ -77,9 +77,6 @@ const log = std.log.scoped(.@"scrollback-compression-bench"); opts: Options, terminal: Terminal, -/// Cursor state used by incremental mode between candidate-bounded steps. -incremental_state: PageList.IncrementalCompressionState = .{}, - pub const Options = struct { /// Set by the shared CLI parser so the `data` string remains valid for the /// lifetime of the benchmark implementation. @@ -167,7 +164,6 @@ pub fn benchmark(self: *ScrollbackCompression) Benchmark { fn setup(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); self.terminal.fullReset(); - self.incremental_state.reset(); self.loadCorpus() catch |err| { log.warn("failed to prepare scrollback compression benchmark err={}", .{err}); @@ -175,7 +171,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { }; if (self.opts.mode == .restore) { - self.pages().compress(); + _ = self.pages().compress(.full); } } @@ -215,27 +211,24 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.pages().compress(); + _ = self.pages().compress(.full); std.mem.doNotOptimizeAway(&self.terminal); } fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); self.drainIncrementalCompression(); - std.mem.doNotOptimizeAway(&self.incremental_state); std.mem.doNotOptimizeAway(&self.terminal); } -/// Drain one complete resumable compression pass. +/// Drain incremental compression through its no-work verification pass. /// /// Each PageList step has a bounded candidate-inspection budget. The benchmark /// loops until no more immediate work is available so its timed result can be /// compared with the existing monolithic `compress` mode. fn drainIncrementalCompression(self: *ScrollbackCompression) void { while (true) { - switch (self.pages().compressIncremental( - &self.incremental_state, - )) { + switch (self.pages().compress(.incremental)) { .pending => continue, .unsupported, .deferred, @@ -271,7 +264,7 @@ fn visitColdPages(self: *ScrollbackCompression) usize { fn stepReport(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.pages().compress(); + _ = self.pages().compress(.full); const memory = self.pages().memoryStats(); std.debug.print( @@ -321,7 +314,7 @@ test "ScrollbackCompression restores cold terminal pages" { defer stream.deinit(); for (0..256) |_| stream.nextSlice("aaaa\r\n"); - impl.pages().compress(); + _ = impl.pages().compress(.full); const compressed = impl.pages().memoryStats(); try testing.expect(compressed.compressed_pages > 0); try testing.expect(impl.visitColdPages() >= compressed.compressed_pages); @@ -329,7 +322,7 @@ test "ScrollbackCompression restores cold terminal pages" { // Restored historical pages are resident and therefore eligible for a // later explicit pass. This also verifies that the benchmark traversal // went through Node.page rather than merely inspecting page metadata. - impl.pages().compress(); + _ = impl.pages().compress(.full); const recompressed = impl.pages().memoryStats(); try testing.expectEqual( compressed.compressed_pages, @@ -351,7 +344,6 @@ test "ScrollbackCompression drains incremental compression steps" { defer stream.deinit(); for (0..256) |_| stream.nextSlice("aaaa\r\n"); - impl.incremental_state.reset(); impl.drainIncrementalCompression(); const incremental = impl.pages().memoryStats(); try testing.expect(incremental.compressed_pages > 0); @@ -359,7 +351,7 @@ test "ScrollbackCompression drains incremental compression steps" { // Restore the same pages and compare against the monolithic operation. // Both paths should produce the same final storage representation. _ = impl.visitColdPages(); - impl.pages().compress(); + _ = impl.pages().compress(.full); const monolithic = impl.pages().memoryStats(); try testing.expectEqual(monolithic, incremental); } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e5a9f514b..e2b4da761 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -107,6 +107,89 @@ const Node = struct { }; } + /// Read-only page with full memory access that doesn't change + /// this node's storage state: if a node is compressed it remains + /// compressed. + /// + /// Resident pages are borrowed directly. So they're basically free. + /// + /// Compressed pages are decoded into an independently owned Page + /// by the caller so callers can inspect their contents. Because resident + /// pages are still borrowed, you have to continue to have exclusive + /// access to the PageList during this operation. + pub const PreservedPage = union(enum) { + borrowed: *const Page, + owned: struct { + page: Page, + alloc: Allocator, + }, + + /// Return the read-only page represented by this value. + pub fn page(self: *const PreservedPage) *const Page { + return switch (self.*) { + .borrowed => |page_| page_, + .owned => |*owned| &owned.page, + }; + } + + /// Release storage owned by this preserved page. + pub fn deinit(self: *PreservedPage) void { + switch (self.*) { + .borrowed => {}, + // The clone buffer came from the caller's allocator rather + // than Page's OS allocator, so it must not use Page.deinit. + .owned => |*owned| owned.alloc.free(owned.page.memory), + } + self.* = undefined; + } + }; + + /// Return read-only page contents without changing this node's storage. + /// + /// `alloc` is unused for a resident node. A compressed node uses it for an + /// exact-sized, page-aligned decode buffer owned by the returned value. + /// + /// The caller must call `PreservedPage.deinit` when finished. See + /// `PreservedPage` for the synchronization required by its borrowed + /// resident representation. + pub fn pagePreservingState( + self: *const Node, + alloc: Allocator, + ) Allocator.Error!PreservedPage { + return switch (self.data) { + .resident => |*page_| .{ .borrowed = page_ }, + .compressed => |*compressed| owned: { + const memory = try alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + compressed.page.memory.len, + ); + + const page_ = compressed.cloneBuf(memory) catch |err| { + // The encoded data was produced by our codec and remains + // immutable while compressed. Failure is internal + // corruption, matching the normal restoration boundary. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + alloc.free(memory); + log.err("failed to clone compressed page err={}", .{err}); + @panic("failed to clone compressed terminal page"); + }; + + break :owned .{ .owned = .{ + .page = page_, + .alloc = alloc, + } }; + }, + }; + } + /// Return the node's backing-memory representation without restoring it. pub inline fn storage(self: *const Node) Storage { return switch (self.data) { @@ -305,6 +388,11 @@ page_serial_min: u64, /// decommitted. It excludes encoded storage and unused preheated pool items. page_size: usize, +/// Continuation state for incremental page compression. This allows +/// compress(.incremental) to work. More details on all that there and +/// in the state struct. +page_compression: IncrementalCompressionState = .{}, + /// Maximum size of the page allocation in bytes. This only includes pages /// that are used ONLY for scrollback. If the active area is still partially /// in a page that also includes scrollback, then that page is not included. @@ -825,6 +913,9 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); + // Reset always resets our compression state since we have new pages. + self.page_compression.reset(); + // Invalidate all external page refs to the previous list. The reset below // rebuilds the page list from the pools, so old untracked refs must be // rejected before any validation attempts to inspect their node pointers. @@ -1124,6 +1215,11 @@ pub const Resize = struct { pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { defer self.assertIntegrity(); + // Resizing forces all nodes to be decompressed today so we need to + // reschedule compression. + // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages. + self.page_compression.reset(); + if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected // upstream @@ -2697,6 +2793,12 @@ pub const Scroll = union(enum) { pub fn scroll(self: *PageList, behavior: Scroll) void { defer self.assertIntegrity(); + // Compression is deferred while viewing history. Restart traversal when + // crossing that boundary so any pages accessed there are reconsidered. + const was_active = self.viewport == .active; + defer if (was_active != (self.viewport == .active)) + self.page_compression.reset(); + // Special case no-scrollback mode to never allow scrolling. if (self.explicit_max_size == 0) { self.viewport = .active; @@ -3743,7 +3845,7 @@ const CompressionScratch = union(enum) { } }; -/// Caller-owned state for incremental compression. +/// PageList-owned state for incremental compression. /// /// The position is stored as a page serial rather than a node pointer so it /// remains safe when PageList operations destroy, replace, or reuse nodes @@ -3752,26 +3854,30 @@ const CompressionScratch = union(enum) { /// exact serial no longer exists in the cold prefix, the next step also /// restarts at the first page. /// -/// A state value belongs to one initialized PageList. In Ghostty's intended -/// runtime use, it will be stored alongside the Screen's PageList and naturally -/// share its lifetime. Reset, resize/reflow, pruning, and node reuse all keep -/// the same PageList, so they do not violate this rule. The state must be reset -/// only if unusual caller code preserves it while deinitializing and replacing -/// the entire PageList, such as assigning a fresh `init` or `clone` result into -/// the same field. Callers may also reset it to reconsider pages which were -/// restored or which failed an earlier attempt. -pub const IncrementalCompressionState = struct { +/// A completed pass is followed by another pass from the oldest cold page. +/// Compression becomes idle only when that verification pass compresses no +/// pages. This converges after pages are restored between incremental steps +/// without requiring consumers to maintain a separate attempt cursor. +const IncrementalCompressionState = struct { /// Serial of the last page inspected by the traversal. This is - /// intentionally an implementation detail; callers should use `reset` - /// rather than changing the continuation marker directly. + /// intentionally an implementation detail and is reset by PageList + /// operations which invalidate traversal progress. last_serial: ?u64 = null, /// First node serial which had not been allocated at the prior step. A /// node at or above this value before the saved marker requires a restart. next_serial: u64 = 0, + /// Whether this pass successfully compressed at least one page. + did_compress: bool = false, + + /// Whether this pass is verifying that the prior pass reached a fixed + /// point. A verification pass which does work starts another verification + /// pass; a verification pass with no work lets compression become idle. + verifying: bool = false, + /// Restart the next step at the first page in the list. - pub fn reset(self: *IncrementalCompressionState) void { + fn reset(self: *IncrementalCompressionState) void { self.* = .{}; } }; @@ -3784,10 +3890,11 @@ pub const IncrementalCompressionResult = enum { /// The viewport is displaying history, so compression is postponed. deferred, - /// More cold pages remain after this invocation's candidate-bounded work. + /// More cold pages or a verification pass remain after this invocation's + /// candidate-bounded work. pending, - /// The traversal reached the page containing the first active row. + /// A complete verification pass compressed zero pages. complete, }; @@ -3803,112 +3910,122 @@ const compressPage_tw = tripwire.module( error{DecommitFailed}, ); -/// Perform one candidate-bounded incremental cold-history compression step. +/// Compress eligible nodes, saving a significant amount of memory. /// -/// Eligibility is identical to `compress`: strict retained-mapping -/// reclamation must be available, the viewport must follow the active area, -/// and only complete pages before the active boundary are candidates. The -/// mixed history/active boundary page is never inspected or compressed. +/// Eligible nodes are complete pages before the active boundary. The boundary +/// page is excluded because it may contain both scrollback and active rows. /// -/// The continuation marker is relocated by serial on every call. This cursor -/// positioning is a metadata-only traversal; the eight-page candidate bound -/// applies after positioning. Since the state intentionally retains no node -/// pointer or tracked pin, positioning may traverse the cold prefix. Missing -/// serials restart at the first page, as does finding a node allocated since -/// the prior step before the marker. This remains safe without retaining a -/// potentially dangling node pointer. +/// Compression requires that we're running on a system that supports +/// reclaiming physical memory for virtual allocations and the viewport +/// must be in the active area. /// -/// Each step attempts at most one resident page and advances after every -/// outcome, including no savings, allocation failure, and failed decommit. An -/// unsuccessful page therefore cannot stall the traversal. Already-compressed -/// pages are skipped but count as inspected. Call -/// `IncrementalCompressionState.reset` when a new traversal should reconsider -/// restored or previously unsuccessful pages. -pub fn compressIncremental( +/// The mode specifies if this is an incremental or full compression. +/// Compression is SLOW (relatively), so incremental compressions during +/// idle periods are recommended. For incremental compressions, the result +/// specifies whether to continue incremental compressing or to stop until +/// an operation changes the state of the PageList (caller must determine +/// this). Full compression always returns `complete`, indicating that it has +/// no continuation to schedule rather than that every page was compressed. +pub fn compress( self: *PageList, - state: *IncrementalCompressionState, + mode: enum { incremental, full }, ) IncrementalCompressionResult { + return switch (mode) { + .incremental => self.compressIncremental(), + .full => full: { + self.compressFull(); + + // Full compression has no continuation. Discard any partial + // incremental cursor so later activity starts at the oldest page. + self.page_compression.reset(); + + break :full .complete; + }, + }; +} + +/// Perform one candidate-bounded incremental cold-history compression step. +fn compressIncremental(self: *PageList) IncrementalCompressionResult { + // If we can't reclaim virtual memory, compression is unsupported. if (!terminal_mem.canReclaim(.strict)) return .unsupported; + // If the viewport isn't in the active area, we don't do any compression. + // The user is likely scrolling and we don't want to cause a bunch of + // compress/decompress churn. Just wait, they have to go back down + // eventually, you'd think! if (self.viewport != .active) return .deferred; - const active_node = self.getTopLeft(.active).node; + // Get our compression state + const state = &self.page_compression; // Find the node following the exact continuation marker within the cold // prefix. A missing marker means the list changed between steps, so begin // again at the current first page. This lookup does not touch page memory. + const active_node = self.getTopLeft(.active).node; var current = self.pages.first.?; - if (state.last_serial) |last_serial| { - var found = false; - var inserted_before_marker = false; - var search = self.pages.first.?; - while (search != active_node) : (search = search.next.?) { - if (search.serial >= state.next_serial) - inserted_before_marker = true; - if (search.serial != last_serial) continue; - + if (state.last_serial) |last_serial| continuation: { + while (current != active_node) : (current = current.next.?) { // A newly allocated or replacement node appeared before the - // marker. Restart so that node cannot be skipped. Appends after - // the marker do not disturb progress through the existing prefix. - if (inserted_before_marker) { - state.last_serial = null; - current = self.pages.first.?; - } else { - current = search.next.?; - } - found = true; - break; + // marker. Restart immediately so that node cannot be skipped. + if (current.serial >= state.next_serial) break; + + // Not a match? Keep looking + if (current.serial != last_serial) continue; + + // Match! + current = current.next.?; + break :continuation; } - if (!found) { - state.last_serial = null; - current = self.pages.first.?; - } + // Not found or otherwise invalid. Reset + state.last_serial = null; + current = self.pages.first.?; } + + // Keep track of our next_serial state.next_serial = self.page_serial; + // We cap the number of pages we look at to do our best to + // time-bound the incremental compression. var inspected_pages: usize = 0; while (current != active_node and - inspected_pages < incremental_compression_max_inspected) + inspected_pages < incremental_compression_max_inspected) : (current = current.next.?) { - const node = current; - current = node.next.?; - state.last_serial = node.serial; + state.last_serial = current.serial; inspected_pages += 1; - if (node.isCompressed()) continue; + // If this page is already compressed, ignore it. + if (current.isCompressed()) continue; - // Compression is substantially more expensive than inspecting - // metadata, so one call attempts at most one resident page regardless - // of whether the attempt succeeds. - _ = self.compressPage(node); + // Compression is substantially more expensive even if it fails. + // So we just try it. + if (self.compressPage(current)) state.did_compress = true; + + // Breaking skips the while loop's continuation expression, so advance + // explicitly before checking whether we reached the active boundary. + current = current.next.?; break; } - return if (current == active_node) .complete else .pending; + // If we didn't reach our active node, then we still have work to do. + if (current != active_node) return .pending; + + // We reached our active node. So we're done, except that we always + // do one pass after the first success so we can recompress nodes that + // were possibly decompressed (e.g. by search, inspector, whatever). + if (!state.verifying or state.did_compress) { + state.* = .{ .verifying = true }; + return .pending; + } + + // Leave the state fresh while idle so later activity naturally begins at + // the oldest cold page, including pages restored after this pass. + state.reset(); + return .complete; } /// Compress every fully historical resident page which is currently cold. -/// -/// A page is fully historical only when it precedes the node containing the -/// first active row. The boundary node itself is excluded because a single -/// page can contain both scrollback and active rows, and compression discards -/// the complete backing mapping rather than an individual row range. -/// -/// The viewport must be following the active area. When the user is viewing -/// history, even pages outside the visible range are left alone so this pass -/// cannot race ahead of rendering and immediately trigger restoration work. -/// -/// This operation is deliberately stateless. Already-compressed pages are -/// skipped, while any resident page is attempted on each pass. Consequently, -/// a page restored by content access becomes eligible for recompression and a -/// page which previously failed opportunistically can be retried without a -/// per-node attempt flag. -/// -/// There are intentionally no production callers yet. This whole-history -/// operation exists so its policy, memory savings, and restoration costs can -/// be measured before choosing an incremental scheduling policy. -pub fn compress(self: *PageList) void { +fn compressFull(self: *PageList) void { // Avoid the codec, scratch allocation, and exact encoded allocation on a // target where strict retained-mapping reclamation cannot succeed. if (!terminal_mem.canReclaim(.strict)) return; @@ -3936,9 +4053,8 @@ pub fn compress(self: *PageList) void { /// Attempt to compress one resident page while retaining its raw mapping. /// /// Compression is opportunistic: every failure leaves the page resident and -/// usable. Candidate selection and retry policy belong to -/// `compress` and `compressIncremental`; this primitive only performs one -/// state transition. +/// usable. Candidate selection and retry policy belong to `compress`; this +/// primitive only performs one state transition. fn compressPage(self: *PageList, node: *List.Node) bool { // Recompression requires first restoring the raw page and is a policy // decision, so this primitive only accepts resident nodes. @@ -6267,34 +6383,85 @@ test "PageList incremental compression defers and completes" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; s.scroll(.top); - const deferred = s.compressIncremental(&state); + const deferred = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.deferred, deferred); const cold = s.pages.first.?; try testing.expect(!cold.isCompressed()); s.scroll(.active); - const complete = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); + const pending = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, pending); try testing.expect(cold.isCompressed()); - // The exact continuation marker positions us directly at the active - // boundary on later calls. Positioning is not candidate inspection. - const repeated = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, repeated); + // Compression becomes idle only after a complete verification pass does + // no work. + const complete = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.complete, complete); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); - // Restoring a page behind the marker does not cause immediate churn. The - // caller explicitly resets when it wants a new pass to reconsider it. + // Completed state is fresh, so a later request naturally starts at the + // oldest page and recompresses restored content. _ = cold.page(); try testing.expect(!cold.isCompressed()); - const skipped = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, skipped); - - state.reset(); - const reconsidered = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, reconsidered); + const reconsidered = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, reconsidered); try testing.expect(cold.isCompressed()); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); +} + +test "PageList owns incremental compression state" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const dirty: IncrementalCompressionState = .{ + .last_serial = 42, + .next_serial = 43, + .did_compress = true, + .verifying = true, + }; + + s.page_compression = dirty; + s.scroll(.top); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + // Scrolling within history preserves progress, while crossing back to the + // active viewport resets it again. + s.page_compression = dirty; + s.scroll(.top); + try testing.expectEqual(dirty, s.page_compression); + s.scroll(.active); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + s.page_compression = dirty; + try s.resize(.{ .cols = 80, .rows = 24 }); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + s.page_compression = dirty; + s.reset(); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + s.page_compression = dirty; + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.full), + ); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + s.page_compression = dirty; + var cloned = try s.clone(testing.allocator, .{ + .top = .{ .active = .{} }, + }); + defer cloned.deinit(); + try testing.expectEqual( + IncrementalCompressionState{}, + cloned.page_compression, + ); } test "PageList incremental compression bounds inspected pages" { @@ -6306,7 +6473,7 @@ test "PageList incremental compression bounds inspected pages" { // Precompress every candidate so the incremental pass exercises its // metadata-only skip budget without stopping at a resident attempt. - s.compress(); + _ = s.compress(.full); try testing.expectEqual( incremental_compression_max_inspected + 1, s.memoryStats().compressed_pages, @@ -6316,15 +6483,27 @@ test "PageList incremental compression bounds inspected pages" { for (1..incremental_compression_max_inspected) |_| expected_last = expected_last.next.?; - var state: IncrementalCompressionState = .{}; - const first = s.compressIncremental(&state); + const first = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, first); - try testing.expectEqual(expected_last.serial, state.last_serial.?); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); - expected_last = expected_last.next.?; - const second = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, second); - try testing.expectEqual(expected_last.serial, state.last_serial.?); + const second = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, second); + try testing.expect(s.page_compression.verifying); + try testing.expect(s.page_compression.last_serial == null); + + // The verification pass is bounded independently, too. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); } test "PageList incremental compression advances after failure" { @@ -6339,15 +6518,14 @@ test "PageList incremental compression advances after failure" { var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353); prng.random().bytes(first.page().memory); - var state: IncrementalCompressionState = .{}; - const failed = s.compressIncremental(&state); + const failed = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, failed); try testing.expect(!first.isCompressed()); // The unsuccessful first page does not stall the pass. The next step // continues at the following serial and compresses that page. - const complete = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); try testing.expect(second.isCompressed()); } @@ -6366,8 +6544,7 @@ test "PageList incremental compression advances after allocation failure" { // next request therefore rejects the exact encoded allocation while the // source page and pass remain valid. failing.fail_index = failing.alloc_index; - var state: IncrementalCompressionState = .{}; - const failed = s.compressIncremental(&state); + const failed = s.compress(.incremental); try testing.expect(failing.has_induced_failure); try testing.expectEqual(IncrementalCompressionResult.pending, failed); try testing.expect(!first.isCompressed()); @@ -6375,8 +6552,8 @@ test "PageList incremental compression advances after allocation failure" { // Allow allocations again. The pass must continue with the following page // rather than retrying the failed candidate. failing.fail_index = std.math.maxInt(usize); - const complete = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); try testing.expect(second.isCompressed()); } @@ -6390,16 +6567,15 @@ test "PageList incremental compression advances after decommit failure" { try s.growColdPagesForTest(2); tw.errorAlways(.decommit, error.DecommitFailed); - var state: IncrementalCompressionState = .{}; - const failed = s.compressIncremental(&state); + const failed = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, failed); try testing.expect(!s.pages.first.?.isCompressed()); try tw.end(.reset); // The failed candidate remains resident and the pass continues at the // next serial once reclamation is available again. - const complete = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); try testing.expect(s.pages.first.?.next.?.isCompressed()); } @@ -6410,9 +6586,8 @@ test "PageList incremental compression restarts after replacement" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; - const initial = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, initial); + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); try testing.expect(s.pages.first.?.isCompressed()); const old = s.pages.first.?; @@ -6430,8 +6605,8 @@ test "PageList incremental compression restarts after replacement" { // The exact continuation serial disappeared with the old node. The pass // restarts at the first page and considers the oversized replacement. - const restarted = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, restarted); + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); try testing.expect(replacement.isCompressed()); } @@ -6442,17 +6617,15 @@ test "PageList incremental compression restarts after reset" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; - const initial = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, initial); + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); try testing.expect(s.pages.first.?.isCompressed()); - // Reset replaces every page and advances their serials. The stale marker - // must restart at the new first page rather than dereferencing old state. + // Reset replaces every page and clears the PageList-owned traversal. s.reset(); try s.growColdPagesForTest(1); - const restarted = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, restarted); + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); try testing.expect(s.pages.first.?.isCompressed()); } @@ -6463,9 +6636,8 @@ test "PageList incremental compression restarts after active boundary resize" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; - const initial = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, initial); + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); try testing.expect(s.pages.first.?.isCompressed()); const first = s.pages.first.?; @@ -6473,20 +6645,23 @@ test "PageList incremental compression restarts after active boundary resize" { try s.resize(.{ .rows = all_rows }); try testing.expectEqual(first, s.getTopLeft(.active).node); - // Restore the page while it is active. Because the continuation marker is - // no longer in the cold prefix, the next step resets itself and completes - // without reconsidering active contents. + // Restore the page while it is active. Resize reset the traversal, and + // active contents remain ineligible. _ = first.page(); - const active = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, active); + const active = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, active); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); // Shrinking the active area makes the page fully historical again. The - // marker was cleared above, so the next step can reclaim it. + // resize reset the PageList-owned cursor, so the next step can reclaim it. try s.resize(.{ .rows = 24 }); try s.growColdPagesForTest(1); try testing.expectEqual( - IncrementalCompressionResult.complete, - s.compressIncremental(&state), + IncrementalCompressionResult.pending, + s.compress(.incremental), ); try testing.expect(first.isCompressed()); } @@ -6503,9 +6678,8 @@ test "PageList incremental compression restarts after prune reuse" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; - const initial = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, initial); + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); try testing.expect(s.pages.first.?.isCompressed()); const reused = s.pages.first.?; @@ -6519,7 +6693,7 @@ test "PageList incremental compression restarts after prune reuse" { // Make the remaining old page fully historical. The continuation serial // disappeared when its node was recycled, so the pass safely restarts. try s.growColdPagesForTest(1); - _ = s.compressIncremental(&state); + _ = s.compress(.incremental); try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages); } @@ -6530,9 +6704,8 @@ test "PageList incremental compression restarts after earlier replacement" { defer s.deinit(); try s.growColdPagesForTest(3); - var state: IncrementalCompressionState = .{}; - _ = s.compressIncremental(&state); - _ = s.compressIncremental(&state); + _ = s.compress(.incremental); + _ = s.compress(.incremental); try testing.expect(s.pages.first.?.isCompressed()); try testing.expect(s.pages.first.?.next.?.isCompressed()); @@ -6548,7 +6721,7 @@ test "PageList incremental compression restarts after earlier replacement" { try testing.expect(replacement.serial != old_serial); try testing.expect(!replacement.isCompressed()); - _ = s.compressIncremental(&state); + _ = s.compress(.incremental); try testing.expect(replacement.isCompressed()); } @@ -6558,25 +6731,26 @@ test "PageList incremental compression keeps progress after tail growth" { var s = try init(testing.allocator, 80, 24, null); defer s.deinit(); try s.growColdPagesForTest(incremental_compression_max_inspected + 1); - s.compress(); + _ = s.compress(.full); var expected_last = s.pages.first.?; for (1..incremental_compression_max_inspected) |_| expected_last = expected_last.next.?; - var state: IncrementalCompressionState = .{}; - const first = s.compressIncremental(&state); + const first = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, first); - try testing.expectEqual(expected_last.serial, state.last_serial.?); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); // Allocate a new page at the active tail between steps. It is after the // continuation marker and must not restart progress through cold history. - expected_last = expected_last.next.?; const next_serial = s.page_serial; while (s.page_serial == next_serial) _ = try s.grow(); - const continued = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, continued); - try testing.expectEqual(expected_last.serial, state.last_serial.?); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.page_compression.verifying); } test "PageList memory stats do not restore compressed pages" { @@ -6600,7 +6774,7 @@ test "PageList memory stats do not restore compressed pages" { ); try testing.expectEqual(@as(usize, 0), before.estimatedSavings()); - s.compress(); + _ = s.compress(.full); const first = s.pages.first.?; try testing.expectEqual(Node.Storage.compressed, first.storage()); try testing.expect(first.pageIfResident() == null); @@ -6651,6 +6825,87 @@ test "PageList memory stats do not restore compressed pages" { ); } +test "PageList preserved page keeps compressed storage" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const resident = node.page(); + resident.dirty = true; + resident.getRowAndCell(3, 2).cell.* = .init('X'); + + // Resident nodes can be borrowed without allocating an unnecessary copy. + { + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + var preserved = try node.pagePreservingState(failing.allocator()); + defer preserved.deinit(); + switch (preserved) { + .borrowed => |page_| try testing.expectEqual(resident, page_), + .owned => try testing.expect(false), + } + try testing.expect(!failing.has_induced_failure); + } + + const expected = try alloc.dupe(u8, resident.memory); + defer alloc.free(expected); + const retained_ptr = resident.memory.ptr; + + try testing.expect(s.compressPage(node)); + const stats = s.memoryStats(); + const expected_encoded = try alloc.dupe(u8, node.data.compressed.encoded); + defer alloc.free(expected_encoded); + + // Test decommit simulates physical reclamation by clearing the retained + // mapping. A preserved page must decode elsewhere rather than restoring + // it. + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + + // Preserved-page allocation is opportunistic for callers. Failure leaves + // the node and its compressed representation untouched. + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + try testing.expectError( + error.OutOfMemory, + node.pagePreservingState(failing.allocator()), + ); + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + + var preserved = try node.pagePreservingState(alloc); + defer preserved.deinit(); + switch (preserved) { + .borrowed => try testing.expect(false), + .owned => {}, + } + const page_ = preserved.page(); + + try testing.expect(page_.memory.ptr != retained_ptr); + try testing.expectEqualSlices(u8, expected, page_.memory); + try testing.expect(page_.dirty); + try testing.expectEqual( + @as(u21, 'X'), + page_.getRowAndCell(3, 2).cell.content.codepoint, + ); + + // The node still owns the same compressed representation, and neither its + // storage accounting nor its discarded raw mapping changed while cloning. + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + try testing.expectEqual(retained_ptr, node.metadata().memory.ptr); + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + try testing.expectEqualSlices( + u8, + expected_encoded, + node.data.compressed.encoded, + ); +} + test "PageList memory stats include unused pool backing" { const testing = std.testing; @@ -6707,7 +6962,7 @@ test "PageList does not compress the mixed history and active page" { try testing.expectEqual(s.pages.first.?, active.node); try testing.expect(active.y > 0); - s.compress(); + _ = s.compress(.full); try testing.expect(!s.pages.first.?.isCompressed()); } @@ -6748,7 +7003,7 @@ test "PageList compresses only complete cold history pages" { const first_memory = first.page().memory.ptr; const page_size = s.page_size; - s.compress(); + _ = s.compress(.full); const memory = s.memoryStats(); try testing.expectEqual(expected_compressed, memory.compressed_pages); try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes); @@ -6788,7 +7043,7 @@ test "PageList lazily restores compressed history made active by resize" { const memory_len = first.page().memory.len; const page_size = s.page_size; - s.compress(); + _ = s.compress(.full); try testing.expect(first.isCompressed()); // Pull all scrollback into the active area by making the viewport as tall @@ -6805,7 +7060,7 @@ test "PageList lazily restores compressed history made active by resize" { // The compression pass must not reconsider the node now that it is active. // Content access follows the normal page boundary, which recommits and // restores the retained mapping before returning the cell. - s.compress(); + _ = s.compress(.full); try testing.expect(first.isCompressed()); const cell = s.getCell(.{ .active = .{} }).?; try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); @@ -6824,22 +7079,22 @@ test "PageList cold compression defers historical viewports and retries restored const page_size = s.page_size; s.scroll(.top); - s.compress(); + _ = s.compress(.full); try testing.expect(!s.pages.first.?.isCompressed()); s.scroll(.{ .row = 1 }); try testing.expect(s.viewport == .pin); - s.compress(); + _ = s.compress(.full); try testing.expect(!s.pages.first.?.isCompressed()); s.scroll(.active); - s.compress(); + _ = s.compress(.full); const initial = s.memoryStats(); try testing.expectEqual(@as(usize, 2), initial.compressed_pages); try testing.expectEqual(page_size, s.page_size); // A second pass skips the two compressed representations entirely. - s.compress(); + _ = s.compress(.full); try testing.expectEqual(initial, s.memoryStats()); // Content access restores one page. Because the policy is stateless, the @@ -6849,7 +7104,7 @@ test "PageList cold compression defers historical viewports and retries restored _ = first.page(); try testing.expect(!first.isCompressed()); - s.compress(); + _ = s.compress(.full); try testing.expectEqual(@as(usize, 2), s.memoryStats().compressed_pages); try testing.expectEqual(memory_ptr, first.metadata().memory.ptr); try testing.expectEqual(page_size, s.page_size); @@ -6868,7 +7123,7 @@ test "PageList cold compression continues after an incompressible page" { prng.random().bytes(first.page().memory); const page_size = s.page_size; - s.compress(); + _ = s.compress(.full); const memory = s.memoryStats(); try testing.expectEqual(@as(usize, 1), memory.compressed_pages); try testing.expect(!first.isCompressed()); @@ -6882,7 +7137,7 @@ test "PageList cold compression continues after an incompressible page" { // Failed resident candidates are deliberately retried on later passes, // while the successful page remains compressed and is skipped. - s.compress(); + _ = s.compress(.full); try testing.expectEqual(memory, s.memoryStats()); } diff --git a/src/terminal/compress/Page.zig b/src/terminal/compress/Page.zig index bc103bb66..b7ad80256 100644 --- a/src/terminal/compress/Page.zig +++ b/src/terminal/compress/Page.zig @@ -131,6 +131,15 @@ pub fn init( }; } +/// Free the encoded block. +/// +/// This intentionally does not free `page.memory`. The PageList node which +/// supplied the source page continues to own that pool or heap allocation. +pub fn deinit(self: *Page) void { + self.alloc.free(self.encoded); + self.* = undefined; +} + /// Restore the embedded terminal page into its retained resident mapping. /// /// The caller must recommit `page.memory` before calling this on platforms @@ -144,13 +153,25 @@ pub fn restore(self: *const Page) lz4.DecompressError!TerminalPage { return result; } -/// Free the encoded block. +/// Clone this page into caller-owned memory without restoring its mapping. /// -/// This intentionally does not free `page.memory`. The PageList node which -/// supplied the source page continues to own that pool or heap allocation. -pub fn deinit(self: *Page) void { - self.alloc.free(self.encoded); - self.* = undefined; +/// `memory` must be at least as large as the retained page mapping. Decoding +/// writes only to that buffer, so both the discarded contents of `page.memory` +/// and this value's encoded representation remain unchanged. The returned Page +/// borrows `memory`; the caller must keep it alive for the Page's lifetime and +/// release it directly rather than calling `TerminalPage.deinit`. +pub fn cloneBuf( + self: *const Page, + memory: []align(std.heap.page_size_min) u8, +) lz4.DecompressError!TerminalPage { + assert(memory.len >= self.page.memory.len); + + // Page internals are offsets into the backing buffer, so all metadata can + // be copied verbatim when paired with an equally laid-out mapping. + var result = self.page; + result.memory = memory[0..self.page.memory.len]; + _ = try lz4.decompress(self.encoded, result.memory); + return result; } test "compressed Page retained mapping round trip" { @@ -208,11 +229,31 @@ test "compressed Page retained mapping round trip" { try testing.expectEqual(memory_ptr, compressed.page.memory.ptr); try testing.expectEqual(memory_len, compressed.page.memory.len); try testing.expect(compressed.encoded.len < resident.memory.len); + const expected_encoded = try testing.allocator.dupe(u8, compressed.encoded); + defer testing.allocator.free(expected_encoded); // Virtual-memory operations belong to PageList, so clearing the contents // models a successful decommit: none of the resident bytes remain. @memset(resident.memory, 0); + // A clone decodes into independent storage for read-only consumers which + // must not change the compressed representation. In particular, it does + // not recommit or overwrite the retained source mapping. + const clone_memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + resident.memory.len, + ); + defer testing.allocator.free(clone_memory); + const cloned = try compressed.cloneBuf(clone_memory); + try testing.expect(cloned.memory.ptr != memory_ptr); + try testing.expect(std.mem.allEqual(u8, resident.memory, 0)); + try testing.expectEqualSlices(u8, expected_encoded, compressed.encoded); + try testing.expectEqualSlices(u8, expected, cloned.memory); + try testing.expectEqual(resident.size, cloned.size); + try testing.expect(cloned.dirty); + try cloned.verifyIntegrity(testing.allocator); + const restored = try compressed.restore(); try testing.expectEqual(memory_ptr, restored.memory.ptr); try testing.expectEqual(memory_len, restored.memory.len); @@ -336,6 +377,17 @@ test "compressed Page can retry after malformed encoded data" { } compressed.encoded = full_encoded[0..1]; compressed.encoded[0] = 0xF0; + + const clone_memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + resident.memory.len, + ); + defer testing.allocator.free(clone_memory); + try testing.expectError( + error.TruncatedInput, + compressed.cloneBuf(clone_memory), + ); try testing.expectError(error.TruncatedInput, compressed.restore()); compressed.encoded = full_encoded;