diff --git a/src/benchmark/ScrollbackCompression.zig b/src/benchmark/ScrollbackCompression.zig index b24d6e348..d541f25fd 100644 --- a/src/benchmark/ScrollbackCompression.zig +++ b/src/benchmark/ScrollbackCompression.zig @@ -24,12 +24,15 @@ //! //! * `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 `compressColdPages` pass and retains its -//! aggregate statistics until teardown. +//! * `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`. //! * `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. -//! * `report` performs one compression pass and prints aggregate page counts, +//! * `report` performs one compression pass and prints compressed page count, //! encoded ratio, and estimated resident-byte savings. It is intended for //! inspecting a corpus rather than timing comparisons. //! @@ -57,6 +60,7 @@ //! hyperfine --warmup 3 \ //! 'ghostty-bench +scrollback-compression --mode=noop --data=/tmp/scrollback.vt' \ //! 'ghostty-bench +scrollback-compression --mode=compress --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=incremental --data=/tmp/scrollback.vt' \ //! 'ghostty-bench +scrollback-compression --mode=restore --data=/tmp/scrollback.vt' const ScrollbackCompression = @This(); @@ -73,10 +77,8 @@ const log = std.log.scoped(.@"scrollback-compression-bench"); opts: Options, terminal: Terminal, -/// Result of the timed compression pass, or the setup compression performed -/// for restore mode. Keeping this on the benchmark state prevents the compiler -/// from treating the complete pass as dead work. -stats: PageList.CompressionStats = .{}, +/// 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 @@ -113,6 +115,9 @@ pub const Mode = enum { /// Compress every eligible fully historical page once. compress, + /// Compress every eligible page through bounded resumable steps. + incremental, + /// Restore pages compressed outside the timed region. restore, @@ -148,6 +153,7 @@ pub fn benchmark(self: *ScrollbackCompression) Benchmark { .stepFn = switch (self.opts.mode) { .noop => stepNoop, .compress => stepCompress, + .incremental => stepIncremental, .restore => stepRestore, .report => stepReport, }, @@ -161,7 +167,7 @@ pub fn benchmark(self: *ScrollbackCompression) Benchmark { fn setup(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); self.terminal.fullReset(); - self.stats = .{}; + self.incremental_state.reset(); self.loadCorpus() catch |err| { log.warn("failed to prepare scrollback compression benchmark err={}", .{err}); @@ -169,7 +175,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { }; if (self.opts.mode == .restore) { - self.stats = self.pages().compressColdPages(); + self.pages().compress(); } } @@ -209,8 +215,34 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.stats = self.pages().compressColdPages(); - std.mem.doNotOptimizeAway(&self.stats); + self.pages().compress(); + 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. +/// +/// 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, + )) { + .pending => continue, + .unsupported, + .deferred, + .complete, + => return, + } + } } fn stepRestore(ptr: *anyopaque) Benchmark.Error!void { @@ -239,19 +271,21 @@ fn visitColdPages(self: *ScrollbackCompression) usize { fn stepReport(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.stats = self.pages().compressColdPages(); + self.pages().compress(); + const memory = self.pages().memoryStats(); - const savings = self.stats.raw_bytes -| self.stats.encoded_bytes; std.debug.print( - "scrollback-compression attempted={d} compressed={d} raw={d} " ++ + "scrollback-compression compressed={d} raw={d} " ++ "encoded={d} ratio={d:.2}% savings={d}\n", .{ - self.stats.attempted_pages, - self.stats.compressed_pages, - self.stats.raw_bytes, - self.stats.encoded_bytes, - percentage(self.stats.encoded_bytes, self.stats.raw_bytes), - savings, + memory.compressed_pages, + memory.decommitted_raw_bytes, + memory.encoded_bytes, + percentage( + memory.encoded_bytes, + memory.decommitted_raw_bytes, + ), + memory.estimatedSavings(), }, ); } @@ -287,13 +321,45 @@ test "ScrollbackCompression restores cold terminal pages" { defer stream.deinit(); for (0..256) |_| stream.nextSlice("aaaa\r\n"); - impl.stats = impl.pages().compressColdPages(); - try testing.expect(impl.stats.compressed_pages > 0); - try testing.expect(impl.visitColdPages() >= impl.stats.compressed_pages); + impl.pages().compress(); + const compressed = impl.pages().memoryStats(); + try testing.expect(compressed.compressed_pages > 0); + try testing.expect(impl.visitColdPages() >= compressed.compressed_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. - const recompressed = impl.pages().compressColdPages(); - try testing.expectEqual(impl.stats.compressed_pages, recompressed.compressed_pages); + impl.pages().compress(); + const recompressed = impl.pages().memoryStats(); + try testing.expectEqual( + compressed.compressed_pages, + recompressed.compressed_pages, + ); +} + +test "ScrollbackCompression drains incremental compression steps" { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{ + .mode = .incremental, + .@"terminal-rows" = 4, + .@"terminal-cols" = 215, + .@"max-scrollback" = 1_000_000, + }); + defer impl.destroy(testing.allocator); + + var stream = impl.terminal.vtStream(); + 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); + + // Restore the same pages and compare against the monolithic operation. + // Both paths should produce the same final storage representation. + _ = impl.visitColdPages(); + impl.pages().compress(); + const monolithic = impl.pages().memoryStats(); + try testing.expectEqual(monolithic, incremental); } diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig index 844abc355..e332ab3c6 100644 --- a/src/inspector/widgets/page.zig +++ b/src/inspector/widgets/page.zig @@ -25,8 +25,9 @@ pub fn inspector(page: *const terminal.Page) void { /// the tree node is open or not. If it is open you must close it with /// TreePop. pub fn treeNode(state: struct { - /// The page - page: *const terminal.Page, + /// Page dimensions available without reading its backing memory. + cols: terminal.size.CellCountInt, + rows: terminal.size.CellCountInt, /// The index of the page in a page list, used for headers. index: usize, /// The range of rows this page covers, inclusive. @@ -34,6 +35,10 @@ pub fn treeNode(state: struct { /// Whether this page is the active or viewport node. active: bool, viewport: bool, + /// Whether the page backing memory is currently compressed. + compressed: bool, + /// Dirty state is unavailable without restoring a compressed page. + dirty: ?bool, }) bool { // Setup our node. const open = open: { @@ -61,8 +66,8 @@ pub fn treeNode(state: struct { // Metadata cimgui.c.ImGui_TextDisabled( "%dc x %dr", - state.page.size.cols, - state.page.size.rows, + state.cols, + state.rows, ); cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_Text("rows %d..%d", state.row_range[0], state.row_range[1]); @@ -76,7 +81,11 @@ pub fn treeNode(state: struct { cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport"); } - if (state.page.isDirty()) { + if (state.compressed) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.8, .y = 0.6, .z = 1.0, .w = 1.0 }, "compressed"); + } + if (state.dirty orelse false) { cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); } diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig index 651882046..4041ba332 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -58,8 +58,10 @@ pub const Inspector = struct { var index: usize = pages.totalPages(); var node = pages.pages.last; while (node) |page_node| : (node = page_node.prev) { - const page = page_node.page(); - row_offset -= page.size.rows; + const rows = page_node.rows(); + const resident = page_node.pageIfResident(); + const compressed = page_node.storage() == .compressed; + row_offset -= rows; index -= 1; // We use our location as the ID so that even if reallocations @@ -69,13 +71,21 @@ pub const Inspector = struct { // Open up the tree node. if (!widgets.page.treeNode(.{ - .page = page, + .cols = page_node.cols(), + .rows = rows, .index = index, - .row_range = .{ row_offset, row_offset + page.size.rows - 1 }, + .row_range = .{ row_offset, row_offset + rows - 1 }, .active = node == active_pin.node, .viewport = node == viewport_pin.node, + .compressed = compressed, + .dirty = if (resident) |page| page.isDirty() else null, })) continue; defer cimgui.c.ImGui_TreePop(); + + // Opening a compressed entry is an explicit request for its + // contents, so restoration is appropriate here. Collapsed + // entries use only metadata and remain compressed. + const page = resident orelse page_node.page(); widgets.page.inspector(page); } } @@ -83,6 +93,8 @@ pub const Inspector = struct { }; fn summaryTable(pages: *const PageList) void { + const memory = pages.memoryStats(); + if (!cimgui.c.ImGui_BeginTable( "pagelist_summary", 3, @@ -106,7 +118,18 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Total number of pages in the linked list."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.totalPages()); + cimgui.c.ImGui_Text("%zu", pages.totalPages()); + + summaryCountRow( + "Resident Pages", + "Pages whose raw backing memory is currently resident.", + memory.resident_pages, + ); + summaryCountRow( + "Compressed Pages", + "Pages retained as encoded data with their raw backing memory discarded.", + memory.compressed_pages, + ); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -114,17 +137,42 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Total rows represented by scrollback + active area."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.total_rows); + cimgui.c.ImGui_Text("%zu", pages.total_rows); - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Page Bytes"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker("Total bytes allocated for active pages."); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text( - "%d KiB", - units.toKibiBytes(pages.page_size), + summaryBytesRow( + "Logical Page Bytes", + "Bytes counted against the scrollback limit. Compression does not change this value.", + pages.page_size, + ); + summaryBytesRow( + "Raw Mapping Bytes", + "Total initialized raw page mappings, including discarded mappings retained for restoration.", + memory.raw_bytes, + ); + summaryBytesRow( + "Resident Raw Bytes", + "Raw page mapping bytes which have not been discarded.", + memory.resident_raw_bytes, + ); + summaryBytesRow( + "Decommitted Raw Bytes", + "Raw page mapping bytes discarded while their virtual addresses remain reserved.", + memory.decommitted_raw_bytes, + ); + summaryBytesRow( + "Encoded Bytes", + "Exact encoded storage retained for compressed pages.", + memory.encoded_bytes, + ); + summaryBytesRow( + "Estimated Resident Bytes", + "Resident raw allocation backing, including unused pool-item tails, plus encoded storage. Allocator and representation overhead are excluded.", + memory.estimatedResidentBytes(), + ); + summaryBytesRow( + "Estimated Savings", + "Decommitted raw mapping bytes minus their replacement encoded storage.", + memory.estimatedSavings(), ); cimgui.c.ImGui_TableNextRow(); @@ -139,7 +187,7 @@ fn summaryTable(pages: *const PageList) void { ); _ = cimgui.c.ImGui_TableSetColumnIndex(2); cimgui.c.ImGui_Text( - "%d KiB", + "%zu KiB", units.toKibiBytes(pages.maxSize()), ); @@ -157,7 +205,39 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Number of pins tracked for automatic updates."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.countTrackedPins()); + cimgui.c.ImGui_Text("%zu", pages.countTrackedPins()); +} + +fn summaryCountRow( + label: [:0]const u8, + help: [:0]const u8, + value: usize, +) void { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", label.ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker(help); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%zu", value); +} + +fn summaryBytesRow( + label: [:0]const u8, + help: [:0]const u8, + bytes: usize, +) void { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", label.ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker(help); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text( + "%zu bytes (%zu KiB)", + bytes, + units.toKibiBytes(bytes), + ); } fn scrollbarInfo(pages: *PageList) void { @@ -321,11 +401,18 @@ fn trackedPinsTable(pages: *const PageList) void { } _ = cimgui.c.ImGui_TableSetColumnIndex(3); - const dirty = pin.isDirty(); - if (dirty) { - cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + if (pin.node.pageIfResident()) |page| { + const dirty = page.dirty or + page.getRowAndCell(pin.x, pin.y).row.dirty; + if (dirty) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } else { + cimgui.c.ImGui_TextDisabled("clean"); + } } else { - cimgui.c.ImGui_TextDisabled("clean"); + // Dirty state lives in the discarded mapping. Keep inspector + // traversal metadata-only rather than restoring this page. + cimgui.c.ImGui_TextDisabled("compressed"); } _ = cimgui.c.ImGui_TableSetColumnIndex(4); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 26a52ec69..e5a9f514b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -12,7 +12,7 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); -const compress = @import("compress.zig"); +const compression = @import("compress.zig"); const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); const terminal_mem = @import("mem.zig"); @@ -74,11 +74,14 @@ const Node = struct { /// virtual allocation so that restoration is infallible. const Data = union(enum) { resident: Page, - compressed: compress.Page, + compressed: compression.Page, }; const Owned = enum { pool, heap }; + /// The backing-memory representation currently stored by this node. + pub const Storage = enum { resident, compressed }; + /// Return the terminal page stored in this node. /// /// WARNING: This will DECOMPRESS compressed pages! Only use this if @@ -91,6 +94,27 @@ const Node = struct { }; } + /// Return the terminal page only when its raw memory is resident. + /// + /// Unlike `page`, this never restores a compressed node. This is useful + /// for diagnostics which can display node metadata without touching the + /// discarded mapping, but may inspect page contents when they are already + /// available. + pub inline fn pageIfResident(self: *Node) ?*Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => null, + }; + } + + /// Return the node's backing-memory representation without restoring it. + pub inline fn storage(self: *const Node) Storage { + return switch (self.data) { + .resident => .resident, + .compressed => .compressed, + }; + } + /// Return a page which the caller knows is already resident. /// /// This avoids the representation check in hot paths which already hold @@ -358,26 +382,6 @@ pub const Viewport = union(enum) { pin, }; -/// Results from one opportunistic cold-history compression pass. -/// -/// Only pages whose raw mappings were successfully discarded contribute to -/// the byte totals. A page that is already compressed is skipped rather than -/// counted as another attempt, while a resident page that fails compression -/// contributes only to `attempted_pages`. -pub const CompressionStats = struct { - /// Resident cold pages passed to the compression primitive. - attempted_pages: usize = 0, - - /// Attempts which produced encoded storage and discarded the raw mapping. - compressed_pages: usize = 0, - - /// Bytes in raw mappings discarded by successful attempts. - raw_bytes: usize = 0, - - /// Exact encoded bytes retained for successful attempts. - encoded_bytes: usize = 0, -}; - /// Returns the minimum valid "max size" for a given number of rows and cols /// such that we can fit the active area AND at least two pages. Note we /// need the two pages for algorithms to work properly (such as grow) but @@ -3739,6 +3743,151 @@ const CompressionScratch = union(enum) { } }; +/// Caller-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 +/// between steps. It also records the first serial which was unallocated at +/// the prior step so new nodes before a valid marker restart safely. If the +/// 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 { + /// 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. + 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, + + /// Restart the next step at the first page in the list. + pub fn reset(self: *IncrementalCompressionState) void { + self.* = .{}; + } +}; + +/// Result of one incremental compression step. +pub const IncrementalCompressionResult = enum { + /// Strict retained-mapping reclamation is unavailable on this target. + unsupported, + + /// The viewport is displaying history, so compression is postponed. + deferred, + + /// More cold pages remain after this invocation's candidate-bounded work. + pending, + + /// The traversal reached the page containing the first active row. + complete, +}; + +/// Bound candidate inspection independently from compression work. Skipping +/// an already-compressed page is cheap, but still counts toward this limit. +const incremental_compression_max_inspected = 8; + +/// Failure injection for the final reclamation step. The operating-system +/// failure path cannot otherwise be exercised by tests because terminal_mem +/// deliberately simulates successful decommit in test builds. +const compressPage_tw = tripwire.module( + enum { decommit }, + error{DecommitFailed}, +); + +/// Perform one candidate-bounded incremental cold-history compression step. +/// +/// 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. +/// +/// 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. +/// +/// 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( + self: *PageList, + state: *IncrementalCompressionState, +) IncrementalCompressionResult { + if (!terminal_mem.canReclaim(.strict)) return .unsupported; + + if (self.viewport != .active) return .deferred; + + const active_node = self.getTopLeft(.active).node; + + // 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. + 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; + + // 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; + } + + if (!found) { + state.last_serial = null; + current = self.pages.first.?; + } + } + state.next_serial = self.page_serial; + + var inspected_pages: usize = 0; + while (current != active_node and + inspected_pages < incremental_compression_max_inspected) + { + const node = current; + current = node.next.?; + state.last_serial = node.serial; + inspected_pages += 1; + + if (node.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); + break; + } + + return if (current == active_node) .complete else .pending; +} + /// Compress every fully historical resident page which is currently cold. /// /// A page is fully historical only when it precedes the node containing the @@ -3759,16 +3908,14 @@ const CompressionScratch = union(enum) { /// 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 compressColdPages(self: *PageList) CompressionStats { - var result: CompressionStats = .{}; - +pub fn compress(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 result; + if (!terminal_mem.canReclaim(.strict)) return; // Rendering a historical viewport may need any history page. Deferring // the complete pass avoids compressing data that is about to be restored. - if (self.viewport != .active) return result; + if (self.viewport != .active) return; // The active top is metadata-only and does not restore its page. Every // node strictly before it is fully historical; the boundary node may @@ -3781,27 +3928,17 @@ pub fn compressColdPages(self: *PageList) CompressionStats { // Don't restore an already-compressed page just to recompress it. if (node.isCompressed()) continue; - // Count attempts and capture the mapping size before changing state. - result.attempted_pages += 1; - const raw_len = node.metadata().memory.len; - // Failure leaves this node resident and unchanged. - if (!self.compressPage(node)) continue; - - // Only successful reclamation contributes to the byte totals. - result.compressed_pages += 1; - result.raw_bytes += raw_len; - result.encoded_bytes += node.data.compressed.encoded.len; + _ = self.compressPage(node); } - - return result; } /// 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 -/// `compressColdPages`; this primitive only performs one state transition. +/// `compress` and `compressIncremental`; 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. @@ -3812,7 +3949,7 @@ fn compressPage(self: *PageList, node: *List.Node) bool { // The scratch size is capped just below the representation's break-even // point. Codec limits and pages too small to cover the compressed-state // overhead simply make this page ineligible for compression. - const required = compress.Page.requiredScratch(page.memory.len) catch |err| + const required = compression.Page.requiredScratch(page.memory.len) catch |err| switch (err) { error.InputTooLarge, error.OutputTooSmall, @@ -3834,8 +3971,8 @@ fn compressPage(self: *PageList, node: *List.Node) bool { }; defer scratch.deinit(&self.pool); - var table: compress.lz4.HashTable = undefined; - break :candidate compress.Page.init( + var table: compression.lz4.HashTable = undefined; + break :candidate compression.Page.init( self.pool.alloc, page, scratch.bytes()[0..required], @@ -3855,7 +3992,13 @@ fn compressPage(self: *PageList, node: *List.Node) bool { // Strict decommit is the final fallible step. It either discards the whole // raw mapping or leaves it untouched, so failure can safely free the // candidate and preserve the resident node exactly as it was. - if (!terminal_mem.decommit( + const decommit_allowed: bool = allowed: { + compressPage_tw.check(.decommit) catch |err| switch (err) { + error.DecommitFailed => break :allowed false, + }; + break :allowed true; + }; + if (!decommit_allowed or !terminal_mem.decommit( .strict, compressed.page.memory, compressed.page.memory.len, @@ -5520,6 +5663,88 @@ pub fn totalPages(self: *const PageList) usize { return pages; } +/// Snapshot of the storage used by page nodes in this list. +/// +/// The raw byte counts describe page backing mappings only. They exclude +/// nodes, allocator metadata, unused preheated pool items, and the small +/// representation values stored in each node. A compressed page retains its +/// raw mapping as virtual address space, but its bytes are counted as +/// decommitted because strict reclamation succeeded before the state was +/// published. +pub const MemoryStats = struct { + /// Pages whose raw backing mappings are resident. + resident_pages: usize = 0, + + /// Pages represented by encoded storage and a decommitted raw mapping. + compressed_pages: usize = 0, + + /// Logical bytes in every raw page mapping. + raw_bytes: usize = 0, + + /// Raw mapping bytes which remain resident. + resident_raw_bytes: usize = 0, + + /// Raw mapping bytes discarded for compressed pages. + decommitted_raw_bytes: usize = 0, + + /// Raw allocation bytes which remain physically resident. + /// + /// This can exceed `resident_raw_bytes` because a pool-owned page uses + /// only part of a standard pool item. Compressing such a page decommits + /// its initialized range, but the unused tail of the item stays resident. + resident_backing_bytes: usize = 0, + + /// Exact encoded allocations retained for compressed pages. + encoded_bytes: usize = 0, + + /// Estimate resident page backing storage after compression. + pub fn estimatedResidentBytes(self: MemoryStats) usize { + return self.resident_backing_bytes + self.encoded_bytes; + } + + /// Estimate physical bytes avoided by compressed page backing storage. + pub fn estimatedSavings(self: MemoryStats) usize { + return self.decommitted_raw_bytes -| self.encoded_bytes; + } +}; + +/// Return a metadata-only snapshot of page backing storage. +/// +/// This never restores compressed pages. It is intended for diagnostics and +/// other infrequent reporting because it traverses the complete page list. +pub fn memoryStats(self: *const PageList) MemoryStats { + var result: MemoryStats = .{}; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + const raw_len = node.metadata().memory.len; + const backing_len = switch (node.owned) { + .pool => PagePool.item_size, + .heap => raw_len, + }; + assert(backing_len >= raw_len); + result.raw_bytes += raw_len; + + switch (node.data) { + .resident => { + result.resident_pages += 1; + result.resident_raw_bytes += raw_len; + result.resident_backing_bytes += backing_len; + }, + + .compressed => |compressed| { + result.compressed_pages += 1; + result.decommitted_raw_bytes += raw_len; + // Strict reclamation covers only Page.memory. A standard pool + // item can have an unused tail which remains resident. + result.resident_backing_bytes += backing_len - raw_len; + result.encoded_bytes += compressed.encoded.len; + }, + } + } + + return result; +} + /// Grow the number of rows available in the page list by n. /// This is only used for testing so it isn't optimized in any way. fn growRows(self: *PageList, n: usize) Allocator.Error!void { @@ -6035,6 +6260,439 @@ fn growColdPagesForTest(self: *PageList, count: usize) !void { } } +test "PageList incremental compression defers and completes" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + s.scroll(.top); + const deferred = s.compressIncremental(&state); + 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); + 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); + + // Restoring a page behind the marker does not cause immediate churn. The + // caller explicitly resets when it wants a new pass to reconsider it. + _ = 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); + try testing.expect(cold.isCompressed()); +} + +test "PageList incremental compression bounds inspected pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + + // Precompress every candidate so the incremental pass exercises its + // metadata-only skip budget without stopping at a resident attempt. + s.compress(); + try testing.expectEqual( + incremental_compression_max_inspected + 1, + s.memoryStats().compressed_pages, + ); + + 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); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual(expected_last.serial, state.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.?); +} + +test "PageList incremental compression advances after failure" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353); + prng.random().bytes(first.page().memory); + + var state: IncrementalCompressionState = .{}; + const failed = s.compressIncremental(&state); + 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); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after allocation failure" { + const testing = std.testing; + + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + const first = s.pages.first.?; + const second = first.next.?; + + // Pool preheating supplies compression scratch. Failing the allocator's + // 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); + try testing.expect(failing.has_induced_failure); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // 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); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after decommit failure" { + const testing = std.testing; + const tw = compressPage_tw; + defer tw.end(.reset) catch unreachable; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + tw.errorAlways(.decommit, error.DecommitFailed); + var state: IncrementalCompressionState = .{}; + const failed = s.compressIncremental(&state); + 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); + try testing.expect(s.pages.first.?.next.?.isCompressed()); +} + +test "PageList incremental compression restarts after replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + const initial = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const old = s.pages.first.?; + const old_serial = old.serial; + var replacement = old; + while (replacement.page().memory.len <= std_size) { + replacement = try s.increaseCapacity( + replacement, + .grapheme_bytes, + ); + } + try testing.expect(replacement.serial != old_serial); + try testing.expect(replacement.page().memory.len > std_size); + try testing.expect(!replacement.isCompressed()); + + // 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); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression restarts after reset" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + const initial = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, 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. + s.reset(); + try s.growColdPagesForTest(1); + const restarted = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, restarted); + try testing.expect(s.pages.first.?.isCompressed()); +} + +test "PageList incremental compression restarts after active boundary resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + const initial = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const first = s.pages.first.?; + const all_rows: size.CellCountInt = @intCast(s.total_rows); + 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. + _ = first.page(); + const active = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, active); + + // Shrinking the active area makes the page fully historical again. The + // marker was cleared above, so the next step can reclaim it. + try s.resize(.{ .rows = 24 }); + try s.growColdPagesForTest(1); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compressIncremental(&state), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList incremental compression restarts after prune reuse" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + const initial = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const reused = s.pages.first.?; + const old_serial = reused.serial; + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + try testing.expectEqual(reused, (try s.grow()).?); + try testing.expect(reused.serial != old_serial); + + // 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); + try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages); +} + +test "PageList incremental compression restarts after earlier replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + var state: IncrementalCompressionState = .{}; + _ = s.compressIncremental(&state); + _ = s.compressIncremental(&state); + try testing.expect(s.pages.first.?.isCompressed()); + try testing.expect(s.pages.first.?.next.?.isCompressed()); + + // Replace a page before the still-valid continuation marker. The list's + // allocation serial changes even though the marker itself remains, so the + // next step must restart and inspect the replacement. + const old_first = s.pages.first.?; + const old_serial = old_first.serial; + const replacement = try s.increaseCapacity( + old_first, + .grapheme_bytes, + ); + try testing.expect(replacement.serial != old_serial); + try testing.expect(!replacement.isCompressed()); + + _ = s.compressIncremental(&state); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression keeps progress after tail growth" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + s.compress(); + + 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); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual(expected_last.serial, state.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.?); +} + +test "PageList memory stats do not restore compressed pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const before = s.memoryStats(); + try testing.expectEqual(s.totalPages(), before.resident_pages); + try testing.expectEqual(@as(usize, 0), before.compressed_pages); + try testing.expectEqual(s.page_size, before.raw_bytes); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(@as(usize, 0), before.decommitted_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(@as(usize, 0), before.encoded_bytes); + try testing.expectEqual( + before.resident_backing_bytes, + before.estimatedResidentBytes(), + ); + try testing.expectEqual(@as(usize, 0), before.estimatedSavings()); + + s.compress(); + const first = s.pages.first.?; + try testing.expectEqual(Node.Storage.compressed, first.storage()); + try testing.expect(first.pageIfResident() == null); + + const after = s.memoryStats(); + try testing.expect(first.isCompressed()); + try testing.expectEqual(s.totalPages(), after.resident_pages + after.compressed_pages); + try testing.expectEqual(@as(usize, 2), after.compressed_pages); + try testing.expectEqual(s.page_size, after.raw_bytes); + try testing.expectEqual( + after.raw_bytes, + after.resident_raw_bytes + after.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + after.encoded_bytes, + after.estimatedResidentBytes(), + ); + try testing.expectEqual( + after.decommitted_raw_bytes - after.encoded_bytes, + after.estimatedSavings(), + ); + + const first_raw_len = first.metadata().memory.len; + const first_encoded_len = first.data.compressed.encoded.len; + _ = first.page(); + try testing.expectEqual(Node.Storage.resident, first.storage()); + try testing.expect(first.pageIfResident() != null); + + const restored = s.memoryStats(); + try testing.expectEqual(after.resident_pages + 1, restored.resident_pages); + try testing.expectEqual(after.compressed_pages - 1, restored.compressed_pages); + try testing.expectEqual(after.raw_bytes, restored.raw_bytes); + try testing.expectEqual( + after.resident_raw_bytes + first_raw_len, + restored.resident_raw_bytes, + ); + try testing.expectEqual( + after.decommitted_raw_bytes - first_raw_len, + restored.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + first_raw_len, + restored.resident_backing_bytes, + ); + try testing.expectEqual( + after.encoded_bytes - first_encoded_len, + restored.encoded_bytes, + ); +} + +test "PageList memory stats include unused pool backing" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // Pool allocation ownership is based on the requested layout fitting in a + // standard item. The Page itself exposes only the initialized prefix. + const node = try s.createPage(.{ .cap = .{ .cols = 1, .rows = 1 } }); + try testing.expectEqual(Node.Owned.pool, node.owned); + try testing.expect(node.page().memory.len < PagePool.item_size); + node.page().size.rows = 1; + s.pages.append(node); + s.total_rows += 1; + + const raw_len = node.metadata().memory.len; + const before = s.memoryStats(); + try testing.expect(before.raw_bytes < s.page_size); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(s.page_size, before.estimatedResidentBytes()); + + try testing.expect(s.compressPage(node)); + const encoded_len = node.data.compressed.encoded.len; + const compressed = s.memoryStats(); + try testing.expectEqual(before.raw_bytes, compressed.raw_bytes); + try testing.expectEqual( + before.resident_raw_bytes - raw_len, + compressed.resident_raw_bytes, + ); + try testing.expectEqual(raw_len, compressed.decommitted_raw_bytes); + try testing.expectEqual( + before.resident_backing_bytes - raw_len, + compressed.resident_backing_bytes, + ); + try testing.expectEqual(encoded_len, compressed.encoded_bytes); + try testing.expectEqual( + before.estimatedResidentBytes() - raw_len + encoded_len, + compressed.estimatedResidentBytes(), + ); +} + test "PageList does not compress the mixed history and active page" { const testing = std.testing; @@ -6049,7 +6707,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); - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); try testing.expect(!s.pages.first.?.isCompressed()); } @@ -6073,15 +6731,15 @@ test "PageList compresses only complete cold history pages" { try testing.expect(active.y > 0); try testing.expect(active_node != s.pages.last.?); - var expected_attempts: usize = 0; + var expected_compressed: usize = 0; var expected_raw_bytes: usize = 0; var current = s.pages.first; while (current) |node| : (current = node.next) { if (node == active_node) break; - expected_attempts += 1; + expected_compressed += 1; expected_raw_bytes += node.metadata().memory.len; } - try testing.expectEqual(@as(usize, 2), expected_attempts); + try testing.expectEqual(@as(usize, 2), expected_compressed); const first = s.pages.first.?; first.page().getRowAndCell(0, 0).cell.* = .init('X'); @@ -6090,11 +6748,11 @@ test "PageList compresses only complete cold history pages" { const first_memory = first.page().memory.ptr; const page_size = s.page_size; - const stats = s.compressColdPages(); - try testing.expectEqual(expected_attempts, stats.attempted_pages); - try testing.expectEqual(expected_attempts, stats.compressed_pages); - try testing.expectEqual(expected_raw_bytes, stats.raw_bytes); - try testing.expect(stats.encoded_bytes < stats.raw_bytes); + s.compress(); + const memory = s.memoryStats(); + try testing.expectEqual(expected_compressed, memory.compressed_pages); + try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); try testing.expectEqual(page_size, s.page_size); current = s.pages.first; @@ -6104,7 +6762,7 @@ test "PageList compresses only complete cold history pages" { try testing.expect(node.isCompressed()); actual_encoded_bytes += node.data.compressed.encoded.len; } - try testing.expectEqual(actual_encoded_bytes, stats.encoded_bytes); + try testing.expectEqual(actual_encoded_bytes, memory.encoded_bytes); current = active_node; while (current) |node| : (current = node.next) { try testing.expect(!node.isCompressed()); @@ -6130,8 +6788,7 @@ test "PageList lazily restores compressed history made active by resize" { const memory_len = first.page().memory.len; const page_size = s.page_size; - const compressed = s.compressColdPages(); - try testing.expectEqual(@as(usize, 1), compressed.compressed_pages); + s.compress(); try testing.expect(first.isCompressed()); // Pull all scrollback into the active area by making the viewport as tall @@ -6148,7 +6805,8 @@ 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. - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); + try testing.expect(first.isCompressed()); const cell = s.getCell(.{ .active = .{} }).?; try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); try testing.expect(!first.isCompressed()); @@ -6166,22 +6824,23 @@ test "PageList cold compression defers historical viewports and retries restored const page_size = s.page_size; s.scroll(.top); - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); try testing.expect(!s.pages.first.?.isCompressed()); s.scroll(.{ .row = 1 }); try testing.expect(s.viewport == .pin); - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); try testing.expect(!s.pages.first.?.isCompressed()); s.scroll(.active); - const initial = s.compressColdPages(); - try testing.expectEqual(@as(usize, 2), initial.attempted_pages); + s.compress(); + 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. - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); + try testing.expectEqual(initial, s.memoryStats()); // Content access restores one page. Because the policy is stateless, the // next explicit pass sees that resident page and compresses it again. @@ -6190,9 +6849,8 @@ test "PageList cold compression defers historical viewports and retries restored _ = first.page(); try testing.expect(!first.isCompressed()); - const retry = s.compressColdPages(); - try testing.expectEqual(@as(usize, 1), retry.attempted_pages); - try testing.expectEqual(@as(usize, 1), retry.compressed_pages); + s.compress(); + 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); } @@ -6210,22 +6868,22 @@ test "PageList cold compression continues after an incompressible page" { prng.random().bytes(first.page().memory); const page_size = s.page_size; - const stats = s.compressColdPages(); - try testing.expectEqual(@as(usize, 2), stats.attempted_pages); - try testing.expectEqual(@as(usize, 1), stats.compressed_pages); + s.compress(); + const memory = s.memoryStats(); + try testing.expectEqual(@as(usize, 1), memory.compressed_pages); try testing.expect(!first.isCompressed()); try testing.expect(second.isCompressed()); - try testing.expectEqual(second.metadata().memory.len, stats.raw_bytes); - try testing.expect(stats.encoded_bytes < stats.raw_bytes); + try testing.expectEqual( + second.metadata().memory.len, + memory.decommitted_raw_bytes, + ); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); try testing.expectEqual(page_size, s.page_size); // Failed resident candidates are deliberately retried on later passes, // while the successful page remains compressed and is skipped. - const retry = s.compressColdPages(); - try testing.expectEqual(@as(usize, 1), retry.attempted_pages); - try testing.expectEqual(@as(usize, 0), retry.compressed_pages); - try testing.expectEqual(@as(usize, 0), retry.raw_bytes); - try testing.expectEqual(@as(usize, 0), retry.encoded_bytes); + s.compress(); + try testing.expectEqual(memory, s.memoryStats()); } test "PageList compression restores through page access" {