mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-10 11:19:45 +00:00
terminal: scrollback page compression (70 to 90% memory savings) (#13264)
This adds transparent compression for non-active/non-viewport scrollback pages, reducing physical memory for compressed pages by anywhere from 70% to 90%. Compression is obviously highly dependent on the shape of the data, but these are the numbers I got for normal scrollback. Due to compression being available, I bumped the default scrollback limit from 10MB to 50MB. On average, a full scrollback still uses less memory than the prior limit due to the compression ratios. ## Demo Here is a demo video showing me filling my scrollback and using the inspector so you can see the live compression/decompression activity and results: https://github.com/user-attachments/assets/7b9d0383-42f7-47bf-8b3f-853e3f89549c ## Resident vs. Virtual Memory This PR works by lowering _resident/physicalmemory, but doesn't touch _virtual_ memory. Practically what this means is that users need to make sure they're looking at resident memory to see the change. We use OS primitives like `MADV_DONTNEED` on Linux or `MADV_FREE_REUSABLE` on Darwin to discard our physical memory, but retain our virtual memory allocations. This is awesome because it means our decompression is infallible: the OS has already given us the memory, but it just remaps it at that point. This is baked into the core implementation, so compression only works on systems that support an OS primitive to retain virtual mappings while discarding physical. Today, that is macOS and 64-bit Linux. Other operating systems have support we just haven't coded it up yet. ## Implementation A major refactor had to happen to PageList to represent nodes as either resident or compressed. Pins typically accessed node content directly so we had to add a bunch of helpers to read metadata without decompression (but some access requires it). Compression is relatively slow and its important we don't impact IO performance. So we support incremental compression passes and they only run when the terminal is idle (250ms timer on the render thread that resets on any activity). Benchmarks show zero regression in IO throughput on this change. In order to maintain the no-libc invariant for libghostty-vt, we use a hand-written (an AI assisted optimization) LZ4 compression implementation. The performance and compression ratio is _okay_. Its a good first step for this. I think in the future I want to look at other implementations we can bring in based on build configuration. ## Performance Measured with a saved 7.3 MB corpus made by repeating `gh --help` output into a 120x80 terminal with a 50 MB scrollback limit on my machine: | compression measurement | result | |-------------------------|--------| | pages compressed | 121 | | raw page backing | 49.56 MB | | encoded storage | 3.03 MB (6.11% of raw) | | estimated physical memory savings | 46.53 MB (93.89%) | | full compression | 30.3 ms total, 12.2 ms over the 18.1 ms no-op baseline (~101 µs/page) | | incremental drain | 29.7 ms total, 11.6 ms over baseline (~96 µs/page) | | compress and restore | 33.5 ms total, 3.2 ms over full compression (~26 µs/page to restore) | The workload above is especially repetitive, so its 6.11% encoded ratio is better than the 10% to 30% expected for text-heavy terminal history in general. Steady-state throughput is unchanged within noise (`terminal-stream` benchmarks and manual `cat` timings). ## libghostty-vt The same caller-driven compression controls are exposed to Zig and C. Note that compression _is not automatic_ for libghostty users. Callers must determine their own definition of "idle" and when to compress and call our incremental callback APIs to perform the compression. Decompression is automatic and as-needed (and will trigger recompression-required flags so callers are aware). ## LLM Notes This work was done in concert with Codex. I reviewed and rewrote/reshaped pretty much every change extensively, particularly PageList/Terminal. This PR message is written by hand, commit messages are LLM written but reviewed.
This commit is contained in:
17
example/c-vt-compression/README.md
Normal file
17
example/c-vt-compression/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Example: Scrollback Compression in C
|
||||
|
||||
This example shows how a libghostty-vt embedding application can track
|
||||
compression-relevant terminal activity and perform incremental scrollback
|
||||
compression after its own idle delay.
|
||||
|
||||
libghostty-vt does not create a timer or background thread. The embedding
|
||||
application remains responsible for scheduling compression and serializing it
|
||||
with other access to the terminal.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the example:
|
||||
|
||||
```shell-session
|
||||
zig build run
|
||||
```
|
||||
32
example/c-vt-compression/build.zig
Normal file
32
example/c-vt-compression/build.zig
Normal file
@@ -0,0 +1,32 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const run_step = b.step("run", "Run the app");
|
||||
|
||||
const exe_mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
exe_mod.addCSourceFiles(.{
|
||||
.root = b.path("src"),
|
||||
.files = &.{"main.c"},
|
||||
});
|
||||
|
||||
if (b.lazyDependency("ghostty", .{})) |dep| {
|
||||
exe_mod.linkLibrary(dep.artifact("ghostty-vt"));
|
||||
}
|
||||
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "c_vt_compression",
|
||||
.root_module = exe_mod,
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| run_cmd.addArgs(args);
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
}
|
||||
14
example/c-vt-compression/build.zig.zon
Normal file
14
example/c-vt-compression/build.zig.zon
Normal file
@@ -0,0 +1,14 @@
|
||||
.{
|
||||
.name = .c_vt_compression,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0x3d527aa68a4cf8d,
|
||||
.minimum_zig_version = "0.15.1",
|
||||
.dependencies = .{
|
||||
.ghostty = .{ .path = "../../" },
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
72
example/c-vt-compression/src/main.c
Normal file
72
example/c-vt-compression/src/main.c
Normal file
@@ -0,0 +1,72 @@
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
//! [compression-idle-step]
|
||||
// Perform one step after the application's idle timer fires. Returning true
|
||||
// asks the application to schedule another step while the terminal is idle.
|
||||
static bool compression_idle_step(GhosttyTerminal terminal) {
|
||||
GhosttyTerminalCompressionResult compression_result;
|
||||
GhosttyResult result = ghostty_terminal_compress(
|
||||
terminal,
|
||||
GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL,
|
||||
&compression_result);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
switch (compression_result) {
|
||||
case GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING:
|
||||
return true;
|
||||
case GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE:
|
||||
case GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED:
|
||||
return false;
|
||||
default:
|
||||
assert(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
//! [compression-idle-step]
|
||||
|
||||
int main(void) {
|
||||
GhosttyTerminal terminal;
|
||||
GhosttyTerminalOptions opts = {
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 10 * 1024 * 1024,
|
||||
};
|
||||
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
//! [compression-activity]
|
||||
uint64_t compression_activity;
|
||||
result = ghostty_terminal_compression_activity(
|
||||
terminal,
|
||||
&compression_activity);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
// Terminal mutations may change the token. When it changes, restart the
|
||||
// application's idle timer rather than compressing on the output path.
|
||||
const char *line = "repeated and compressible terminal history\r\n";
|
||||
for (size_t i = 0; i < 4000; i++) {
|
||||
ghostty_terminal_vt_write(
|
||||
terminal,
|
||||
(const uint8_t *)line,
|
||||
strlen(line));
|
||||
}
|
||||
|
||||
uint64_t new_activity;
|
||||
result = ghostty_terminal_compression_activity(terminal, &new_activity);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
if (new_activity != compression_activity) {
|
||||
compression_activity = new_activity;
|
||||
// Restart the application's compression idle timer here.
|
||||
}
|
||||
//! [compression-activity]
|
||||
|
||||
// Simulate the idle timer and its short pending-work continuations.
|
||||
while (compression_idle_step(terminal)) {}
|
||||
|
||||
ghostty_terminal_free(terminal);
|
||||
return 0;
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
* - @ref c-vt-formatter/src/main.c - Terminal formatter example
|
||||
* - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs
|
||||
* - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example
|
||||
* - @ref c-vt-compression/src/main.c - Idle scrollback compression example
|
||||
*
|
||||
*/
|
||||
|
||||
@@ -105,6 +106,11 @@
|
||||
* detect when it loses its value, and move it to a new point.
|
||||
*/
|
||||
|
||||
/** @example c-vt-compression/src/main.c
|
||||
* This example demonstrates how to schedule incremental scrollback compression
|
||||
* after compression-relevant terminal activity becomes idle.
|
||||
*/
|
||||
|
||||
/** @example c-vt-selection-gesture/src/main.c
|
||||
* This example demonstrates how to use synthetic selection gesture events to
|
||||
* derive drag and deep-press selection snapshots.
|
||||
|
||||
@@ -40,6 +40,17 @@ extern "C" {
|
||||
* @snippet c-vt-stream/src/main.c vt-stream-init
|
||||
* @snippet c-vt-stream/src/main.c vt-stream-write
|
||||
*
|
||||
* ## Scrollback Compression
|
||||
*
|
||||
* Scrollback compression is caller-driven. The terminal exposes an opaque
|
||||
* activity token so an embedding application can restart an idle timer only
|
||||
* when compression-relevant state changes. Once idle, call incremental
|
||||
* compression until it no longer reports pending work. libghostty-vt does not
|
||||
* create a timer or background thread.
|
||||
*
|
||||
* @snippet c-vt-compression/src/main.c compression-activity
|
||||
* @snippet c-vt-compression/src/main.c compression-idle-step
|
||||
*
|
||||
* ## Effects
|
||||
*
|
||||
* By default, the terminal sequence processing with ghostty_terminal_vt_write()
|
||||
@@ -177,6 +188,37 @@ typedef struct {
|
||||
// future options.
|
||||
} GhosttyTerminalOptions;
|
||||
|
||||
/**
|
||||
* Amount of compression work to perform before returning.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** Perform one bounded compression step suitable for idle scheduling. */
|
||||
GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL = 0,
|
||||
|
||||
/** Synchronously inspect every currently eligible page. */
|
||||
GHOSTTY_TERMINAL_COMPRESSION_MODE_FULL = 1,
|
||||
GHOSTTY_TERMINAL_COMPRESSION_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalCompressionMode;
|
||||
|
||||
/**
|
||||
* Scheduling result from terminal compression.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** Retained-mapping reclamation is unavailable on this target. */
|
||||
GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED = 0,
|
||||
|
||||
/** More incremental compression work remains. */
|
||||
GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING = 1,
|
||||
|
||||
/** The pass has no continuation to schedule. */
|
||||
GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE = 2,
|
||||
GHOSTTY_TERMINAL_COMPRESSION_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalCompressionResult;
|
||||
|
||||
/**
|
||||
* Scroll viewport behavior tag.
|
||||
*
|
||||
@@ -1158,6 +1200,60 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal,
|
||||
GHOSTTY_API void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal,
|
||||
GhosttyTerminalScrollViewport behavior);
|
||||
|
||||
/**
|
||||
* Return the current compression activity token.
|
||||
*
|
||||
* The token is opaque and only equality comparisons are meaningful. An
|
||||
* embedding application should cache it and restart its compression idle
|
||||
* delay whenever the value changes. The value may wrap and changes in either
|
||||
* direction have the same meaning.
|
||||
*
|
||||
* This function only observes terminal state. It does not perform or schedule
|
||||
* compression.
|
||||
*
|
||||
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
|
||||
* @param[out] out_activity Receives the current activity token
|
||||
* @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument
|
||||
* is NULL
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_terminal_compression_activity(
|
||||
GhosttyTerminal terminal,
|
||||
uint64_t* out_activity);
|
||||
|
||||
/**
|
||||
* Compress eligible terminal scrollback.
|
||||
*
|
||||
* Incremental mode performs bounded work suitable for an idle callback. A
|
||||
* pending result means the application should invoke another step while the
|
||||
* terminal remains idle. A complete result means no continuation is needed
|
||||
* until ghostty_terminal_compression_activity() changes. Full mode performs
|
||||
* one synchronous scan and can stall on large scrollback buffers.
|
||||
*
|
||||
* Compression is opportunistic. Complete means the pass has finished, not
|
||||
* that every page was compressed: pages may be unprofitable or encounter an
|
||||
* allocation or reclamation failure. Compression changes only the terminal's
|
||||
* storage representation and never its logical contents or scrollback limit.
|
||||
* Accessing compressed history restores it transparently.
|
||||
*
|
||||
* This function is not thread-safe with other operations on the same
|
||||
* terminal. The caller must serialize it with writes, rendering, searches,
|
||||
* and other terminal access.
|
||||
*
|
||||
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
|
||||
* @param mode The amount of compression work to perform
|
||||
* @param[out] out_result Receives the compression scheduling result
|
||||
* @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument
|
||||
* or mode is invalid
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_terminal_compress(
|
||||
GhosttyTerminal terminal,
|
||||
GhosttyTerminalCompressionMode mode,
|
||||
GhosttyTerminalCompressionResult* out_result);
|
||||
|
||||
/**
|
||||
* Get the current value of a terminal mode.
|
||||
*
|
||||
|
||||
@@ -4438,7 +4438,7 @@ fn openUrl(
|
||||
/// if there is no hyperlink.
|
||||
fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 {
|
||||
_ = self;
|
||||
const page = &pin.node.data;
|
||||
const page = pin.node.page();
|
||||
const cell = pin.rowAndCell().cell;
|
||||
const link_id = page.lookupHyperlink(cell) orelse return null;
|
||||
const entry = page.hyperlink_set.get(page.memory, link_id);
|
||||
|
||||
519
src/benchmark/PageCompression.zig
Normal file
519
src/benchmark/PageCompression.zig
Normal file
@@ -0,0 +1,519 @@
|
||||
//! Benchmarks raw LZ4 compression and decompression on page-sized byte
|
||||
//! buffers.
|
||||
//!
|
||||
//! This benchmark is intentionally independent of terminal page ownership and
|
||||
//! lifecycle. It treats its input as opaque bytes and calls only the standalone
|
||||
//! LZ4 block codec. In particular, it does not compress pages owned by a live
|
||||
//! terminal or measure the scheduling and state transitions used by automatic
|
||||
//! scrollback compression.
|
||||
//!
|
||||
//! ## Input
|
||||
//!
|
||||
//! `--data` names a pre-generated raw byte corpus. The corpus is divided into
|
||||
//! `--page-size` byte chunks, with a final short chunk retained when the file
|
||||
//! size is not an exact multiple. The default page size is 400 KiB, matching a
|
||||
//! standard terminal page in ReleaseFast builds on the current target.
|
||||
//!
|
||||
//! A raw dump of actual page backing memory is the most representative input:
|
||||
//! it includes cells, rows, styles, graphemes, hyperlinks, allocator metadata,
|
||||
//! and unused capacity exactly as the codec would see them. Keep such corpora
|
||||
//! outside the repository and reuse the same file when comparing branches.
|
||||
//! Arbitrary files are accepted too, but their compression ratios should not be
|
||||
//! interpreted as terminal scrollback ratios.
|
||||
//!
|
||||
//! ## Modes
|
||||
//!
|
||||
//! * `noop` walks the input chunks without invoking the codec. This measures the
|
||||
//! benchmark loop's minimum overhead.
|
||||
//! * `compress` compresses every input chunk into a reusable output buffer.
|
||||
//! * `store` additionally copies each useful result into an exact-sized
|
||||
//! allocation. It retains the most recent `--retained-pages` blocks so that
|
||||
//! allocation, eviction, and allocator reuse resemble bounded scrollback.
|
||||
//! * `decompress` prepares compressed blocks during setup, then decompresses
|
||||
//! every block into a reusable output buffer.
|
||||
//! * `report` compresses each chunk once and prints raw and encoded sizes. It is
|
||||
//! for inspecting ratios, not timing comparisons.
|
||||
//!
|
||||
//! Dataset loading, output allocation, and preparation of blocks for
|
||||
//! decompression happen in `setup` and are outside `Benchmark`'s timed region.
|
||||
//! The `compress` and `decompress` steps perform no allocation. `store`
|
||||
//! deliberately performs encoded-block allocations and frees inside the timed
|
||||
//! step; only its reusable workspace and ring metadata are prepared in setup.
|
||||
//! `hyperfine` still measures full process lifetime, so use `--loops` to
|
||||
//! amortize setup and teardown when comparing small corpora.
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! Build benchmarks in ReleaseFast mode:
|
||||
//!
|
||||
//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false
|
||||
//!
|
||||
//! Inspect the compression ratio of a page corpus:
|
||||
//!
|
||||
//! ghostty-bench +page-compression --mode=report --data=/tmp/pages.raw
|
||||
//!
|
||||
//! Measure the cost of exact encoded storage relative to the codec alone:
|
||||
//!
|
||||
//! hyperfine --warmup 3 \
|
||||
//! 'ghostty-bench +page-compression --mode=compress --loops=100 --data=/tmp/pages.raw' \
|
||||
//! 'ghostty-bench +page-compression --mode=store --loops=100 --data=/tmp/pages.raw'
|
||||
const PageCompression = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Benchmark = @import("Benchmark.zig");
|
||||
const options = @import("options.zig");
|
||||
const compress = @import("../terminal/compress.zig");
|
||||
const CompressedPage = compress.Page;
|
||||
const lz4 = compress.lz4;
|
||||
|
||||
const log = std.log.scoped(.@"page-compression-bench");
|
||||
|
||||
/// Prevent a malformed or accidentally enormous corpus from consuming
|
||||
/// unbounded memory during benchmark setup.
|
||||
const max_data_size = 64 * 1024 * 1024;
|
||||
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
|
||||
/// Complete contents of the input corpus. Individual pages are slices into
|
||||
/// this allocation, so it remains alive until teardown.
|
||||
data: []u8 = &.{},
|
||||
|
||||
/// Compressed blocks prepared during setup for `decompress` mode.
|
||||
encoded: std.ArrayList(Encoded) = .empty,
|
||||
|
||||
/// Exact encoded allocations retained by `store` mode. Null entries have not
|
||||
/// been reached yet; once full, `stored_next` identifies the oldest block.
|
||||
stored: []?[]u8 = &.{},
|
||||
stored_next: usize = 0,
|
||||
|
||||
/// Reused by compression and report modes. Its length is the compression
|
||||
/// bound of the largest input chunk.
|
||||
compression_output: []u8 = &.{},
|
||||
|
||||
/// Reused by decompression mode. Its length is at least one input chunk.
|
||||
decompression_output: []u8 = &.{},
|
||||
|
||||
/// Fixed 16 KiB scratch table required by the compressor.
|
||||
table: lz4.HashTable = undefined,
|
||||
|
||||
pub const Options = struct {
|
||||
/// Set by the shared CLI parser for string option ownership.
|
||||
_arena: ?std.heap.ArenaAllocator = null,
|
||||
|
||||
/// Select the operation performed inside the timed benchmark step.
|
||||
mode: Mode = .compress,
|
||||
|
||||
/// Repeat the complete corpus this many times per benchmark step. Increase
|
||||
/// this when the corpus is too small for stable `hyperfine` measurements.
|
||||
loops: u32 = 1,
|
||||
|
||||
/// Number of bytes treated as one independent LZ4 block. Real page dumps
|
||||
/// should use the exact backing-memory size of the pages being measured.
|
||||
@"page-size": usize = 400 * 1024,
|
||||
|
||||
/// Number of exact encoded allocations retained by `store` mode before
|
||||
/// the oldest allocation is freed and replaced. The default approximates
|
||||
/// a 10 MB scrollback composed of standard 400 KiB terminal pages.
|
||||
@"retained-pages": usize = 25,
|
||||
|
||||
/// Pre-generated input corpus. `-` reads stdin, although a regular file is
|
||||
/// recommended so identical bytes can be reused across benchmark runs.
|
||||
/// When unset, all modes are no-ops.
|
||||
data: ?[]const u8 = null,
|
||||
|
||||
pub fn deinit(self: *Options) void {
|
||||
if (self._arena) |arena| arena.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Mode = enum {
|
||||
/// Walk page boundaries and establish the benchmark loop overhead.
|
||||
noop,
|
||||
|
||||
/// Compress each raw page into a reusable compression-bound buffer.
|
||||
compress,
|
||||
|
||||
/// Compress and retain exact-sized encoded blocks in a bounded ring.
|
||||
store,
|
||||
|
||||
/// Decompress blocks prepared before the timed region.
|
||||
decompress,
|
||||
|
||||
/// Print per-page and aggregate encoded sizes. Not a timing benchmark.
|
||||
report,
|
||||
};
|
||||
|
||||
const Encoded = struct {
|
||||
/// Exact compressed block bytes.
|
||||
bytes: []u8,
|
||||
|
||||
/// Exact output length expected by the raw block decoder.
|
||||
raw_len: usize,
|
||||
};
|
||||
|
||||
/// Allocate benchmark state. Input data is intentionally loaded later by
|
||||
/// `setup` so construction is cheap and follows the other benchmarks.
|
||||
pub fn create(
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
) !*PageCompression {
|
||||
const ptr = try alloc.create(PageCompression);
|
||||
ptr.* = .{
|
||||
.alloc = alloc,
|
||||
.opts = opts,
|
||||
};
|
||||
return ptr;
|
||||
}
|
||||
|
||||
/// Release allocations retained across benchmark steps.
|
||||
pub fn destroy(self: *PageCompression, alloc: Allocator) void {
|
||||
self.clearPreparedData();
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
/// Select one operation for the benchmark harness to time.
|
||||
pub fn benchmark(self: *PageCompression) Benchmark {
|
||||
return .init(self, .{
|
||||
.stepFn = switch (self.opts.mode) {
|
||||
.noop => stepNoop,
|
||||
.compress => stepCompress,
|
||||
.store => stepStore,
|
||||
.decompress => stepDecompress,
|
||||
.report => stepReport,
|
||||
},
|
||||
.setupFn = setup,
|
||||
.teardownFn = teardown,
|
||||
});
|
||||
}
|
||||
|
||||
/// Load and partition the input corpus. For decompression mode this also
|
||||
/// creates the encoded blocks, keeping compression outside the timed region.
|
||||
fn setup(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *PageCompression = @ptrCast(@alignCast(ptr));
|
||||
assert(self.data.len == 0);
|
||||
assert(self.encoded.items.len == 0);
|
||||
|
||||
self.setupData() catch |err| {
|
||||
log.warn("failed to prepare page compression benchmark err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
}
|
||||
|
||||
fn setupData(self: *PageCompression) !void {
|
||||
if (self.opts.loops == 0) return error.InvalidLoops;
|
||||
if (self.opts.@"page-size" == 0) return error.InvalidPageSize;
|
||||
if (self.opts.mode == .store and self.opts.@"retained-pages" == 0)
|
||||
return error.InvalidRetainedPages;
|
||||
|
||||
const data_file = try options.dataFile(self.opts.data) orelse return;
|
||||
defer data_file.close();
|
||||
|
||||
self.data = try data_file.readToEndAlloc(self.alloc, max_data_size);
|
||||
errdefer {
|
||||
self.alloc.free(self.data);
|
||||
self.data = &.{};
|
||||
}
|
||||
if (self.data.len == 0) return;
|
||||
if (self.opts.mode == .noop) return;
|
||||
|
||||
const largest_page = @min(self.opts.@"page-size", self.data.len);
|
||||
self.compression_output = try self.alloc.alloc(
|
||||
u8,
|
||||
try lz4.compressBound(largest_page),
|
||||
);
|
||||
errdefer {
|
||||
self.alloc.free(self.compression_output);
|
||||
self.compression_output = &.{};
|
||||
}
|
||||
|
||||
if (self.opts.mode == .store) try self.prepareStored();
|
||||
if (self.opts.mode == .decompress) try self.prepareEncoded();
|
||||
}
|
||||
|
||||
/// Allocate only the ring metadata for `store` mode. The encoded blocks which
|
||||
/// populate it are intentionally allocated by the timed benchmark step.
|
||||
fn prepareStored(self: *PageCompression) !void {
|
||||
self.stored = try self.alloc.alloc(?[]u8, self.opts.@"retained-pages");
|
||||
@memset(self.stored, null);
|
||||
self.stored_next = 0;
|
||||
}
|
||||
|
||||
/// Precompress every input page and verify one decode before benchmarking.
|
||||
/// This catches corpus or codec problems before the timer starts.
|
||||
fn prepareEncoded(self: *PageCompression) !void {
|
||||
self.decompression_output = try self.alloc.alloc(
|
||||
u8,
|
||||
@min(self.opts.@"page-size", self.data.len),
|
||||
);
|
||||
errdefer {
|
||||
self.alloc.free(self.decompression_output);
|
||||
self.decompression_output = &.{};
|
||||
}
|
||||
|
||||
var it = self.pages();
|
||||
while (it.next()) |page| {
|
||||
const encoded_len = try lz4.compress(
|
||||
page,
|
||||
self.compression_output,
|
||||
&self.table,
|
||||
);
|
||||
const encoded = try self.alloc.dupe(
|
||||
u8,
|
||||
self.compression_output[0..encoded_len],
|
||||
);
|
||||
self.encoded.append(self.alloc, .{
|
||||
.bytes = encoded,
|
||||
.raw_len = page.len,
|
||||
}) catch |err| {
|
||||
self.alloc.free(encoded);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
var page_it = self.pages();
|
||||
for (self.encoded.items) |block| {
|
||||
const page = page_it.next().?;
|
||||
const output = self.decompression_output[0..block.raw_len];
|
||||
_ = try lz4.decompress(block.bytes, output);
|
||||
if (!std.mem.eql(u8, page, output)) return error.RoundTripMismatch;
|
||||
}
|
||||
}
|
||||
|
||||
/// Release everything created by setup. This is shared by teardown and
|
||||
/// destroy so errors and direct unit-test use remain leak-free.
|
||||
fn clearPreparedData(self: *PageCompression) void {
|
||||
for (self.encoded.items) |block| self.alloc.free(block.bytes);
|
||||
self.encoded.deinit(self.alloc);
|
||||
self.encoded = .empty;
|
||||
|
||||
for (self.stored) |block| if (block) |bytes| self.alloc.free(bytes);
|
||||
if (self.stored.len > 0) self.alloc.free(self.stored);
|
||||
self.stored = &.{};
|
||||
self.stored_next = 0;
|
||||
|
||||
if (self.compression_output.len > 0)
|
||||
self.alloc.free(self.compression_output);
|
||||
self.compression_output = &.{};
|
||||
|
||||
if (self.decompression_output.len > 0)
|
||||
self.alloc.free(self.decompression_output);
|
||||
self.decompression_output = &.{};
|
||||
|
||||
if (self.data.len > 0) self.alloc.free(self.data);
|
||||
self.data = &.{};
|
||||
}
|
||||
|
||||
fn teardown(ptr: *anyopaque) void {
|
||||
const self: *PageCompression = @ptrCast(@alignCast(ptr));
|
||||
self.clearPreparedData();
|
||||
}
|
||||
|
||||
/// Baseline mode: traverse exactly the same page boundaries as compression
|
||||
/// without invoking the codec.
|
||||
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *PageCompression = @ptrCast(@alignCast(ptr));
|
||||
for (0..self.opts.loops) |_| {
|
||||
var it = self.pages();
|
||||
while (it.next()) |page| std.mem.doNotOptimizeAway(page);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compress all pages into one reusable output buffer. Only the returned
|
||||
/// encoded length is consumed because retaining output pages would measure
|
||||
/// allocation and ownership rather than codec throughput.
|
||||
fn stepCompress(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *PageCompression = @ptrCast(@alignCast(ptr));
|
||||
for (0..self.opts.loops) |_| {
|
||||
var it = self.pages();
|
||||
while (it.next()) |page| {
|
||||
const encoded_len = lz4.compress(
|
||||
page,
|
||||
self.compression_output,
|
||||
&self.table,
|
||||
) catch |err| {
|
||||
log.warn("page compression failed err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
std.mem.doNotOptimizeAway(encoded_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compress pages and retain their exact encoded allocations in a bounded
|
||||
/// ring. This models the part of `compress.Page.init` which differs from the
|
||||
/// allocation-free codec benchmark: copying the result, allocating its
|
||||
/// persistent storage, and freeing an old block when scrollback is full.
|
||||
fn stepStore(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *PageCompression = @ptrCast(@alignCast(ptr));
|
||||
if (self.data.len == 0) return;
|
||||
assert(self.stored.len > 0);
|
||||
|
||||
for (0..self.opts.loops) |_| {
|
||||
var it = self.pages();
|
||||
while (it.next()) |page| {
|
||||
const output_len = CompressedPage.requiredScratch(page.len) catch |err| {
|
||||
log.warn("failed to size stored page output err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
if (output_len == 0) continue;
|
||||
|
||||
const encoded_len = lz4.compress(
|
||||
page,
|
||||
self.compression_output[0..output_len],
|
||||
&self.table,
|
||||
) catch |err| switch (err) {
|
||||
// This is the same profitability outcome as
|
||||
// compress.Page.init: a block which exceeds the useful output
|
||||
// limit remains resident and consumes no encoded allocation.
|
||||
error.OutputTooSmall => continue,
|
||||
error.InputTooLarge => {
|
||||
log.warn("stored page compression failed err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
},
|
||||
};
|
||||
|
||||
const encoded = self.alloc.dupe(
|
||||
u8,
|
||||
self.compression_output[0..encoded_len],
|
||||
) catch |err| {
|
||||
log.warn("stored page allocation failed err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
|
||||
const slot = &self.stored[self.stored_next];
|
||||
if (slot.*) |previous| self.alloc.free(previous);
|
||||
slot.* = encoded;
|
||||
self.stored_next = (self.stored_next + 1) % self.stored.len;
|
||||
std.mem.doNotOptimizeAway(encoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompress blocks prepared by setup. The output allocation is reused so
|
||||
/// this measures only decoding and the required memory writes.
|
||||
fn stepDecompress(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *PageCompression = @ptrCast(@alignCast(ptr));
|
||||
for (0..self.opts.loops) |_| {
|
||||
for (self.encoded.items) |block| {
|
||||
const output = self.decompression_output[0..block.raw_len];
|
||||
_ = lz4.decompress(block.bytes, output) catch |err| {
|
||||
log.warn("page decompression failed err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
std.mem.doNotOptimizeAway(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print size information for evaluating compression ratio. This shares the
|
||||
/// input and codec paths with compression mode but deliberately makes no
|
||||
/// timing claims.
|
||||
fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *PageCompression = @ptrCast(@alignCast(ptr));
|
||||
if (self.data.len == 0) return;
|
||||
|
||||
var page_index: usize = 0;
|
||||
var raw_total: usize = 0;
|
||||
var encoded_total: usize = 0;
|
||||
var it = self.pages();
|
||||
while (it.next()) |page| : (page_index += 1) {
|
||||
const encoded_len = lz4.compress(
|
||||
page,
|
||||
self.compression_output,
|
||||
&self.table,
|
||||
) catch |err| {
|
||||
log.warn("page compression report failed err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
raw_total += page.len;
|
||||
encoded_total += encoded_len;
|
||||
std.debug.print(
|
||||
"page-compression page={d} raw={d} encoded={d} ratio={d:.2}%\n",
|
||||
.{ page_index, page.len, encoded_len, percentage(encoded_len, page.len) },
|
||||
);
|
||||
}
|
||||
|
||||
std.debug.print(
|
||||
"page-compression total pages={d} raw={d} encoded={d} ratio={d:.2}% " ++
|
||||
"workspace={d} output_bound={d}\n",
|
||||
.{
|
||||
page_index,
|
||||
raw_total,
|
||||
encoded_total,
|
||||
percentage(encoded_total, raw_total),
|
||||
@sizeOf(lz4.HashTable),
|
||||
self.compression_output.len,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Iterate fixed-size page chunks without allocating an index table.
|
||||
fn pages(self: *const PageCompression) PageIterator {
|
||||
return .{
|
||||
.data = self.data,
|
||||
.page_size = self.opts.@"page-size",
|
||||
};
|
||||
}
|
||||
|
||||
const PageIterator = struct {
|
||||
data: []const u8,
|
||||
page_size: usize,
|
||||
offset: usize = 0,
|
||||
|
||||
fn next(self: *PageIterator) ?[]const u8 {
|
||||
if (self.offset >= self.data.len) return null;
|
||||
const len = @min(self.page_size, self.data.len - self.offset);
|
||||
const end = self.offset + len;
|
||||
defer self.offset = end;
|
||||
return self.data[self.offset..end];
|
||||
}
|
||||
};
|
||||
|
||||
fn percentage(part: usize, whole: usize) f64 {
|
||||
if (whole == 0) return 0;
|
||||
return @as(f64, @floatFromInt(part)) * 100 /
|
||||
@as(f64, @floatFromInt(whole));
|
||||
}
|
||||
|
||||
test PageCompression {
|
||||
const testing = std.testing;
|
||||
const impl: *PageCompression = try .create(testing.allocator, .{});
|
||||
defer impl.destroy(testing.allocator);
|
||||
|
||||
const bench = impl.benchmark();
|
||||
_ = try bench.run(.once);
|
||||
}
|
||||
|
||||
test "PageCompression store retains exact encoded allocations" {
|
||||
const testing = std.testing;
|
||||
const page_size = 1024;
|
||||
const impl: *PageCompression = try .create(testing.allocator, .{
|
||||
.mode = .store,
|
||||
.@"page-size" = page_size,
|
||||
.@"retained-pages" = 2,
|
||||
});
|
||||
defer impl.destroy(testing.allocator);
|
||||
|
||||
impl.data = try testing.allocator.alloc(u8, 3 * page_size);
|
||||
@memset(impl.data, 0);
|
||||
impl.compression_output = try testing.allocator.alloc(
|
||||
u8,
|
||||
try lz4.compressBound(page_size),
|
||||
);
|
||||
try impl.prepareStored();
|
||||
|
||||
try stepStore(impl);
|
||||
try testing.expectEqual(@as(usize, 1), impl.stored_next);
|
||||
for (impl.stored) |block| {
|
||||
const encoded = block.?;
|
||||
try testing.expect(encoded.len < page_size);
|
||||
|
||||
var decoded: [page_size]u8 = undefined;
|
||||
_ = try lz4.decompress(encoded, &decoded);
|
||||
try testing.expect(std.mem.allEqual(u8, &decoded, 0));
|
||||
}
|
||||
}
|
||||
343
src/benchmark/ScrollbackCompression.zig
Normal file
343
src/benchmark/ScrollbackCompression.zig
Normal file
@@ -0,0 +1,343 @@
|
||||
//! Benchmarks cold-history compression and restoration on pages owned by a
|
||||
//! live terminal.
|
||||
//!
|
||||
//! Unlike `page-compression`, which measures the standalone codec against raw
|
||||
//! byte chunks, this benchmark first parses a VT corpus into a real `Terminal`.
|
||||
//! Its timed operations therefore include the PageList state transition,
|
||||
//! exact-sized encoded allocation, retained-mapping reclamation, and transparent
|
||||
//! restoration through `PageList.Node.page`.
|
||||
//!
|
||||
//! ## Input
|
||||
//!
|
||||
//! `--data` names a pre-generated VT byte stream. Parsing happens during setup
|
||||
//! and is not part of the benchmark's timed region. Use the same saved corpus,
|
||||
//! terminal dimensions, and scrollback limit when comparing revisions. In
|
||||
//! particular, do not pipe a generator into this benchmark: generation and
|
||||
//! pipe scheduling add noise which can overwhelm the operation being measured.
|
||||
//!
|
||||
//! The benchmark always operates on the primary screen's PageList. This keeps
|
||||
//! the result focused on scrollback even if malformed or incomplete input
|
||||
//! leaves the terminal in its alternate screen at end of file. `--max-scrollback`
|
||||
//! is expressed in bytes and defaults to 10 MB.
|
||||
//!
|
||||
//! ## Modes
|
||||
//!
|
||||
//! * `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` reaches the same final representation through
|
||||
//! `compress(.drain)`. 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.
|
||||
//! * `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.
|
||||
//!
|
||||
//! A fully historical page is a node strictly before the node containing the
|
||||
//! top of the active area. The boundary node is deliberately excluded because
|
||||
//! it can contain both history and active rows. Pages intersecting the current
|
||||
//! viewport are also excluded so visible contents remain resident. Normal
|
||||
//! terminal execution compresses eligible pages incrementally after activity
|
||||
//! becomes idle. The benchmark invokes PageList operations directly so its
|
||||
//! timed regions exclude the production scheduler's idle delay and
|
||||
//! renderer-thread coordination.
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! Build the benchmark in ReleaseFast mode:
|
||||
//!
|
||||
//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false
|
||||
//!
|
||||
//! Inspect the memory reduction for a saved VT corpus:
|
||||
//!
|
||||
//! ghostty-bench +scrollback-compression --mode=report \
|
||||
//! --data=/tmp/scrollback.vt --terminal-cols=120 --terminal-rows=80
|
||||
//!
|
||||
//! Compare PageList compression and restoration cost. Setup still contributes
|
||||
//! to full process time, so use a sufficiently large corpus and compare against
|
||||
//! `noop` with identical arguments:
|
||||
//!
|
||||
//! 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();
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const terminalpkg = @import("../terminal/main.zig");
|
||||
const Benchmark = @import("Benchmark.zig");
|
||||
const options = @import("options.zig");
|
||||
const PageList = terminalpkg.PageList;
|
||||
const Terminal = terminalpkg.Terminal;
|
||||
|
||||
const log = std.log.scoped(.@"scrollback-compression-bench");
|
||||
|
||||
opts: Options,
|
||||
terminal: Terminal,
|
||||
|
||||
pub const Options = struct {
|
||||
/// Set by the shared CLI parser so the `data` string remains valid for the
|
||||
/// lifetime of the benchmark implementation.
|
||||
_arena: ?std.heap.ArenaAllocator = null,
|
||||
|
||||
/// Select the PageList operation performed inside the timed benchmark.
|
||||
mode: Mode = .compress,
|
||||
|
||||
/// Dimensions used to construct the terminal which consumes the corpus.
|
||||
/// These affect wrapping and therefore the number and contents of pages.
|
||||
@"terminal-rows": u16 = 80,
|
||||
@"terminal-cols": u16 = 120,
|
||||
|
||||
/// Maximum primary-screen scrollback allocation in bytes. PageList rounds
|
||||
/// this as required by its page allocation policy.
|
||||
@"max-scrollback": usize = 10_000_000,
|
||||
|
||||
/// Pre-generated VT corpus. `-` reads stdin, although a regular file is
|
||||
/// strongly recommended so comparisons can reuse identical input bytes.
|
||||
/// When unset, every mode operates on the initial empty terminal.
|
||||
data: ?[]const u8 = null,
|
||||
|
||||
pub fn deinit(self: *Options) void {
|
||||
if (self._arena) |arena| arena.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Mode = enum {
|
||||
/// Establish process and setup overhead without a PageList operation.
|
||||
noop,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// Compress once and print aggregate memory statistics.
|
||||
report,
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
) !*ScrollbackCompression {
|
||||
const ptr = try alloc.create(ScrollbackCompression);
|
||||
errdefer alloc.destroy(ptr);
|
||||
|
||||
ptr.* = .{
|
||||
.opts = opts,
|
||||
.terminal = try .init(alloc, .{
|
||||
.rows = opts.@"terminal-rows",
|
||||
.cols = opts.@"terminal-cols",
|
||||
.max_scrollback = opts.@"max-scrollback",
|
||||
}),
|
||||
};
|
||||
return ptr;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *ScrollbackCompression, alloc: Allocator) void {
|
||||
self.terminal.deinit(alloc);
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
pub fn benchmark(self: *ScrollbackCompression) Benchmark {
|
||||
return .init(self, .{
|
||||
.stepFn = switch (self.opts.mode) {
|
||||
.noop => stepNoop,
|
||||
.compress => stepCompress,
|
||||
.incremental => stepIncremental,
|
||||
.restore => stepRestore,
|
||||
.report => stepReport,
|
||||
},
|
||||
.setupFn = setup,
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset the terminal and consume the complete VT corpus before timing starts.
|
||||
/// Restore mode also prepares its compressed representation here so its timed
|
||||
/// step contains decoding and mapping writes, but not encoding.
|
||||
fn setup(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
|
||||
self.terminal.fullReset();
|
||||
|
||||
self.loadCorpus() catch |err| {
|
||||
log.warn("failed to prepare scrollback compression benchmark err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
|
||||
if (self.opts.mode == .restore) {
|
||||
_ = self.pages().compress(.full);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed the corpus in the same 64 KiB chunks used by the real IO thread and
|
||||
/// terminal-stream benchmark. Parser and file IO costs remain in setup.
|
||||
fn loadCorpus(self: *ScrollbackCompression) !void {
|
||||
const data_file = try options.dataFile(self.opts.data) orelse return;
|
||||
defer data_file.close();
|
||||
|
||||
var stream = self.terminal.vtStream();
|
||||
defer stream.deinit();
|
||||
|
||||
var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined;
|
||||
var file_reader = data_file.reader(&read_buf);
|
||||
const reader = &file_reader.interface;
|
||||
|
||||
var buf: [64 * 1024]u8 = undefined;
|
||||
while (true) {
|
||||
const n = reader.readSliceShort(&buf) catch
|
||||
return file_reader.err orelse error.ReadFailed;
|
||||
if (n == 0) return;
|
||||
stream.nextSlice(buf[0..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the primary screen because it is the terminal screen which owns
|
||||
/// scrollback. A corpus ending in the alternate screen must not turn this into
|
||||
/// an alternate-screen allocation benchmark.
|
||||
fn pages(self: *ScrollbackCompression) *PageList {
|
||||
return &self.terminal.screens.get(.primary).?.pages;
|
||||
}
|
||||
|
||||
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
|
||||
std.mem.doNotOptimizeAway(&self.terminal);
|
||||
}
|
||||
|
||||
fn stepCompress(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
|
||||
_ = self.pages().compress(.full);
|
||||
std.mem.doNotOptimizeAway(&self.terminal);
|
||||
}
|
||||
|
||||
fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
|
||||
_ = self.pages().compress(.drain);
|
||||
std.mem.doNotOptimizeAway(&self.terminal);
|
||||
}
|
||||
|
||||
fn stepRestore(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
|
||||
std.mem.doNotOptimizeAway(self.visitColdPages());
|
||||
}
|
||||
|
||||
/// Visit all nodes which were eligible for the setup compression pass.
|
||||
///
|
||||
/// `page` is intentionally used instead of inspecting the union payload so the
|
||||
/// benchmark follows the same transparent restoration boundary as consumers.
|
||||
/// Compressible nodes restore here; resident candidates which failed the
|
||||
/// opportunistic pass are harmlessly visited through the same boundary.
|
||||
fn visitColdPages(self: *ScrollbackCompression) usize {
|
||||
const page_list = self.pages();
|
||||
const active_node = page_list.getTopLeft(.active).node;
|
||||
var visited: usize = 0;
|
||||
var node_ = page_list.pages.first;
|
||||
while (node_) |node| : (node_ = node.next) {
|
||||
if (node == active_node) break;
|
||||
std.mem.doNotOptimizeAway(node.page());
|
||||
visited += 1;
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
|
||||
fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
|
||||
_ = self.pages().compress(.full);
|
||||
const memory = self.pages().memoryStats();
|
||||
|
||||
std.debug.print(
|
||||
"scrollback-compression compressed={d} raw={d} " ++
|
||||
"encoded={d} ratio={d:.2}% savings={d}\n",
|
||||
.{
|
||||
memory.compressed_pages,
|
||||
memory.decommitted_raw_bytes,
|
||||
memory.encoded_bytes,
|
||||
percentage(
|
||||
memory.encoded_bytes,
|
||||
memory.decommitted_raw_bytes,
|
||||
),
|
||||
memory.estimatedSavings(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn percentage(part: usize, whole: usize) f64 {
|
||||
if (whole == 0) return 0;
|
||||
return @as(f64, @floatFromInt(part)) * 100 /
|
||||
@as(f64, @floatFromInt(whole));
|
||||
}
|
||||
|
||||
test ScrollbackCompression {
|
||||
const testing = std.testing;
|
||||
const impl: *ScrollbackCompression = try .create(testing.allocator, .{});
|
||||
defer impl.destroy(testing.allocator);
|
||||
|
||||
const bench = impl.benchmark();
|
||||
_ = try bench.run(.once);
|
||||
}
|
||||
|
||||
test "ScrollbackCompression restores cold terminal pages" {
|
||||
const testing = std.testing;
|
||||
const impl: *ScrollbackCompression = try .create(testing.allocator, .{
|
||||
.mode = .restore,
|
||||
.@"terminal-rows" = 4,
|
||||
// Standard-width pages hold 215 rows. Keeping that row capacity here
|
||||
// makes this test corpus small while still producing cold history.
|
||||
.@"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.pages().compress(.full);
|
||||
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.
|
||||
_ = impl.pages().compress(.full);
|
||||
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.pages().compress(.drain);
|
||||
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(.full);
|
||||
const monolithic = impl.pages().memoryStats();
|
||||
try testing.expectEqual(monolithic, incremental);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const cli = @import("../cli.zig");
|
||||
pub const Action = enum {
|
||||
@"codepoint-width",
|
||||
@"grapheme-break",
|
||||
@"page-compression",
|
||||
@"scrollback-compression",
|
||||
@"screen-clone",
|
||||
@"terminal-parser",
|
||||
@"terminal-stream",
|
||||
@@ -25,6 +27,8 @@ pub const Action = enum {
|
||||
pub fn Struct(comptime action: Action) type {
|
||||
return switch (action) {
|
||||
.@"screen-clone" => @import("ScreenClone.zig"),
|
||||
.@"page-compression" => @import("PageCompression.zig"),
|
||||
.@"scrollback-compression" => @import("ScrollbackCompression.zig"),
|
||||
.@"terminal-stream" => @import("TerminalStream.zig"),
|
||||
.@"codepoint-width" => @import("CodepointWidth.zig"),
|
||||
.@"grapheme-break" => @import("GraphemeBreak.zig"),
|
||||
|
||||
@@ -7,6 +7,8 @@ pub const GraphemeBreak = @import("GraphemeBreak.zig");
|
||||
pub const ScreenClone = @import("ScreenClone.zig");
|
||||
pub const TerminalParser = @import("TerminalParser.zig");
|
||||
pub const IsSymbol = @import("IsSymbol.zig");
|
||||
pub const PageCompression = @import("PageCompression.zig");
|
||||
pub const ScrollbackCompression = @import("ScrollbackCompression.zig");
|
||||
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
|
||||
@@ -1373,10 +1373,17 @@ input: RepeatableReadableIO = .{},
|
||||
/// When this limit is reached, the oldest lines are removed from the
|
||||
/// scrollback.
|
||||
///
|
||||
/// Scrollback currently exists completely in memory. This means that the
|
||||
/// larger this value, the larger potential memory usage. Scrollback is
|
||||
/// allocated lazily up to this limit, so if you set this to a very large
|
||||
/// value, it will not immediately consume a lot of memory.
|
||||
/// Scrollback is stored in memory and allocated lazily up to this limit, so
|
||||
/// setting a very large limit does not immediately consume that amount of
|
||||
/// memory. On supported systems with scrollback compression enabled, Ghostty
|
||||
/// attempts to compress fully historical pages which are not currently visible
|
||||
/// while the terminal is idle. This can reduce physical memory usage, depending
|
||||
/// on the contents of the scrollback.
|
||||
///
|
||||
/// This limit always measures the uncompressed logical size of the terminal
|
||||
/// pages. Compression does not allow Ghostty to retain more history than the
|
||||
/// configured limit. Accessing compressed history restores it transparently
|
||||
/// and may increase the terminal's physical memory usage again.
|
||||
///
|
||||
/// This size is per terminal surface, not for the entire application.
|
||||
///
|
||||
@@ -1384,7 +1391,31 @@ input: RepeatableReadableIO = .{},
|
||||
/// This is a future planned feature.
|
||||
///
|
||||
/// This can be changed at runtime but will only affect new terminal surfaces.
|
||||
@"scrollback-limit": usize = 10_000_000, // 10MB
|
||||
@"scrollback-limit": usize = 50_000_000, // 50MB
|
||||
|
||||
/// Whether to compress scrollback pages while the terminal is idle.
|
||||
///
|
||||
/// Ghostty does its best to only compress when idle and decompress
|
||||
/// as needed. This means that compression doesn't lower IO throughput.
|
||||
/// We recommend you keep it on.
|
||||
///
|
||||
/// The scrollback limit remains an uncompressed logical limit regardless of
|
||||
/// this setting, so disabling compression can increase physical memory usage
|
||||
/// but does not change how much history is retained.
|
||||
///
|
||||
/// Text-heavy terminal history generally compresses to approximately 10% to
|
||||
/// 30% of its uncompressed page memory, corresponding to a 70% to 90% reduction
|
||||
/// in physical memory for pages which are compressed. Compression savings are
|
||||
/// content-dependent.
|
||||
///
|
||||
/// Note that the way Ghostty works is that we compress and discard the
|
||||
/// physical/resident memory but we retain virtual mappings. You will not
|
||||
/// see a decrease in virtual memory usage, but you will see a decrease
|
||||
/// in physical/memory usage.
|
||||
///
|
||||
/// Changing this at runtime affects future compression work. Pages which are
|
||||
/// already compressed remain compressed until their contents are accessed.
|
||||
@"scrollback-compression": bool = true,
|
||||
|
||||
/// Control when the scrollbar is shown to scroll the scrollback buffer.
|
||||
///
|
||||
|
||||
@@ -1333,7 +1333,7 @@ test "shape emoji width long" {
|
||||
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var page = t.screens.active.pages.pages.first.?.data;
|
||||
var page = t.screens.active.pages.pages.first.?.page();
|
||||
var row = page.getRow(1);
|
||||
const cell = &row.cells.ptr(page.memory)[0];
|
||||
cell.* = .{
|
||||
|
||||
@@ -811,7 +811,7 @@ test "shape emoji width long" {
|
||||
);
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var page = t.screens.active.pages.pages.first.?.data;
|
||||
var page = t.screens.active.pages.pages.first.?.page();
|
||||
var row = page.getRow(1);
|
||||
const cell = &row.cells.ptr(page.memory)[0];
|
||||
cell.* = .{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
const std = @import("std");
|
||||
const cimgui = @import("dcimgui");
|
||||
const terminal = @import("../../terminal/main.zig");
|
||||
const pagepkg = @import("../../terminal/page.zig");
|
||||
const stylepkg = @import("../../terminal/style.zig");
|
||||
const widgets = @import("../widgets.zig");
|
||||
const units = @import("../units.zig");
|
||||
|
||||
const PageList = terminal.PageList;
|
||||
|
||||
/// Cell pointers resolved against a Node.PreservedPage rather than the
|
||||
/// PageList node.
|
||||
/// Omitting the node makes it impossible for inspector helpers to
|
||||
/// accidentally restore the original compressed page.
|
||||
const InspectedCell = struct {
|
||||
row: *pagepkg.Row,
|
||||
cell: *pagepkg.Cell,
|
||||
row_idx: terminal.size.CellCountInt,
|
||||
col_idx: terminal.size.CellCountInt,
|
||||
};
|
||||
|
||||
/// PageList inspector widget.
|
||||
pub const Inspector = struct {
|
||||
pub const empty: Inspector = .{};
|
||||
@@ -25,6 +36,13 @@ pub const Inspector = struct {
|
||||
summaryTable(pages);
|
||||
}
|
||||
|
||||
if (cimgui.c.ImGui_CollapsingHeader(
|
||||
"Page Compression",
|
||||
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
|
||||
)) {
|
||||
compressionTable(pages);
|
||||
}
|
||||
|
||||
if (cimgui.c.ImGui_CollapsingHeader(
|
||||
"Scrollbar & Regions",
|
||||
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
|
||||
@@ -58,8 +76,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.data;
|
||||
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,14 +89,29 @@ 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();
|
||||
widgets.page.inspector(page);
|
||||
|
||||
// Decode compressed contents into a temporary page. The
|
||||
// original node stays compressed while its entry is open.
|
||||
var preserved = page_node.pagePreservingState(
|
||||
std.heap.page_allocator,
|
||||
) catch {
|
||||
cimgui.c.ImGui_TextDisabled(
|
||||
"(unable to copy compressed page)",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
defer preserved.deinit();
|
||||
widgets.page.inspector(preserved.page());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +141,7 @@ 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());
|
||||
|
||||
cimgui.c.ImGui_TableNextRow();
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
|
||||
@@ -114,34 +149,21 @@ 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),
|
||||
);
|
||||
|
||||
cimgui.c.ImGui_TableNextRow();
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
|
||||
cimgui.c.ImGui_Text("Max Size");
|
||||
cimgui.c.ImGui_Text("Scrollback Limit");
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
|
||||
widgets.helpMarker(
|
||||
\\Maximum bytes before pages must be evicated. The total
|
||||
\\used bytes may be higher due to minimum individual page
|
||||
\\sizes but the next allocation that would exceed this limit
|
||||
\\will evict pages from the front of the list to free up space.
|
||||
\\Maximum uncompressed logical page memory before the oldest
|
||||
\\history is evicted. Minimum page allocation sizes can make
|
||||
\\current usage temporarily exceed this value.
|
||||
);
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
|
||||
cimgui.c.ImGui_Text(
|
||||
"%d KiB",
|
||||
units.toKibiBytes(pages.maxSize()),
|
||||
);
|
||||
var limit_buf: [64]u8 = undefined;
|
||||
const limit = formatBytes(&limit_buf, pages.maxSize());
|
||||
cimgui.c.ImGui_TextUnformatted(limit.ptr);
|
||||
|
||||
cimgui.c.ImGui_TableNextRow();
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
|
||||
@@ -150,14 +172,142 @@ fn summaryTable(pages: *const PageList) void {
|
||||
widgets.helpMarker("Current viewport anchoring mode.");
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
|
||||
cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr);
|
||||
}
|
||||
|
||||
fn compressionTable(pages: *const PageList) void {
|
||||
const memory = pages.memoryStats();
|
||||
|
||||
if (!cimgui.c.ImGui_BeginTable(
|
||||
"pagelist_compression",
|
||||
3,
|
||||
cimgui.c.ImGuiTableFlags_BordersInnerV |
|
||||
cimgui.c.ImGuiTableFlags_RowBg |
|
||||
cimgui.c.ImGuiTableFlags_SizingFixedFit,
|
||||
)) return;
|
||||
defer cimgui.c.ImGui_EndTable();
|
||||
|
||||
compressionTextRow(
|
||||
"Platform Support",
|
||||
"Whether this target can discard physical page memory while retaining " ++
|
||||
"its virtual address range. The scrollback-compression setting may " ++
|
||||
"still disable automatic compression on a supported platform.",
|
||||
if (terminal.compression_enabled) "supported" else "unsupported",
|
||||
);
|
||||
|
||||
var state_buf: [96]u8 = undefined;
|
||||
const state = std.fmt.bufPrintZ(
|
||||
&state_buf,
|
||||
"{d} compressed, {d} resident",
|
||||
.{ memory.compressed_pages, memory.resident_pages },
|
||||
) catch unreachable;
|
||||
compressionTextRow(
|
||||
"Page States",
|
||||
"Compressed pages retain an encoded allocation while their raw mapping " ++
|
||||
"is decommitted. Resident pages still have physical raw backing. " ++
|
||||
"Active, visible, and recently changed pages are expected to be resident.",
|
||||
state,
|
||||
);
|
||||
|
||||
var raw_buf: [64]u8 = undefined;
|
||||
compressionTextRow(
|
||||
"Uncompressed Size",
|
||||
"Total size of all raw page mappings. This is the approximate page " ++
|
||||
"backing memory required if no pages were compressed and is also " ++
|
||||
"the virtual address space retained across compression.",
|
||||
formatBytes(&raw_buf, memory.raw_bytes),
|
||||
);
|
||||
|
||||
var encoded_buf: [64]u8 = undefined;
|
||||
var compressed_raw_buf: [64]u8 = undefined;
|
||||
var storage_buf: [160]u8 = undefined;
|
||||
const storage = if (memory.decommitted_raw_bytes > 0) storage: {
|
||||
const ratio = percentage(
|
||||
memory.encoded_bytes,
|
||||
memory.decommitted_raw_bytes,
|
||||
);
|
||||
break :storage std.fmt.bufPrintZ(
|
||||
&storage_buf,
|
||||
"{s} encoded / {s} raw ({d:.1}%)",
|
||||
.{
|
||||
formatBytes(&encoded_buf, memory.encoded_bytes),
|
||||
formatBytes(
|
||||
&compressed_raw_buf,
|
||||
memory.decommitted_raw_bytes,
|
||||
),
|
||||
ratio,
|
||||
},
|
||||
) catch unreachable;
|
||||
} else "none";
|
||||
compressionTextRow(
|
||||
"Compressed Storage",
|
||||
"Encoded allocation size compared with the original raw size of only " ++
|
||||
"the compressed pages. The percentage is the compression ratio, " ++
|
||||
"so smaller is better. Raw physical pages have been discarded.",
|
||||
storage,
|
||||
);
|
||||
|
||||
var resident_buf: [64]u8 = undefined;
|
||||
compressionTextRow(
|
||||
"Estimated Resident",
|
||||
"Physical page backing estimated to remain after compression: resident " ++
|
||||
"raw allocations, including unused pool tails, plus encoded storage. " ++
|
||||
"Node and allocator metadata and unrelated terminal memory are excluded.",
|
||||
formatBytes(&resident_buf, memory.estimatedResidentBytes()),
|
||||
);
|
||||
|
||||
var savings_bytes_buf: [64]u8 = undefined;
|
||||
var savings_buf: [128]u8 = undefined;
|
||||
const savings = memory.estimatedSavings();
|
||||
const savings_text = std.fmt.bufPrintZ(
|
||||
&savings_buf,
|
||||
"{s} ({d:.1}% of raw page memory)",
|
||||
.{
|
||||
formatBytes(&savings_bytes_buf, savings),
|
||||
percentage(savings, memory.raw_bytes),
|
||||
},
|
||||
) catch unreachable;
|
||||
compressionTextRow(
|
||||
"Estimated Savings",
|
||||
"Physical page backing avoided across the complete PageList. This is " ++
|
||||
"decommitted raw memory minus replacement encoded storage and is " ++
|
||||
"not a measurement of total process RSS.",
|
||||
savings_text,
|
||||
);
|
||||
}
|
||||
|
||||
fn compressionTextRow(
|
||||
label: [:0]const u8,
|
||||
help: [:0]const u8,
|
||||
value: [:0]const u8,
|
||||
) void {
|
||||
cimgui.c.ImGui_TableNextRow();
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
|
||||
cimgui.c.ImGui_Text("Tracked Pins");
|
||||
cimgui.c.ImGui_Text("%s", label.ptr);
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
|
||||
widgets.helpMarker("Number of pins tracked for automatic updates.");
|
||||
widgets.helpMarker(help);
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
|
||||
cimgui.c.ImGui_Text("%d", pages.countTrackedPins());
|
||||
cimgui.c.ImGui_TextUnformatted(value.ptr);
|
||||
}
|
||||
|
||||
fn formatBytes(buf: []u8, bytes: usize) [:0]const u8 {
|
||||
if (bytes >= 1024 * 1024) {
|
||||
const value: f64 = @as(f64, @floatFromInt(bytes)) / (1024 * 1024);
|
||||
return std.fmt.bufPrintZ(buf, "{d:.2} MiB", .{value}) catch unreachable;
|
||||
}
|
||||
|
||||
if (bytes >= 1024) {
|
||||
const value: f64 = @as(f64, @floatFromInt(bytes)) / 1024;
|
||||
return std.fmt.bufPrintZ(buf, "{d:.1} KiB", .{value}) catch unreachable;
|
||||
}
|
||||
|
||||
return std.fmt.bufPrintZ(buf, "{d} B", .{bytes}) catch unreachable;
|
||||
}
|
||||
|
||||
fn percentage(numerator: usize, denominator: usize) f64 {
|
||||
if (denominator == 0) return 0;
|
||||
return 100 *
|
||||
@as(f64, @floatFromInt(numerator)) /
|
||||
@as(f64, @floatFromInt(denominator));
|
||||
}
|
||||
|
||||
fn scrollbarInfo(pages: *PageList) void {
|
||||
@@ -321,11 +471,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);
|
||||
@@ -540,17 +697,37 @@ pub const CellChooser = struct {
|
||||
.history => terminal.Point{ .history = self.lookup_coord },
|
||||
};
|
||||
|
||||
const cell = pages.getCell(pt) orelse {
|
||||
const pin = pages.pin(pt) orelse {
|
||||
cimgui.c.ImGui_TextDisabled("(cell out of range)");
|
||||
return;
|
||||
};
|
||||
|
||||
self.cell_info.draw(cell, pt);
|
||||
// Cell pointers must come from the same page whose auxiliary tables
|
||||
// we inspect. For compressed nodes this is an independent preserved
|
||||
// page, leaving the PageList node compressed across inspector frames.
|
||||
var preserved = pin.node.pagePreservingState(
|
||||
std.heap.page_allocator,
|
||||
) catch {
|
||||
cimgui.c.ImGui_TextDisabled("(unable to copy compressed page)");
|
||||
return;
|
||||
};
|
||||
defer preserved.deinit();
|
||||
|
||||
const page = preserved.page();
|
||||
const rac = page.getRowAndCell(pin.x, pin.y);
|
||||
const cell: InspectedCell = .{
|
||||
.row = rac.row,
|
||||
.cell = rac.cell,
|
||||
.row_idx = pin.y,
|
||||
.col_idx = pin.x,
|
||||
};
|
||||
|
||||
self.cell_info.draw(cell, pt, page);
|
||||
|
||||
if (cell.cell.style_id != stylepkg.default_id) {
|
||||
cimgui.c.ImGui_SeparatorText("Style");
|
||||
const style = cell.node.data.styles.get(
|
||||
cell.node.data.memory,
|
||||
const style = page.styles.get(
|
||||
page.memory,
|
||||
cell.cell.style_id,
|
||||
).*;
|
||||
widgets.style.table(style, null);
|
||||
@@ -558,12 +735,12 @@ pub const CellChooser = struct {
|
||||
|
||||
if (cell.cell.hyperlink) {
|
||||
cimgui.c.ImGui_SeparatorText("Hyperlink");
|
||||
hyperlinkTable(cell);
|
||||
hyperlinkTable(cell, page);
|
||||
}
|
||||
|
||||
if (cell.cell.hasGrapheme()) {
|
||||
cimgui.c.ImGui_SeparatorText("Grapheme");
|
||||
graphemeTable(cell);
|
||||
graphemeTable(cell, page);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -577,7 +754,7 @@ fn maxCoord(
|
||||
return br_point.coord();
|
||||
}
|
||||
|
||||
fn hyperlinkTable(cell: PageList.Cell) void {
|
||||
fn hyperlinkTable(cell: InspectedCell, page: *const terminal.Page) void {
|
||||
if (!cimgui.c.ImGui_BeginTable(
|
||||
"cell_hyperlink",
|
||||
2,
|
||||
@@ -585,7 +762,6 @@ fn hyperlinkTable(cell: PageList.Cell) void {
|
||||
)) return;
|
||||
defer cimgui.c.ImGui_EndTable();
|
||||
|
||||
const page = &cell.node.data;
|
||||
const link_id = page.lookupHyperlink(cell.cell) orelse {
|
||||
cimgui.c.ImGui_TableNextRow();
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
|
||||
@@ -632,7 +808,7 @@ fn hyperlinkTable(cell: PageList.Cell) void {
|
||||
cimgui.c.ImGui_Text("%d", refs);
|
||||
}
|
||||
|
||||
fn graphemeTable(cell: PageList.Cell) void {
|
||||
fn graphemeTable(cell: InspectedCell, page: *const terminal.Page) void {
|
||||
if (!cimgui.c.ImGui_BeginTable(
|
||||
"cell_grapheme",
|
||||
2,
|
||||
@@ -640,7 +816,6 @@ fn graphemeTable(cell: PageList.Cell) void {
|
||||
)) return;
|
||||
defer cimgui.c.ImGui_EndTable();
|
||||
|
||||
const page = &cell.node.data;
|
||||
const cps = page.lookupGrapheme(cell.cell) orelse {
|
||||
cimgui.c.ImGui_TableNextRow();
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
|
||||
@@ -680,8 +855,9 @@ pub const CellInfo = struct {
|
||||
|
||||
pub fn draw(
|
||||
_: *const CellInfo,
|
||||
cell: PageList.Cell,
|
||||
cell: InspectedCell,
|
||||
point: terminal.Point,
|
||||
page: *const terminal.Page,
|
||||
) void {
|
||||
if (!cimgui.c.ImGui_BeginTable(
|
||||
"cell_info",
|
||||
@@ -747,7 +923,7 @@ pub const CellInfo = struct {
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
|
||||
if (cimgui.c.ImGui_BeginListBox("##cell_grapheme", .{ .x = 0, .y = 0 })) {
|
||||
defer cimgui.c.ImGui_EndListBox();
|
||||
if (cell.node.data.lookupGrapheme(cell.cell)) |cps| {
|
||||
if (page.lookupGrapheme(cell.cell)) |cps| {
|
||||
var buf: [96]u8 = undefined;
|
||||
for (cps) |cp| {
|
||||
const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?";
|
||||
@@ -845,7 +1021,7 @@ pub const CellInfo = struct {
|
||||
widgets.helpMarker("OSC8 hyperlink ID associated with this cell.");
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
|
||||
|
||||
const link_id = cell.node.data.lookupHyperlink(cell.cell) orelse 0;
|
||||
const link_id = page.lookupHyperlink(cell.cell) orelse 0;
|
||||
cimgui.c.ImGui_Text("id=%d", link_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +261,8 @@ comptime {
|
||||
@export(&c.terminal_set, .{ .name = "ghostty_terminal_set" });
|
||||
@export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" });
|
||||
@export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" });
|
||||
@export(&c.terminal_compression_activity, .{ .name = "ghostty_terminal_compression_activity" });
|
||||
@export(&c.terminal_compress, .{ .name = "ghostty_terminal_compress" });
|
||||
@export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" });
|
||||
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
|
||||
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
|
||||
|
||||
@@ -10,6 +10,7 @@ const internal_os = @import("../os/main.zig");
|
||||
const rendererpkg = @import("../renderer.zig");
|
||||
const apprt = @import("../apprt.zig");
|
||||
const configpkg = @import("../config.zig");
|
||||
const terminalpkg = @import("../terminal/main.zig");
|
||||
const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue;
|
||||
const App = @import("../App.zig");
|
||||
|
||||
@@ -72,6 +73,9 @@ cursor_h: xev.Timer,
|
||||
cursor_c: xev.Completion = .{},
|
||||
cursor_c_cancel: xev.Completion = .{},
|
||||
|
||||
/// Incremental scrollback compression scheduling.
|
||||
compression: Compression = undefined,
|
||||
|
||||
/// The surface we're rendering to.
|
||||
surface: *apprt.Surface,
|
||||
|
||||
@@ -111,10 +115,12 @@ flags: packed struct {
|
||||
|
||||
pub const DerivedConfig = struct {
|
||||
custom_shader_animation: configpkg.CustomShaderAnimation,
|
||||
scrollback_compression: bool,
|
||||
|
||||
pub fn init(config: *const configpkg.Config) DerivedConfig {
|
||||
return .{
|
||||
.custom_shader_animation = config.@"custom-shader-animation",
|
||||
.scrollback_compression = config.@"scrollback-compression",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -162,7 +168,7 @@ pub fn init(
|
||||
var mailbox = try Mailbox.create(alloc);
|
||||
errdefer mailbox.destroy(alloc);
|
||||
|
||||
return .{
|
||||
var result: Thread = .{
|
||||
.alloc = alloc,
|
||||
.config = .init(config),
|
||||
.loop = loop,
|
||||
@@ -178,6 +184,14 @@ pub fn init(
|
||||
.mailbox = mailbox,
|
||||
.app_mailbox = app_mailbox,
|
||||
};
|
||||
|
||||
// Only enable compression if we have it enabled... save some
|
||||
// minor resources.
|
||||
if (comptime terminalpkg.compression_enabled) {
|
||||
result.compression = try .init();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Clean up the thread. This is only safe to call once the thread
|
||||
@@ -189,6 +203,8 @@ pub fn deinit(self: *Thread) void {
|
||||
self.draw_h.deinit();
|
||||
self.draw_now.deinit();
|
||||
self.cursor_h.deinit();
|
||||
if (comptime terminalpkg.compression_enabled)
|
||||
self.compression.deinit();
|
||||
self.loop.deinit();
|
||||
|
||||
// Nothing can possibly access the mailbox anymore, destroy it.
|
||||
@@ -492,6 +508,16 @@ fn drainMailbox(self: *Thread) !void {
|
||||
}
|
||||
|
||||
fn changeConfig(self: *Thread, config: *const DerivedConfig) !void {
|
||||
// A newly enabled scheduler must reconsider existing history even when no
|
||||
// terminal activity occurred while compression was disabled.
|
||||
if (comptime terminalpkg.compression_enabled) {
|
||||
if (!self.config.scrollback_compression and
|
||||
config.scrollback_compression)
|
||||
{
|
||||
self.compression.activity = null;
|
||||
}
|
||||
}
|
||||
|
||||
self.config = config.*;
|
||||
}
|
||||
|
||||
@@ -537,6 +563,10 @@ fn wakeupCallback(
|
||||
// Render immediately
|
||||
_ = renderCallback(t, undefined, undefined, {});
|
||||
|
||||
// PageList mutations maintain their own compression dirty state. Checking
|
||||
// it here covers output, resize, and viewport scrolling uniformly.
|
||||
t.compression.wake(t);
|
||||
|
||||
// The below is not used anymore but if we ever want to introduce
|
||||
// a configuration to introduce a delay to coalesce renders, we can
|
||||
// use this.
|
||||
@@ -723,3 +753,113 @@ fn cursorBlinkInterval() u64 {
|
||||
|
||||
return CURSOR_BLINK_INTERVAL;
|
||||
}
|
||||
|
||||
/// Schedules incremental terminal compression after renderer activity stops.
|
||||
///
|
||||
/// This owns all renderer-specific compression state. The terminal decides
|
||||
/// when compression-relevant activity changes and performs the actual work;
|
||||
/// the renderer only provides idle scheduling and avoids waiting for the
|
||||
/// terminal lock.
|
||||
const Compression = struct {
|
||||
const idle_interval = 250;
|
||||
const step_interval = 1;
|
||||
|
||||
timer: xev.Timer,
|
||||
completion: xev.Completion = .{},
|
||||
reset_completion: xev.Completion = .{},
|
||||
activity: ?u64 = null,
|
||||
|
||||
fn init() !Compression {
|
||||
return .{ .timer = try xev.Timer.init() };
|
||||
}
|
||||
|
||||
fn deinit(self: *Compression) void {
|
||||
self.timer.deinit();
|
||||
}
|
||||
|
||||
/// Start or postpone compression after a renderer wake.
|
||||
fn wake(self: *Compression, thread: *Thread) void {
|
||||
// If we have no compression then don't do anything.
|
||||
if (comptime !terminalpkg.compression_enabled) return;
|
||||
if (!thread.config.scrollback_compression) return;
|
||||
|
||||
// PageList activity, rather than a generic renderer wake, restarts the
|
||||
// idle interval. In particular, the inspector wakes the renderer every
|
||||
// frame without changing terminal contents and must not starve this
|
||||
// timer indefinitely.
|
||||
if (thread.state.mutex.tryLock()) {
|
||||
defer thread.state.mutex.unlock();
|
||||
const activity = thread.state.terminal.compressionActivity();
|
||||
if (self.activity == activity) return;
|
||||
self.activity = activity;
|
||||
} else if (self.completion.state() == .active) {
|
||||
// Contention doesn't prove that compression-relevant activity
|
||||
// changed. Keep an existing deadline so frequent inspector frames
|
||||
// cannot postpone compression forever. The timer rechecks both the
|
||||
// activity token and lock availability before doing any work.
|
||||
return;
|
||||
}
|
||||
|
||||
// Contention may mean parsing is active. Scheduling is a harmless
|
||||
// false positive when no compression work is actually pending, but is
|
||||
// necessary when no timer is already active.
|
||||
self.schedule(thread, idle_interval);
|
||||
}
|
||||
|
||||
/// Start the one-shot timer, or move its deadline if it is already active.
|
||||
fn schedule(self: *Compression, thread: *Thread, delay_ms: u64) void {
|
||||
self.timer.reset(
|
||||
&thread.loop,
|
||||
&self.completion,
|
||||
&self.reset_completion,
|
||||
delay_ms,
|
||||
Thread,
|
||||
thread,
|
||||
timerCallback,
|
||||
);
|
||||
}
|
||||
|
||||
fn timerCallback(
|
||||
thread_: ?*Thread,
|
||||
_: *xev.Loop,
|
||||
_: *xev.Completion,
|
||||
result: xev.Timer.RunError!void,
|
||||
) xev.CallbackAction {
|
||||
_ = result catch |err| switch (err) {
|
||||
error.Canceled => return .disarm,
|
||||
else => {
|
||||
log.warn("error in compression timer err={}", .{err});
|
||||
return .disarm;
|
||||
},
|
||||
};
|
||||
|
||||
const thread = thread_ orelse return .disarm;
|
||||
const self = &thread.compression;
|
||||
|
||||
if (self.step(thread)) |delay| self.schedule(thread, delay);
|
||||
return .disarm;
|
||||
}
|
||||
|
||||
/// Try one bounded step without waiting for the terminal lock. The return
|
||||
/// value is the delay before another attempt, or null when work is done.
|
||||
fn step(self: *Compression, thread: *Thread) ?u64 {
|
||||
if (!thread.config.scrollback_compression) return null;
|
||||
|
||||
const state = thread.state;
|
||||
if (!state.mutex.tryLock()) return idle_interval;
|
||||
defer state.mutex.unlock();
|
||||
|
||||
const activity = state.terminal.compressionActivity();
|
||||
if (self.activity != activity) {
|
||||
self.activity = activity;
|
||||
return idle_interval;
|
||||
}
|
||||
|
||||
return switch (state.terminal.compress(.incremental)) {
|
||||
.pending => step_interval,
|
||||
.unsupported,
|
||||
.complete,
|
||||
=> null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -432,7 +432,7 @@ pub fn adjust(
|
||||
var current = end_pin.*;
|
||||
while (current.down(1)) |next| : (current = next) {
|
||||
const rac = next.rowAndCell();
|
||||
const cells = next.node.data.getCells(rac.row);
|
||||
const cells = next.node.page().getCells(rac.row);
|
||||
if (page.Cell.hasTextAny(cells)) {
|
||||
end_pin.* = next;
|
||||
break;
|
||||
@@ -494,7 +494,7 @@ pub fn adjust(
|
||||
);
|
||||
while (it.next()) |next| {
|
||||
const rac = next.rowAndCell();
|
||||
const cells = next.node.data.getCells(rac.row);
|
||||
const cells = next.node.page().getCells(rac.row);
|
||||
if (page.Cell.hasTextAny(cells)) {
|
||||
end_pin.* = next;
|
||||
end_pin.x = @intCast(cells.len - 1);
|
||||
@@ -505,7 +505,7 @@ pub fn adjust(
|
||||
|
||||
.beginning_of_line => end_pin.x = 0,
|
||||
|
||||
.end_of_line => end_pin.x = end_pin.node.data.size.cols - 1,
|
||||
.end_of_line => end_pin.x = end_pin.node.cols() - 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -98,7 +98,8 @@ pub fn grid_ref_hyperlink_uri(
|
||||
out_len: *usize,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const p = ref.toPin() orelse return .invalid_value;
|
||||
const rac = p.node.data.getRowAndCell(p.x, p.y);
|
||||
const terminal_page = p.node.page();
|
||||
const rac = terminal_page.getRowAndCell(p.x, p.y);
|
||||
const cell = rac.cell;
|
||||
|
||||
if (!cell.hyperlink) {
|
||||
@@ -106,12 +107,15 @@ pub fn grid_ref_hyperlink_uri(
|
||||
return .success;
|
||||
}
|
||||
|
||||
const link_id = p.node.data.lookupHyperlink(cell) orelse {
|
||||
const link_id = terminal_page.lookupHyperlink(cell) orelse {
|
||||
out_len.* = 0;
|
||||
return .success;
|
||||
};
|
||||
const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id);
|
||||
const uri = entry.uri.slice(p.node.data.memory);
|
||||
const entry = terminal_page.hyperlink_set.get(
|
||||
terminal_page.memory,
|
||||
link_id,
|
||||
);
|
||||
const uri = entry.uri.slice(terminal_page.memory);
|
||||
|
||||
if (out_buf == null or buf_len < uri.len) {
|
||||
out_len.* = uri.len;
|
||||
@@ -133,8 +137,9 @@ pub fn grid_ref_style(
|
||||
if (cell.style_id == stylepkg.default_id) {
|
||||
o.* = .fromStyle(.{});
|
||||
} else {
|
||||
o.* = .fromStyle(p.node.data.styles.get(
|
||||
p.node.data.memory,
|
||||
const terminal_page = p.node.page();
|
||||
o.* = .fromStyle(terminal_page.styles.get(
|
||||
terminal_page.memory,
|
||||
cell.style_id,
|
||||
).*);
|
||||
}
|
||||
|
||||
@@ -183,6 +183,8 @@ pub const terminal_resize = terminal.resize;
|
||||
pub const terminal_set = terminal.set;
|
||||
pub const terminal_vt_write = terminal.vt_write;
|
||||
pub const terminal_scroll_viewport = terminal.scroll_viewport;
|
||||
pub const terminal_compression_activity = terminal.compression_activity;
|
||||
pub const terminal_compress = terminal.compress;
|
||||
pub const terminal_mode_get = terminal.mode_get;
|
||||
pub const terminal_mode_set = terminal.mode_set;
|
||||
pub const terminal_get = terminal.get;
|
||||
|
||||
@@ -223,6 +223,12 @@ const Effects = struct {
|
||||
/// C: GhosttyTerminal
|
||||
pub const Terminal = ?*TerminalWrapper;
|
||||
|
||||
/// C: GhosttyTerminalCompressionMode
|
||||
pub const CompressionMode = ZigTerminal.CompressionMode;
|
||||
|
||||
/// C: GhosttyTerminalCompressionResult
|
||||
pub const CompressionResult = ZigTerminal.CompressionResult;
|
||||
|
||||
pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal {
|
||||
return (terminal_ orelse return null).terminal;
|
||||
}
|
||||
@@ -315,6 +321,30 @@ pub fn vt_write(
|
||||
wrapper.stream.nextSlice(ptr[0..len]);
|
||||
}
|
||||
|
||||
pub fn compression_activity(
|
||||
terminal_: Terminal,
|
||||
out_activity_: ?*u64,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
|
||||
const out_activity = out_activity_ orelse return .invalid_value;
|
||||
out_activity.* = t.compressionActivity();
|
||||
return .success;
|
||||
}
|
||||
|
||||
pub fn compress(
|
||||
terminal_: Terminal,
|
||||
mode_: c_int,
|
||||
out_result_: ?*CompressionResult,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
|
||||
const out_result = out_result_ orelse return .invalid_value;
|
||||
const mode = std.meta.intToEnum(CompressionMode, mode_) catch
|
||||
return .invalid_value;
|
||||
|
||||
out_result.* = t.compress(mode);
|
||||
return .success;
|
||||
}
|
||||
|
||||
/// C: GhosttyTerminalOption
|
||||
pub const Option = enum(c_int) {
|
||||
userdata = 0,
|
||||
@@ -1099,6 +1129,112 @@ test "scroll_viewport null" {
|
||||
scroll_viewport(null, .{ .tag = .row, .value = .{ .row = 1 } });
|
||||
}
|
||||
|
||||
test "compression invalid arguments" {
|
||||
var activity: u64 = undefined;
|
||||
var compression_result: CompressionResult = undefined;
|
||||
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
compression_activity(null, &activity),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
compress(null, @intFromEnum(CompressionMode.incremental), &compression_result),
|
||||
);
|
||||
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 10_000_000,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
compression_activity(t, null),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
compress(t, @intFromEnum(CompressionMode.incremental), null),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
compress(t, -1, &compression_result),
|
||||
);
|
||||
}
|
||||
|
||||
test "compression activity and incremental scheduling" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 10_000_000,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
var initial_activity: u64 = undefined;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
compression_activity(t, &initial_activity),
|
||||
);
|
||||
|
||||
const line = "repeated and compressible terminal history\r\n";
|
||||
const repeat = 4_000;
|
||||
const input = try testing.allocator.alloc(u8, line.len * repeat);
|
||||
defer testing.allocator.free(input);
|
||||
for (0..repeat) |i|
|
||||
@memcpy(input[i * line.len ..][0..line.len], line);
|
||||
vt_write(t, input.ptr, input.len);
|
||||
|
||||
var activity: u64 = undefined;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
compression_activity(t, &activity),
|
||||
);
|
||||
try testing.expect(activity != initial_activity);
|
||||
|
||||
var compression_result: CompressionResult = undefined;
|
||||
for (0..1_000) |_| {
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
compress(
|
||||
t,
|
||||
@intFromEnum(CompressionMode.incremental),
|
||||
&compression_result,
|
||||
),
|
||||
);
|
||||
|
||||
switch (compression_result) {
|
||||
.pending => continue,
|
||||
.complete => break,
|
||||
.unsupported => unreachable,
|
||||
}
|
||||
} else return error.TestUnexpectedResult;
|
||||
|
||||
// Compression changes storage representation, not the activity token.
|
||||
var final_activity: u64 = undefined;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
compression_activity(t, &final_activity),
|
||||
);
|
||||
try testing.expectEqual(activity, final_activity);
|
||||
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
compress(t, @intFromEnum(CompressionMode.full), &compression_result),
|
||||
);
|
||||
try testing.expectEqual(CompressionResult.complete, compression_result);
|
||||
}
|
||||
|
||||
test "reset" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
15
src/terminal/compress.zig
Normal file
15
src/terminal/compress.zig
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Compression primitives used by the terminal.
|
||||
//!
|
||||
//! This namespace contains only the representation and codecs. Policy about
|
||||
//! which pages to compress, when to decommit their resident memory, and when
|
||||
//! to restore them belongs to `PageList`.
|
||||
|
||||
/// The raw LZ4 block codec used for terminal page memory.
|
||||
pub const lz4 = @import("compress/lz4.zig");
|
||||
|
||||
/// A compressed terminal page which retains its original virtual mapping.
|
||||
pub const Page = @import("compress/Page.zig");
|
||||
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
}
|
||||
90
src/terminal/compress/AGENTS.md
Normal file
90
src/terminal/compress/AGENTS.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Terminal Compression
|
||||
|
||||
Guidance for the codecs and the compressed page representation
|
||||
(`Page.zig`) in this directory. These compress terminal page backing
|
||||
memory (`terminal.Page`).
|
||||
|
||||
## Priorities
|
||||
|
||||
When making tradeoffs, in order:
|
||||
|
||||
1. **Compression ratio on page-shaped data.** Encoded bytes are retained
|
||||
scrollback memory, and raw `terminal.Page` backing memory is the only
|
||||
thing we actually compress. Ratio on text files or synthetic data is a
|
||||
secondary signal.
|
||||
2. **Decompression throughput.** Pages are compressed once when they go
|
||||
cold but restored on demand (scrollback access, search, inspection), so
|
||||
restore latency is felt directly.
|
||||
3. **Compression throughput.** Runs on idle pages in the background; being
|
||||
fast is nice, being slow is tolerable.
|
||||
|
||||
## Testing
|
||||
|
||||
- Targeted tests: `zig build test -Dtest-filter=<codec>`
|
||||
- Prefer `zig build test-lib-vt -Dtest-filter=<codec>` when practical;
|
||||
this code ships in libghostty-vt.
|
||||
- Codecs must keep building for `wasm32-freestanding` (libghostty-vt):
|
||||
no libc, no `src/simd` (Highway) dependencies. Verify with
|
||||
`zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`.
|
||||
- Every codec needs a differential property suite: round-trip identity,
|
||||
an independent format walker, wrong-size output rejection, and
|
||||
corruption/truncation decoding. Keep a light version in normal unit
|
||||
tests and gate the exhaustive version behind an environment variable so
|
||||
the default test suite stays fast.
|
||||
|
||||
## Verifying Correctness
|
||||
|
||||
- Decoders must be memory-safe for arbitrary input bytes. Every blind or
|
||||
wide copy needs a stated margin argument bounding it by the output
|
||||
buffer; keep those arguments in comments next to the code.
|
||||
- Writing scratch bytes past a copy's logical end is safe only inside the
|
||||
output buffer, because in-order decoding rewrites them before any match
|
||||
can read them back. Do not weaken the exact-size output contract.
|
||||
- When a change should not alter compressor output, prove it: compare
|
||||
encoded sizes (or a sequence-count fingerprint) on the same corpus
|
||||
before and after. Ratio drift is a functional change, not noise.
|
||||
|
||||
## Benchmarking
|
||||
|
||||
- Use `ghostty-bench +page-compression` (see `src/benchmark/AGENTS.md`
|
||||
for the general workflow). Modes: `compress`, `decompress`, `store`,
|
||||
and `report` for ratio.
|
||||
- Build: `zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false`
|
||||
- The most representative corpus is a raw dump of real page backing
|
||||
memory, chunked at the page size (400 KiB on ReleaseFast targets).
|
||||
Supplement with a text corpus and random bytes for worst cases, but
|
||||
weigh page corpora highest per the priorities above. Keep corpora
|
||||
outside the repository and reuse identical files across comparisons.
|
||||
- `ghostty-bench +scrollback-compression` measures the PageList
|
||||
transitions around the codec rather than the codec itself.
|
||||
- For fast iteration, keep codecs dependent only on `std` so a standalone
|
||||
harness can build them directly with `zig build-exe -O ReleaseFast` and
|
||||
time the codec in-process (report min-of-N, verify round-trips).
|
||||
- Measure one change at a time and re-measure the final state; run-to-run
|
||||
noise is a few percent, so re-run before believing small deltas.
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Real page data decodes as millions of tiny operations (in LZ4: mostly
|
||||
zero literals plus a 4-18 byte match). Per-item overhead dominates, so
|
||||
branch-light fast paths with blind fixed-size copies win.
|
||||
- Wide copies are the only SIMD that pays here. Vectorized compares and
|
||||
other wide-stride tricks measured as net losses because matches are
|
||||
short; prefer the simple word loop unless a measurement on page corpora
|
||||
says otherwise.
|
||||
- `@memcpy` beats stride loops only for long copies (roughly 64 bytes and
|
||||
up); call overhead loses below that.
|
||||
|
||||
## LZ4 Specific
|
||||
|
||||
- The codec is `lz4.zig`, an allocation-free raw block (not frame)
|
||||
implementation. Blocks do not carry their decoded size; callers supply
|
||||
an exact-size output buffer.
|
||||
- Tests: `zig build test -Dtest-filter=lz4`. The differential suite is
|
||||
`lz4_differential.zig`; run the exhaustive version for any codec
|
||||
change:
|
||||
`GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential"`
|
||||
- The compressor must keep the standard format restrictions (final five
|
||||
bytes literal, matches start at least twelve bytes before the end) so
|
||||
blocks stay consumable by optimized external decoders. The differential
|
||||
walker checks this.
|
||||
398
src/terminal/compress/Page.zig
Normal file
398
src/terminal/compress/Page.zig
Normal file
@@ -0,0 +1,398 @@
|
||||
//! A compressed terminal page which retains its resident virtual mapping.
|
||||
//!
|
||||
//! Terminal pages have two kinds of state: the large `Page.memory` allocation
|
||||
//! and a comparatively small `Page` value containing offsets, dimensions,
|
||||
//! dirty state, and allocator metadata. Not all of the latter state lives in
|
||||
//! the backing memory, so preserving only the memory bytes is insufficient.
|
||||
//! We preserve the complete `Page` value instead. This follows the same model
|
||||
//! as `Page.cloneBuf`: page internals use offsets, so a shallow page copy remains
|
||||
//! valid when its memory contents are restored at the same address.
|
||||
//!
|
||||
//! The resident memory is deliberately not freed by this type. `PageList`
|
||||
//! keeps the virtual range allocated while asking the operating system to
|
||||
//! discard its physical pages. Keeping the range has two useful properties:
|
||||
//! the embedded page never contains a dangling pointer, and restoring the page
|
||||
//! does not require a fallible allocation.
|
||||
//!
|
||||
//! The intended state transition is:
|
||||
//!
|
||||
//! 1. Create this value while the source page is resident.
|
||||
//! 2. Ask the OS to decommit the source page's memory.
|
||||
//! 3. Replace the PageList node's resident state with this value.
|
||||
//! 4. To restore, recommit the retained range and call `restore`.
|
||||
//! 5. After committing the resident state, call `deinit` to free the encoded
|
||||
//! bytes.
|
||||
//!
|
||||
//! If decommit is unavailable or fails, the caller should deinitialize the
|
||||
//! compressed candidate and leave the source page resident. This type does not
|
||||
//! perform any virtual-memory operations itself.
|
||||
//!
|
||||
//! The planned native implementation uses `MADV_DONTNEED` on Linux and pairs
|
||||
//! `MADV_FREE_REUSABLE` with `MADV_FREE_REUSE` on Darwin. Targets without a
|
||||
//! reliable retained-mapping decommit operation should leave page compression
|
||||
//! disabled rather than free and later reallocate the resident memory.
|
||||
const Page = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const TerminalPage = @import("../page.zig").Page;
|
||||
const lz4 = @import("lz4.zig");
|
||||
|
||||
/// Complete page metadata together with the retained resident mapping.
|
||||
///
|
||||
/// The bytes in `page.memory` may have been discarded by the OS while this
|
||||
/// value is in compressed state. They must not be read until `restore` has
|
||||
/// successfully decoded the page.
|
||||
page: TerminalPage,
|
||||
|
||||
/// Exact raw LZ4 block for `page.memory`.
|
||||
encoded: []u8,
|
||||
|
||||
/// Allocator which owns `encoded`.
|
||||
///
|
||||
/// The allocator's backing state must outlive this compressed page. Storing
|
||||
/// the allocator here lets a PageList node restore and discard its compressed
|
||||
/// state without needing a reference back to the PageList.
|
||||
alloc: Allocator,
|
||||
|
||||
/// Return the largest scratch buffer that can produce a useful compressed
|
||||
/// representation for `raw_len` bytes.
|
||||
///
|
||||
/// This is deliberately smaller than the general LZ4 compression bound. The
|
||||
/// compressed representation includes an additional slice compared to a
|
||||
/// resident terminal page, so an encoded block which fills more than this
|
||||
/// buffer cannot reduce resident memory. Limiting the output here lets a
|
||||
/// PageList borrow a standard page-pool item as scratch instead of retaining a
|
||||
/// larger, compression-bound allocation.
|
||||
///
|
||||
/// Callers are expected to reuse the scratch memory when practical. The
|
||||
/// scratch buffer and hash table are never retained by this type.
|
||||
pub fn requiredScratch(raw_len: usize) lz4.CompressError!usize {
|
||||
// Validate the codec's input limit before doing arithmetic based on the
|
||||
// raw size. The bound itself is intentionally not returned; see above.
|
||||
_ = try lz4.compressBound(raw_len);
|
||||
|
||||
const representation_overhead = @sizeOf(Page) - @sizeOf(TerminalPage);
|
||||
if (raw_len <= representation_overhead) return 0;
|
||||
|
||||
// The savings comparison is strict, so reserve one fewer byte than the
|
||||
// break-even encoded size.
|
||||
return raw_len - representation_overhead - 1;
|
||||
}
|
||||
|
||||
/// Create a compressed representation of `source`.
|
||||
///
|
||||
/// `source` is not modified and continues to own its backing allocation. The
|
||||
/// scratch buffer must be at least `requiredScratch(source.memory.len)` bytes.
|
||||
/// It must not overlap the source memory. On success, the returned value aliases
|
||||
/// the source's resident mapping and owns only its exact-sized `encoded`
|
||||
/// allocation. The allocator and its backing state must remain valid until
|
||||
/// `deinit` is called.
|
||||
///
|
||||
/// Returns null if retaining the compressed representation would not use less
|
||||
/// memory than the resident representation. An `OutputTooSmall` result from
|
||||
/// the codec also means the encoding crossed that break-even point and is
|
||||
/// therefore returned as null. This comparison includes the in-memory size of
|
||||
/// both representation structs but does not include allocator metadata or the
|
||||
/// retained virtual address range.
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
source: *const TerminalPage,
|
||||
scratch: []u8,
|
||||
table: *lz4.HashTable,
|
||||
) (Allocator.Error || lz4.CompressError)!?Page {
|
||||
const required = try requiredScratch(source.memory.len);
|
||||
if (scratch.len < required) return error.OutputTooSmall;
|
||||
if (required == 0) return null;
|
||||
|
||||
const encoded_len = lz4.compress(
|
||||
source.memory,
|
||||
scratch[0..required],
|
||||
table,
|
||||
) catch |err| switch (err) {
|
||||
// The scratch limit is the largest representation worth retaining.
|
||||
// Running out of room therefore means compression cannot save memory,
|
||||
// rather than that the caller failed to provide the documented size.
|
||||
error.OutputTooSmall => return null,
|
||||
error.InputTooLarge => return err,
|
||||
};
|
||||
|
||||
// requiredScratch already accounts for the representation structs. The
|
||||
// retained virtual range contributes no resident bytes after PageList
|
||||
// decommits it.
|
||||
assert(@sizeOf(Page) + encoded_len <
|
||||
@sizeOf(TerminalPage) + source.memory.len);
|
||||
|
||||
return .{
|
||||
.page = source.*,
|
||||
.encoded = try alloc.dupe(u8, scratch[0..encoded_len]),
|
||||
.alloc = alloc,
|
||||
};
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// which require an explicit recommit operation. The compressed value remains
|
||||
/// valid whether decoding succeeds or fails, allowing the caller to retry or
|
||||
/// discard it. On success the returned page aliases the same resident mapping;
|
||||
/// it does not own a new allocation.
|
||||
pub fn restore(self: *const Page) lz4.DecompressError!TerminalPage {
|
||||
const result = self.page;
|
||||
_ = try lz4.decompress(self.encoded, result.memory);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Clone this page into caller-owned memory without restoring its mapping.
|
||||
///
|
||||
/// `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" {
|
||||
const testing = std.testing;
|
||||
|
||||
var resident = try TerminalPage.init(.{
|
||||
.cols = 12,
|
||||
.rows = 9,
|
||||
.styles = 8,
|
||||
.grapheme_bytes = 128,
|
||||
.string_bytes = 128,
|
||||
});
|
||||
defer resident.deinit();
|
||||
resident.size = .{ .cols = 10, .rows = 7 };
|
||||
resident.dirty = true;
|
||||
|
||||
// Put state in both the backing memory and the Page value. In particular,
|
||||
// the style and hyperlink sets retain live counters outside Page.memory.
|
||||
const rac = resident.getRowAndCell(2, 3);
|
||||
rac.cell.* = .init('A');
|
||||
try resident.appendGrapheme(rac.row, rac.cell, 0x0301);
|
||||
|
||||
const style_id = try resident.styles.add(resident.memory, .{ .flags = .{
|
||||
.bold = true,
|
||||
} });
|
||||
rac.cell.style_id = style_id;
|
||||
rac.row.styled = true;
|
||||
|
||||
const hyperlink_id = try resident.insertHyperlink(.{
|
||||
.id = .{ .explicit = "compressed-page" },
|
||||
.uri = "https://ghostty.org/docs",
|
||||
});
|
||||
try resident.setHyperlink(rac.row, rac.cell, hyperlink_id);
|
||||
try resident.verifyIntegrity(testing.allocator);
|
||||
|
||||
const expected = try testing.allocator.dupe(u8, resident.memory);
|
||||
defer testing.allocator.free(expected);
|
||||
const memory_ptr = resident.memory.ptr;
|
||||
const memory_len = resident.memory.len;
|
||||
|
||||
const scratch = try testing.allocator.alloc(
|
||||
u8,
|
||||
try requiredScratch(resident.memory.len),
|
||||
);
|
||||
defer testing.allocator.free(scratch);
|
||||
var table: lz4.HashTable = undefined;
|
||||
var compressed = (try Page.init(
|
||||
testing.allocator,
|
||||
&resident,
|
||||
scratch,
|
||||
&table,
|
||||
)).?;
|
||||
defer compressed.deinit();
|
||||
|
||||
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);
|
||||
try testing.expectEqualSlices(u8, expected, restored.memory);
|
||||
try testing.expectEqual(resident.size, restored.size);
|
||||
try testing.expect(restored.dirty);
|
||||
try restored.verifyIntegrity(testing.allocator);
|
||||
|
||||
const restored_rac = restored.getRowAndCell(2, 3);
|
||||
try testing.expectEqualSlices(
|
||||
u21,
|
||||
&.{0x0301},
|
||||
restored.lookupGrapheme(restored_rac.cell).?,
|
||||
);
|
||||
try testing.expect(restored.styles.get(
|
||||
restored.memory,
|
||||
restored_rac.cell.style_id,
|
||||
).flags.bold);
|
||||
|
||||
const restored_hyperlink_id = restored.lookupHyperlink(restored_rac.cell).?;
|
||||
const restored_hyperlink = restored.hyperlink_set.get(
|
||||
restored.memory,
|
||||
restored_hyperlink_id,
|
||||
);
|
||||
try testing.expectEqualStrings(
|
||||
"https://ghostty.org/docs",
|
||||
restored_hyperlink.uri.slice(restored.memory),
|
||||
);
|
||||
}
|
||||
|
||||
test "compressed Page requires the maximum useful scratch" {
|
||||
const testing = std.testing;
|
||||
|
||||
var resident = try TerminalPage.init(.{ .cols = 4, .rows = 4 });
|
||||
defer resident.deinit();
|
||||
const expected = try testing.allocator.dupe(u8, resident.memory);
|
||||
defer testing.allocator.free(expected);
|
||||
|
||||
const required = try requiredScratch(resident.memory.len);
|
||||
try testing.expectEqual(
|
||||
resident.memory.len - (@sizeOf(Page) - @sizeOf(TerminalPage)) - 1,
|
||||
required,
|
||||
);
|
||||
try testing.expect(required < try lz4.compressBound(resident.memory.len));
|
||||
|
||||
const scratch = try testing.allocator.alloc(u8, required - 1);
|
||||
defer testing.allocator.free(scratch);
|
||||
var table: lz4.HashTable = undefined;
|
||||
|
||||
try testing.expectError(error.OutputTooSmall, Page.init(
|
||||
testing.allocator,
|
||||
&resident,
|
||||
scratch,
|
||||
&table,
|
||||
));
|
||||
try testing.expectEqualSlices(u8, expected, resident.memory);
|
||||
}
|
||||
|
||||
test "compressed Page rejects a representation without savings" {
|
||||
const testing = std.testing;
|
||||
|
||||
var resident = try TerminalPage.init(.{
|
||||
.cols = 4,
|
||||
.rows = 4,
|
||||
.styles = 0,
|
||||
.grapheme_bytes = 0,
|
||||
.string_bytes = 0,
|
||||
.hyperlink_bytes = 0,
|
||||
});
|
||||
defer resident.deinit();
|
||||
|
||||
var prng = std.Random.DefaultPrng.init(0x4C5A_3402);
|
||||
prng.random().bytes(resident.memory);
|
||||
|
||||
const scratch = try testing.allocator.alloc(
|
||||
u8,
|
||||
try requiredScratch(resident.memory.len),
|
||||
);
|
||||
defer testing.allocator.free(scratch);
|
||||
var table: lz4.HashTable = undefined;
|
||||
var failing = testing.FailingAllocator.init(testing.allocator, .{
|
||||
.fail_index = 0,
|
||||
});
|
||||
|
||||
try testing.expect((try Page.init(
|
||||
failing.allocator(),
|
||||
&resident,
|
||||
scratch,
|
||||
&table,
|
||||
)) == null);
|
||||
try testing.expect(!failing.has_induced_failure);
|
||||
}
|
||||
|
||||
test "compressed Page can retry after malformed encoded data" {
|
||||
const testing = std.testing;
|
||||
|
||||
var resident = try TerminalPage.init(.{ .cols = 8, .rows = 8 });
|
||||
defer resident.deinit();
|
||||
const expected = try testing.allocator.dupe(u8, resident.memory);
|
||||
defer testing.allocator.free(expected);
|
||||
|
||||
const scratch = try testing.allocator.alloc(
|
||||
u8,
|
||||
try requiredScratch(resident.memory.len),
|
||||
);
|
||||
defer testing.allocator.free(scratch);
|
||||
var table: lz4.HashTable = undefined;
|
||||
var compressed = (try Page.init(
|
||||
testing.allocator,
|
||||
&resident,
|
||||
scratch,
|
||||
&table,
|
||||
)).?;
|
||||
defer compressed.deinit();
|
||||
|
||||
const full_encoded = compressed.encoded;
|
||||
const first_byte = full_encoded[0];
|
||||
defer {
|
||||
compressed.encoded = full_encoded;
|
||||
compressed.encoded[0] = first_byte;
|
||||
}
|
||||
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;
|
||||
compressed.encoded[0] = first_byte;
|
||||
@memset(resident.memory, 0);
|
||||
const restored = try compressed.restore();
|
||||
try testing.expectEqualSlices(u8, expected, restored.memory);
|
||||
}
|
||||
891
src/terminal/compress/lz4.zig
Normal file
891
src/terminal/compress/lz4.zig
Normal file
@@ -0,0 +1,891 @@
|
||||
//! An allocation-free implementation of the raw LZ4 block format.
|
||||
//!
|
||||
//! LZ4 has two relevant layers: the block format describes the compressed
|
||||
//! bytes, while the frame format adds headers, sizes, checksums, and support
|
||||
//! for a stream of blocks. Terminal pages already have their own ownership
|
||||
//! and metadata, so this implements only blocks. In particular, an encoded
|
||||
//! block does not contain its decompressed size. The caller must store that
|
||||
//! separately and provide an exactly sized buffer when decoding.
|
||||
//!
|
||||
//! A block is a series of sequences. Each non-final sequence has this shape:
|
||||
//!
|
||||
//! token | literal length extensions | literals | offset | match length extensions
|
||||
//!
|
||||
//! The token's high nibble contains the literal length and its low nibble
|
||||
//! contains the match length minus four. A nibble value of 15 means that the
|
||||
//! length continues in extension bytes at the corresponding point in the
|
||||
//! sequence. Each extension byte adds to the length; a value of 255 means
|
||||
//! another byte follows. The literal bytes are copied directly. The two-byte
|
||||
//! little-endian offset then points backwards in the already decompressed
|
||||
//! output to the match bytes.
|
||||
//!
|
||||
//! The last sequence is special: it contains literals only and ends directly
|
||||
//! after them. The reference format also requires the last five input bytes to
|
||||
//! be literals and the final match to begin at least twelve bytes before the
|
||||
//! end of the input. The compressor observes these restrictions so its output
|
||||
//! can be consumed by optimized LZ4 decoders which copy in larger units.
|
||||
//!
|
||||
//! Compression uses the fast LZ4 strategy: hash each four-byte input sequence
|
||||
//! and test recent positions as match candidates. Two 16-bit positions fit in
|
||||
//! each hash-table entry because the format cannot refer further than 64 KiB
|
||||
//! backwards. Retaining the displaced position recovers useful matches after
|
||||
//! hash collisions without increasing the fixed 16 KiB workspace. Long runs
|
||||
//! without matches gradually skip input positions, while matching runs are
|
||||
//! extended a machine word at a time. The implementation allocates nothing;
|
||||
//! all input, output, and scratch memory is supplied by the caller.
|
||||
//!
|
||||
//! Format reference:
|
||||
//! https://github.com/lz4/lz4/blob/dev/doc/lz4_Block_format.md
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const testing = std.testing;
|
||||
|
||||
/// Maximum input accepted by the reference LZ4 block API. Keeping the same
|
||||
/// limit means `compressBound` fits in the integer sizes used by LZ4 callers
|
||||
/// and gives us the same compatibility boundary as other implementations.
|
||||
pub const max_input_size: usize = 0x7E000000;
|
||||
|
||||
/// Every LZ4 match represents at least four bytes. The token stores the number
|
||||
/// of bytes beyond this minimum rather than the full match length.
|
||||
const min_match = 4;
|
||||
|
||||
/// Number of bytes at the end of a conforming block which must remain literals.
|
||||
const last_literals = 5;
|
||||
|
||||
/// A match may not begin in the final 12 bytes. This leaves enough room for the
|
||||
/// minimum match and the required five trailing literals.
|
||||
const match_find_limit = 12;
|
||||
|
||||
/// We retain one input position for each 12-bit hash. LZ4 refers to this as
|
||||
/// memory usage 14 because the 4096 entries are four bytes each (16 KiB).
|
||||
const hash_log = 12;
|
||||
|
||||
/// Multiplicative hash used by the reference LZ4 fast compressor. The high
|
||||
/// `hash_log` bits provide the table index.
|
||||
const hash_multiplier: u32 = 2_654_435_761;
|
||||
|
||||
/// Scratch memory used while compressing one block. Each entry packs the low
|
||||
/// 16 bits of the two most recent input positions for its hash. All-ones marks
|
||||
/// an empty half; LZ4's 16-bit offset is enough to reconstruct the only useful
|
||||
/// preceding address from the current position.
|
||||
/// The table is reset by every call to `compress` and can be reused afterwards.
|
||||
pub const HashTable = [1 << hash_log]u32;
|
||||
|
||||
/// Errors which can occur while encoding a block.
|
||||
pub const CompressError = error{
|
||||
/// The input exceeds the maximum size supported by the block compressor.
|
||||
InputTooLarge,
|
||||
|
||||
/// The provided output buffer cannot hold the encoded block.
|
||||
OutputTooSmall,
|
||||
};
|
||||
|
||||
/// Errors which can occur while decoding a block.
|
||||
pub const DecompressError = error{
|
||||
/// The encoded block ended in the middle of a sequence.
|
||||
TruncatedInput,
|
||||
|
||||
/// A match offset was zero or pointed before the produced output.
|
||||
InvalidOffset,
|
||||
|
||||
/// A sequence would write beyond the provided output buffer.
|
||||
OutputTooSmall,
|
||||
|
||||
/// The block ended before filling the exact-size output buffer.
|
||||
OutputSizeMismatch,
|
||||
};
|
||||
|
||||
/// Return the maximum number of bytes needed to encode `input_len` bytes.
|
||||
///
|
||||
/// Incompressible input is represented as one literal run. Every 255 literal
|
||||
/// bytes can require one extension byte. The additional 16-byte margin covers
|
||||
/// the token and the format's fixed overhead. Callers can allocate this amount
|
||||
/// once and reuse it for any block no larger than `input_len`.
|
||||
pub fn compressBound(input_len: usize) CompressError!usize {
|
||||
if (input_len > max_input_size) return error.InputTooLarge;
|
||||
return input_len + input_len / 255 + 16;
|
||||
}
|
||||
|
||||
/// Compress `input` into a raw LZ4 block in `output`.
|
||||
///
|
||||
/// Returns the initialized length of `output`. The input and output buffers
|
||||
/// must not overlap. `table` is scratch space and does not need to be
|
||||
/// initialized by the caller; it is reset before use.
|
||||
pub fn compress(
|
||||
input: []const u8,
|
||||
output: []u8,
|
||||
table: *HashTable,
|
||||
) CompressError!usize {
|
||||
if (input.len > max_input_size) return error.InputTooLarge;
|
||||
|
||||
// All-ones in either packed half means "no previous position".
|
||||
@memset(table, std.math.maxInt(u32));
|
||||
|
||||
// `ip` is the current input position, `anchor` is the first literal not yet
|
||||
// emitted, and `op` is the next output position. A successful match emits
|
||||
// input[anchor..ip] as literals followed by the match, then moves both input
|
||||
// positions to the end of that match.
|
||||
var op: usize = 0;
|
||||
var anchor: usize = 0;
|
||||
|
||||
// LZ4's format leaves the final five input bytes as literals and starts
|
||||
// the final match at least twelve bytes before the end. This is not
|
||||
// required by our safe decoder, but makes blocks compatible with fast
|
||||
// decoders that rely on the standard format restrictions. Inputs too
|
||||
// short for any match are emitted below as one literal-only sequence.
|
||||
if (input.len >= match_find_limit) {
|
||||
const search_end = input.len - match_find_limit;
|
||||
const match_end_limit = input.len - last_literals;
|
||||
var ip: usize = 0;
|
||||
var search_attempts: usize = 0;
|
||||
|
||||
search: while (ip <= search_end) {
|
||||
// Hash the next four bytes and replace the table entry
|
||||
// immediately. Hash collisions are expected, so equality is
|
||||
// checked below before accepting a saved position as a match.
|
||||
const sequence = readU32(input, ip);
|
||||
const hash = hashSequence(sequence);
|
||||
const candidates = table[hash];
|
||||
rememberPosition(table, hash, ip);
|
||||
|
||||
var match_pos = candidatePosition(ip, @truncate(candidates)) orelse
|
||||
candidatePosition(ip, @truncate(candidates >> 16)) orelse
|
||||
{
|
||||
advanceSearch(&ip, anchor, &search_attempts);
|
||||
continue :search;
|
||||
};
|
||||
|
||||
if (readU32(input, match_pos) != sequence) {
|
||||
// The nearest candidate collided. Fall back to the older
|
||||
// one, which must additionally match one byte beyond the
|
||||
// minimum so collision-prone minimum-length matches from
|
||||
// the stale half are not emitted.
|
||||
match_pos = older: {
|
||||
if (candidatePosition(
|
||||
ip,
|
||||
@truncate(candidates >> 16),
|
||||
)) |older| {
|
||||
if (readU32(input, older) == sequence and
|
||||
input[older + min_match] == input[ip + min_match])
|
||||
{
|
||||
break :older older;
|
||||
}
|
||||
}
|
||||
|
||||
advanceSearch(&ip, anchor, &search_attempts);
|
||||
continue :search;
|
||||
};
|
||||
}
|
||||
|
||||
// Pull the match backwards into the current literal run. This is
|
||||
// particularly helpful around aligned cell records. As with
|
||||
// forward extension, compare words before locating the first
|
||||
// differing byte.
|
||||
const match_begin = matchBegin(input, ip, match_pos, anchor);
|
||||
ip = match_begin.position;
|
||||
match_pos = match_begin.candidate;
|
||||
|
||||
// We already compared the first four bytes. Continue up to the
|
||||
// point where the required last five literals begin. `matchEnd`
|
||||
// compares a machine word at a time before locating the first
|
||||
// differing byte.
|
||||
const match_end = matchEnd(
|
||||
input,
|
||||
ip + min_match,
|
||||
match_pos + min_match,
|
||||
match_end_limit,
|
||||
);
|
||||
|
||||
try emitSequence(
|
||||
output,
|
||||
&op,
|
||||
input[anchor..ip],
|
||||
@intCast(ip - match_pos),
|
||||
match_end - ip,
|
||||
);
|
||||
|
||||
// The main loop jumps over the matched bytes rather than hashing
|
||||
// every position within them. Seed one position near the end so
|
||||
// an adjacent repeated record can still refer back into this
|
||||
// match. The next loop iteration will then seed `match_end`
|
||||
// normally.
|
||||
if (match_end >= 2 and match_end - 2 + min_match <= input.len) {
|
||||
const seed = match_end - 2;
|
||||
rememberPosition(
|
||||
table,
|
||||
hashSequence(readU32(input, seed)),
|
||||
seed,
|
||||
);
|
||||
}
|
||||
|
||||
ip = match_end;
|
||||
anchor = ip;
|
||||
search_attempts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever remains after the last match is the terminal literal-only
|
||||
// sequence. For short inputs this is also the only sequence in the block.
|
||||
try emitLastLiterals(output, &op, input[anchor..]);
|
||||
return op;
|
||||
}
|
||||
|
||||
/// Decompress a raw LZ4 block into an exact-size output buffer.
|
||||
///
|
||||
/// Returns `output.len` on success. Both consuming all input and filling all
|
||||
/// output are required. Raw LZ4 blocks do not carry their decoded size, so this
|
||||
/// exact-size contract validates the size metadata maintained by the caller.
|
||||
/// The input and output buffers must not overlap.
|
||||
pub fn decompress(input: []const u8, output: []u8) DecompressError!usize {
|
||||
// `ip` and `op` always identify the next unread input byte and the next
|
||||
// unwritten output byte respectively.
|
||||
//
|
||||
// The decoder is written around one observation: almost every sequence
|
||||
// in real blocks has a short literal run and a short match. Both fast
|
||||
// paths below copy a fixed number of bytes blindly and let the length
|
||||
// arithmetic sort out how many of them were meaningful. Writing past a
|
||||
// run's logical end is safe within the output buffer because decoding
|
||||
// is strictly in order: every byte past `op` is either rewritten by a
|
||||
// later copy before anything can read it, or lies beyond the block's
|
||||
// final length and is never part of the result. The margin conditions
|
||||
// on the fast paths also subsume the exact bounds checks they replace,
|
||||
// which keeps decoding of malformed blocks memory-safe.
|
||||
var ip: usize = 0;
|
||||
var op: usize = 0;
|
||||
|
||||
while (true) {
|
||||
// A normal block ends after the literal bytes of its final sequence.
|
||||
// This also accepts the empty block produced by our compressor, which
|
||||
// consists of a zero token and no literals.
|
||||
if (ip == input.len) {
|
||||
if (op != output.len) return error.OutputSizeMismatch;
|
||||
return op;
|
||||
}
|
||||
|
||||
const token = input[ip];
|
||||
ip += 1;
|
||||
|
||||
// The high nibble and any extension bytes describe the literal run.
|
||||
// The literals are copied as a side effect of computing the length.
|
||||
const literal_len: usize = len: {
|
||||
const nibble: usize = token >> 4;
|
||||
if (nibble != 15 and
|
||||
@min(input.len - ip, output.len - op) >= 16)
|
||||
{
|
||||
// A run below the extension threshold is at most 14 bytes,
|
||||
// so with a 16-byte margin on both buffers one wide copy
|
||||
// covers it.
|
||||
copyIntAt(u128, output, op, input, ip);
|
||||
break :len nibble;
|
||||
}
|
||||
|
||||
// Extended or margin-poor runs take the checked path. Bounds are
|
||||
// verified before copying so malformed blocks never cause a
|
||||
// partial read or write.
|
||||
const len = try decodeLength(input, &ip, nibble);
|
||||
if (len > input.len - ip) return error.TruncatedInput;
|
||||
if (len > output.len - op) return error.OutputTooSmall;
|
||||
@memcpy(output[op..][0..len], input[ip..][0..len]);
|
||||
break :len len;
|
||||
};
|
||||
ip += literal_len;
|
||||
op += literal_len;
|
||||
|
||||
// Ending immediately after the literals marks the final sequence. Any
|
||||
// non-final sequence must continue with an offset and match length.
|
||||
if (ip == input.len) {
|
||||
if (op != output.len) return error.OutputSizeMismatch;
|
||||
return op;
|
||||
}
|
||||
|
||||
if (input.len - ip < 2) return error.TruncatedInput;
|
||||
const offset = readIntAt(u16, input, ip);
|
||||
ip += 2;
|
||||
if (offset == 0 or offset > op) return error.InvalidOffset;
|
||||
|
||||
// The token stores the match length minus the four-byte minimum. As
|
||||
// with literals, a low nibble of 15 is extended by following bytes.
|
||||
const match_nibble: usize = token & 0x0F;
|
||||
|
||||
// A match whose length fits its nibble spans at most 18 bytes, so
|
||||
// three blind copies always cover it. They are overlap-safe when the
|
||||
// offset is at least a word: each load lies a full word behind the
|
||||
// store which could observe it, so repeating patterns propagate
|
||||
// correctly.
|
||||
if (match_nibble != 15 and offset >= 8 and output.len - op >= 18) {
|
||||
const match = op - offset;
|
||||
copyIntAt(u64, output, op, output, match);
|
||||
copyIntAt(u64, output, op + 8, output, match + 8);
|
||||
copyIntAt(u16, output, op + 16, output, match + 16);
|
||||
op += match_nibble + min_match;
|
||||
continue;
|
||||
}
|
||||
|
||||
const encoded_match_len = try decodeLength(input, &ip, match_nibble);
|
||||
const match_len = std.math.add(
|
||||
usize,
|
||||
encoded_match_len,
|
||||
min_match,
|
||||
) catch return error.OutputTooSmall;
|
||||
if (match_len > output.len - op) return error.OutputTooSmall;
|
||||
|
||||
// Match copies may overlap, so this cannot always be one memcpy.
|
||||
// `copyMatch` expands the common small repeating periods into wide
|
||||
// stores and uses word copies for larger offsets.
|
||||
copyMatch(output, op, offset, match_len);
|
||||
op += match_len;
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit one non-final sequence.
|
||||
///
|
||||
/// A sequence starts with a token, followed by optional literal length bytes,
|
||||
/// the literals themselves, the two-byte offset, and optional match length
|
||||
/// bytes. This function computes the complete size first so `OutputTooSmall`
|
||||
/// is reported without partially writing a sequence.
|
||||
fn emitSequence(
|
||||
output: []u8,
|
||||
op: *usize,
|
||||
literals: []const u8,
|
||||
offset: u16,
|
||||
match_len: usize,
|
||||
) CompressError!void {
|
||||
assert(match_len >= min_match);
|
||||
assert(offset > 0);
|
||||
|
||||
const encoded_match_len = match_len - min_match;
|
||||
|
||||
// One byte is always needed for the token and two for the offset. Each
|
||||
// length may additionally need extension bytes after its token nibble.
|
||||
const required = 1 +
|
||||
encodedLengthBytes(literals.len) + literals.len +
|
||||
2 + encodedLengthBytes(encoded_match_len);
|
||||
if (required > output.len - op.*) return error.OutputTooSmall;
|
||||
|
||||
const token_pos = op.*;
|
||||
op.* += 1;
|
||||
|
||||
// Lengths below 15 fit directly in their nibble. Larger values put 15 in
|
||||
// the nibble and encode the remainder immediately after the token.
|
||||
output[token_pos] = (@as(u8, @intCast(@min(literals.len, 15))) << 4) |
|
||||
@as(u8, @intCast(@min(encoded_match_len, 15)));
|
||||
|
||||
// Literal length extensions precede the literals they describe.
|
||||
if (literals.len >= 15) writeLength(output, op, literals.len - 15);
|
||||
@memcpy(output[op.*..][0..literals.len], literals);
|
||||
op.* += literals.len;
|
||||
|
||||
// Match length extensions follow the offset because this is where the
|
||||
// decoder expects them in an LZ4 sequence.
|
||||
writeIntAt(u16, output, op.*, offset);
|
||||
op.* += 2;
|
||||
if (encoded_match_len >= 15)
|
||||
writeLength(output, op, encoded_match_len - 15);
|
||||
}
|
||||
|
||||
/// Emit the literal-only sequence which terminates every block.
|
||||
///
|
||||
/// There is no offset or match length after these bytes. As with
|
||||
/// `emitSequence`, capacity is checked before modifying the output.
|
||||
fn emitLastLiterals(
|
||||
output: []u8,
|
||||
op: *usize,
|
||||
literals: []const u8,
|
||||
) CompressError!void {
|
||||
const required = 1 + encodedLengthBytes(literals.len) + literals.len;
|
||||
if (required > output.len - op.*) return error.OutputTooSmall;
|
||||
|
||||
output[op.*] = @as(u8, @intCast(@min(literals.len, 15))) << 4;
|
||||
op.* += 1;
|
||||
if (literals.len >= 15) writeLength(output, op, literals.len - 15);
|
||||
@memcpy(output[op.*..][0..literals.len], literals);
|
||||
op.* += literals.len;
|
||||
}
|
||||
|
||||
/// Return the number of extension bytes needed when a length is represented by
|
||||
/// a token nibble plus zero or more bytes. An extended length always ends with
|
||||
/// a byte below 255, so an exact multiple of 255 requires a final zero byte.
|
||||
fn encodedLengthBytes(encoded_len: usize) usize {
|
||||
if (encoded_len < 15) return 0;
|
||||
return (encoded_len - 15) / 255 + 1;
|
||||
}
|
||||
|
||||
/// Write the portion of a length which did not fit in the token nibble.
|
||||
///
|
||||
/// Each 255 byte means "add 255 and continue". The final byte is always less
|
||||
/// than 255 and may be zero.
|
||||
fn writeLength(output: []u8, op: *usize, length_: usize) void {
|
||||
var length = length_;
|
||||
while (length >= 255) {
|
||||
output[op.*] = 255;
|
||||
op.* += 1;
|
||||
length -= 255;
|
||||
}
|
||||
output[op.*] = @intCast(length);
|
||||
op.* += 1;
|
||||
}
|
||||
|
||||
/// Decode a length from its token nibble and any following extension bytes.
|
||||
/// `ip` is advanced past every consumed extension byte.
|
||||
fn decodeLength(
|
||||
input: []const u8,
|
||||
ip: *usize,
|
||||
nibble: usize,
|
||||
) DecompressError!usize {
|
||||
var length: usize = nibble;
|
||||
if (nibble != 15) return length;
|
||||
|
||||
while (true) {
|
||||
if (ip.* >= input.len) return error.TruncatedInput;
|
||||
const value = input[ip.*];
|
||||
ip.* += 1;
|
||||
length = std.math.add(usize, length, value) catch
|
||||
return error.TruncatedInput;
|
||||
if (value != 255) return length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an unaligned little-endian integer at `position`.
|
||||
inline fn readIntAt(
|
||||
comptime Int: type,
|
||||
input: []const u8,
|
||||
position: usize,
|
||||
) Int {
|
||||
return std.mem.readInt(
|
||||
Int,
|
||||
input[position..][0..@sizeOf(Int)],
|
||||
.little,
|
||||
);
|
||||
}
|
||||
|
||||
/// Write an unaligned little-endian integer at `position`.
|
||||
inline fn writeIntAt(
|
||||
comptime Int: type,
|
||||
output: []u8,
|
||||
position: usize,
|
||||
value: Int,
|
||||
) void {
|
||||
std.mem.writeInt(
|
||||
Int,
|
||||
output[position..][0..@sizeOf(Int)],
|
||||
value,
|
||||
.little,
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy one fixed-size integer between non-overlapping byte ranges.
|
||||
inline fn copyIntAt(
|
||||
comptime Int: type,
|
||||
output: []u8,
|
||||
output_position: usize,
|
||||
input: []const u8,
|
||||
input_position: usize,
|
||||
) void {
|
||||
writeIntAt(
|
||||
Int,
|
||||
output,
|
||||
output_position,
|
||||
readIntAt(Int, input, input_position),
|
||||
);
|
||||
}
|
||||
|
||||
/// Read the four-byte sequence used for match finding. Callers only use this
|
||||
/// where at least four input bytes remain.
|
||||
inline fn readU32(input: []const u8, pos: usize) u32 {
|
||||
return readIntAt(u32, input, pos);
|
||||
}
|
||||
|
||||
/// Add a position to one hash slot and shift the previous newest position into
|
||||
/// the fallback half. A position whose low bits are 0xFFFF conflicts with the
|
||||
/// sentinel and is simply not stored.
|
||||
inline fn rememberPosition(table: *HashTable, hash: usize, position: usize) void {
|
||||
const low: u16 = @truncate(position);
|
||||
if (low == std.math.maxInt(u16)) return;
|
||||
table[hash] = (@as(u32, @truncate(table[hash])) << 16) | low;
|
||||
}
|
||||
|
||||
/// Recover the nearest preceding position from a stored low half. Modular
|
||||
/// subtraction directly produces the LZ4 offset within the current window.
|
||||
inline fn candidatePosition(ip: usize, stored: u16) ?usize {
|
||||
if (stored == std.math.maxInt(u16)) return null;
|
||||
const distance: u16 = @as(u16, @truncate(ip)) -% stored;
|
||||
if (distance == 0) return null;
|
||||
return ip - distance;
|
||||
}
|
||||
|
||||
/// Advance through a literal run. The first KiB inspects every byte without
|
||||
/// maintaining a counter, which keeps ordinary terminal-page searches cheap.
|
||||
/// Longer runs enable gradually increasing steps for incompressible data.
|
||||
inline fn advanceSearch(
|
||||
ip: *usize,
|
||||
anchor: usize,
|
||||
attempts: *usize,
|
||||
) void {
|
||||
if (attempts.* == 0) {
|
||||
ip.* += 1;
|
||||
if (ip.* - anchor == 1024) attempts.* = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
attempts.* += 1;
|
||||
ip.* += 1 + attempts.* / 64;
|
||||
}
|
||||
|
||||
/// Extend a match backwards without crossing the current literal anchor or the
|
||||
/// beginning of the candidate. The returned positions preserve their offset.
|
||||
fn matchBegin(
|
||||
input: []const u8,
|
||||
position_: usize,
|
||||
candidate_: usize,
|
||||
anchor: usize,
|
||||
) struct { position: usize, candidate: usize } {
|
||||
var position = position_;
|
||||
var candidate = candidate_;
|
||||
|
||||
while (@min(position - anchor, candidate) >= @sizeOf(u64)) {
|
||||
const position_word = position - @sizeOf(u64);
|
||||
const candidate_word = candidate - @sizeOf(u64);
|
||||
const difference = readIntAt(u64, input, position_word) ^
|
||||
readIntAt(u64, input, candidate_word);
|
||||
if (difference != 0) {
|
||||
const equal_bytes: usize = @intCast(@clz(difference) / 8);
|
||||
position -= equal_bytes;
|
||||
candidate -= equal_bytes;
|
||||
return .{ .position = position, .candidate = candidate };
|
||||
}
|
||||
|
||||
position = position_word;
|
||||
candidate = candidate_word;
|
||||
}
|
||||
|
||||
while (position > anchor and candidate > 0 and
|
||||
input[position - 1] == input[candidate - 1])
|
||||
{
|
||||
position -= 1;
|
||||
candidate -= 1;
|
||||
}
|
||||
return .{ .position = position, .candidate = candidate };
|
||||
}
|
||||
|
||||
/// Return the first input position where two matching runs differ, or `limit`
|
||||
/// when they remain equal. Both positions are known to have matched through
|
||||
/// `min_match` before this is called.
|
||||
fn matchEnd(
|
||||
input: []const u8,
|
||||
position_: usize,
|
||||
candidate_: usize,
|
||||
limit: usize,
|
||||
) usize {
|
||||
var position = position_;
|
||||
var candidate = candidate_;
|
||||
|
||||
// Reading as little endian makes the least-significant differing bit map
|
||||
// to the earliest byte in memory on every target. Unaligned reads are
|
||||
// lowered appropriately by Zig and require no target-specific intrinsics.
|
||||
while (limit - position >= @sizeOf(u64)) {
|
||||
const difference = readIntAt(u64, input, position) ^
|
||||
readIntAt(u64, input, candidate);
|
||||
if (difference != 0) {
|
||||
const equal_bytes: usize = @intCast(@ctz(difference) / 8);
|
||||
return position + equal_bytes;
|
||||
}
|
||||
|
||||
position += @sizeOf(u64);
|
||||
candidate += @sizeOf(u64);
|
||||
}
|
||||
|
||||
while (position < limit and input[position] == input[candidate]) {
|
||||
position += 1;
|
||||
candidate += 1;
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
/// Copy one decoded match from `offset` bytes behind `op`.
|
||||
///
|
||||
/// The caller has validated that the match fits: `op + match_len` never
|
||||
/// exceeds `output.len`. Wide copies may write a few scratch bytes past the
|
||||
/// match's logical end; as described in `decompress`, that is safe anywhere
|
||||
/// the write stays inside the output buffer. Every path therefore bounds its
|
||||
/// wide stores by both the match end and the buffer end, and the bytewise
|
||||
/// loop at the bottom finishes whatever remains.
|
||||
fn copyMatch(output: []u8, op_: usize, offset: usize, match_len: usize) void {
|
||||
var op = op_;
|
||||
const end = op_ + match_len;
|
||||
|
||||
// A source which ends behind the copy can never overlap it. Long
|
||||
// distant matches are common in structured pages (repeated rows and
|
||||
// whole blank regions), and one exact memcpy moves them in cache-line
|
||||
// units. Shorter matches are not worth the call overhead.
|
||||
if (offset >= match_len and match_len >= 64) {
|
||||
@memcpy(
|
||||
output[op..][0..match_len],
|
||||
output[op - offset ..][0..match_len],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (offset) {
|
||||
// A period which divides the word size expands into one repeated
|
||||
// pattern word. Long runs (blank lines, repeated cells) then become
|
||||
// independent stores, with no load waiting on a preceding store.
|
||||
// Stores advance by whole words from `op`, which preserves the
|
||||
// pattern's phase.
|
||||
1, 2, 4, 8 => {
|
||||
const pattern: u64 = switch (offset) {
|
||||
1 => @as(u64, output[op - 1]) * 0x0101_0101_0101_0101,
|
||||
2 => @as(u64, readIntAt(u16, output, op - 2)) *
|
||||
0x0001_0001_0001_0001,
|
||||
4 => @as(u64, readIntAt(u32, output, op - 4)) *
|
||||
0x0000_0001_0000_0001,
|
||||
8 => readIntAt(u64, output, op - 8),
|
||||
else => unreachable,
|
||||
};
|
||||
|
||||
const limit = @min(end, output.len -| 7);
|
||||
while (op < limit) : (op += 8)
|
||||
writeIntAt(u64, output, op, pattern);
|
||||
},
|
||||
|
||||
// Wide copies are overlap-safe for the remaining offsets of at
|
||||
// least a copy unit: each load lies a full unit behind the store
|
||||
// which could observe it. Offsets 3, 5, 6, and 7 fall through to
|
||||
// the bytewise loop; they are rare in real data and word tricks
|
||||
// for them cost more in complexity than they return.
|
||||
else => if (offset >= 16) {
|
||||
const limit = @min(end, output.len -| 15);
|
||||
while (op < limit) : (op += 16)
|
||||
copyIntAt(u128, output, op, output, op - offset);
|
||||
} else if (offset >= 8) {
|
||||
const limit = @min(end, output.len -| 7);
|
||||
while (op < limit) : (op += 8)
|
||||
copyIntAt(u64, output, op, output, op - offset);
|
||||
},
|
||||
}
|
||||
|
||||
while (op < end) : (op += 1) output[op] = output[op - offset];
|
||||
}
|
||||
|
||||
/// Map a four-byte input sequence to its scratch-table slot.
|
||||
inline fn hashSequence(sequence: u32) usize {
|
||||
return @intCast((sequence *% hash_multiplier) >> (32 - hash_log));
|
||||
}
|
||||
|
||||
/// Shared round-trip assertion used by the corpus-style tests below.
|
||||
fn expectRoundTrip(input: []const u8) !void {
|
||||
const bound = try compressBound(input.len);
|
||||
const encoded = try testing.allocator.alloc(u8, bound);
|
||||
defer testing.allocator.free(encoded);
|
||||
const decoded = try testing.allocator.alloc(u8, input.len);
|
||||
defer testing.allocator.free(decoded);
|
||||
|
||||
var table: HashTable = undefined;
|
||||
const encoded_len = try compress(input, encoded, &table);
|
||||
try testing.expectEqual(input.len, try decompress(
|
||||
encoded[0..encoded_len],
|
||||
decoded,
|
||||
));
|
||||
try testing.expectEqualSlices(u8, input, decoded);
|
||||
}
|
||||
|
||||
test "compressBound" {
|
||||
try testing.expectEqual(@as(usize, 16), try compressBound(0));
|
||||
try testing.expectEqual(@as(usize, 272), try compressBound(255));
|
||||
try testing.expectError(error.InputTooLarge, compressBound(max_input_size + 1));
|
||||
}
|
||||
|
||||
test "literal-only compatibility vectors" {
|
||||
var empty: [0]u8 = .{};
|
||||
try testing.expectEqual(@as(usize, 0), try decompress(&.{0}, &empty));
|
||||
|
||||
var hello: [5]u8 = undefined;
|
||||
try testing.expectEqual(@as(usize, 5), try decompress(
|
||||
&.{ 0x50, 'h', 'e', 'l', 'l', 'o' },
|
||||
&hello,
|
||||
));
|
||||
try testing.expectEqualStrings("hello", &hello);
|
||||
|
||||
var fifteen: [15]u8 = undefined;
|
||||
var encoded: [17]u8 = undefined;
|
||||
encoded[0] = 0xF0;
|
||||
encoded[1] = 0;
|
||||
@memset(encoded[2..], 'x');
|
||||
_ = try decompress(&encoded, &fifteen);
|
||||
try testing.expect(std.mem.allEqual(u8, &fifteen, 'x'));
|
||||
}
|
||||
|
||||
test "overlapping match compatibility vector" {
|
||||
// One literal 'a', followed by a four-byte match at distance one.
|
||||
var output: [5]u8 = undefined;
|
||||
try testing.expectEqual(@as(usize, 5), try decompress(
|
||||
&.{ 0x10, 'a', 0x01, 0x00 },
|
||||
&output,
|
||||
));
|
||||
try testing.expectEqualStrings("aaaaa", &output);
|
||||
}
|
||||
|
||||
test "extended overlapping match compatibility vector" {
|
||||
// One literal followed by a 274-byte match. The match extension is
|
||||
// encoded as 255 + 0 after the low token nibble's initial 15 bytes.
|
||||
var output: [275]u8 = undefined;
|
||||
try testing.expectEqual(@as(usize, output.len), try decompress(
|
||||
&.{ 0x1F, 'a', 0x01, 0x00, 0xFF, 0x00 },
|
||||
&output,
|
||||
));
|
||||
try testing.expect(std.mem.allEqual(u8, &output, 'a'));
|
||||
}
|
||||
|
||||
test "short offset compatibility vectors" {
|
||||
|
||||
// These blocks end immediately after their match. Besides covering the
|
||||
// repeating-pattern paths, they verify that the decoder uses exact copies
|
||||
// when the block does not provide the standard trailing-literal margin.
|
||||
var offset_two: [6]u8 = undefined;
|
||||
_ = try decompress(&.{ 0x20, 'a', 'b', 0x02, 0x00 }, &offset_two);
|
||||
try testing.expectEqualStrings("ababab", &offset_two);
|
||||
|
||||
var offset_three: [9]u8 = undefined;
|
||||
_ = try decompress(
|
||||
&.{ 0x32, 'a', 'b', 'c', 0x03, 0x00 },
|
||||
&offset_three,
|
||||
);
|
||||
try testing.expectEqualStrings("abcabcabc", &offset_three);
|
||||
|
||||
var offset_four: [8]u8 = undefined;
|
||||
_ = try decompress(
|
||||
&.{ 0x40, 'a', 'b', 'c', 'd', 0x04, 0x00 },
|
||||
&offset_four,
|
||||
);
|
||||
try testing.expectEqualStrings("abcdabcd", &offset_four);
|
||||
}
|
||||
|
||||
test "bounded wild copies are overwritten by final literals" {
|
||||
|
||||
// The first sequence's nine-byte match leaves the five final literals
|
||||
// required by the LZ4 block format. Its logical one-byte tail is copied as
|
||||
// a word and the following literal sequence overwrites the extra bytes.
|
||||
var repeated_byte: [15]u8 = undefined;
|
||||
_ = try decompress(
|
||||
&.{
|
||||
0x15, 'a', 0x01, 0x00,
|
||||
0x50, '1', '2', '3',
|
||||
'4', '5',
|
||||
},
|
||||
&repeated_byte,
|
||||
);
|
||||
try testing.expectEqualStrings("aaaaaaaaaa12345", &repeated_byte);
|
||||
|
||||
// Exercise the same bounded tail copy with a non-overlapping offset.
|
||||
var word_offset: [22]u8 = undefined;
|
||||
_ = try decompress(
|
||||
&.{
|
||||
0x85, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 0x08, 0x00,
|
||||
0x50, '1', '2', '3', '4', '5',
|
||||
},
|
||||
&word_offset,
|
||||
);
|
||||
try testing.expectEqualStrings(
|
||||
"abcdefghabcdefgha12345",
|
||||
&word_offset,
|
||||
);
|
||||
}
|
||||
|
||||
test "maximum match offset compatibility vector" {
|
||||
const literal_len = std.math.maxInt(u16);
|
||||
const extension_len = (literal_len - 15) / 255 + 1;
|
||||
const encoded = try testing.allocator.alloc(
|
||||
u8,
|
||||
1 + extension_len + literal_len + 2,
|
||||
);
|
||||
defer testing.allocator.free(encoded);
|
||||
const output = try testing.allocator.alloc(u8, literal_len + min_match);
|
||||
defer testing.allocator.free(output);
|
||||
|
||||
var op: usize = 0;
|
||||
encoded[op] = 0xF0;
|
||||
op += 1;
|
||||
writeLength(encoded, &op, literal_len - 15);
|
||||
for (encoded[op..][0..literal_len], 0..) |*byte, i|
|
||||
byte.* = @truncate(i);
|
||||
op += literal_len;
|
||||
std.mem.writeInt(u16, encoded[op..][0..2], std.math.maxInt(u16), .little);
|
||||
op += 2;
|
||||
|
||||
try testing.expectEqual(encoded.len, op);
|
||||
try testing.expectEqual(output.len, try decompress(encoded, output));
|
||||
try testing.expectEqualSlices(u8, encoded[1 + extension_len ..][0..4], output[literal_len..]);
|
||||
}
|
||||
|
||||
test "round trips boundary-sized inputs" {
|
||||
const lengths = [_]usize{
|
||||
0, 1, 3, 4, 5, 12, 15, 16, 19,
|
||||
20, 254, 255, 256, 269, 270, 271, 510, 511,
|
||||
512, 65_535, 65_536, 65_537,
|
||||
};
|
||||
|
||||
for (lengths) |len| {
|
||||
const buf = try testing.allocator.alloc(u8, len);
|
||||
defer testing.allocator.free(buf);
|
||||
for (buf, 0..) |*byte, i| byte.* = @truncate(i *% 31);
|
||||
try expectRoundTrip(buf);
|
||||
}
|
||||
}
|
||||
|
||||
test "round trips compressible page-sized inputs" {
|
||||
const page_len = 400 * 1024;
|
||||
|
||||
const zeros = try testing.allocator.alloc(u8, page_len);
|
||||
defer testing.allocator.free(zeros);
|
||||
@memset(zeros, 0);
|
||||
try expectRoundTrip(zeros);
|
||||
|
||||
const structured = try testing.allocator.alloc(u8, page_len);
|
||||
defer testing.allocator.free(structured);
|
||||
@memset(structured, 0);
|
||||
for (0..page_len / 8) |i| {
|
||||
structured[i * 8] = @truncate(' ' + i % 95);
|
||||
structured[i * 8 + 4] = @truncate((i / 80) % 16);
|
||||
}
|
||||
try expectRoundTrip(structured);
|
||||
}
|
||||
|
||||
test "round trips deterministic random inputs" {
|
||||
var prng = std.Random.DefaultPrng.init(0x4C5A_3401);
|
||||
const random = prng.random();
|
||||
|
||||
for (0..256) |_| {
|
||||
const len = random.uintLessThan(usize, 32 * 1024);
|
||||
const input = try testing.allocator.alloc(u8, len);
|
||||
defer testing.allocator.free(input);
|
||||
random.bytes(input);
|
||||
try expectRoundTrip(input);
|
||||
}
|
||||
}
|
||||
|
||||
test "compress reports short output" {
|
||||
const input = "a terminal page needs enough output space";
|
||||
var table: HashTable = undefined;
|
||||
var output: [4]u8 = undefined;
|
||||
try testing.expectError(
|
||||
error.OutputTooSmall,
|
||||
compress(input, &output, &table),
|
||||
);
|
||||
}
|
||||
|
||||
test "decompress rejects malformed blocks" {
|
||||
var output: [32]u8 = undefined;
|
||||
|
||||
try testing.expectError(error.TruncatedInput, decompress(&.{0xF0}, &output));
|
||||
try testing.expectError(error.TruncatedInput, decompress(&.{ 0x10, 'a', 1 }, output[0..5]));
|
||||
try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 0, 0 }, output[0..5]));
|
||||
try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 2, 0 }, output[0..5]));
|
||||
try testing.expectError(error.OutputTooSmall, decompress(
|
||||
&.{ 0x10, 'a', 1, 0 },
|
||||
output[0..4],
|
||||
));
|
||||
try testing.expectError(error.OutputSizeMismatch, decompress(&.{0}, output[0..1]));
|
||||
}
|
||||
|
||||
test {
|
||||
_ = @import("lz4_differential.zig");
|
||||
}
|
||||
493
src/terminal/compress/lz4_differential.zig
Normal file
493
src/terminal/compress/lz4_differential.zig
Normal file
@@ -0,0 +1,493 @@
|
||||
//! Differential and property tests for the LZ4 block codec.
|
||||
//!
|
||||
//! Every generated input must compress into a block which:
|
||||
//!
|
||||
//! 1. fits within `compressBound`,
|
||||
//! 2. is structurally valid LZ4 with the stricter guarantees our
|
||||
//! compressor documents (final five bytes are literals, matches start
|
||||
//! at least twelve bytes before the end), verified by an independent
|
||||
//! walker which shares no code with the codec,
|
||||
//! 3. decompresses to exactly the original bytes, and
|
||||
//! 4. is rejected when decompressed into a buffer of the wrong size.
|
||||
//!
|
||||
//! Valid blocks are additionally mutated (bit flips, splices, truncations)
|
||||
//! and fed to the decompressor, which must fail cleanly or succeed, but
|
||||
//! never read or write out of bounds. The unit-test build enables runtime
|
||||
//! safety, so any out-of-bounds slice access fails the test.
|
||||
//!
|
||||
//! The light suite below runs as a normal unit test and finishes quickly.
|
||||
//! The exhaustive suite multiplies the same properties across far more
|
||||
//! sizes, periods, seeds, and mutations; it is slow and therefore skipped
|
||||
//! unless the environment variable `GHOSTTY_LZ4_SLOW` is set:
|
||||
//!
|
||||
//! GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential"
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const lz4 = @import("lz4.zig");
|
||||
|
||||
/// Number of trailing block bytes which must be literals, mirroring the
|
||||
/// documented guarantee of the compressor. Kept as an independent constant
|
||||
/// so a codec regression cannot silently weaken the check.
|
||||
const last_literals = 5;
|
||||
|
||||
/// A match may not begin in the final twelve bytes. See `last_literals`.
|
||||
const match_find_limit = 12;
|
||||
|
||||
/// Sizes around every encoding boundary: token nibble limits (14/15),
|
||||
/// minimum match and find limits (4/12), length-extension steps (15 + 255k),
|
||||
/// and power-of-two neighborhoods.
|
||||
const boundary_sizes = [_]usize{
|
||||
0, 1, 2, 3, 4, 5, 6, 7,
|
||||
8, 9, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 31, 32, 33, 63,
|
||||
64, 65, 254, 255, 256, 269, 270, 271,
|
||||
272, 1023, 1024, 4095, 4096, 4097,
|
||||
};
|
||||
|
||||
/// Input generators exercising distinct codec behaviors. Every generator is
|
||||
/// deterministic for a given random state.
|
||||
const Generator = enum {
|
||||
/// Uniform random bytes; largely incompressible.
|
||||
random_bytes,
|
||||
|
||||
/// Runs of one repeated byte with random lengths; period-one matches.
|
||||
runs,
|
||||
|
||||
/// One repeating pattern; exercises a fixed match period end to end.
|
||||
periodic,
|
||||
|
||||
/// Eight-byte records with random small payloads and zero padding, with
|
||||
/// some records repeated; resembles terminal cell memory.
|
||||
cells,
|
||||
|
||||
/// Dictionary words with separators; text-like literal/match mix.
|
||||
words,
|
||||
|
||||
/// Mostly zeros with scattered random bytes; long matches with isolated
|
||||
/// literals.
|
||||
sparse,
|
||||
|
||||
/// Random segments of all other generators; exercises transitions.
|
||||
mixed,
|
||||
|
||||
fn fill(gen: Generator, random: std.Random, buf: []u8) void {
|
||||
switch (gen) {
|
||||
.random_bytes => random.bytes(buf),
|
||||
|
||||
.runs => {
|
||||
var i: usize = 0;
|
||||
while (i < buf.len) {
|
||||
const run = @min(
|
||||
random.intRangeAtMost(usize, 1, 300),
|
||||
buf.len - i,
|
||||
);
|
||||
@memset(buf[i..][0..run], random.int(u8));
|
||||
i += run;
|
||||
}
|
||||
},
|
||||
|
||||
.periodic => fillPeriodic(
|
||||
random,
|
||||
buf,
|
||||
random.intRangeAtMost(usize, 1, 40),
|
||||
),
|
||||
|
||||
.cells => {
|
||||
var i: usize = 0;
|
||||
while (i + 8 <= buf.len) : (i += 8) {
|
||||
const cell = buf[i..][0..8];
|
||||
if (i >= 8 and random.boolean()) {
|
||||
// Repeat one of the last 256 records.
|
||||
const back = 8 * random.intRangeAtMost(
|
||||
usize,
|
||||
1,
|
||||
@min(i / 8, 256),
|
||||
);
|
||||
cell.* = buf[i - back ..][0..8].*;
|
||||
} else {
|
||||
@memset(cell, 0);
|
||||
cell[0] = ' ' + random.uintLessThan(u8, 95);
|
||||
cell[1] = random.uintLessThan(u8, 4);
|
||||
}
|
||||
}
|
||||
random.bytes(buf[i..]);
|
||||
},
|
||||
|
||||
.words => {
|
||||
const words = [_][]const u8{
|
||||
"the", "terminal", "page", "compress",
|
||||
"row", "cell", "style", "zig",
|
||||
"lz4", "block", "offset", "match",
|
||||
"a", "of", "and", "literal",
|
||||
"0x00", "0xFF", " ", "\r\n",
|
||||
"-----", "=", "pub fn", "const",
|
||||
};
|
||||
var i: usize = 0;
|
||||
while (i < buf.len) {
|
||||
const word = words[random.uintLessThan(usize, words.len)];
|
||||
const n = @min(word.len, buf.len - i);
|
||||
@memcpy(buf[i..][0..n], word[0..n]);
|
||||
i += n;
|
||||
if (i < buf.len) {
|
||||
buf[i] = if (random.boolean()) ' ' else '\n';
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
.sparse => {
|
||||
@memset(buf, 0);
|
||||
if (buf.len == 0) return;
|
||||
for (0..buf.len / 32 + 1) |_| {
|
||||
const at = random.uintLessThan(usize, buf.len);
|
||||
buf[at] = random.int(u8);
|
||||
}
|
||||
},
|
||||
|
||||
.mixed => {
|
||||
var i: usize = 0;
|
||||
while (i < buf.len) {
|
||||
const segment = @min(
|
||||
random.intRangeAtMost(usize, 1, 2048),
|
||||
buf.len - i,
|
||||
);
|
||||
const sub = random.enumValue(Generator);
|
||||
if (sub != .mixed) sub.fill(random, buf[i..][0..segment]);
|
||||
i += segment;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Fill `buf` with one repeating pattern of the given period.
|
||||
fn fillPeriodic(random: std.Random, buf: []u8, period: usize) void {
|
||||
if (buf.len == 0) return;
|
||||
const head = @min(period, buf.len);
|
||||
random.bytes(buf[0..head]);
|
||||
for (head..buf.len) |i| buf[i] = buf[i - period];
|
||||
}
|
||||
|
||||
/// Reusable buffers sized for the largest input a suite generates.
|
||||
const Workspace = struct {
|
||||
input: []u8,
|
||||
encoded: []u8,
|
||||
decoded: []u8,
|
||||
table: *lz4.HashTable,
|
||||
|
||||
fn init(alloc: Allocator, max_input: usize) !Workspace {
|
||||
const input = try alloc.alloc(u8, max_input);
|
||||
errdefer alloc.free(input);
|
||||
const encoded = try alloc.alloc(u8, try lz4.compressBound(max_input));
|
||||
errdefer alloc.free(encoded);
|
||||
// One extra byte so wrong-size decompression can be tested above
|
||||
// the exact length as well as below it.
|
||||
const decoded = try alloc.alloc(u8, max_input + 1);
|
||||
errdefer alloc.free(decoded);
|
||||
const table = try alloc.create(lz4.HashTable);
|
||||
return .{
|
||||
.input = input,
|
||||
.encoded = encoded,
|
||||
.decoded = decoded,
|
||||
.table = table,
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(ws: *Workspace, alloc: Allocator) void {
|
||||
alloc.free(ws.input);
|
||||
alloc.free(ws.encoded);
|
||||
alloc.free(ws.decoded);
|
||||
alloc.destroy(ws.table);
|
||||
ws.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// Compress one input and verify every property promised by the codec.
|
||||
/// Returns the encoded length so callers can reuse the encoded block.
|
||||
fn expectCodecProperties(ws: *Workspace, input: []const u8) !usize {
|
||||
const encoded_len = try lz4.compress(input, ws.encoded, ws.table);
|
||||
try testing.expect(encoded_len <= try lz4.compressBound(input.len));
|
||||
const encoded = ws.encoded[0..encoded_len];
|
||||
|
||||
try expectValidBlock(encoded, input.len);
|
||||
|
||||
// Exact-size decompression must reproduce the input bit for bit. The
|
||||
// output is poisoned first so unwritten bytes cannot pass as correct.
|
||||
const output = ws.decoded[0..input.len];
|
||||
@memset(output, 0xAA);
|
||||
try testing.expectEqual(input.len, try lz4.decompress(encoded, output));
|
||||
try testing.expectEqualSlices(u8, input, output);
|
||||
|
||||
// The exact-size contract must reject both smaller and larger buffers.
|
||||
if (input.len > 0) {
|
||||
try testing.expectError(
|
||||
error.OutputTooSmall,
|
||||
lz4.decompress(encoded, ws.decoded[0 .. input.len - 1]),
|
||||
);
|
||||
}
|
||||
try testing.expectError(
|
||||
error.OutputSizeMismatch,
|
||||
lz4.decompress(encoded, ws.decoded[0 .. input.len + 1]),
|
||||
);
|
||||
|
||||
return encoded_len;
|
||||
}
|
||||
|
||||
/// Structurally validate one encoded block against the LZ4 block format and
|
||||
/// the stricter guarantees documented by our compressor. This deliberately
|
||||
/// reimplements the format rather than reusing codec internals.
|
||||
fn expectValidBlock(encoded: []const u8, raw_len: usize) !void {
|
||||
var ip: usize = 0;
|
||||
var op: usize = 0;
|
||||
var last_match_end: usize = 0;
|
||||
|
||||
while (true) {
|
||||
// Every sequence, including the final one, starts with a token.
|
||||
try testing.expect(ip < encoded.len);
|
||||
const token = encoded[ip];
|
||||
ip += 1;
|
||||
|
||||
var literal_len: usize = token >> 4;
|
||||
if (literal_len == 15) literal_len += try readExtension(encoded, &ip);
|
||||
try testing.expect(encoded.len - ip >= literal_len);
|
||||
ip += literal_len;
|
||||
op += literal_len;
|
||||
|
||||
// The final sequence contains only literals and ends the block.
|
||||
if (ip == encoded.len) {
|
||||
try testing.expectEqual(raw_len, op);
|
||||
if (last_match_end > 0)
|
||||
try testing.expect(raw_len - last_match_end >= last_literals);
|
||||
return;
|
||||
}
|
||||
|
||||
try testing.expect(encoded.len - ip >= 2);
|
||||
const offset = std.mem.readInt(u16, encoded[ip..][0..2], .little);
|
||||
ip += 2;
|
||||
try testing.expect(offset >= 1);
|
||||
try testing.expect(offset <= op);
|
||||
|
||||
// Our compressor starts matches at least `match_find_limit` bytes
|
||||
// before the end and never lets one run into the final literals.
|
||||
try testing.expect(op + match_find_limit <= raw_len);
|
||||
var match_len: usize = (token & 0x0F) + 4;
|
||||
if (token & 0x0F == 15) match_len += try readExtension(encoded, &ip);
|
||||
op += match_len;
|
||||
try testing.expect(op + last_literals <= raw_len);
|
||||
last_match_end = op;
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one length extension: bytes of 255 accumulate until a terminator
|
||||
/// below 255, which is included in the sum.
|
||||
fn readExtension(encoded: []const u8, ip: *usize) !usize {
|
||||
var total: usize = 0;
|
||||
while (true) {
|
||||
try testing.expect(ip.* < encoded.len);
|
||||
const value = encoded[ip.*];
|
||||
ip.* += 1;
|
||||
total += value;
|
||||
if (value != 255) return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode deterministic corruptions of a valid block. Any result is
|
||||
/// acceptable except memory unsafety, which the safety-checked test build
|
||||
/// turns into a failure. Mutations may still form a valid block, so output
|
||||
/// contents are intentionally not asserted.
|
||||
fn expectMutationSafety(
|
||||
ws: *Workspace,
|
||||
random: std.Random,
|
||||
encoded_len: usize,
|
||||
raw_len: usize,
|
||||
mutations: usize,
|
||||
) !void {
|
||||
const original = try testing.allocator.dupe(u8, ws.encoded[0..encoded_len]);
|
||||
defer testing.allocator.free(original);
|
||||
|
||||
for (0..mutations) |_| {
|
||||
const block = ws.encoded[0..encoded_len];
|
||||
@memcpy(block, original);
|
||||
|
||||
switch (random.uintLessThan(u8, 4)) {
|
||||
// Flip up to eight random bits.
|
||||
0 => for (0..random.intRangeAtMost(usize, 1, 8)) |_| {
|
||||
const at = random.uintLessThan(usize, block.len);
|
||||
block[at] ^= @as(u8, 1) << random.int(u3);
|
||||
},
|
||||
// Overwrite one random byte. Token and length bytes are the
|
||||
// most interesting targets and small blocks are mostly tokens.
|
||||
1 => block[random.uintLessThan(usize, block.len)] =
|
||||
random.int(u8),
|
||||
// Splice random garbage over a random span.
|
||||
2 => {
|
||||
const at = random.uintLessThan(usize, block.len);
|
||||
const span = @min(
|
||||
random.intRangeAtMost(usize, 1, 16),
|
||||
block.len - at,
|
||||
);
|
||||
random.bytes(block[at..][0..span]);
|
||||
},
|
||||
// Decode a random prefix of the intact block.
|
||||
else => {},
|
||||
}
|
||||
|
||||
const len = if (random.boolean())
|
||||
random.uintAtMost(usize, block.len)
|
||||
else
|
||||
block.len;
|
||||
_ = lz4.decompress(block[0..len], ws.decoded[0..raw_len]) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Workload knobs shared by the light and exhaustive suites.
|
||||
const Budget = struct {
|
||||
/// Upper bound and step of the contiguous small-size sweep applied to
|
||||
/// every generator.
|
||||
sweep_max: usize,
|
||||
sweep_step: usize,
|
||||
|
||||
/// Number and maximum size of random-parameter inputs.
|
||||
random_inputs: usize,
|
||||
random_max: usize,
|
||||
|
||||
/// Explicit match periods checked with `fillPeriodic`.
|
||||
periods: []const usize,
|
||||
period_len: usize,
|
||||
|
||||
/// Number of corrupted decode attempts per mutation base block.
|
||||
mutations: usize,
|
||||
|
||||
fn maxInput(budget: Budget) usize {
|
||||
return @max(
|
||||
budget.random_max,
|
||||
@max(budget.period_len, boundary_sizes[boundary_sizes.len - 1]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const light_budget: Budget = .{
|
||||
.sweep_max = 96,
|
||||
.sweep_step = 1,
|
||||
.random_inputs = 24,
|
||||
.random_max = 32 * 1024,
|
||||
// One period per copy strategy: byte propagation (3), pattern words
|
||||
// (1/2/4/8), word strides (9), wide strides (17), plus the 64 KiB
|
||||
// window edge cases.
|
||||
.periods = &.{ 1, 2, 3, 4, 8, 9, 17, 65534, 65535, 65536, 65537 },
|
||||
.period_len = 160 * 1024,
|
||||
.mutations = 64,
|
||||
};
|
||||
|
||||
const exhaustive_budget: Budget = .{
|
||||
.sweep_max = 2048,
|
||||
.sweep_step = 1,
|
||||
.random_inputs = 512,
|
||||
.random_max = 512 * 1024,
|
||||
.periods = &.{
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 23, 24, 31, 32, 33,
|
||||
48, 63, 64, 65, 127, 128, 255, 256,
|
||||
257, 4095, 4096, 32768, 65533, 65534, 65535, 65536,
|
||||
65537, 65538,
|
||||
},
|
||||
.period_len = 320 * 1024,
|
||||
.mutations = 4096,
|
||||
};
|
||||
|
||||
fn runSuite(budget: Budget, seed: u64) !void {
|
||||
var prng = std.Random.DefaultPrng.init(seed);
|
||||
const random = prng.random();
|
||||
|
||||
var ws: Workspace = try .init(testing.allocator, budget.maxInput());
|
||||
defer ws.deinit(testing.allocator);
|
||||
|
||||
// Every generator across every boundary size and the small-size sweep.
|
||||
// Small inputs hit the literal-only format edges: below the minimum
|
||||
// match sizes, around nibble limits, and around extension steps.
|
||||
inline for (@typeInfo(Generator).@"enum".fields) |field| {
|
||||
const gen: Generator = @enumFromInt(field.value);
|
||||
|
||||
for (boundary_sizes) |size| {
|
||||
const input = ws.input[0..size];
|
||||
gen.fill(random, input);
|
||||
_ = try expectCodecProperties(&ws, input);
|
||||
}
|
||||
|
||||
var size: usize = 0;
|
||||
while (size <= budget.sweep_max) : (size += budget.sweep_step) {
|
||||
const input = ws.input[0..size];
|
||||
gen.fill(random, input);
|
||||
_ = try expectCodecProperties(&ws, input);
|
||||
}
|
||||
}
|
||||
|
||||
// Random generator and size pairs, biased toward interesting sizes by
|
||||
// squaring so both tiny and large inputs appear.
|
||||
for (0..budget.random_inputs) |_| {
|
||||
const gen = random.enumValue(Generator);
|
||||
const scale = random.float(f64);
|
||||
const size: usize = @intFromFloat(
|
||||
scale * scale * @as(f64, @floatFromInt(budget.random_max)),
|
||||
);
|
||||
const input = ws.input[0..size];
|
||||
gen.fill(random, input);
|
||||
const encoded_len = try expectCodecProperties(&ws, input);
|
||||
|
||||
// Reuse a handful of these encodings as corruption bases.
|
||||
try expectMutationSafety(
|
||||
&ws,
|
||||
random,
|
||||
encoded_len,
|
||||
input.len,
|
||||
budget.mutations / budget.random_inputs + 1,
|
||||
);
|
||||
}
|
||||
|
||||
// Directed match periods, including the 64 KiB window edge where the
|
||||
// compressor's 16-bit position arithmetic wraps.
|
||||
for (budget.periods) |period| {
|
||||
const input = ws.input[0..budget.period_len];
|
||||
fillPeriodic(random, input, period);
|
||||
_ = try expectCodecProperties(&ws, input);
|
||||
}
|
||||
|
||||
// Dedicated corruption run over a text-like block, plus an exhaustive
|
||||
// truncation sweep: every prefix of a valid block must decode cleanly
|
||||
// or fail cleanly.
|
||||
{
|
||||
const input = ws.input[0..@min(16 * 1024, budget.random_max)];
|
||||
Generator.words.fill(random, input);
|
||||
const encoded_len = try expectCodecProperties(&ws, input);
|
||||
try expectMutationSafety(
|
||||
&ws,
|
||||
random,
|
||||
encoded_len,
|
||||
input.len,
|
||||
budget.mutations,
|
||||
);
|
||||
|
||||
for (0..encoded_len) |prefix| {
|
||||
_ = lz4.decompress(
|
||||
ws.encoded[0..prefix],
|
||||
ws.decoded[0..input.len],
|
||||
) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "lz4 differential light" {
|
||||
try runSuite(light_budget, 0x4C5A_3403);
|
||||
}
|
||||
|
||||
test "lz4 differential exhaustive" {
|
||||
// Slow. Enable explicitly, ideally together with a test filter:
|
||||
// GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential"
|
||||
if (!std.process.hasEnvVarConstant("GHOSTTY_LZ4_SLOW"))
|
||||
return error.SkipZigTest;
|
||||
|
||||
// Several independent seeds; the suite is deterministic per seed.
|
||||
for (0..4) |seed| try runSuite(exhaustive_budget, 0x4C5A_4000 + seed);
|
||||
}
|
||||
@@ -747,7 +747,7 @@ pub const PageListFormatter = struct {
|
||||
assert(chunk.start < chunk.end);
|
||||
assert(chunk.end > 0);
|
||||
|
||||
var formatter: PageFormatter = .init(&chunk.node.data, self.opts);
|
||||
var formatter: PageFormatter = .init(chunk.node.page(), self.opts);
|
||||
formatter.start_y = chunk.start;
|
||||
formatter.end_y = chunk.end - 1;
|
||||
formatter.trailing_state = page_state;
|
||||
@@ -1612,7 +1612,7 @@ test "Page plain single line" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
// Test our point map.
|
||||
@@ -1659,7 +1659,7 @@ test "Page plain single line soft-wrapped unwrapped" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{
|
||||
.emit = .plain,
|
||||
.unwrap = true,
|
||||
@@ -1729,7 +1729,7 @@ test "Page plain single wide char" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
// Test our point map.
|
||||
@@ -1820,7 +1820,7 @@ test "Page plain single wide char soft-wrapped unwrapped" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.opts.unwrap = true;
|
||||
|
||||
@@ -1937,7 +1937,7 @@ test "Page plain multiline" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -1988,7 +1988,7 @@ test "Page plain multiline rectangle" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_x = 1;
|
||||
formatter.end_x = 3;
|
||||
@@ -2042,7 +2042,7 @@ test "Page plain multi blank lines" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -2095,7 +2095,7 @@ test "Page plain trailing blank lines" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -2148,7 +2148,7 @@ test "Page plain trailing whitespace" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -2201,7 +2201,7 @@ test "Page plain trailing whitespace no trim" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{
|
||||
.emit = .plain,
|
||||
.trim = false,
|
||||
@@ -2255,7 +2255,7 @@ test "Page plain with prior trailing state rows" {
|
||||
try testing.expect(pages.pages.first != null);
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.trailing_state = .{ .rows = 2, .cells = 0 };
|
||||
|
||||
@@ -2301,7 +2301,7 @@ test "Page plain with prior trailing state cells no wrapped line" {
|
||||
try testing.expect(pages.pages.first != null);
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.trailing_state = .{ .rows = 0, .cells = 3 };
|
||||
|
||||
@@ -2346,7 +2346,7 @@ test "Page plain with prior trailing state cells with wrap continuation" {
|
||||
try testing.expect(pages.pages.first != null);
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Surgically modify the first row to be a wrap continuation
|
||||
const row = page.getRow(0);
|
||||
@@ -2400,7 +2400,7 @@ test "Page plain soft-wrapped without unwrap" {
|
||||
try testing.expect(pages.pages.first != null);
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -2449,7 +2449,7 @@ test "Page plain soft-wrapped with unwrap" {
|
||||
try testing.expect(pages.pages.first != null);
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true });
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -2497,7 +2497,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" {
|
||||
try testing.expect(pages.pages.first != null);
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -2551,7 +2551,7 @@ test "Page plain soft-wrapped 3 lines with unwrap" {
|
||||
try testing.expect(pages.pages.first != null);
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true });
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -2600,7 +2600,7 @@ test "Page plain start_y subset" {
|
||||
s.nextSlice("hello\r\nworld\r\ntest");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_y = 1;
|
||||
@@ -2647,7 +2647,7 @@ test "Page plain end_y subset" {
|
||||
s.nextSlice("hello\r\nworld\r\ntest");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.end_y = 1;
|
||||
@@ -2694,7 +2694,7 @@ test "Page plain start_y and end_y range" {
|
||||
s.nextSlice("hello\r\nworld\r\ntest\r\nfoo");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_y = 1;
|
||||
@@ -2742,7 +2742,7 @@ test "Page plain start_y out of bounds" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_y = 30;
|
||||
@@ -2780,7 +2780,7 @@ test "Page plain end_y greater than rows" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.end_y = 30;
|
||||
@@ -2823,7 +2823,7 @@ test "Page plain end_y less than start_y" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_y = 5;
|
||||
@@ -2862,7 +2862,7 @@ test "Page plain start_x on first row only" {
|
||||
s.nextSlice("hello world");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_x = 6;
|
||||
@@ -2904,7 +2904,7 @@ test "Page plain end_x on last row only" {
|
||||
s.nextSlice("first line\r\nsecond line\r\nthird line");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.end_y = 2;
|
||||
@@ -2957,7 +2957,7 @@ test "Page plain start_x and end_x multiline" {
|
||||
s.nextSlice("hello world\r\ntest case\r\nfoo bar");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_x = 6;
|
||||
@@ -3014,7 +3014,7 @@ test "Page plain start_x out of bounds" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_x = 100;
|
||||
@@ -3052,7 +3052,7 @@ test "Page plain end_x greater than cols" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.end_x = 100;
|
||||
@@ -3094,7 +3094,7 @@ test "Page plain end_x less than start_x single row" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_x = 10;
|
||||
@@ -3134,7 +3134,7 @@ test "Page plain start_y non-zero ignores trailing state" {
|
||||
s.nextSlice("hello\r\nworld");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_y = 1;
|
||||
@@ -3178,7 +3178,7 @@ test "Page plain start_x non-zero ignores trailing state" {
|
||||
s.nextSlice("hello world");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_x = 6;
|
||||
@@ -3222,7 +3222,7 @@ test "Page plain start_y and start_x zero uses trailing state" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
formatter.start_y = 0;
|
||||
@@ -3274,7 +3274,7 @@ test "Page plain single line with styling" {
|
||||
try testing.expect(pages.pages.first == pages.pages.last);
|
||||
|
||||
// Create the formatter
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .plain);
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -3315,7 +3315,7 @@ test "Page VT single line plain text" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .vt);
|
||||
|
||||
@@ -3354,7 +3354,7 @@ test "Page VT single line with bold" {
|
||||
s.nextSlice("\x1b[1mhello\x1b[0m");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .vt);
|
||||
|
||||
@@ -3400,7 +3400,7 @@ test "Page VT multiple styles" {
|
||||
s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .vt);
|
||||
|
||||
@@ -3435,7 +3435,7 @@ test "Page VT with foreground color" {
|
||||
s.nextSlice("\x1b[31mred\x1b[0m");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .vt);
|
||||
|
||||
@@ -3481,7 +3481,7 @@ test "Page VT with background and foreground colors" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .{
|
||||
.emit = .vt,
|
||||
@@ -3518,7 +3518,7 @@ test "Page VT multi-line with styles" {
|
||||
s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .vt);
|
||||
|
||||
@@ -3555,7 +3555,7 @@ test "Page VT duplicate style not emitted twice" {
|
||||
s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .vt);
|
||||
|
||||
@@ -3624,7 +3624,7 @@ test "PageList plain spanning two pages" {
|
||||
defer s.deinit();
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const first_page_rows = pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill the first page almost completely
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
@@ -3697,7 +3697,7 @@ test "PageList soft-wrapped line spanning two pages without unwrap" {
|
||||
defer s.deinit();
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const first_page_rows = pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill the first page with soft-wrapped content
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
@@ -3761,7 +3761,7 @@ test "PageList soft-wrapped line spanning two pages with unwrap" {
|
||||
defer s.deinit();
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const first_page_rows = pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill the first page with soft-wrapped content
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
@@ -3822,7 +3822,7 @@ test "PageList VT spanning two pages" {
|
||||
defer s.deinit();
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const first_page_rows = pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill the first page almost completely
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
@@ -3928,7 +3928,7 @@ test "PageList plain with x offset spanning two pages" {
|
||||
defer s.deinit();
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const first_page_rows = pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill first page almost completely
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
@@ -3950,7 +3950,7 @@ test "PageList plain with x offset spanning two pages" {
|
||||
defer pin_map.deinit(alloc);
|
||||
|
||||
var formatter: PageListFormatter = .init(pages, .plain);
|
||||
formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 };
|
||||
formatter.top_left = .{ .node = first_node, .y = first_node.rows() - 1, .x = 6 };
|
||||
formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 2 };
|
||||
formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
|
||||
|
||||
@@ -5175,7 +5175,7 @@ test "Page html with multiple styles" {
|
||||
s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -5210,7 +5210,7 @@ test "Page html plain text" {
|
||||
s.nextSlice("hello, world");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -5243,7 +5243,7 @@ test "Page html with colors" {
|
||||
s.nextSlice("\x1b[31;44mcolored");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -5313,7 +5313,7 @@ test "Page html with background and foreground colors" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{
|
||||
.emit = .html,
|
||||
.background = .{ .r = 0x12, .g = 0x34, .b = 0x56 },
|
||||
@@ -5348,7 +5348,7 @@ test "Page html with escaping" {
|
||||
s.nextSlice("<tag>&\"'text");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
@@ -5419,7 +5419,7 @@ test "Page html with unicode as numeric entities" {
|
||||
s.nextSlice("╰─ ❯");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -5452,7 +5452,7 @@ test "Page html ascii characters unchanged" {
|
||||
s.nextSlice("hello world");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -5484,7 +5484,7 @@ test "Page html mixed ascii and unicode" {
|
||||
s.nextSlice("test ╰─❯ ok");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -5518,7 +5518,7 @@ test "Page VT with palette option emits RGB" {
|
||||
s.nextSlice("\x1b[31mred");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Without palette option - should emit palette index
|
||||
{
|
||||
@@ -5562,7 +5562,7 @@ test "Page html with palette option emits RGB" {
|
||||
s.nextSlice("\x1b[31mred");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Without palette option - should emit CSS variable
|
||||
{
|
||||
@@ -5615,7 +5615,7 @@ test "Page VT style reset properly closes styles" {
|
||||
s.nextSlice("\x1b[1mbold\x1b[0mnormal");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
builder.clearRetainingCapacity();
|
||||
var formatter: PageFormatter = .init(page, .vt);
|
||||
@@ -5645,7 +5645,7 @@ test "Page codepoint_map single replacement" {
|
||||
s.nextSlice("hello world");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Replace 'o' with 'x'
|
||||
var map: std.MultiArrayList(CodepointMap) = .{};
|
||||
@@ -5704,7 +5704,7 @@ test "Page codepoint_map conflicting replacement prefers last" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Replace 'o' with 'x', then with 'y' - should prefer last
|
||||
var map: std.MultiArrayList(CodepointMap) = .{};
|
||||
@@ -5746,7 +5746,7 @@ test "Page codepoint_map replace with string" {
|
||||
s.nextSlice("hello");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Replace 'o' with a multi-byte string
|
||||
var map: std.MultiArrayList(CodepointMap) = .{};
|
||||
@@ -5802,7 +5802,7 @@ test "Page codepoint_map range replacement" {
|
||||
s.nextSlice("abcdefg");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Replace 'b' through 'e' with 'X'
|
||||
var map: std.MultiArrayList(CodepointMap) = .{};
|
||||
@@ -5840,7 +5840,7 @@ test "Page codepoint_map multiple ranges" {
|
||||
s.nextSlice("hello world");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z'
|
||||
var map: std.MultiArrayList(CodepointMap) = .{};
|
||||
@@ -5884,7 +5884,7 @@ test "Page codepoint_map unicode replacement" {
|
||||
s.nextSlice("hello ⚡ world");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Replace lightning bolt with fire emoji
|
||||
var map: std.MultiArrayList(CodepointMap) = .{};
|
||||
@@ -5949,7 +5949,7 @@ test "Page codepoint_map with styled formats" {
|
||||
s.nextSlice("\x1b[31mred text\x1b[0m");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Replace 'e' with 'X' in styled text
|
||||
var map: std.MultiArrayList(CodepointMap) = .{};
|
||||
@@ -5990,7 +5990,7 @@ test "Page codepoint_map empty map" {
|
||||
s.nextSlice("hello world");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
// Empty map should not change anything
|
||||
var map: std.MultiArrayList(CodepointMap) = .{};
|
||||
@@ -6032,7 +6032,7 @@ test "Page VT background color on trailing blank cells" {
|
||||
s.nextSlice("\x1b[0m\r\nline2");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
|
||||
var formatter: PageFormatter = .init(page, .vt);
|
||||
formatter.opts.trim = false; // Don't trim so we can see the trailing behavior
|
||||
@@ -6079,7 +6079,7 @@ test "Page HTML with hyperlinks" {
|
||||
s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -6114,7 +6114,7 @@ test "Page HTML with multiple hyperlinks" {
|
||||
s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -6150,7 +6150,7 @@ test "Page HTML with hyperlink escaping" {
|
||||
s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -6184,7 +6184,7 @@ test "Page HTML with styled hyperlink" {
|
||||
s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -6219,7 +6219,7 @@ test "Page HTML hyperlink closes style before anchor" {
|
||||
s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
@@ -6253,7 +6253,7 @@ test "Page HTML hyperlink point map maps closing to previous cell" {
|
||||
s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
const page = pages.pages.last.?.page();
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
|
||||
@@ -75,6 +75,9 @@ pub const Attribute = sgr.Attribute;
|
||||
pub const Options = @import("build_options.zig").Options;
|
||||
pub const options = @import("terminal_options");
|
||||
|
||||
/// Whether this target supports terminal page compression.
|
||||
pub const compression_enabled = @import("mem.zig").canReclaim(.strict);
|
||||
|
||||
/// This is set to true when we're building the C library.
|
||||
pub const c_api = if (options.c_abi) @import("c/main.zig") else void;
|
||||
|
||||
@@ -83,7 +86,9 @@ test {
|
||||
|
||||
// Internals
|
||||
_ = @import("bitmap_allocator.zig");
|
||||
_ = @import("compress.zig");
|
||||
_ = @import("hash_map.zig");
|
||||
_ = @import("mem.zig");
|
||||
_ = @import("ref_counted_set.zig");
|
||||
_ = @import("size.zig");
|
||||
}
|
||||
|
||||
222
src/terminal/mem.zig
Normal file
222
src/terminal/mem.zig
Normal file
@@ -0,0 +1,222 @@
|
||||
//! Virtual-memory operations shared by terminal page owners.
|
||||
//!
|
||||
//! Terminal pages use page-aligned, page-multiple mappings. This module can
|
||||
//! discard the physical pages behind one of those mappings without releasing
|
||||
//! its virtual address range, then prepare the same range for reuse. It does
|
||||
//! not allocate memory or decide which terminal pages should be discarded.
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
|
||||
const log = std.log.scoped(.terminal_mem);
|
||||
|
||||
/// What guarantee decommit must provide when the OS cannot discard a mapping.
|
||||
pub const DecommitMode = enum {
|
||||
/// The dirty prefix must read as zero after this call, even when physical
|
||||
/// reclamation is unavailable. Bytes after the prefix must already be zero.
|
||||
zero,
|
||||
|
||||
/// Physical-memory reclamation is required. Do not touch the mapping when
|
||||
/// reclamation is unsupported or fails; report the failure to the caller.
|
||||
strict,
|
||||
};
|
||||
|
||||
/// Return whether this target can reclaim physical memory for `mode` while
|
||||
/// retaining the mapping's virtual address range.
|
||||
///
|
||||
/// Test builds support both modes because `decommit` simulates reclamation by
|
||||
/// clearing the supplied range. Runtime reclamation is intentionally limited
|
||||
/// to 64-bit Linux and Darwin. Other targets must leave strict callers' memory
|
||||
/// resident; zero mode still provides its documented memset fallback through
|
||||
/// `decommit` even when this function returns false.
|
||||
pub inline fn canReclaim(comptime mode: DecommitMode) bool {
|
||||
// Both modes use the same retained-mapping primitives. Keeping the switch
|
||||
// exhaustive makes additions to DecommitMode choose target support
|
||||
// explicitly rather than inheriting it accidentally.
|
||||
return switch (mode) {
|
||||
.zero, .strict => supported: {
|
||||
// Tests never call into the OS because their allocator ranges can
|
||||
// share mappings with unrelated allocations. `decommit` simulates
|
||||
// successful reclamation by zeroing the requested range instead,
|
||||
// so both modes are always available to tests on every target.
|
||||
if (builtin.is_test) break :supported true;
|
||||
|
||||
// Compression currently retains complete page mappings for its
|
||||
// lifetime. Limit the initial runtime support to 64-bit address
|
||||
// spaces where that virtual-memory cost is negligible and where
|
||||
// the retained-mapping behavior has been validated.
|
||||
if (builtin.target.ptrBitWidth() != 64) break :supported false;
|
||||
|
||||
// Linux provides MADV_DONTNEED, which immediately discards pages
|
||||
// from a private anonymous mapping and faults them back as zeroes.
|
||||
// Zig reaches this through the raw syscall path without libc.
|
||||
if (builtin.target.os.tag == .linux) break :supported true;
|
||||
|
||||
// Darwin provides the paired MADV_FREE_REUSABLE/FREE_REUSE
|
||||
// operations used below. Darwin requires libc independently of
|
||||
// this feature, so using its madvise entry point adds no new
|
||||
// dependency to libghostty-vt.
|
||||
if (builtin.target.os.tag.isDarwin()) break :supported true;
|
||||
|
||||
// Other targets have no retained-mapping reclamation contract in
|
||||
// this module. Zero mode can still clear through its memset
|
||||
// fallback, but strict callers must leave their mapping resident.
|
||||
break :supported false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Discard physical pages while retaining a mapping's virtual address range.
|
||||
///
|
||||
/// The complete mapping must be page-aligned and a multiple of the minimum
|
||||
/// system page size. `dirty_len` identifies the prefix whose contents may be
|
||||
/// nonzero. Strict mode requires the complete mapping to be dirty because a
|
||||
/// successful discard invalidates all of its contents.
|
||||
///
|
||||
/// The return value reports whether the OS accepted the reclamation request.
|
||||
/// Test builds return true after simulating reclamation by zeroing dirty bytes.
|
||||
/// In zero mode, the requested bytes are guaranteed to be zero regardless of
|
||||
/// the return value.
|
||||
pub fn decommit(
|
||||
comptime mode: DecommitMode,
|
||||
memory: []align(std.heap.page_size_min) u8,
|
||||
dirty_len: usize,
|
||||
) bool {
|
||||
assert(memory.len > 0);
|
||||
assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0);
|
||||
assert(memory.len % std.heap.page_size_min == 0);
|
||||
assert(dirty_len <= memory.len);
|
||||
if (comptime mode == .strict) assert(dirty_len == memory.len);
|
||||
|
||||
// Testing allocator ranges may share an OS mapping with unrelated memory,
|
||||
// so madvise is not safe. Zeroing models the only content guarantee callers
|
||||
// have after a successful discard.
|
||||
if (comptime builtin.is_test) {
|
||||
@memset(memory[0..dirty_len], 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// DONTNEED immediately reclaims private anonymous pages on Linux and
|
||||
// faults them back as zero-filled pages. We deliberately avoid MADV_FREE:
|
||||
// it does not reduce RSS until memory pressure and does not guarantee that
|
||||
// the next read is zero.
|
||||
if (comptime builtin.os.tag == .linux) {
|
||||
if (std.posix.madvise(
|
||||
memory.ptr,
|
||||
memory.len,
|
||||
std.posix.MADV.DONTNEED,
|
||||
)) |_| return true else |err| {
|
||||
log.warn("madvise(DONTNEED) failed err={}", .{err});
|
||||
if (comptime mode == .strict) return false;
|
||||
// Zero mode falls through to the memset below.
|
||||
}
|
||||
}
|
||||
|
||||
// FREE_REUSABLE removes the range from the Darwin process footprint while
|
||||
// retaining its mapping. Zero mode clears its dirty prefix first because
|
||||
// the kernel may preserve the contents. Strict mode avoids that write
|
||||
// because its caller will replace the entire mapping after recommit.
|
||||
if (comptime builtin.os.tag.isDarwin()) {
|
||||
if (comptime mode == .zero) @memset(memory[0..dirty_len], 0);
|
||||
|
||||
if (std.posix.madvise(
|
||||
memory.ptr,
|
||||
memory.len,
|
||||
std.posix.MADV.FREE_REUSABLE,
|
||||
)) |_| return true else |err| {
|
||||
switch (mode) {
|
||||
.strict => {
|
||||
log.warn("madvise(FREE_REUSABLE) failed err={}", .{err});
|
||||
return false;
|
||||
},
|
||||
|
||||
.zero => {
|
||||
// Plain FREE can still reclaim the already-zero mapping
|
||||
// under pressure and does not require a reuse pairing.
|
||||
std.posix.madvise(
|
||||
memory.ptr,
|
||||
memory.len,
|
||||
std.posix.MADV.FREE,
|
||||
) catch {};
|
||||
return false;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (comptime mode == .zero) @memset(memory[0..dirty_len], 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Prepare a mapping previously passed to decommit for reuse.
|
||||
///
|
||||
/// Linux and test builds need no explicit operation. Darwin pairs
|
||||
/// FREE_REUSABLE with FREE_REUSE so pages touched by the caller are accounted
|
||||
/// to the process again. Failure does not invalidate the retained mapping, so
|
||||
/// reuse can continue after logging the accounting failure.
|
||||
pub fn recommit(memory: []align(std.heap.page_size_min) u8) void {
|
||||
assert(memory.len > 0);
|
||||
assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0);
|
||||
assert(memory.len % std.heap.page_size_min == 0);
|
||||
|
||||
if (comptime builtin.is_test) return;
|
||||
if (comptime builtin.os.tag.isDarwin()) {
|
||||
std.posix.madvise(
|
||||
memory.ptr,
|
||||
memory.len,
|
||||
std.posix.MADV.FREE_REUSE,
|
||||
) catch |err| {
|
||||
log.warn("madvise(FREE_REUSE) failed err={}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test "decommit with zero fallback clears the dirty prefix" {
|
||||
const testing = std.testing;
|
||||
const memory_len = 2 * std.heap.page_size_min;
|
||||
const memory = try testing.allocator.alignedAlloc(
|
||||
u8,
|
||||
.fromByteUnits(std.heap.page_size_min),
|
||||
memory_len,
|
||||
);
|
||||
defer testing.allocator.free(memory);
|
||||
|
||||
@memset(memory, 0xAA);
|
||||
_ = decommit(.zero, memory, memory.len);
|
||||
try testing.expect(std.mem.allEqual(u8, memory, 0));
|
||||
|
||||
// The tail is already zero by contract, so a partially dirty mapping only
|
||||
// needs its dirty prefix cleared.
|
||||
@memset(memory[0..1024], 0xAA);
|
||||
_ = decommit(.zero, memory, 1024);
|
||||
try testing.expect(std.mem.allEqual(u8, memory, 0));
|
||||
}
|
||||
|
||||
test "strict decommit retains the mapping for recommit" {
|
||||
const testing = std.testing;
|
||||
const memory_len = 2 * std.heap.page_size_min;
|
||||
const memory = try testing.allocator.alignedAlloc(
|
||||
u8,
|
||||
.fromByteUnits(std.heap.page_size_min),
|
||||
memory_len,
|
||||
);
|
||||
defer testing.allocator.free(memory);
|
||||
@memset(memory, 0xAA);
|
||||
|
||||
const original_ptr = memory.ptr;
|
||||
const original_len = memory.len;
|
||||
try testing.expect(decommit(.strict, memory, memory.len));
|
||||
try testing.expectEqual(original_ptr, memory.ptr);
|
||||
try testing.expectEqual(original_len, memory.len);
|
||||
try testing.expect(std.mem.allEqual(u8, memory, 0));
|
||||
|
||||
recommit(memory);
|
||||
@memset(memory, 0xBB);
|
||||
try testing.expect(std.mem.allEqual(u8, memory, 0xBB));
|
||||
}
|
||||
|
||||
test "test builds can reclaim retained mappings" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(canReclaim(.zero));
|
||||
try testing.expect(canReclaim(.strict));
|
||||
}
|
||||
@@ -511,7 +511,7 @@ pub const RenderState = struct {
|
||||
while (y < self.rows) {
|
||||
const chunk = page_it.next() orelse break;
|
||||
const node = chunk.node;
|
||||
const p: *page.Page = &node.data;
|
||||
const p: *page.Page = node.page();
|
||||
|
||||
// The number of rows we consume from this chunk. The chunk
|
||||
// may extend beyond the viewport (the viewport is always
|
||||
@@ -910,7 +910,7 @@ pub const RenderState = struct {
|
||||
|
||||
// Grab our link ID
|
||||
const link_pin: PageList.Pin = row_pins[viewport_point.y];
|
||||
const link_page: *page.Page = &link_pin.node.data;
|
||||
const link_page: *page.Page = link_pin.node.page();
|
||||
const link = link: {
|
||||
const rac = link_page.getRowAndCell(
|
||||
viewport_point.x,
|
||||
@@ -939,7 +939,7 @@ pub const RenderState = struct {
|
||||
for (0.., cells.items(.raw)) |x, cell| {
|
||||
if (!cell.hyperlink) continue;
|
||||
|
||||
const other_page: *page.Page = &pin.node.data;
|
||||
const other_page: *page.Page = pin.node.page();
|
||||
const other = link: {
|
||||
const rac = other_page.getRowAndCell(x, pin.y);
|
||||
const link_id = other_page.lookupHyperlink(rac.cell) orelse continue;
|
||||
@@ -1978,7 +1978,7 @@ test "linkCells with scrollback spanning pages" {
|
||||
defer s.deinit();
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const first_page_cap = pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_cap = pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill first page
|
||||
for (0..first_page_cap - 1) |_| s.nextSlice("\r\n");
|
||||
|
||||
@@ -68,26 +68,22 @@ pub const ActiveSearch = struct {
|
||||
// page that contains the active area. We go to the previous
|
||||
// page once more since its the first page of our required
|
||||
// overlap.
|
||||
if (rem <= node.data.size.rows) {
|
||||
if (rem <= node.rows()) {
|
||||
node_ = node.prev;
|
||||
break;
|
||||
}
|
||||
|
||||
rem -= node.data.size.rows;
|
||||
rem -= node.rows();
|
||||
}
|
||||
|
||||
// Next, add enough overlap to cover needle.len - 1 bytes (if it
|
||||
// exists) so we can cover the overlap.
|
||||
while (node_) |node| : (node_ = node.prev) {
|
||||
// If the last row of this node isn't wrapped we can't overlap.
|
||||
const row = node.data.getRow(node.data.size.rows - 1);
|
||||
if (!row.wrap) break;
|
||||
|
||||
// We could be more accurate here and count bytes since the
|
||||
// last wrap but its complicated and unlikely multiple pages
|
||||
// wrap so this should be fine.
|
||||
const added = try self.window.append(node);
|
||||
if (added >= self.window.needle.len - 1) break;
|
||||
const appended = try self.window.appendIfWrapped(node) orelse break;
|
||||
if (appended.content_len >= self.window.needle.len - 1) break;
|
||||
}
|
||||
|
||||
// Return the last node we added to our window.
|
||||
|
||||
@@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator;
|
||||
const testing = std.testing;
|
||||
const terminal = @import("../main.zig");
|
||||
const point = terminal.point;
|
||||
const size = terminal.size;
|
||||
const FlattenedHighlight = @import("../highlight.zig").Flattened;
|
||||
const Page = terminal.Page;
|
||||
const PageList = terminal.PageList;
|
||||
@@ -56,8 +57,8 @@ pub const PageListSearch = struct {
|
||||
// be moved somewhere safe.
|
||||
const pin = try list.trackPin(.{
|
||||
.node = start,
|
||||
.y = start.data.size.rows - 1,
|
||||
.x = start.data.size.cols - 1,
|
||||
.y = start.rows() - 1,
|
||||
.x = start.cols() - 1,
|
||||
});
|
||||
errdefer list.untrackPin(pin);
|
||||
|
||||
@@ -121,7 +122,7 @@ pub const PageListSearch = struct {
|
||||
// get our desired amount of data.
|
||||
var node_: ?*PageList.List.Node = self.pin.node.prev;
|
||||
while (node_) |node| : (node_ = node.prev) {
|
||||
rem -|= try self.window.append(node);
|
||||
rem -|= (try self.window.append(node)).content_len;
|
||||
|
||||
// Move our tracked pin to the new node.
|
||||
self.pin.node = node;
|
||||
@@ -190,7 +191,7 @@ test "feed multiple pages with matches" {
|
||||
defer s.deinit();
|
||||
|
||||
// Fill up first page
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
s.nextSlice("Fizz");
|
||||
try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last);
|
||||
@@ -234,7 +235,7 @@ test "feed multiple pages no matches" {
|
||||
defer s.deinit();
|
||||
|
||||
// Fill up first page
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
s.nextSlice("Hello");
|
||||
|
||||
@@ -272,7 +273,7 @@ test "feed iteratively through multiple matches" {
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill first page with a match at the end
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
@@ -313,7 +314,7 @@ test "feed with match spanning page boundary" {
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill first page ending with "Te"
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
@@ -359,6 +360,76 @@ test "feed with match spanning page boundary" {
|
||||
try testing.expect(!try search.feed());
|
||||
}
|
||||
|
||||
test "compressed history match spanning page boundary remains compressed" {
|
||||
const alloc = testing.allocator;
|
||||
var t: Terminal = try .init(alloc, .{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 10 * 1024 * 1024,
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var stream = t.vtStream();
|
||||
defer stream.deinit();
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const first = pages.pages.first.?;
|
||||
const first_page_rows = first.capacity().rows;
|
||||
|
||||
// Put half the needle on either side of a soft-wrapped page boundary.
|
||||
for (0..first_page_rows - 1) |_| stream.nextSlice("\r\n");
|
||||
for (0..pages.cols - 2) |_| stream.nextSlice("x");
|
||||
stream.nextSlice("Test");
|
||||
const second = first.next.?;
|
||||
try testing.expectEqual(second, pages.pages.last.?);
|
||||
|
||||
// Move both matching pages completely into history. The third page holds
|
||||
// the active area, making the first two eligible for compression.
|
||||
while (pages.pages.last.? == second) stream.nextSlice("\r\n");
|
||||
for (0..pages.rows) |_| stream.nextSlice("\r\n");
|
||||
try testing.expect(pages.getTopLeft(.active).node != second);
|
||||
|
||||
_ = pages.compress(.full);
|
||||
try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage());
|
||||
try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage());
|
||||
const compressed = pages.memoryStats();
|
||||
try testing.expect(compressed.compressed_pages >= 2);
|
||||
|
||||
var search: PageListSearch = try .init(
|
||||
alloc,
|
||||
"Test",
|
||||
pages,
|
||||
pages.pages.last.?,
|
||||
);
|
||||
defer search.deinit();
|
||||
|
||||
// Drain and feed incrementally until the boundary match is available. The
|
||||
// exact number of feeds depends on how much blank-page text the formatter
|
||||
// trims, so bound progress by the number of pages rather than encoding
|
||||
// that implementation detail in the test.
|
||||
var found: ?FlattenedHighlight = null;
|
||||
for (0..pages.totalPages() + 1) |_| {
|
||||
if (search.next()) |match| {
|
||||
found = match;
|
||||
break;
|
||||
}
|
||||
if (!try search.feed()) break;
|
||||
}
|
||||
try testing.expect(found != null);
|
||||
|
||||
const match = found.?.untracked();
|
||||
try testing.expectEqual(first, match.start.node);
|
||||
try testing.expectEqual(second, match.end.node);
|
||||
try testing.expectEqual(@as(size.CellCountInt, pages.cols - 2), match.start.x);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 1), match.end.x);
|
||||
|
||||
// Search owns formatted text and coordinate maps, not page snapshots. The
|
||||
// original raw mappings therefore remain discarded after matching.
|
||||
try testing.expectEqual(compressed, pages.memoryStats());
|
||||
try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage());
|
||||
try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage());
|
||||
}
|
||||
|
||||
test "feed with match spanning page boundary with newline" {
|
||||
const alloc = testing.allocator;
|
||||
var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 });
|
||||
@@ -367,7 +438,7 @@ test "feed with match spanning page boundary with newline" {
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows;
|
||||
|
||||
// Fill first page ending with "Te"
|
||||
for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
@@ -404,14 +475,14 @@ test "feed with pruned page" {
|
||||
|
||||
// Grow to capacity
|
||||
const page1_node = p.pages.last.?;
|
||||
const page1 = page1_node.data;
|
||||
const page1 = page1_node.page();
|
||||
for (0..page1.capacity.rows - page1.size.rows) |_| {
|
||||
try testing.expect(try p.grow() == null);
|
||||
}
|
||||
|
||||
// Grow and allocate one more page. Then fill that page up.
|
||||
const page2_node = (try p.grow()).?;
|
||||
const page2 = page2_node.data;
|
||||
const page2 = page2_node.page();
|
||||
for (0..page2.capacity.rows - page2.size.rows) |_| {
|
||||
try testing.expect(try p.grow() == null);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ pub const SlidingWindow = struct {
|
||||
const Meta = struct {
|
||||
node: *PageList.List.Node,
|
||||
serial: u64,
|
||||
rows: size.CellCountInt,
|
||||
cell_map: std.ArrayList(point.Coordinate),
|
||||
|
||||
pub fn deinit(self: *Meta, alloc: Allocator) void {
|
||||
@@ -96,6 +97,17 @@ pub const SlidingWindow = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Information copied from a page while appending it to the window.
|
||||
///
|
||||
/// Both values remain safe after the node's preserved page is released.
|
||||
/// In particular, callers use `last_row_wrapped` to decide whether an
|
||||
/// adjacent page can contribute to a cross-page match without reading the
|
||||
/// node again.
|
||||
pub const AppendResult = struct {
|
||||
content_len: usize,
|
||||
last_row_wrapped: bool,
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
direction: Direction,
|
||||
@@ -309,6 +321,12 @@ pub const SlidingWindow = struct {
|
||||
self.chunk_buf.clearRetainingCapacity();
|
||||
var result: terminal.highlight.Flattened = .empty;
|
||||
|
||||
// A reverse cross-page match needs the row count of the last meta in
|
||||
// search order after the chunks themselves are reversed. Snapshot it
|
||||
// while traversing Meta rather than dereferencing a PageList node after
|
||||
// the terminal lock has been released.
|
||||
var cross_page_end_rows: ?size.CellCountInt = null;
|
||||
|
||||
// Go through the meta nodes to find our start.
|
||||
const tl: struct {
|
||||
/// If non-null, we need to continue searching for the bottom-right.
|
||||
@@ -375,7 +393,7 @@ pub const SlidingWindow = struct {
|
||||
.node = meta.node,
|
||||
.serial = meta.serial,
|
||||
.start = @intCast(map.y),
|
||||
.end = meta.node.data.size.rows,
|
||||
.end = meta.rows,
|
||||
});
|
||||
|
||||
break :tl .{
|
||||
@@ -410,7 +428,7 @@ pub const SlidingWindow = struct {
|
||||
.node = meta.node,
|
||||
.serial = meta.serial,
|
||||
.start = 0,
|
||||
.end = meta.node.data.size.rows,
|
||||
.end = meta.rows,
|
||||
});
|
||||
|
||||
meta_consumed += meta.cell_map.items.len;
|
||||
@@ -420,6 +438,7 @@ pub const SlidingWindow = struct {
|
||||
// We found it
|
||||
const map = meta.cell_map.items[meta_i];
|
||||
result.bot_x = map.x;
|
||||
cross_page_end_rows = meta.rows;
|
||||
self.chunk_buf.appendAssumeCapacity(.{
|
||||
.node = meta.node,
|
||||
.serial = meta.serial,
|
||||
@@ -491,7 +510,7 @@ pub const SlidingWindow = struct {
|
||||
// order.
|
||||
assert(nodes.len >= 2);
|
||||
starts[0] = ends[0] - 1;
|
||||
ends[0] = nodes[0].data.size.rows;
|
||||
ends[0] = cross_page_end_rows.?;
|
||||
ends[nodes.len - 1] = starts[nodes.len - 1] + 1;
|
||||
starts[nodes.len - 1] = 0;
|
||||
} else {
|
||||
@@ -526,11 +545,49 @@ pub const SlidingWindow = struct {
|
||||
pub fn append(
|
||||
self: *SlidingWindow,
|
||||
node: *PageList.List.Node,
|
||||
) Allocator.Error!usize {
|
||||
) Allocator.Error!AppendResult {
|
||||
var preserved = try node.pagePreservingState(self.alloc);
|
||||
defer preserved.deinit();
|
||||
|
||||
const page = preserved.page();
|
||||
const last_row_wrapped = page.getRow(page.size.rows - 1).wrap;
|
||||
return self.appendPage(node, page, last_row_wrapped);
|
||||
}
|
||||
|
||||
/// Append a node only when its last row is soft wrapped.
|
||||
///
|
||||
/// This acquires one preserved page for both the wrap check and formatting
|
||||
/// so a compressed node is decoded at most once. It is used when loading
|
||||
/// the overlap around an otherwise complete search region.
|
||||
pub fn appendIfWrapped(
|
||||
self: *SlidingWindow,
|
||||
node: *PageList.List.Node,
|
||||
) Allocator.Error!?AppendResult {
|
||||
var preserved = try node.pagePreservingState(self.alloc);
|
||||
defer preserved.deinit();
|
||||
|
||||
const page = preserved.page();
|
||||
const last_row_wrapped = page.getRow(page.size.rows - 1).wrap;
|
||||
if (!last_row_wrapped) return null;
|
||||
return try self.appendPage(node, page, last_row_wrapped);
|
||||
}
|
||||
|
||||
/// Copy one preserved page into the window's owned search buffers.
|
||||
///
|
||||
/// No pointer into `page` may escape this function: compressed preserved
|
||||
/// pages own temporary decode storage, while resident values borrow node
|
||||
/// memory.
|
||||
fn appendPage(
|
||||
self: *SlidingWindow,
|
||||
node: *PageList.List.Node,
|
||||
page: *const terminal.Page,
|
||||
last_row_wrapped: bool,
|
||||
) Allocator.Error!AppendResult {
|
||||
// Initialize our metadata for the node.
|
||||
var meta: Meta = .{
|
||||
.node = node,
|
||||
.serial = node.serial,
|
||||
.rows = page.size.rows,
|
||||
.cell_map = .empty,
|
||||
};
|
||||
errdefer meta.deinit(self.alloc);
|
||||
@@ -544,7 +601,7 @@ pub const SlidingWindow = struct {
|
||||
|
||||
// Encode the page into the buffer.
|
||||
const formatter: PageFormatter = formatter: {
|
||||
var formatter: PageFormatter = .init(&meta.node.data, .{
|
||||
var formatter: PageFormatter = .init(page, .{
|
||||
.emit = .plain,
|
||||
.unwrap = true,
|
||||
});
|
||||
@@ -563,8 +620,7 @@ pub const SlidingWindow = struct {
|
||||
|
||||
// If the node we're adding isn't soft-wrapped, we add the
|
||||
// trailing newline.
|
||||
const row = node.data.getRow(node.data.size.rows - 1);
|
||||
if (!row.wrap) {
|
||||
if (!last_row_wrapped) {
|
||||
encoded.writer.writeByte('\n') catch return error.OutOfMemory;
|
||||
try meta.cell_map.append(
|
||||
self.alloc,
|
||||
@@ -580,7 +636,10 @@ pub const SlidingWindow = struct {
|
||||
const written = encoded.written();
|
||||
if (written.len == 0) {
|
||||
self.assertIntegrity();
|
||||
return 0;
|
||||
return .{
|
||||
.content_len = 0,
|
||||
.last_row_wrapped = last_row_wrapped,
|
||||
};
|
||||
}
|
||||
|
||||
// Get our written data. If we're doing a reverse search then we
|
||||
@@ -603,7 +662,10 @@ pub const SlidingWindow = struct {
|
||||
self.meta.appendAssumeCapacity(meta);
|
||||
|
||||
self.assertIntegrity();
|
||||
return written.len;
|
||||
return .{
|
||||
.content_len = written.len,
|
||||
.last_row_wrapped = last_row_wrapped,
|
||||
};
|
||||
}
|
||||
|
||||
/// Only for tests!
|
||||
@@ -813,7 +875,7 @@ test "SlidingWindow two pages" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("boo!");
|
||||
@@ -868,7 +930,7 @@ test "SlidingWindow two pages single char" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("boo!");
|
||||
@@ -923,7 +985,7 @@ test "SlidingWindow two pages match across boundary" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("hell");
|
||||
@@ -968,7 +1030,7 @@ test "SlidingWindow two pages no match across boundary with newline" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("hell");
|
||||
@@ -1001,7 +1063,7 @@ test "SlidingWindow two pages no match across boundary with newline reverse" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("hell");
|
||||
@@ -1031,7 +1093,7 @@ test "SlidingWindow two pages no match prunes first page" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("boo!");
|
||||
@@ -1063,7 +1125,7 @@ test "SlidingWindow two pages no match keeps both pages" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("boo!");
|
||||
@@ -1164,7 +1226,7 @@ test "SlidingWindow single append match on boundary" {
|
||||
// We need to surgically modify the last row to be soft-wrapped
|
||||
try testing.expect(s.pages.pages.first == s.pages.pages.last);
|
||||
const node: *PageList.List.Node = s.pages.pages.first.?;
|
||||
node.data.getRow(node.data.size.rows - 1).wrap = true;
|
||||
node.page().getRow(node.rows() - 1).wrap = true;
|
||||
|
||||
// We are trying to break a circular buffer boundary so the way we
|
||||
// do this is to duplicate the data then do a failing search. This
|
||||
@@ -1290,7 +1352,7 @@ test "SlidingWindow two pages reversed" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("boo!");
|
||||
@@ -1345,7 +1407,7 @@ test "SlidingWindow two pages match across boundary reversed" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "hell"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("hell");
|
||||
@@ -1391,7 +1453,7 @@ test "SlidingWindow two pages no match prunes first page reversed" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("boo!");
|
||||
@@ -1423,7 +1485,7 @@ test "SlidingWindow two pages no match keeps both pages reversed" {
|
||||
|
||||
// Fill up the first page. The final bytes in the first page
|
||||
// are "boo!"
|
||||
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = s.pages.pages.first.?.capacity().rows;
|
||||
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
|
||||
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
|
||||
try s.testWriteString("boo!");
|
||||
@@ -1525,7 +1587,7 @@ test "SlidingWindow single append match on boundary reversed" {
|
||||
// We need to surgically modify the last row to be soft-wrapped
|
||||
try testing.expect(s.pages.pages.first == s.pages.pages.last);
|
||||
const node: *PageList.List.Node = s.pages.pages.first.?;
|
||||
node.data.getRow(node.data.size.rows - 1).wrap = true;
|
||||
node.page().getRow(node.rows() - 1).wrap = true;
|
||||
|
||||
// We are trying to break a circular buffer boundary so the way we
|
||||
// do this is to duplicate the data then do a failing search. This
|
||||
@@ -1665,7 +1727,7 @@ test "SlidingWindow append whitespace only node" {
|
||||
// This is invasive but its otherwise hard to reproduce naturally
|
||||
// without creating a slow test.
|
||||
const node: *PageList.List.Node = s.pages.pages.first.?;
|
||||
const last_row = node.data.getRow(node.data.size.rows - 1);
|
||||
const last_row = node.page().getRow(node.rows() - 1);
|
||||
last_row.wrap = true;
|
||||
|
||||
try testing.expect(s.pages.pages.first == s.pages.pages.last);
|
||||
|
||||
@@ -137,37 +137,35 @@ pub const ViewportSearch = struct {
|
||||
var node_ = fingerprint.nodes[0].prev;
|
||||
var added: usize = 0;
|
||||
while (node_) |node| : (node_ = node.prev) {
|
||||
// If the last row of this node isn't wrapped we can't overlap.
|
||||
const row = node.data.getRow(node.data.size.rows - 1);
|
||||
if (!row.wrap) break;
|
||||
|
||||
// We could be more accurate here and count bytes since the
|
||||
// last wrap but its complicated and unlikely multiple pages
|
||||
// wrap so this should be fine.
|
||||
added += try self.window.append(node);
|
||||
const appended = try self.window.appendIfWrapped(node) orelse break;
|
||||
added += appended.content_len;
|
||||
if (added >= self.window.needle.len - 1) break;
|
||||
}
|
||||
|
||||
// We can use our fingerprint nodes to initialize our sliding
|
||||
// window, since we already traversed the viewport once.
|
||||
var end_append: SlidingWindow.AppendResult = undefined;
|
||||
for (fingerprint.nodes) |node| {
|
||||
_ = try self.window.append(node);
|
||||
end_append = try self.window.append(node);
|
||||
}
|
||||
|
||||
// Add any trailing overlap as well.
|
||||
trailing: {
|
||||
const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1];
|
||||
if (!end.data.getRow(end.data.size.rows - 1).wrap) break :trailing;
|
||||
if (!end_append.last_row_wrapped) break :trailing;
|
||||
|
||||
node_ = end.next;
|
||||
added = 0;
|
||||
while (node_) |node| : (node_ = node.next) {
|
||||
added += try self.window.append(node);
|
||||
const appended = try self.window.append(node);
|
||||
added += appended.content_len;
|
||||
if (added >= self.window.needle.len - 1) break;
|
||||
|
||||
// If this row doesn't wrap, then we can quit
|
||||
const row = node.data.getRow(node.data.size.rows - 1);
|
||||
if (!row.wrap) break;
|
||||
if (!appended.last_row_wrapped) break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +346,7 @@ test "history search, no active area" {
|
||||
defer s.deinit();
|
||||
|
||||
// Fill up first page
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows;
|
||||
const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows;
|
||||
s.nextSlice("Fizz\r\n");
|
||||
for (1..first_page_rows - 1) |_| s.nextSlice("\r\n");
|
||||
try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last);
|
||||
|
||||
Reference in New Issue
Block a user