From d7bb4b8639614c7d6eeac403bf92d64066b2c73f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 3 Aug 2026 12:38:08 -0700 Subject: [PATCH] libghostty-vt: add C API for snapshotting functions Expose terminal snapshot through the libghostty-vt C API and add a new C example that runs in CI to verify this stuff works! ## Example ```c size_t continuation_limit = 1024; assert(ghostty_terminal_set( terminal, GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES, &continuation_limit) == GHOSTTY_SUCCESS); uint8_t *bytes = NULL; size_t len = 0; assert(ghostty_snapshot_encode_alloc( terminal, NULL, &bytes, &len) == GHOSTTY_SUCCESS); GhosttySnapshotDecoder decoder = NULL; assert(ghostty_snapshot_decoder_new_buf( NULL, &decoder, bytes, len) == GHOSTTY_SUCCESS); GhosttyTerminal restored = NULL; assert(ghostty_snapshot_decoder_decode( decoder, &restored) == GHOSTTY_SUCCESS); ghostty_snapshot_decoder_free(decoder); ghostty_free(NULL, bytes, len); ``` Streaming decode: ```c GhosttyReader reader = { .read = read_snapshot, .userdata = source, }; GhosttySnapshotDecoder decoder = NULL; assert(ghostty_snapshot_decoder_new( NULL, &decoder, reader) == GHOSTTY_SUCCESS); GhosttyTerminal terminal = NULL; assert(ghostty_snapshot_decoder_ready( decoder, &terminal) == GHOSTTY_SUCCESS); GhosttyResult result; while ((result = ghostty_snapshot_decoder_next(decoder)) == GHOSTTY_SUCCESS) { size_t rows = 0; assert(ghostty_snapshot_decoder_get( decoder, GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS, &rows) == GHOSTTY_SUCCESS); render(terminal); } assert(result == GHOSTTY_NO_VALUE); ``` --- example/c-vt-snapshot/README.md | 15 + example/c-vt-snapshot/build.zig | 33 + example/c-vt-snapshot/build.zig.zon | 14 + example/c-vt-snapshot/src/main.c | 162 ++++ include/ghostty/vt.h | 4 + include/ghostty/vt/io.h | 107 +++ include/ghostty/vt/snapshot.h | 525 +++++++++++ include/ghostty/vt/terminal.h | 123 +++ include/ghostty/vt/types.h | 11 + src/lib_vt.zig | 15 + src/terminal/apc.zig | 11 + src/terminal/c/io.zig | 594 +++++++++++++ src/terminal/c/main.zig | 20 + src/terminal/c/result.zig | 2 + src/terminal/c/snapshot.zig | 1253 +++++++++++++++++++++++++++ src/terminal/c/terminal.zig | 795 +++++++++++++++-- src/terminal/c/types.zig | 17 +- src/terminal/snapshot/grid.zig | 11 +- src/terminal/stream.zig | 2 +- 19 files changed, 3652 insertions(+), 62 deletions(-) create mode 100644 example/c-vt-snapshot/README.md create mode 100644 example/c-vt-snapshot/build.zig create mode 100644 example/c-vt-snapshot/build.zig.zon create mode 100644 example/c-vt-snapshot/src/main.c create mode 100644 include/ghostty/vt/io.h create mode 100644 include/ghostty/vt/snapshot.h create mode 100644 src/terminal/c/io.zig create mode 100644 src/terminal/c/snapshot.zig diff --git a/example/c-vt-snapshot/README.md b/example/c-vt-snapshot/README.md new file mode 100644 index 000000000..f9e9c01f6 --- /dev/null +++ b/example/c-vt-snapshot/README.md @@ -0,0 +1,15 @@ +# Example: Terminal Snapshots in C + +This example creates a terminal with continuation tracking, encodes its full +state, and restores the snapshot using both the one-shot and incremental C +decoder APIs. The incremental path uses a synchronous `GhosttyReader` callback +and reports each restored history page. The standalone project links the static +libghostty-vt artifact so it can run consistently on every supported host. + +## Usage + +Run the example: + +```shell-session +zig build run +``` diff --git a/example/c-vt-snapshot/build.zig b/example/c-vt-snapshot/build.zig new file mode 100644 index 000000000..50c5c5f5a --- /dev/null +++ b/example/c-vt-snapshot/build.zig @@ -0,0 +1,33 @@ +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"}, + }); + exe_mod.addCMacro("GHOSTTY_STATIC", ""); + + if (b.lazyDependency("ghostty", .{})) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt-static")); + } + + const exe = b.addExecutable(.{ + .name = "c_vt_snapshot", + .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); +} diff --git a/example/c-vt-snapshot/build.zig.zon b/example/c-vt-snapshot/build.zig.zon new file mode 100644 index 000000000..3514a1166 --- /dev/null +++ b/example/c-vt-snapshot/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .c_vt_snapshot, + .version = "0.0.0", + .fingerprint = 0xff13ccc637fd5383, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + .ghostty = .{ .path = "../../" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-snapshot/src/main.c b/example/c-vt-snapshot/src/main.c new file mode 100644 index 000000000..63302d860 --- /dev/null +++ b/example/c-vt-snapshot/src/main.c @@ -0,0 +1,162 @@ +#include +#include +#include +#include +#include +#include + +//! [snapshot-buffer-reader] +typedef struct { + const uint8_t *data; + size_t len; + size_t offset; +} BufferReader; + +// GhosttyReader callbacks are synchronous. A successful zero-byte read is +// permanent EOF; returning false would report an I/O error. +static bool buffer_read(void *userdata, + uint8_t *buffer, + size_t capacity, + size_t *out_read) { + BufferReader *reader = userdata; + size_t remaining = reader->len - reader->offset; + size_t count = remaining < capacity ? remaining : capacity; + + // Deliberately return short reads to demonstrate that the decoder retries. + if (count > 64) count = 64; + memcpy(buffer, reader->data + reader->offset, count); + reader->offset += count; + *out_read = count; + return true; +} +//! [snapshot-buffer-reader] + +int main(void) { + GhosttyResult result; + + //! [snapshot-encode] + GhosttyTerminal source = NULL; + // A wide, shallow screen fills backing pages quickly enough to leave older + // PAGE records after READY for the incremental decoder to demonstrate. + result = ghostty_terminal_new(NULL, &source, 215, 2); + assert(result == GHOSTTY_SUCCESS); + + // Snapshot encoding requires continuation tracking to be enabled before + // feeding input. The limit bounds unfinished VT sequence retention. + const size_t continuation_limit = 1024; + result = ghostty_terminal_set( + source, + GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES, + &continuation_limit); + assert(result == GHOSTTY_SUCCESS); + + // Keep enough scrollback to demonstrate incremental history restoration. + result = ghostty_terminal_set( + source, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES, NULL); + assert(result == GHOSTTY_SUCCESS); + + const char *line = "snapshot history line\r\n"; + for (size_t i = 0; i < 1000; i++) { + ghostty_terminal_vt_write( + source, (const uint8_t *)line, strlen(line)); + } + + // Leave an SGR sequence unfinished so its continuation is snapshotted too. + const char *unfinished = "\x1b[31"; + ghostty_terminal_vt_write( + source, (const uint8_t *)unfinished, strlen(unfinished)); + + uint8_t *snapshot = NULL; + size_t snapshot_len = 0; + result = ghostty_snapshot_encode_alloc( + source, NULL, &snapshot, &snapshot_len); + assert(result == GHOSTTY_SUCCESS); + printf("encoded %zu snapshot bytes\n", snapshot_len); + //! [snapshot-encode] + + //! [snapshot-decode] + GhosttySnapshotDecoder full_decoder = NULL; + result = ghostty_snapshot_decoder_new_buf( + NULL, &full_decoder, snapshot, snapshot_len); + assert(result == GHOSTTY_SUCCESS); + + GhosttyTerminal full_terminal = NULL; + result = ghostty_snapshot_decoder_decode(full_decoder, &full_terminal); + assert(result == GHOSTTY_SUCCESS); + + ghostty_snapshot_decoder_free(full_decoder); + ghostty_terminal_free(full_terminal); + //! [snapshot-decode] + + //! [snapshot-incremental] + BufferReader reader_state = { + .data = snapshot, + .len = snapshot_len, + .offset = 0, + }; + GhosttyReader reader = { + .read = buffer_read, + .userdata = &reader_state, + }; + + GhosttySnapshotDecoder incremental_decoder = NULL; + result = ghostty_snapshot_decoder_new( + NULL, &incremental_decoder, reader); + assert(result == GHOSTTY_SUCCESS); + + // READY authenticates and returns a renderable terminal before old history. + GhosttyTerminal incremental_terminal = NULL; + result = ghostty_snapshot_decoder_ready( + incremental_decoder, &incremental_terminal); + assert(result == GHOSTTY_SUCCESS); + + uint64_t history_rows = 0; + result = ghostty_snapshot_decoder_get( + incremental_decoder, + GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY, + &history_rows); + assert(result == GHOSTTY_SUCCESS); + printf("snapshot advertises %llu primary history rows\n", + (unsigned long long)history_rows); + + size_t page_count = 0; + while ((result = ghostty_snapshot_decoder_next(incremental_decoder)) == + GHOSTTY_SUCCESS) { + GhosttyTerminalScreen screen; + size_t rows = 0; + uint32_t remaining = 0; + const GhosttySnapshotDecoderData keys[] = { + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN, + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS, + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING, + }; + void *values[] = {&screen, &rows, &remaining}; + size_t written = 0; + result = ghostty_snapshot_decoder_get_multi( + incremental_decoder, + sizeof(keys) / sizeof(keys[0]), + keys, + values, + &written); + assert(result == GHOSTTY_SUCCESS); + assert(written == sizeof(keys) / sizeof(keys[0])); + + printf("restored %zu rows to screen %d (%u pages remain)\n", + rows, (int)screen, remaining); + page_count++; + } + + // NO_VALUE means FINISH authenticated successfully and is idempotent. + assert(result == GHOSTTY_NO_VALUE); + assert(page_count > 0); + assert(ghostty_snapshot_decoder_next(incremental_decoder) == + GHOSTTY_NO_VALUE); + + ghostty_snapshot_decoder_free(incremental_decoder); + ghostty_terminal_free(incremental_terminal); + //! [snapshot-incremental] + + ghostty_free(NULL, snapshot, snapshot_len); + ghostty_terminal_free(source); + return 0; +} diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 5606f1690..7519a3f25 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -31,12 +31,14 @@ * - @ref terminal "Terminal" - Complete terminal emulator state and rendering * - @ref render "Render State" - Incremental render state updates for custom renderers * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML + * - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences * - @ref paste "Paste Utilities" - Validate paste data safety * - @ref unicode "Unicode Utilities" - Codepoint properties for text layout * - @ref build_info "Build Info" - Query compile-time build configuration * - @ref allocator "Memory Management" - Memory management and custom allocators + * - @ref io "Byte-stream I/O" - Reusable synchronous reader and writer callbacks * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions * * Encoding related APIs: @@ -140,6 +142,7 @@ extern "C" { #include #include #include +#include #include #include #include @@ -153,6 +156,7 @@ extern "C" { #include #include #include +#include #include #include diff --git a/include/ghostty/vt/io.h b/include/ghostty/vt/io.h new file mode 100644 index 000000000..2d8e67029 --- /dev/null +++ b/include/ghostty/vt/io.h @@ -0,0 +1,107 @@ +/** + * @file io.h + * + * Generic IO callbacks for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_IO_H +#define GHOSTTY_VT_IO_H + +#include +#include +#include + +/** @defgroup io I/O + * + * Synchronous callback interfaces used by APIs that consume or produce byte + * streams. The callback and userdata pointers must remain valid for the full + * lifetime documented by the API receiving a GhosttyReader or GhosttyWriter. + * + * @{ + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Read bytes from a source. + * + * The callback must set @p out_read to a value no greater than @p capacity + * when returning true. A positive value reports progress; it may be less than + * capacity and does not indicate end-of-file. A zero value is definitive + * end-of-file. It must not be used to report temporary input starvation or a + * would-block condition. + * + * Returning false reports a fatal read error and the value of @p out_read is + * ignored. The library does not inspect or modify errno. + * + * All pointer arguments are borrowed and valid only for the duration of the + * callback. The callback is invoked synchronously on the calling thread. + * + * @param userdata Opaque userdata from GhosttyReader + * @param buffer Destination for read bytes; always non-NULL + * @param capacity Writable capacity of @p buffer; always greater than zero + * @param[out] out_read Number of bytes read when returning true; non-NULL + * @return true for a successful read or end-of-file, false for a fatal error + */ +typedef bool (*GhosttyReaderFn)( + void* userdata, + uint8_t* buffer, + size_t capacity, + size_t* out_read); + +/** + * Write bytes to a destination. + * + * Returning true means all @p len bytes were accepted. Returning false + * reports a fatal write error. A callback wrapping an interface that permits + * partial writes must retry internally until the full slice is accepted or + * an error occurs. + * + * On failure, the destination may already contain a prefix of the bytes. The + * calling operation fails and must not be resumed from that partial output. + * The library does not inspect or modify errno. + * + * @p data is borrowed and valid only for the duration of the callback. The + * callback is invoked synchronously on the calling thread. Successful return + * means the bytes were handed to the destination; it does not imply that the + * destination was flushed or made durable. + * + * @param userdata Opaque userdata from GhosttyWriter + * @param data Source bytes; always non-NULL + * @param len Number of source bytes; always greater than zero + * @return true if the complete slice was accepted, false on fatal error + */ +typedef bool (*GhosttyWriterFn)( + void* userdata, + const uint8_t* data, + size_t len); + +/** + * A byte source callback and its opaque context. + * + * The struct is passed by value. @p read must be non-NULL. + */ +typedef struct { + GhosttyReaderFn read; + void* userdata; +} GhosttyReader; + +/** + * A byte destination callback and its opaque context. + * + * The struct is passed by value. @p write must be non-NULL. + */ +typedef struct { + GhosttyWriterFn write; + void* userdata; +} GhosttyWriter; + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_IO_H */ diff --git a/include/ghostty/vt/snapshot.h b/include/ghostty/vt/snapshot.h new file mode 100644 index 000000000..f7c30841e --- /dev/null +++ b/include/ghostty/vt/snapshot.h @@ -0,0 +1,525 @@ +/** + * @file snapshot.h + * + * Encode and restore complete terminal snapshots. + */ + +#ifndef GHOSTTY_VT_SNAPSHOT_H +#define GHOSTTY_VT_SNAPSHOT_H + +#include +#include + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup snapshot Terminal Snapshot + * + * Encode and restore the complete state of a terminal via a binary format. + * + * A snapshot is an ordered, authenticated record stream. Its READY checkpoint + * contains enough state to render and resume the terminal, including any + * unfinished VT parser input. Older scrollback pages follow READY and the + * FINISH checkpoint authenticates the complete snapshot. + * + * End-of-file before an operation's required READY or FINISH checkpoint is + * malformed, truncated snapshot data and returns GHOSTTY_INVALID_VALUE. + * GHOSTTY_IO_ERROR is reserved for a reader callback that returns false. + * + * ## Examples + * + * The complete working example is available in `example/c-vt-snapshot`. + * + * ### Encode a terminal and its unfinished VT continuation + * @snippet c-vt-snapshot/src/main.c snapshot-encode + * + * ### Restore a complete snapshot in one call + * @snippet c-vt-snapshot/src/main.c snapshot-decode + * + * ### Adapt a byte source to GhosttyReader + * @snippet c-vt-snapshot/src/main.c snapshot-buffer-reader + * + * ### Restore READY first, then incrementally prepend history + * @snippet c-vt-snapshot/src/main.c snapshot-incremental + * + * ## Format + * + * Every integer is unsigned and little-endian. The stream begins with this + * fixed ten-byte envelope: + * + * @code{.unparsed} + * byte 0 8 10 + * +---------------+--------+ + * | "GHOSTSNP" | version| + * | 8-byte magic | u16 | + * +---------------+--------+ + * @endcode + * + * The envelope is followed by independently checksummed records. A record's + * CRC32C covers its encoded tag and payload length followed by its payload; it + * does not cover the CRC field itself. + * + * @code{.unparsed} + * byte 0 2 6 10 10 + payload_len + * +-------+-------------+-----------+----------------+ + * | tag | payload_len | CRC32C | payload | + * | u16 | u32 | u32 | payload_len B | + * +-------+-------------+-----------+----------------+ + * \____________________/ \______________/ + * CRC prefix CRC suffix + * @endcode + * + * Record groups occur in this strict order. SCREEN and HISTORY groups contain + * one entry for each screen declared by TERMINAL. Each manifest is followed + * by the number of PAGE records it declares. Active SCREEN pages make the + * terminal renderable; HISTORY pages are older scrollback ordered newest to + * oldest so an incremental decoder can prepend them as they arrive. + * + * @code{.unparsed} + * +---------------- TERMINAL ----------------+ + * | terminal-wide state and screen count | + * +----------------- SCREEN -----------------+ repeated per screen + * | active-screen manifest | + * +------------------ PAGE ------------------+ repeated per manifest + * | active screen rows | + * +------------- CONTINUATION ---------------+ + * | unfinished VT/UTF-8 input, or ground | + * +------------------ READY -----------------+ + * | BLAKE3-256 of every preceding byte | ready() returns here + * +----------------- HISTORY ----------------+ repeated per screen + * | scrollback manifest | + * +------------------ PAGE ------------------+ next() consumes one page + * | older screen rows | + * +------------------ FINISH ----------------+ + * | BLAKE3-256 of every preceding byte | next() returns NO_VALUE + * +------------------------------------------+ + * | trailing transport bytes (not consumed) | + * +------------------------------------------+ + * @endcode + * + * READY authenticates the renderable prefix through CONTINUATION. FINISH + * authenticates READY and every history record as well as the earlier prefix. + * Thus record CRC32C detects local corruption while the BLAKE3 checkpoints + * also bind the ordering and completeness of the record stream. + * + * Snapshot format version 1 is a work in progress and does not yet carry a + * binary-compatibility guarantee. + * + * @see Snapshot format and Zig codec documentation + * + * @{ + */ + +/** + * Configurable snapshot decoder options. + * + * Options may only be changed before decoding starts. Calling + * ghostty_snapshot_decoder_set() after ghostty_snapshot_decoder_ready() or + * ghostty_snapshot_decoder_decode() returns GHOSTTY_INVALID_VALUE. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Largest non-ground continuation the decoder will accept. + * + * A value of zero accepts only snapshots whose VT parser is in the ground + * state. The decoder default matches the largest built-in APC protocol + * buffer limit, currently 65 MiB. + * + * This is an input validation limit only. It does not configure continuation + * tracking on a terminal returned by the decoder. + * + * Input type: size_t * + */ + GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES = 0, + + GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySnapshotDecoderOption; + +/** + * Queryable snapshot decoder data. + * + * Each variant documents the output pointer type expected by + * ghostty_snapshot_decoder_get(). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in data extraction. */ + GHOSTTY_SNAPSHOT_DECODER_DATA_INVALID = 0, + + /** + * Current maximum accepted continuation size. + * + * This value is available in every non-failed decoder state. + * + * Output type: size_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_CONTINUATION_BYTES = 1, + + /** + * Number of snapshot source bytes consumed so far. + * + * At FINISH this identifies the first byte after the snapshot. Trailing + * bytes are not consumed. This value is unavailable after a decoding error, + * because the decoder can no longer guarantee its source position. + * + * Output type: size_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET = 2, + + /** + * Advisory complete logical history extent for the primary screen. + * + * The value counts rows before the active area, including any resident + * overlap carried before READY. It becomes available after READY validates. + * + * Output type: uint64_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY = 3, + + /** + * Advisory complete logical history extent for the alternate screen. + * + * The value has the same semantics and lifetime as + * GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY. Querying it returns + * GHOSTTY_NO_VALUE when the snapshot does not declare an alternate screen. + * + * Output type: uint64_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_ALTERNATE = 4, + + /** + * Screen associated with the most recently decoded history page. + * + * This value is available only after ghostty_snapshot_decoder_next() + * returns GHOSTTY_SUCCESS. A later call to next replaces it or clears it + * when FINISH is reached or an error occurs. + * + * Output type: GhosttyTerminalScreen * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN = 5, + + /** + * Rows prepended by the most recently decoded history page. + * + * Zero means the page was consumed and authenticated but could not be + * applied to the live terminal. + * + * Output type: size_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS = 6, + + /** + * Page records remaining in the same screen's HISTORY sequence. + * + * This is not a count of all pages remaining in the snapshot. + * + * Output type: uint32_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING = 7, + + GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySnapshotDecoderData; + +/** + * Encode a complete terminal snapshot to a writer. + * + * The terminal's persistent VT stream supplies the continuation bytes needed + * to reconstruct unfinished parser state. The caller must prevent concurrent + * writes or other terminal mutation for the duration of this call. The writer + * callback must not call terminal APIs with the same terminal handle. + * A terminal can be encoded with tracking disabled when its VT parser and + * UTF-8 decoder are both at ground. If either is unfinished, tracking must + * have been enabled before the input that produced that state was written; + * otherwise this returns GHOSTTY_INVALID_VALUE. + * + * Encoding begins at the writer's current position. If an error occurs, the + * writer may contain a partial snapshot without a valid FINISH checkpoint. + * Calls to the writer are synchronous; this function does not flush or make + * the caller's destination durable. + * + * @param terminal Terminal to encode (must not be NULL) + * @param writer Destination writer whose write callback must not be NULL + * @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects + * output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or + * another error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_encode(GhosttyTerminal terminal, + GhosttyWriter writer); + +/** + * Encode a complete terminal snapshot to a caller-provided buffer. + * + * Pass NULL for buf with buf_len zero to query the required size. If the + * buffer is too small, this returns GHOSTTY_OUT_OF_SPACE and stores the + * required capacity in out_written. A non-NULL undersized buffer may contain + * a partial snapshot prefix. On success, out_written receives the number of + * bytes encoded. + * + * A terminal can be encoded with tracking disabled when its VT parser and + * UTF-8 decoder are both at ground. If either is unfinished, tracking must + * have been enabled before the input that produced that state was written; + * otherwise this returns GHOSTTY_INVALID_VALUE. + * + * @param terminal Terminal to encode (must not be NULL) + * @param buf Destination buffer, or NULL when buf_len is zero + * @param buf_len Destination buffer capacity in bytes + * @param[out] out_written Bytes written, or required capacity on + * GHOSTTY_OUT_OF_SPACE (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_encode_buf( + GhosttyTerminal terminal, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Encode a complete terminal snapshot to an allocated buffer. + * + * The returned buffer is allocated with allocator, or the default allocator + * when allocator is NULL. The caller must release it with ghostty_free(), + * passing the same allocator used here. + * + * A terminal can be encoded with tracking disabled when its VT parser and + * UTF-8 decoder are both at ground. If either is unfinished, tracking must + * have been enabled before the input that produced that state was written; + * otherwise this returns GHOSTTY_INVALID_VALUE. + * + * @param terminal Terminal to encode (must not be NULL) + * @param allocator Allocator for the output, or NULL for the default allocator + * @param[out] out_ptr Allocated snapshot bytes (must not be NULL) + * @param[out] out_len Number of allocated snapshot bytes (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_encode_alloc( + GhosttyTerminal terminal, + const GhosttyAllocator* allocator, + uint8_t** out_ptr, + size_t* out_len); + +/** + * Create a snapshot decoder that reads from a caller-provided reader. + * + * The decoder stores a copy of reader. Its read callback must not be NULL, and + * both the callback and its caller-owned context must remain valid until + * FINISH is reached or the decoder is freed. Reads are synchronous and occur + * only during ready, next, or decode calls. A zero-byte successful read is + * permanent end-of-file, not temporary starvation; nonblocking sources must + * wait outside the decoder or block in their callback. The read callback must + * not call APIs, including ghostty_snapshot_decoder_free(), on the decoder + * that owns it. Returning false reports GHOSTTY_IO_ERROR; returning true with + * zero bytes before a required checkpoint reports truncated snapshot data as + * GHOSTTY_INVALID_VALUE. + * + * @param allocator Allocator for decoder and decoded terminal state, or NULL + * for the default allocator + * @param decoder Pointer to receive the decoder handle (must not be NULL) + * @param reader Snapshot source reader + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_new( + const GhosttyAllocator* allocator, + GhosttySnapshotDecoder* decoder, + GhosttyReader reader); + +/** + * Create a snapshot decoder over a borrowed byte buffer. + * + * The bytes are not copied. ptr must remain valid and immutable until FINISH + * is reached or the decoder is freed. Bytes after FINISH are not consumed; + * query GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET to locate them. + * + * @param allocator Allocator for decoder and decoded terminal state, or NULL + * for the default allocator + * @param decoder Pointer to receive the decoder handle (must not be NULL) + * @param ptr Snapshot source bytes + * @param len Number of source bytes + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_new_buf( + const GhosttyAllocator* allocator, + GhosttySnapshotDecoder* decoder, + const uint8_t* ptr, + size_t len); + +/** + * Free a snapshot decoder. + * + * This does not release the caller's ownership of a terminal returned by + * ready or decode. Abandoning an incremental decode leaves that terminal + * usable with whatever history had already been restored. + * + * @param decoder Decoder to free (may be NULL) + * + * @ingroup snapshot + */ +GHOSTTY_API void ghostty_snapshot_decoder_free(GhosttySnapshotDecoder decoder); + +/** + * Set a snapshot decoder option. + * + * The value pointer must have the type documented by option. Options may only + * be changed before decoding starts. + * + * @param decoder Decoder handle (must not be NULL) + * @param option Option to change + * @param value Pointer to the option value (must not be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if decoding has + * started or an argument is invalid, or another error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_set( + GhosttySnapshotDecoder decoder, + GhosttySnapshotDecoderOption option, + const void* value); + +/** + * Decode and authenticate the renderable snapshot prefix through READY. + * + * On success, terminal receives a caller-owned terminal with its persistent + * VT stream already restored from the snapshot continuation. The terminal is + * immediately usable for rendering and live input. Older scrollback remains + * to be restored with ghostty_snapshot_decoder_next(). + * + * The restored parser state may be unfinished, but terminal continuation + * tracking is disabled; GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns + * zero. The decoder's continuation option is an input limit, not terminal + * runtime policy. + * + * The caller must keep the returned terminal alive until FINISH validates or + * the decoder is freed. The decoder borrows this terminal handle while it + * restores history; ghostty_snapshot_decoder_next() uses it automatically. + * + * This operation may only be called once and only before decoding starts. + * terminal is set to NULL on every error. A decoding, I/O, or allocation + * error after input consumption begins poisons the decoder, after which it + * must be freed. An invalid argument or lifecycle error detected before the + * operation consumes input does not poison it. + * + * @param decoder Decoder handle (must not be NULL) + * @param[out] terminal Pointer to receive the terminal (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_ready( + GhosttySnapshotDecoder decoder, + GhosttyTerminal* terminal); + +/** + * Decode one history page into the terminal returned by READY. + * + * Each GHOSTTY_SUCCESS consumes and authenticates one PAGE record. Query the + * GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_* values before calling next again. + * GHOSTTY_NO_VALUE means FINISH was validated; repeated calls after FINISH + * also return GHOSTTY_NO_VALUE. + * + * The terminal may be rendered, resized, and fed live PTY input between calls. + * If a history page can no longer be applied safely, it is still consumed and + * authenticated and progress reports zero rows. The decoder applies history + * to the caller-owned terminal produced by its READY operation. + * + * A decoding error invalidates the decoder's source position. The terminal + * remains caller-owned and usable with its already-restored history, but only + * ghostty_snapshot_decoder_free() may subsequently be called on the decoder. + * + * @param decoder Decoder handle (must not be NULL) + * @return GHOSTTY_SUCCESS for one page, GHOSTTY_NO_VALUE after FINISH, or an + * error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_next( + GhosttySnapshotDecoder decoder); + +/** + * Decode and authenticate one complete snapshot. + * + * This is the one-shot form of READY followed by all history pages through + * FINISH. It may only be called before decoding starts. Bytes following FINISH + * are left unread. On success terminal receives a caller-owned terminal with + * its persistent VT stream restored. Continuation tracking on the returned + * terminal is disabled and GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES + * returns zero. terminal is set to NULL on every error. + * A decoding, I/O, or allocation error after input consumption begins poisons + * the decoder, after which it must be freed. An invalid argument or + * lifecycle error detected before the operation consumes input does not + * poison it. + * + * @param decoder Decoder handle (must not be NULL) + * @param[out] terminal Pointer to receive the terminal (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_decode( + GhosttySnapshotDecoder decoder, + GhosttyTerminal* terminal); + +/** + * Get typed data from a snapshot decoder. + * + * The output pointer must have the type documented by data. A phase-dependent + * value that is not currently available returns GHOSTTY_NO_VALUE. + * + * @param decoder Decoder handle (must not be NULL) + * @param data Data kind to query + * @param[out] out Pointer to receive the value (must not be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data + * is unavailable, or another error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_get( + GhosttySnapshotDecoder decoder, + GhosttySnapshotDecoderData data, + void* out); + +/** + * Get multiple snapshot decoder data fields in a single call. + * + * Each keys element selects a data kind and the corresponding values element + * points to storage of the documented output type. Processing stops at the + * first error. On success out_written is set to count; on error it is set to + * the number of values written before the failing key. Invalid array arguments + * report zero values written. + * + * @param decoder Decoder handle (must not be NULL) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers corresponding to keys + * @param[out] out_written Number of successfully written values (may be NULL) + * @return GHOSTTY_SUCCESS if every query succeeds, or the first error + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_get_multi( + GhosttySnapshotDecoder decoder, + size_t count, + const GhosttySnapshotDecoderData* keys, + void** values, + size_t* out_written); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SNAPSHOT_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 9480d37a3..7013029e4 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -1027,6 +1028,26 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttyTerminalProgressReportFn */ GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT = 30, + + /** + * Set the maximum number of replay-safe VT continuation bytes retained. + * + * Continuation bytes reconstruct an escape sequence or UTF-8 codepoint + * which was unfinished at the end of the most recent + * ghostty_terminal_vt_write() call. They are used automatically by terminal + * snapshots and may also be exported directly with the continuation APIs. + * + * Tracking is disabled by default. A nonzero value enables tracking and + * sets its byte limit. Passing NULL or a pointer to zero disables tracking. + * Lowering the limit below an already-retained + * continuation, or enabling tracking while the parser is already + * unfinished, makes the current continuation unavailable because earlier + * bytes cannot be reconstructed. Tracking recovers automatically after a + * later write reaches the ground state or contains a fresh replay start. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES = 31, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; @@ -1376,6 +1397,17 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: size_t * */ GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_LINES = 35, + + /** + * The configured maximum retained VT continuation size in bytes. + * + * A value of zero means continuation tracking is disabled. This reports the + * configured limit even when a current unfinished continuation is + * temporarily unavailable. + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES = 36, GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; @@ -1501,6 +1533,97 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, const uint8_t* data, size_t len); +/** + * Write the terminal's replay-safe VT continuation to a callback writer. + * + * The continuation is the exact byte suffix needed to reconstruct unfinished + * VT parser or UTF-8 decoder state in an equivalent terminal. It is empty + * when the stream is at ground. The callback is invoked synchronously and + * may be called more than once. It must not call terminal APIs with the same + * terminal handle. + * + * Continuation tracking must have been enabled by setting + * GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + * input that produced the continuation was written. + * + * The caller must serialize this operation with ghostty_terminal_vt_write() + * and all other access to the same terminal. + * + * @param terminal Terminal to read from (must not be NULL) + * @param writer Destination writer whose write callback must not be NULL + * @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the callback rejects + * a write, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or + * GHOSTTY_INVALID_VALUE if an argument is invalid, tracking is + * disabled, or the current continuation is unavailable + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_continuation_write( + GhosttyTerminal terminal, + GhosttyWriter writer); + +/** + * Copy the terminal's replay-safe VT continuation into a caller buffer. + * + * Pass NULL for buf with buf_len zero to query the required size. A size query + * returns GHOSTTY_OUT_OF_SPACE and stores the required size in out_written, + * including zero when the stream is at ground. If a non-NULL buffer is too + * small, the function has the same result and reports the full required size. + * Continuation tracking must have been enabled by setting + * GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + * input that produced the continuation was written. + * + * The caller must serialize this operation with all other access to the same + * terminal. + * + * @param terminal Terminal to read from (must not be NULL) + * @param buf Destination buffer, or NULL when buf_len is zero + * @param buf_len Destination buffer capacity in bytes + * @param[out] out_written Bytes written, or required size on + * GHOSTTY_OUT_OF_SPACE (must not be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE for a size query or + * insufficient buffer, or GHOSTTY_INVALID_VALUE if an argument is + * invalid, tracking is disabled, or the current continuation is + * unavailable + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_continuation_buf( + GhosttyTerminal terminal, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Return an allocated copy of the terminal's replay-safe VT continuation. + * + * The returned bytes are allocated with allocator, or the default allocator + * when allocator is NULL. The caller must release them with ghostty_free(), + * passing the same allocator and returned length. An empty continuation is a + * successful zero-length allocation. + * Continuation tracking must have been enabled by setting + * GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + * input that produced the continuation was written. + * + * The caller must serialize this operation with all other access to the same + * terminal. + * + * @param terminal Terminal to read from (must not be NULL) + * @param allocator Allocator for the output, or NULL for the default allocator + * @param[out] out_ptr Allocated continuation bytes (must not be NULL) + * @param[out] out_len Number of continuation bytes (must not be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure, or GHOSTTY_INVALID_VALUE if an argument is invalid, + * tracking is disabled, or the current continuation is unavailable + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_continuation_alloc( + GhosttyTerminal terminal, + const GhosttyAllocator* allocator, + uint8_t** out_ptr, + size_t* out_len); + /** * Scroll the terminal viewport. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 214d28229..672faa67c 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -82,6 +82,10 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OUT_OF_SPACE = -3, /** The requested value has no value */ GHOSTTY_NO_VALUE = -4, + /** Operation failed while reading from or writing to external I/O */ + GHOSTTY_IO_ERROR = -5, + /** Operation failed because encoded input exceeded a configured limit */ + GHOSTTY_LIMIT_EXCEEDED = -6, GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyResult; @@ -94,6 +98,13 @@ typedef enum GHOSTTY_ENUM_TYPED { */ typedef struct GhosttyTerminalImpl* GhosttyTerminal; +/** + * Opaque handle to an incremental terminal snapshot decoder. + * + * @ingroup snapshot + */ +typedef struct GhosttySnapshotDecoderImpl* GhosttySnapshotDecoder; + /** * Opaque handle to a tracked grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 0d9fda7c6..58be6b3e6 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -288,6 +288,9 @@ comptime { @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); + @export(&c.terminal_continuation_write, .{ .name = "ghostty_terminal_continuation_write" }); + @export(&c.terminal_continuation_buf, .{ .name = "ghostty_terminal_continuation_buf" }); + @export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" }); @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); @@ -310,6 +313,18 @@ comptime { @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); + @export(&c.snapshot_encode, .{ .name = "ghostty_snapshot_encode" }); + @export(&c.snapshot_encode_buf, .{ .name = "ghostty_snapshot_encode_buf" }); + @export(&c.snapshot_encode_alloc, .{ .name = "ghostty_snapshot_encode_alloc" }); + @export(&c.snapshot_decoder_new, .{ .name = "ghostty_snapshot_decoder_new" }); + @export(&c.snapshot_decoder_new_buf, .{ .name = "ghostty_snapshot_decoder_new_buf" }); + @export(&c.snapshot_decoder_free, .{ .name = "ghostty_snapshot_decoder_free" }); + @export(&c.snapshot_decoder_set, .{ .name = "ghostty_snapshot_decoder_set" }); + @export(&c.snapshot_decoder_get, .{ .name = "ghostty_snapshot_decoder_get" }); + @export(&c.snapshot_decoder_get_multi, .{ .name = "ghostty_snapshot_decoder_get_multi" }); + @export(&c.snapshot_decoder_ready, .{ .name = "ghostty_snapshot_decoder_ready" }); + @export(&c.snapshot_decoder_next, .{ .name = "ghostty_snapshot_decoder_next" }); + @export(&c.snapshot_decoder_decode, .{ .name = "ghostty_snapshot_decoder_decode" }); @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 121f906a0..3029fbbc1 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -248,6 +248,17 @@ pub const Protocol = enum { .glyph => 1 * 1024 * 1024, }; } + + /// Return the largest default buffer limit across every APC protocol. + /// Consumers that must retain any unfinished APC can derive their limit + /// here instead of duplicating a particular protocol's current default. + pub fn maxDefaultBytes() usize { + var result: usize = 0; + for (std.enums.values(Protocol)) |protocol| { + result = @max(result, protocol.defaultMaxBytes()); + } + return result; + } }; /// Possible APC commands. diff --git a/src/terminal/c/io.zig b/src/terminal/c/io.zig new file mode 100644 index 000000000..014935dc0 --- /dev/null +++ b/src/terminal/c/io.zig @@ -0,0 +1,594 @@ +//! C byte-stream callbacks and adapters for `std.Io`. +//! +//! The public callbacks deliberately expose a smaller contract than Zig's I/O +//! interfaces: reads are synchronous progress/EOF/error, and writes accept an +//! entire slice or fail. These adapters translate that contract while keeping +//! enough state to map failures back to distinct C result codes. + +const std = @import("std"); +const assert = std.debug.assert; +const lib = @import("../lib.zig"); + +/// C: GhosttyReaderFn +pub const ReaderFn = *const fn ( + userdata: ?*anyopaque, + buffer: [*]u8, + capacity: usize, + out_read: *usize, +) callconv(lib.calling_conv) bool; + +/// C: GhosttyWriterFn +pub const WriterFn = *const fn ( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, +) callconv(lib.calling_conv) bool; + +/// C: GhosttyReader +pub const Reader = extern struct { + read: ?ReaderFn = null, + userdata: ?*anyopaque = null, + + pub fn valid(self: Reader) bool { + return self.read != null; + } +}; + +/// C: GhosttyWriter +pub const Writer = extern struct { + write: ?WriterFn = null, + userdata: ?*anyopaque = null, + + pub fn valid(self: Writer) bool { + return self.write != null; + } +}; + +/// Adapts a `GhosttyReader` to `std.Io.Reader`. +/// +/// The adapter must have a stable address while `interface` is in use. Its +/// interface starts unbuffered so `init` can safely return it by value. A +/// one-byte buffer is attached lazily when an operation such as `peekByte` +/// requires buffering. This also prevents reads beyond a requested framing +/// boundary. +pub const ReaderAdapter = struct { + /// Copy of the C callback pair. The pointed-to userdata remains borrowed. + source: Reader, + + /// Zig-facing interface. Its vtable recovers this enclosing adapter with + /// `@fieldParentPtr`, which is why the adapter's address must remain stable. + interface: std.Io.Reader, + + /// Bytes successfully obtained from the callback. + offset: usize = 0, + + /// The callback returned false. This distinguishes an I/O failure from + /// the callback returning true with zero bytes at end-of-file. + callback_failed: bool = false, + + /// The callback was NULL, returned too many bytes, or overflowed offset. + invalid_read: bool = false, + + /// The callback returned true with zero bytes. EOF is permanent. + eof: bool = false, + + /// `std.Io.Reader` needs backing storage for `peekByte`. One byte is + /// intentional: it satisfies that operation without allowing the adapter + /// to pull a second byte across a snapshot checkpoint or FINISH boundary. + peek_buffer: [1]u8 = undefined, + + /// Scratch space used only when the destination writer has no writable + /// buffer of its own. 4 KiB amortizes callback overhead for streaming + /// operations while keeping every adapter's fixed allocation modest. The + /// caller's `limit` always truncates this slice, so the size cannot cause + /// framing read-ahead. + transfer_buffer: [4096]u8 = undefined, + + pub fn init(source: Reader) ReaderAdapter { + return .{ + .source = source, + .interface = .{ + .vtable = &.{ + .stream = stream, + .rebase = rebase, + }, + // `rebase` attaches peek_buffer after the adapter reaches its + // stable destination address. + .buffer = &.{}, + .seek = 0, + .end = 0, + }, + }; + } + + pub fn reader(self: *ReaderAdapter) *std.Io.Reader { + // Calling this only after the adapter reaches its final address makes + // the parent-pointer recovery in `stream` and `rebase` valid. + return &self.interface; + } + + /// Perform exactly one C callback invocation into caller-selected storage. + /// This is the single place that interprets the public callback contract. + fn readInto(self: *ReaderAdapter, destination: []u8) std.Io.Reader.Error!usize { + assert(destination.len > 0); + + // Failure and EOF are sticky. In particular, a callback that reports + // EOF is never polled again because the C API defines it as permanent. + if (self.callback_failed or self.invalid_read) return error.ReadFailed; + if (self.eof) return error.EndOfStream; + + // A missing callback is an invalid C value rather than external I/O + // failure, so record it separately for the public result mapper. + const read_fn = self.source.read orelse { + self.invalid_read = true; + return error.ReadFailed; + }; + + // Initialize this ourselves: a callback returning false is not + // required to initialize out_read. + var read_len: usize = 0; + if (!read_fn( + self.source.userdata, + destination.ptr, + destination.len, + &read_len, + )) { + self.callback_failed = true; + return error.ReadFailed; + } + + // Treat a callback that violates the capacity contract as an invalid + // reader without constructing an out-of-bounds slice. + if (read_len > destination.len) { + self.invalid_read = true; + return error.ReadFailed; + } + // Successful zero-length reads are the C representation of EOF. + if (read_len == 0) { + self.eof = true; + return error.EndOfStream; + } + + // Offset counts only bytes actually supplied by successful callbacks. + // Saturating or wrapping would make SOURCE_OFFSET untrustworthy. + self.offset = std.math.add(usize, self.offset, read_len) catch { + self.invalid_read = true; + return error.ReadFailed; + }; + return read_len; + } + + /// `stream` is the fundamental read primitive in `std.Io.Reader`'s + /// vtable. It moves at most `limit` bytes from the C source to the Zig + /// writer and reports the number transferred. Higher-level operations + /// such as `readSliceAll` and `peekByte` are implemented in terms of it. + fn stream( + reader_: *std.Io.Reader, + writer: *std.Io.Writer, + limit: std.Io.Limit, + ) std.Io.Reader.StreamError!usize { + // A zero limit is a successful no-op and must not invoke user code. + if (limit == .nothing) return 0; + + // The std.Io callback receives only the embedded interface pointer. + // Recover the adapter to reach the C callback and accounting flags. + const self: *ReaderAdapter = @alignCast(@fieldParentPtr( + "interface", + reader_, + )); + + // Use the destination's storage directly when possible. This is the + // normal path for readSlice operations and for filling peek_buffer. + const direct = limit.slice(writer.unusedCapacitySlice()); + if (direct.len > 0) { + const read_len = try self.readInto(direct); + // `readInto` initialized these bytes outside the Writer API, so + // explicitly publish them to the destination. + writer.advance(read_len); + return read_len; + } + + // An unbuffered Writer has no direct destination storage. Read into + // bounded adapter storage and then let its drain implementation take + // ownership of the complete slice. + const transfer = limit.slice(&self.transfer_buffer); + assert(transfer.len > 0); + const read_len = try self.readInto(transfer); + // writeAll either transfers this complete callback result or reports + // failure; returning a partial count would violate stream's contract. + try writer.writeAll(transfer[0..read_len]); + return read_len; + } + + /// Give buffered reader operations contiguous storage on first demand. + /// Initialization cannot point at `peek_buffer` because `init` returns the + /// adapter by value and its final address is not known until afterward. + fn rebase( + reader_: *std.Io.Reader, + capacity: usize, + ) std.Io.Reader.RebaseError!void { + const self: *ReaderAdapter = @alignCast(@fieldParentPtr( + "interface", + reader_, + )); + + if (reader_.buffer.len == 0) { + // No operation could have buffered bytes before storage existed. + assert(reader_.seek == 0); + assert(reader_.end == 0); + reader_.buffer = &self.peek_buffer; + } + + // Like other std.Io.Reader implementations, contiguous peek capacity + // is limited by the reader's declared buffer capacity. + assert(capacity <= reader_.buffer.len); + // Avoid the default memmove when the unread tail already has room. + if (reader_.buffer.len - reader_.seek >= capacity) return; + return std.Io.Reader.defaultRebase(reader_, capacity); + } +}; + +/// Adapts a `GhosttyWriter` to an unbuffered `std.Io.Writer`. +/// +/// Keeping the Zig writer unbuffered ensures success means the C callback has +/// already accepted every byte; there is no hidden flush step at API return. +pub const WriterAdapter = struct { + /// Copy of the C callback pair. The pointed-to userdata remains borrowed. + destination: Writer, + + /// Zig-facing interface whose drain vtable points back to this adapter. + interface: std.Io.Writer, + + /// Bytes accepted by successful callback invocations. + offset: usize = 0, + + /// The callback returned false. + callback_failed: bool = false, + + /// The callback was NULL or offset accounting overflowed. + invalid_write: bool = false, + + pub fn init(destination: Writer) WriterAdapter { + return .{ + .destination = destination, + .interface = .{ + // With no buffer, every Zig write reaches `drain` immediately. + .vtable = &.{ .drain = drain }, + .buffer = &.{}, + .end = 0, + }, + }; + } + + pub fn writer(self: *WriterAdapter) *std.Io.Writer { + // As with ReaderAdapter, the returned interface borrows this stable + // enclosing address for parent-pointer recovery in the vtable. + return &self.interface; + } + + /// Submit one ordinary slice through the all-or-nothing C write callback. + fn writeAll(self: *WriterAdapter, data: []const u8) std.Io.Writer.Error!void { + // Empty writes carry no information and should not call foreign code. + if (data.len == 0) return; + + // Make failures sticky so a higher-level Zig retry cannot produce a + // misleading second callback after the output is already partial. + if (self.callback_failed or self.invalid_write) return error.WriteFailed; + + // Missing callbacks are invalid arguments; false callback returns are + // external I/O errors. Preserve that distinction for the C wrapper. + const write_fn = self.destination.write orelse { + self.invalid_write = true; + return error.WriteFailed; + }; + if (!write_fn(self.destination.userdata, data.ptr, data.len)) { + self.callback_failed = true; + return error.WriteFailed; + } + + // Count bytes only after the callback accepts the complete slice. + self.offset = std.math.add(usize, self.offset, data.len) catch { + self.invalid_write = true; + return error.WriteFailed; + }; + } + + /// Implement the sole primitive required by an unbuffered std.Io.Writer. + /// + /// Zig represents a vector write as ordinary slices followed by the last + /// slice repeated `splat` times. The C callback has no vector form, so this + /// method expands that representation into ordered all-or-nothing calls + /// and returns the total logical byte count consumed. + fn drain( + writer_: *std.Io.Writer, + data: []const []const u8, + splat: usize, + ) std.Io.Writer.Error!usize { + assert(data.len > 0); // The final element is always the splat pattern. + assert(writer_.end == 0); // This adapter intentionally has no buffer. + + // The vtable receives the embedded Writer rather than our adapter. + const self: *WriterAdapter = @alignCast(@fieldParentPtr( + "interface", + writer_, + )); + + var consumed: usize = 0; + // Every element except the last is written exactly once. + for (data[0 .. data.len - 1]) |slice| { + try self.writeAll(slice); + consumed = std.math.add(usize, consumed, slice.len) catch { + self.invalid_write = true; + return error.WriteFailed; + }; + } + + // std.Io uses the final element as a compact repeated pattern. A + // splat of zero means the final element is not part of this write. + const pattern = data[data.len - 1]; + for (0..splat) |_| { + try self.writeAll(pattern); + consumed = std.math.add(usize, consumed, pattern.len) catch { + self.invalid_write = true; + return error.WriteFailed; + }; + } + return consumed; + } +}; + +test "C reader and writer layouts keep callback first" { + try std.testing.expectEqual(@as(usize, 0), @offsetOf(Reader, "read")); + try std.testing.expectEqual(@sizeOf(?ReaderFn), @offsetOf(Reader, "userdata")); + try std.testing.expectEqual(@sizeOf(?ReaderFn) + @sizeOf(?*anyopaque), @sizeOf(Reader)); + + try std.testing.expectEqual(@as(usize, 0), @offsetOf(Writer, "write")); + try std.testing.expectEqual(@sizeOf(?WriterFn), @offsetOf(Writer, "userdata")); + try std.testing.expectEqual(@sizeOf(?WriterFn) + @sizeOf(?*anyopaque), @sizeOf(Writer)); +} + +test "ReaderAdapter supports short reads and permanent EOF" { + const Context = struct { + data: []const u8, + offset: usize = 0, + calls: usize = 0, + + fn read( + userdata: ?*anyopaque, + buffer: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + self.calls += 1; + const remaining = self.data[self.offset..]; + const len = @min(remaining.len, capacity, 2); + @memcpy(buffer[0..len], remaining[0..len]); + self.offset += len; + out_read.* = len; + return true; + } + }; + + var context: Context = .{ .data = "abcdef" }; + var adapter: ReaderAdapter = .init(.{ + .read = &Context.read, + .userdata = &context, + }); + + var actual: [6]u8 = undefined; + try adapter.interface.readSliceAll(&actual); + try std.testing.expectEqualStrings("abcdef", &actual); + try std.testing.expectEqual(@as(usize, 6), adapter.offset); + try std.testing.expect(!adapter.eof); + + try std.testing.expectError(error.EndOfStream, adapter.interface.takeByte()); + try std.testing.expect(adapter.eof); + try std.testing.expect(!adapter.callback_failed); + + const calls_at_eof = context.calls; + try std.testing.expectError(error.EndOfStream, adapter.interface.takeByte()); + try std.testing.expectEqual(calls_at_eof, context.calls); +} + +test "ReaderAdapter peek reads only the requested byte" { + const Context = struct { + data: []const u8, + offset: usize = 0, + last_capacity: usize = 0, + + fn read( + userdata: ?*anyopaque, + buffer: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + self.last_capacity = capacity; + const remaining = self.data[self.offset..]; + const len = @min(remaining.len, capacity); + @memcpy(buffer[0..len], remaining[0..len]); + self.offset += len; + out_read.* = len; + return true; + } + }; + + var context: Context = .{ .data = "ab" }; + var adapter: ReaderAdapter = .init(.{ + .read = &Context.read, + .userdata = &context, + }); + + try std.testing.expectEqual(@as(u8, 'a'), try adapter.interface.peekByte()); + try std.testing.expectEqual(@as(usize, 1), context.last_capacity); + try std.testing.expectEqual(@as(usize, 1), adapter.offset); + try std.testing.expectEqual(@as(u8, 'a'), try adapter.interface.takeByte()); + try std.testing.expectEqual(@as(usize, 1), adapter.offset); +} + +test "ReaderAdapter distinguishes callback failure and invalid length" { + const Failing = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + _: usize, + _: *usize, + ) callconv(lib.calling_conv) bool { + return false; + } + }; + var failing: ReaderAdapter = .init(.{ .read = &Failing.read }); + try std.testing.expectError(error.ReadFailed, failing.interface.takeByte()); + try std.testing.expect(failing.callback_failed); + try std.testing.expect(!failing.eof); + try std.testing.expect(!failing.invalid_read); + + const Oversized = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + out_read.* = capacity + 1; + return true; + } + }; + var oversized: ReaderAdapter = .init(.{ .read = &Oversized.read }); + try std.testing.expectError(error.ReadFailed, oversized.interface.takeByte()); + try std.testing.expect(!oversized.callback_failed); + try std.testing.expect(!oversized.eof); + try std.testing.expect(oversized.invalid_read); +} + +test "ReaderAdapter rejects a null callback" { + var adapter: ReaderAdapter = .init(.{}); + try std.testing.expectError(error.ReadFailed, adapter.interface.takeByte()); + try std.testing.expect(adapter.invalid_read); + try std.testing.expect(!adapter.callback_failed); +} + +test "WriterAdapter writes vectors and splats" { + const Context = struct { + data: [32]u8 = undefined, + len: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + @memcpy(self.data[self.len..][0..len], data[0..len]); + self.len += len; + return true; + } + }; + + var context: Context = .{}; + var adapter: WriterAdapter = .init(.{ + .write = &Context.write, + .userdata = &context, + }); + + var vectors: [2][]const u8 = .{ "ab", "cd" }; + try adapter.interface.writeVecAll(&vectors); + var repeated: [1][]const u8 = .{"!"}; + try adapter.interface.writeSplatAll(&repeated, 2); + + try std.testing.expectEqualStrings("abcd!!", context.data[0..context.len]); + try std.testing.expectEqual(@as(usize, 6), adapter.offset); + try std.testing.expect(!adapter.callback_failed); +} + +test "WriterAdapter makes callback failure sticky" { + const Context = struct { + calls: usize = 0, + + fn write( + userdata: ?*anyopaque, + _: [*]const u8, + _: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + self.calls += 1; + return false; + } + }; + + var context: Context = .{}; + var adapter: WriterAdapter = .init(.{ + .write = &Context.write, + .userdata = &context, + }); + try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("x")); + try std.testing.expect(adapter.callback_failed); + try std.testing.expectEqual(@as(usize, 0), adapter.offset); + + try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("y")); + try std.testing.expectEqual(@as(usize, 1), context.calls); +} + +test "WriterAdapter rejects a null callback" { + var adapter: WriterAdapter = .init(.{}); + try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("x")); + try std.testing.expect(adapter.invalid_write); + try std.testing.expect(!adapter.callback_failed); +} + +test "ReaderAdapter streams into an unbuffered WriterAdapter" { + const ReadContext = struct { + data: []const u8, + offset: usize = 0, + + fn read( + userdata: ?*anyopaque, + buffer: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + const remaining = self.data[self.offset..]; + const len = @min(remaining.len, capacity); + @memcpy(buffer[0..len], remaining[0..len]); + self.offset += len; + out_read.* = len; + return true; + } + }; + const WriteContext = struct { + data: [8]u8 = undefined, + len: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + @memcpy(self.data[self.len..][0..len], data[0..len]); + self.len += len; + return true; + } + }; + + var read_context: ReadContext = .{ .data = "stream" }; + var reader: ReaderAdapter = .init(.{ + .read = &ReadContext.read, + .userdata = &read_context, + }); + var write_context: WriteContext = .{}; + var writer: WriterAdapter = .init(.{ + .write = &WriteContext.write, + .userdata = &write_context, + }); + + try reader.interface.streamExact(&writer.interface, "stream".len); + try std.testing.expectEqualStrings( + "stream", + write_context.data[0..write_context.len], + ); + try std.testing.expectEqual(@as(usize, "stream".len), reader.offset); + try std.testing.expectEqual(@as(usize, "stream".len), writer.offset); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 37dc57684..a217cffc7 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -10,6 +10,7 @@ pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); pub const grid_ref_tracked = @import("grid_ref_tracked.zig"); +pub const io = @import("io.zig"); pub const kitty_graphics = @import("kitty_graphics.zig"); pub const kitty_graphics_get = kitty_graphics.get; pub const kitty_graphics_image = kitty_graphics.image_get_handle; @@ -41,6 +42,7 @@ pub const paste = @import("paste.zig"); pub const row = @import("row.zig"); pub const sgr = @import("sgr.zig"); pub const size_report = @import("size_report.zig"); +pub const snapshot = @import("snapshot.zig"); pub const style = @import("style.zig"); pub const sys = @import("sys.zig"); pub const terminal = @import("terminal.zig"); @@ -189,6 +191,9 @@ pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; +pub const terminal_continuation_write = terminal.continuation_write; +pub const terminal_continuation_buf = terminal.continuation_buf; +pub const terminal_continuation_alloc = terminal.continuation_alloc; pub const terminal_select_word = selection.word; pub const terminal_select_word_between = selection.word_between; pub const terminal_select_line = selection.line; @@ -214,6 +219,19 @@ pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; +pub const snapshot_encode = snapshot.encode; +pub const snapshot_encode_buf = snapshot.encode_buf; +pub const snapshot_encode_alloc = snapshot.encode_alloc; +pub const snapshot_decoder_new = snapshot.decoder_new; +pub const snapshot_decoder_new_buf = snapshot.decoder_new_buf; +pub const snapshot_decoder_free = snapshot.decoder_free; +pub const snapshot_decoder_set = snapshot.decoder_set; +pub const snapshot_decoder_get = snapshot.decoder_get; +pub const snapshot_decoder_get_multi = snapshot.decoder_get_multi; +pub const snapshot_decoder_ready = snapshot.decoder_ready; +pub const snapshot_decoder_next = snapshot.decoder_next; +pub const snapshot_decoder_decode = snapshot.decoder_decode; + pub const type_json = types.get_json; pub const unicode_codepoint_width = unicode.codepoint_width; @@ -238,6 +256,7 @@ test { _ = color_scheme; _ = grid_ref; _ = grid_ref_tracked; + _ = io; _ = kitty_graphics; _ = row; _ = focus; @@ -254,6 +273,7 @@ test { _ = paste; _ = sgr; _ = size_report; + _ = snapshot; _ = style; _ = sys; _ = terminal; diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index 328663285..a127d4386 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -5,4 +5,6 @@ pub const Result = enum(c_int) { invalid_value = -2, out_of_space = -3, no_value = -4, + io_error = -5, + limit_exceeded = -6, }; diff --git a/src/terminal/c/snapshot.zig b/src/terminal/c/snapshot.zig new file mode 100644 index 000000000..938f4273a --- /dev/null +++ b/src/terminal/c/snapshot.zig @@ -0,0 +1,1253 @@ +//! C ABI wrappers for snapshot encoding and incremental restoration. + +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const io_c = @import("io.zig"); +const Result = @import("result.zig").Result; +const snapshot_core = @import("../snapshot/main.zig"); +const apc = @import("../apc.zig"); + +/// Snapshot readers accept the largest default APC payload. Deriving this from +/// the APC protocols keeps snapshot validation in sync as protocols change. +/// This remains independent of opt-in terminal-side continuation tracking. +const default_max_continuation_bytes = apc.Protocol.maxDefaultBytes(); +const terminal_c = @import("terminal.zig"); + +/// C: GhosttySnapshotDecoderOption. +/// +/// Options configure validation policy before snapshot decoding begins. +pub const DecoderOption = enum(c_int) { + max_continuation_bytes = 0, + _, +}; + +/// C: GhosttySnapshotDecoderData. +/// +/// Data keys select typed values exposed by the decoder getter APIs. +pub const DecoderData = enum(c_int) { + invalid = 0, + max_continuation_bytes = 1, + source_offset = 2, + history_rows_primary = 3, + history_rows_alternate = 4, + progress_screen = 5, + progress_rows = 6, + progress_remaining = 7, + _, + + /// Return the Zig type stored through the C API output pointer for a key. + fn OutType(comptime self: DecoderData) type { + return switch (self) { + .invalid => void, + .max_continuation_bytes, .source_offset => usize, + .history_rows_primary, .history_rows_alternate => u64, + .progress_screen => terminal_c.TerminalScreen, + .progress_rows => usize, + .progress_remaining => u32, + _ => void, + }; + } +}; + +/// Owns the source adapter and all state needed by an opaque C decoder handle. +const DecoderWrapper = struct { + /// A callback or borrowed-buffer source with uniform reader access. + const Source = union(enum) { + callback: io_c.ReaderAdapter, + buffer: std.Io.Reader, + + /// Return the stable `std.Io.Reader` used by the snapshot decoder. + fn reader(self: *Source) *std.Io.Reader { + return switch (self.*) { + .callback => |*value| &value.interface, + .buffer => |*value| value, + }; + } + + /// Return the number of source bytes consumed without reading ahead. + fn offset(self: *const Source) usize { + return switch (self.*) { + .callback => |*value| value.offset, + .buffer => |*value| value.seek, + }; + } + }; + + /// READY metadata that remains queryable through FINISH or a later error. + const Metadata = struct { + history_rows: std.EnumMap(terminal_c.TerminalScreen, u64), + }; + + /// Decoder lifecycle and the data available in each phase. + const State = union(enum) { + configuring, + + history: struct { + terminal: terminal_c.Terminal, + metadata: Metadata, + progress: ?snapshot_core.Decoder.Progress = null, + }, + + finished: Metadata, + + /// READY failures have no metadata. Failures from `next` retain the + /// READY metadata even though decoding cannot continue. + failed: ?Metadata, + }; + + alloc: std.mem.Allocator, + source: Source, + decoder: snapshot_core.Decoder, + state: State, + max_continuation_bytes: usize, +}; + +/// C: GhosttySnapshotDecoder, an opaque nullable decoder handle. +pub const Decoder = ?*DecoderWrapper; + +/// Create a decoder backed by a synchronous C reader callback. +pub fn decoder_new( + alloc_: ?*const CAllocator, + out_: ?*Decoder, + reader: io_c.Reader, +) callconv(lib.calling_conv) Result { + // Validate C-owned output and callback pointers before allocating anything. + const out = out_ orelse return .invalid_value; + out.* = null; + if (reader.read == null) return .invalid_value; + + // Store the adapter inline so its std.Io.Reader has a stable address. + return decoderNewSource(alloc_, out, .{ + .callback = io_c.ReaderAdapter.init(reader), + }); +} + +/// Create a decoder backed by immutable, caller-owned bytes. +pub fn decoder_new_buf( + alloc_: ?*const CAllocator, + out_: ?*Decoder, + ptr: ?[*]const u8, + len: usize, +) callconv(lib.calling_conv) Result { + // Normalize the C pointer and length into a borrowed Zig slice. + const out = out_ orelse return .invalid_value; + out.* = null; + const bytes: []const u8 = if (ptr) |value| + value[0..len] + else if (len == 0) + &.{} + else + return .invalid_value; + + // The fixed reader records its current seek position as the source offset. + return decoderNewSource(alloc_, out, .{ + .buffer = .fixed(bytes), + }); +} + +/// Allocate and initialize a decoder around either supported source type. +fn decoderNewSource( + alloc_: ?*const CAllocator, + out: *Decoder, + source: DecoderWrapper.Source, +) Result { + // Allocate first so the source and its embedded reader never move again. + const alloc = lib.alloc.default(alloc_); + const wrapper = alloc.create(DecoderWrapper) catch return .out_of_memory; + errdefer alloc.destroy(wrapper); + + // Initialize the core decoder only after its reader has a stable address. + wrapper.* = undefined; + wrapper.alloc = alloc; + wrapper.source = source; + wrapper.state = .configuring; + wrapper.max_continuation_bytes = default_max_continuation_bytes; + wrapper.decoder = .init(wrapper.source.reader()); + out.* = wrapper; + return .success; +} + +/// Destroy a decoder without taking ownership of any returned terminal. +pub fn decoder_free(decoder_: Decoder) callconv(lib.calling_conv) void { + const decoder = decoder_ orelse return; + const alloc = decoder.alloc; + alloc.destroy(decoder); +} + +/// Set a decoder option while the decoder is still configuring. +pub fn decoder_set( + decoder_: Decoder, + option: DecoderOption, + value_: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + // Options freeze as soon as any decoding attempt starts. + const decoder = decoder_ orelse return .invalid_value; + switch (decoder.state) { + .configuring => {}, + else => return .invalid_value, + } + + // Interpret the erased C pointer according to the selected option. + const value = value_ orelse return .invalid_value; + switch (option) { + .max_continuation_bytes => decoder.max_continuation_bytes = + @as(*const usize, @ptrCast(@alignCast(value))).*, + _ => return .invalid_value, + } + return .success; +} + +/// Read one typed decoder value through the type-erased C API. +pub fn decoder_get( + decoder_: Decoder, + data: DecoderData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + // Enumerate known keys so each branch recovers the correct output type. + return switch (data) { + inline .invalid, + .max_continuation_bytes, + .source_offset, + .history_rows_primary, + .history_rows_alternate, + .progress_screen, + .progress_rows, + .progress_remaining, + => |comptime_data| decoderGetTyped( + decoder_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + _ => .invalid_value, + }; +} + +/// Implement a getter after the key has selected its concrete output type. +fn decoderGetTyped( + decoder_: Decoder, + comptime data: DecoderData, + out: *data.OutType(), +) Result { + const decoder = decoder_ orelse return .invalid_value; + switch (data) { + // Decoder-wide configuration and source state. + .invalid => return .invalid_value, + .max_continuation_bytes => { + if (decoderFailed(decoder)) return .no_value; + out.* = decoder.max_continuation_bytes; + }, + .source_offset => { + if (decoderFailed(decoder)) return .no_value; + out.* = decoder.source.offset(); + }, + + // READY metadata remains available after history decoding completes. + .history_rows_primary => { + const metadata = decoderMetadata(decoder) orelse return .no_value; + out.* = metadata.history_rows.get(.primary) orelse return .no_value; + }, + .history_rows_alternate => { + const metadata = decoderMetadata(decoder) orelse return .no_value; + out.* = metadata.history_rows.get(.alternate) orelse + return .no_value; + }, + + // Progress describes only the most recent successful `next` call. + .progress_screen => { + const progress = decoderProgress(decoder) orelse return .no_value; + out.* = progress.key; + }, + .progress_rows => { + const progress = decoderProgress(decoder) orelse return .no_value; + out.* = progress.rows; + }, + .progress_remaining => { + const progress = decoderProgress(decoder) orelse return .no_value; + out.* = progress.remaining; + }, + else => return .invalid_value, + } + return .success; +} + +/// Return whether a consuming operation has permanently failed. +fn decoderFailed(decoder: *const DecoderWrapper) bool { + return switch (decoder.state) { + .failed => true, + else => false, + }; +} + +/// Return READY metadata in every state where it remains meaningful. +fn decoderMetadata( + decoder: *const DecoderWrapper, +) ?DecoderWrapper.Metadata { + return switch (decoder.state) { + .history => |value| value.metadata, + .finished => |value| value, + .failed => |value| value, + .configuring => null, + }; +} + +/// Return page progress only during incremental history decoding. +fn decoderProgress( + decoder: *const DecoderWrapper, +) ?snapshot_core.Decoder.Progress { + return switch (decoder.state) { + .history => |value| value.progress, + else => null, + }; +} + +/// Query several typed decoder values in order, stopping at the first error. +pub fn decoder_get_multi( + decoder_: Decoder, + count: usize, + keys_: ?[*]const DecoderData, + values_: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + // Invalid arrays and first-key failures both report zero completed writes. + if (out_written) |written| written.* = 0; + const keys = keys_ orelse return .invalid_value; + const values = values_ orelse return .invalid_value; + + // Reuse the single-key path so availability rules remain identical. + for (0..count) |i| { + const result = decoder_get(decoder_, keys[i], values[i]); + if (result != .success) { + if (out_written) |written| written.* = i; + return result; + } + } + if (out_written) |written| written.* = count; + return .success; +} + +/// Decode through READY and return a renderable terminal for incremental use. +pub fn decoder_ready( + decoder_: Decoder, + out_: ?*terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + // Validate the output and require an untouched decoder lifecycle. + const out = out_ orelse return .invalid_value; + out.* = null; + const decoder = decoder_ orelse return .invalid_value; + switch (decoder.state) { + .configuring => {}, + else => return .invalid_value, + } + + // Decode READY and transfer the decoded state into a C-owned terminal. + const ready = decoderReadyTerminal(decoder) catch |err| { + decoder.state = .{ .failed = null }; + return decoderMapError(decoder, err); + }; + + // Keep only the terminal handle and queryable metadata for page decoding. + decoder.state = .{ .history = .{ + .terminal = ready.terminal, + .metadata = ready.metadata, + } }; + out.* = ready.terminal; + return .success; +} + +/// Decode and apply exactly one history page, or authenticate FINISH. +pub fn decoder_next( + decoder_: Decoder, +) callconv(lib.calling_conv) Result { + // FINISH is idempotent; every other non-history state rejects `next`. + const decoder = decoder_ orelse return .invalid_value; + switch (decoder.state) { + .finished => return .no_value, + .history => {}, + else => return .invalid_value, + } + + // Resolve the terminal retained by READY and clear stale page progress. + const history = &decoder.state.history; + const terminal = history.terminal orelse return .invalid_value; + const native = terminal_c.zigTerminal(terminal).?; + history.progress = null; + + // Consume one authenticated record and preserve READY metadata on failure. + const progress = decoder.decoder.next(decoder.alloc, native) catch |err| { + const metadata = history.metadata; + decoder.state = .{ .failed = metadata }; + return decoderMapError(decoder, err); + }; + + // Publish page progress, or transition permanently to authenticated FINISH. + if (progress) |value| { + history.progress = value; + return .success; + } + + const metadata = history.metadata; + decoder.state = .{ .finished = metadata }; + return .no_value; +} + +/// Decode and authenticate the complete snapshot transactionally. +pub fn decoder_decode( + decoder_: Decoder, + out_: ?*terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + // Validate the output and require an untouched decoder lifecycle. + const out = out_ orelse return .invalid_value; + out.* = null; + const decoder = decoder_ orelse return .invalid_value; + switch (decoder.state) { + .configuring => {}, + else => return .invalid_value, + } + + // Build the terminal from the renderable READY prefix. + const ready = decoderReadyTerminal(decoder) catch |err| { + decoder.state = .{ .failed = null }; + return decoderMapError(decoder, err); + }; + const native = terminal_c.zigTerminal(ready.terminal).?; + + // Apply every history page and require an authenticated FINISH record. + while (true) { + const progress = decoder.decoder.next(decoder.alloc, native) catch |err| { + terminal_c.free(ready.terminal); + decoder.state = .{ .failed = ready.metadata }; + return decoderMapError(decoder, err); + }; + if (progress == null) break; + } + + // Publish the terminal only after the entire snapshot succeeds. + decoder.state = .{ .finished = ready.metadata }; + out.* = ready.terminal; + return .success; +} + +/// Values produced while moving a core READY result into a C terminal. +const ReadyTerminal = struct { + terminal: terminal_c.Terminal, + metadata: DecoderWrapper.Metadata, +}; + +/// Decode READY, create terminal-owned I/O, and construct the C terminal. +fn decoderReadyTerminal(decoder: *DecoderWrapper) anyerror!ReadyTerminal { + // Terminal I/O is intentionally allocated only when decoding begins. + const io = try terminal_c.Io.init(decoder.alloc); + + // Decode READY while the fresh I/O implementation is still locally owned. + var decoded = decoder.decoder.ready( + decoder.alloc, + io.io(), + .{ .max_continuation_bytes = decoder.max_continuation_bytes }, + ) catch |err| { + io.deinit(decoder.alloc); + return err; + }; + defer decoded.deinit(decoder.alloc); + + // Copy small query metadata before `fromDecoded` consumes the core result. + const metadata: DecoderWrapper.Metadata = .{ + .history_rows = decoded.history_rows, + }; + + // Move terminal state and I/O into their final C-owned allocation. + const terminal = try terminal_c.fromDecoded( + decoder.alloc, + io, + &decoded, + ); + return .{ .terminal = terminal, .metadata = metadata }; +} + +/// Map decoder and source-adapter failures to public C result codes. +fn decoderMapError(decoder: *DecoderWrapper, err: anyerror) Result { + // Callback protocol failures are more specific than the core read error. + switch (decoder.source) { + .callback => |adapter| { + if (adapter.invalid_read) return .invalid_value; + if (adapter.callback_failed) return .io_error; + }, + .buffer => {}, + } + + // A successful zero-byte read is clean EOF by contract. Reaching it before + // the required checkpoint therefore means truncated snapshot data, not an + // external I/O failure, and deliberately maps to invalid_value below. + return switch (err) { + error.OutOfMemory => .out_of_memory, + error.ContinuationLimitExceeded => .limit_exceeded, + error.ContinuationDisabled, + error.ContinuationUnavailable, + error.DecoderNotReady, + error.DecoderFailed, + => .invalid_value, + error.InvalidContinuation => .invalid_value, + else => .invalid_value, + }; +} + +/// Export the allocator-owned continuation required by snapshot encoding. +fn continuationOwned(terminal: terminal_c.Terminal) struct { + result: Result, + bytes: []u8, +} { + // The terminal allocator also owns the temporary continuation copy. + const native = terminal_c.zigTerminal(terminal) orelse return .{ + .result = .invalid_value, + .bytes = &.{}, + }; + const alloc = native.gpa(); + + // Snapshotting can prove an untracked ground stream needs no replay bytes; + // only non-ground streams require tracking to have retained their prefix. + const bytes = terminal_c.continuationAllocIo( + terminal, + alloc, + true, + ) catch |err| { + return .{ + .result = switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + error.ContinuationDisabled, + error.ContinuationUnavailable, + => .invalid_value, + }, + .bytes = &.{}, + }; + }; + return .{ .result = .success, .bytes = bytes }; +} + +/// Convert exported bytes to the core snapshot continuation representation. +fn continuationValue(bytes: []const u8) snapshot_core.Continuation { + return if (bytes.len == 0) .ground else .{ .bytes = bytes }; +} + +/// Encode a terminal snapshot to a synchronous C writer callback. +pub fn encode( + terminal: terminal_c.Terminal, + writer: io_c.Writer, +) callconv(lib.calling_conv) Result { + // Validate both handles and preflight the terminal continuation. + const native = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + if (writer.write == null) return .invalid_value; + const continuation = continuationOwned(terminal); + if (continuation.result != .success) return continuation.result; + defer native.gpa().free(continuation.bytes); + + // Stream the snapshot directly through the callback adapter. + var adapter: io_c.WriterAdapter = .init(writer); + snapshot_core.encode( + native.gpa(), + &adapter.interface, + native, + .{ .continuation = continuationValue(continuation.bytes) }, + ) catch |err| return mapEncodeError(err, &adapter); + return .success; +} + +/// Encode a terminal snapshot into a caller-provided fixed buffer. +pub fn encode_buf( + terminal: terminal_c.Terminal, + buf: ?[*]u8, + buf_len: usize, + out_written_: ?*usize, +) callconv(lib.calling_conv) Result { + // Validate output storage and preflight the terminal continuation. + const out_written = out_written_ orelse return .invalid_value; + out_written.* = 0; + if (buf == null and buf_len != 0) return .invalid_value; + const native = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const continuation = continuationOwned(terminal); + if (continuation.result != .success) return continuation.result; + defer native.gpa().free(continuation.bytes); + + // A null size query becomes a zero-capacity fixed writer and follows the + // same WriteFailed counting path as every other undersized destination. + const destination: []u8 = if (buf) |ptr| ptr[0..buf_len] else &.{}; + var writer: std.Io.Writer = .fixed(destination); + snapshot_core.encode( + native.gpa(), + &writer, + native, + .{ .continuation = continuationValue(continuation.bytes) }, + ) catch |err| switch (err) { + error.WriteFailed => { + // The fixed writer may already contain a valid snapshot prefix. + // Repeat only on this failure path to report the exact capacity. + var counter: std.Io.Writer.Discarding = .init(&.{}); + snapshot_core.encode( + native.gpa(), + &counter.writer, + native, + .{ .continuation = continuationValue(continuation.bytes) }, + ) catch |count_err| return mapEncodeError(count_err, null); + out_written.* = std.math.cast(usize, counter.count) orelse + return .limit_exceeded; + return .out_of_space; + }, + else => return mapEncodeError(err, null), + }; + out_written.* = writer.end; + return .success; +} + +/// Encode a terminal snapshot into a newly allocated C-owned buffer. +pub fn encode_alloc( + terminal: terminal_c.Terminal, + alloc_: ?*const CAllocator, + out_ptr_: ?*?[*]u8, + out_len_: ?*usize, +) callconv(lib.calling_conv) Result { + // Initialize outputs before validating terminal state or allocating bytes. + const out_ptr = out_ptr_ orelse return .invalid_value; + const out_len = out_len_ orelse return .invalid_value; + out_ptr.* = null; + out_len.* = 0; + + // Preflight continuation state before building an allocating writer. + const native = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const continuation = continuationOwned(terminal); + if (continuation.result != .success) return continuation.result; + defer native.gpa().free(continuation.bytes); + + // Encode once, then transfer ownership of the writer's completed slice. + const alloc = lib.alloc.default(alloc_); + var writer: std.Io.Writer.Allocating = .init(alloc); + defer writer.deinit(); + snapshot_core.encode( + native.gpa(), + &writer.writer, + native, + .{ .continuation = continuationValue(continuation.bytes) }, + ) catch |err| return mapEncodeError(err, null); + const bytes = writer.toOwnedSlice() catch return .out_of_memory; + out_ptr.* = bytes.ptr; + out_len.* = bytes.len; + return .success; +} + +/// Map core encoder and callback-adapter failures to public result codes. +fn mapEncodeError( + err: anyerror, + adapter: ?*const io_c.WriterAdapter, +) Result { + // Callback validation happens before encoding, so invalid_write can only + // mean output accounting overflow; callback_failed is external I/O. + if (adapter) |value| { + if (value.invalid_write) return .limit_exceeded; + if (value.callback_failed) return .io_error; + } + + // Fixed and allocating writers surface only core allocation or size errors. + return switch (err) { + error.OutOfMemory, error.WriteFailed => .out_of_memory, + error.Overflow, + error.PayloadTooLarge, + error.PageCountOverflow, + error.ScrollbackLimitOverflow, + error.StringTooLong, + => .limit_exceeded, + else => .invalid_value, + }; +} + +/// Enable continuation tracking for tests that encode C terminal snapshots. +fn testEnableContinuation(terminal: terminal_c.Terminal) !void { + const limit: usize = default_max_continuation_bytes; + try testing.expectEqual(Result.success, terminal_c.set( + terminal, + .continuation_max_bytes, + &limit, + )); +} + +test "decoder option and empty source" { + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &decoder, + null, + 0, + )); + defer decoder_free(decoder); + + var limit: usize = 0; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .max_continuation_bytes, + &limit, + )); + try testing.expectEqual(default_max_continuation_bytes, limit); + + limit = 1234; + try testing.expectEqual(Result.success, decoder_set( + decoder, + .max_continuation_bytes, + &limit, + )); + limit = 0; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .max_continuation_bytes, + &limit, + )); + try testing.expectEqual(1234, limit); + + var written: usize = 99; + try testing.expectEqual(Result.invalid_value, decoder_get_multi( + decoder, + 1, + null, + null, + &written, + )); + try testing.expectEqual(@as(usize, 0), written); + + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.invalid_value, decoder_decode( + decoder, + &terminal, + )); + try testing.expectEqual(null, terminal); +} + +test "snapshot decoder defers terminal I/O allocation until READY" { + var failing = testing.FailingAllocator.init(testing.allocator, .{ + // The decoder wrapper is the only allocation performed by new_buf. + // Fail the following allocation, which creates terminal-owned I/O. + .fail_index = 1, + }); + const failing_zig = failing.allocator(); + const failing_c: CAllocator = .fromZig(&failing_zig); + + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &failing_c, + &decoder, + null, + 0, + )); + defer decoder_free(decoder); + + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.out_of_memory, decoder_ready( + decoder, + &terminal, + )); + try testing.expectEqual(null, terminal); + try testing.expectEqual(@as(usize, 0), decoder.?.source.offset()); +} + +test "snapshot C API full round trip restores continuation" { + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 20, + 4, + )); + defer terminal_c.free(source); + try testEnableContinuation(source); + terminal_c.vt_write(source, "hello\x1b[31", "hello\x1b[31".len); + + var encoded_ptr: ?[*]u8 = null; + var encoded_len: usize = 0; + try testing.expectEqual(Result.success, encode_alloc( + source, + &lib.alloc.test_allocator, + &encoded_ptr, + &encoded_len, + )); + const encoded = encoded_ptr.?[0..encoded_len]; + defer lib.alloc.default(&lib.alloc.test_allocator).free(encoded); + + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &decoder, + encoded.ptr, + encoded.len, + )); + defer decoder_free(decoder); + + var restored: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_decode(decoder, &restored)); + defer terminal_c.free(restored); + + const source_text = try terminal_c.zigTerminal(source).?.plainString(testing.allocator); + defer testing.allocator.free(source_text); + const restored_text = try terminal_c.zigTerminal(restored).?.plainString(testing.allocator); + defer testing.allocator.free(restored_text); + try testing.expectEqualStrings(source_text, restored_text); + + var restored_limit: usize = std.math.maxInt(usize); + try testing.expectEqual(Result.success, terminal_c.get( + restored, + .continuation_max_bytes, + &restored_limit, + )); + try testing.expectEqual(@as(usize, 0), restored_limit); + + // Parser state is restored, but its tracking policy is not. The terminal + // cannot be snapshotted again until the restored sequence reaches ground. + var restored_required: usize = 0; + try testing.expectEqual(Result.invalid_value, encode_buf( + restored, + null, + 0, + &restored_required, + )); + terminal_c.vt_write(restored, "m", 1); + try testing.expectEqual(Result.out_of_space, encode_buf( + restored, + null, + 0, + &restored_required, + )); + try testing.expect(restored_required > 0); + + var offset: usize = 0; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .source_offset, + &offset, + )); + try testing.expectEqual(encoded.len, offset); + try testing.expectEqual(Result.no_value, decoder_next(decoder)); + + var limited: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &limited, + encoded.ptr, + encoded.len, + )); + defer decoder_free(limited); + const limit: usize = 3; + try testing.expectEqual(Result.success, decoder_set( + limited, + .max_continuation_bytes, + &limit, + )); + var rejected: terminal_c.Terminal = null; + try testing.expectEqual(Result.limit_exceeded, decoder_decode( + limited, + &rejected, + )); + try testing.expectEqual(null, rejected); + try testing.expectEqual(Result.invalid_value, decoder_set( + limited, + .max_continuation_bytes, + &limit, + )); + var failed_offset: usize = 0; + try testing.expectEqual(Result.no_value, decoder_get( + limited, + .source_offset, + &failed_offset, + )); + + const disabled: usize = 0; + try testing.expectEqual(Result.success, terminal_c.set( + source, + .continuation_max_bytes, + &disabled, + )); + var required: usize = 0; + try testing.expectEqual(Result.invalid_value, encode_buf( + source, + null, + 0, + &required, + )); +} + +test "snapshot encoding accepts an untracked ground terminal" { + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 10, + 3, + )); + defer terminal_c.free(source); + + var limit: usize = std.math.maxInt(usize); + try testing.expectEqual(Result.success, terminal_c.get( + source, + .continuation_max_bytes, + &limit, + )); + try testing.expectEqual(@as(usize, 0), limit); + + terminal_c.vt_write(source, "ground", "ground".len); + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode_buf( + source, + null, + 0, + &required, + )); + try testing.expect(required > 0); + + // Once untracked input leaves ground, the missing prefix is unknowable. + terminal_c.vt_write(source, "\x1b[31", "\x1b[31".len); + try testing.expectEqual(Result.invalid_value, encode_buf( + source, + null, + 0, + &required, + )); +} + +test "snapshot callbacks stop at FINISH and map I/O failures" { + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 10, + 3, + )); + defer terminal_c.free(source); + try testEnableContinuation(source); + terminal_c.vt_write(source, "callback", "callback".len); + + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode_buf( + source, + null, + 0, + &required, + )); + const buffer_encoded = try testing.allocator.alloc(u8, required); + defer testing.allocator.free(buffer_encoded); + var buffer_written: usize = 0; + try testing.expectEqual(Result.success, encode_buf( + source, + buffer_encoded.ptr, + buffer_encoded.len, + &buffer_written, + )); + try testing.expectEqual(required, buffer_written); + + // A non-null undersized buffer takes the fixed-first path. It may contain + // the prefix written before capacity exhaustion while still reporting the + // exact complete size. + var short: [10]u8 = @splat(0xAA); + var short_required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode_buf( + source, + &short, + short.len, + &short_required, + )); + try testing.expectEqual(required, short_required); + try testing.expectEqualStrings("GHOSTSNP", short[0..8]); + + const encoded = try testing.allocator.alloc(u8, required); + defer testing.allocator.free(encoded); + + const WriteContext = struct { + destination: []u8, + offset: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + if (len > self.destination.len - self.offset) return false; + @memcpy(self.destination[self.offset..][0..len], data[0..len]); + self.offset += len; + return true; + } + }; + var write_context: WriteContext = .{ .destination = encoded }; + try testing.expectEqual(Result.success, encode(source, .{ + .write = &WriteContext.write, + .userdata = &write_context, + })); + try testing.expectEqual(required, write_context.offset); + try testing.expectEqualSlices(u8, buffer_encoded, encoded); + + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + const failing_zig = failing.allocator(); + const failing_c: CAllocator = .fromZig(&failing_zig); + var failed_ptr: ?[*]u8 = null; + var failed_len: usize = 99; + try testing.expectEqual(Result.out_of_memory, encode_alloc( + source, + &failing_c, + &failed_ptr, + &failed_len, + )); + try testing.expectEqual(null, failed_ptr); + try testing.expectEqual(@as(usize, 0), failed_len); + + const FailWriter = struct { + fn write(_: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) bool { + return false; + } + }; + try testing.expectEqual(Result.io_error, encode(source, .{ + .write = &FailWriter.write, + })); + + const combined = try testing.allocator.alloc(u8, encoded.len + 4); + defer testing.allocator.free(combined); + @memcpy(combined[0..encoded.len], encoded); + @memcpy(combined[encoded.len..], "tail"); + + const ReadContext = struct { + source: []const u8, + offset: usize = 0, + + fn read( + userdata: ?*anyopaque, + destination: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + const remaining = self.source[self.offset..]; + const len = @min(remaining.len, capacity, 3); + @memcpy(destination[0..len], remaining[0..len]); + self.offset += len; + out_read.* = len; + return true; + } + }; + var read_context: ReadContext = .{ .source = combined }; + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new( + &lib.alloc.test_allocator, + &decoder, + .{ .read = &ReadContext.read, .userdata = &read_context }, + )); + defer decoder_free(decoder); + var restored: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_decode(decoder, &restored)); + defer terminal_c.free(restored); + try testing.expectEqual(encoded.len, read_context.offset); + try testing.expectEqualStrings("tail", read_context.source[read_context.offset..]); + + const FailReader = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + _: usize, + _: *usize, + ) callconv(lib.calling_conv) bool { + return false; + } + }; + var failed_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new( + &lib.alloc.test_allocator, + &failed_decoder, + .{ .read = &FailReader.read }, + )); + defer decoder_free(failed_decoder); + var failed_terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.io_error, decoder_decode( + failed_decoder, + &failed_terminal, + )); + + const EofReader = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + _: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + out_read.* = 0; + return true; + } + }; + var eof_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new( + &lib.alloc.test_allocator, + &eof_decoder, + .{ .read = &EofReader.read }, + )); + defer decoder_free(eof_decoder); + // Successful EOF before READY/FINISH is truncated snapshot data. It is + // intentionally INVALID_VALUE; only a false callback is IO_ERROR. + try testing.expectEqual(Result.invalid_value, decoder_decode( + eof_decoder, + &failed_terminal, + )); + + const InvalidReader = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + out_read.* = capacity + 1; + return true; + } + }; + var invalid_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new( + &lib.alloc.test_allocator, + &invalid_decoder, + .{ .read = &InvalidReader.read }, + )); + defer decoder_free(invalid_decoder); + try testing.expectEqual(Result.invalid_value, decoder_decode( + invalid_decoder, + &failed_terminal, + )); +} + +test "snapshot incremental decoder exposes READY and page progress" { + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 215, + 2, + )); + defer terminal_c.free(source); + try testEnableContinuation(source); + // Keep multiple complete pages. The ordinary C-terminal byte limit keeps + // at least one page but can evict older ones before snapshot encoding. + try testing.expectEqual(Result.success, terminal_c.set( + source, + .scrollback_max_bytes, + null, + )); + const first_page_rows = terminal_c.zigTerminal(source).?.screens + .get(.primary).?.pages.pages.first.?.capacity().rows; + for (0..@as(usize, first_page_rows) * 3) |_| + terminal_c.vt_write(source, "x\r\n", 3); + + var encoded_ptr: ?[*]u8 = null; + var encoded_len: usize = 0; + try testing.expectEqual(Result.success, encode_alloc( + source, + &lib.alloc.test_allocator, + &encoded_ptr, + &encoded_len, + )); + const encoded = encoded_ptr.?[0..encoded_len]; + defer lib.alloc.default(&lib.alloc.test_allocator).free(encoded); + + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &decoder, + encoded.ptr, + encoded.len, + )); + defer decoder_free(decoder); + + var restored: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_ready(decoder, &restored)); + defer terminal_c.free(restored); + + var history_rows: u64 = 0; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .history_rows_primary, + &history_rows, + )); + try testing.expect(history_rows > 0); + + var page_count: usize = 0; + while (true) { + const result = decoder_next(decoder); + if (result == .no_value) break; + try testing.expectEqual(Result.success, result); + page_count += 1; + + var screen: terminal_c.TerminalScreen = undefined; + var rows: usize = 0; + var remaining: u32 = 0; + const keys = [_]DecoderData{ + .progress_screen, + .progress_rows, + .progress_remaining, + }; + const values = [_]?*anyopaque{ &screen, &rows, &remaining }; + var written: usize = 0; + try testing.expectEqual(Result.success, decoder_get_multi( + decoder, + keys.len, + &keys, + @constCast(&values), + &written, + )); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(terminal_c.TerminalScreen.primary, screen); + try testing.expect(rows > 0); + } + try testing.expect(page_count > 0); + var rows: usize = 0; + try testing.expectEqual(Result.no_value, decoder_get( + decoder, + .progress_rows, + &rows, + )); + try testing.expectEqual(Result.no_value, decoder_next(decoder)); + + var source_total: usize = 0; + var restored_total: usize = 0; + try testing.expectEqual(Result.success, terminal_c.get( + source, + .total_rows, + &source_total, + )); + try testing.expectEqual(Result.success, terminal_c.get( + restored, + .total_rows, + &restored_total, + )); + try testing.expectEqual(source_total, restored_total); + + var dropped_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &dropped_decoder, + encoded.ptr, + encoded.len, + )); + defer decoder_free(dropped_decoder); + var dropped: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_ready( + dropped_decoder, + &dropped, + )); + defer terminal_c.free(dropped); + try testing.expectEqual(Result.success, terminal_c.resize( + dropped, + 214, + 2, + 0, + 0, + )); + try testing.expectEqual(Result.success, decoder_next(dropped_decoder)); + var dropped_rows: usize = 1; + try testing.expectEqual(Result.success, decoder_get( + dropped_decoder, + .progress_rows, + &dropped_rows, + )); + try testing.expectEqual(@as(usize, 0), dropped_rows); + while (decoder_next(dropped_decoder) == .success) {} + try testing.expectEqual(Result.no_value, decoder_next(dropped_decoder)); +} diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index e81d1f106..7ca8045d2 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -28,6 +28,8 @@ const selection_c = @import("selection.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); const clipboard = @import("../clipboard.zig"); +const c_io = @import("io.zig"); +const snapshot_core = @import("../snapshot/main.zig"); const Result = @import("result.zig").Result; const assert = @import("../../quirks.zig").inlineAssert; @@ -37,20 +39,62 @@ const max_path_bytes = if (builtin.os.tag == .freestanding) 4096 else std.fs.max const log = std.log.scoped(.terminal_c); +/// C terminals do not retain replay bytes unless the embedding application +/// opts in through `GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES`. +pub const default_continuation_max_bytes: usize = 0; + +/// Owns the `std.Io` implementation retained by every C terminal. +/// +/// Snapshot decoding creates this before the native terminal exists and +/// transfers it into the final C wrapper after READY. +pub const Io = struct { + impl: Impl, + + /// Platform-specific storage backing the public `std.Io` value. + const Impl = if (builtin.os.tag != .freestanding) + *std.Io.Threaded + else + void; + + /// Allocation failures possible while constructing an I/O owner. + pub const Error = error{OutOfMemory}; + + /// Allocate the native I/O implementation when the platform requires it. + pub fn init(alloc: std.mem.Allocator) Error!Io { + if (comptime builtin.os.tag == .freestanding) return .{ .impl = {} }; + + const ptr = alloc.create(std.Io.Threaded) catch + return error.OutOfMemory; + ptr.* = .init_single_threaded; + return .{ .impl = ptr }; + } + + /// Return the value passed to native terminal construction and decoding. + pub fn io(self: Io) std.Io { + if (comptime builtin.os.tag == .freestanding) { + return std.Io.failing; + } + return self.impl.io(); + } + + /// Release an I/O implementation that has not already been transferred. + pub fn deinit(self: Io, alloc: std.mem.Allocator) void { + if (comptime builtin.os.tag != .freestanding) { + self.impl.deinit(); + alloc.destroy(self.impl); + } + } +}; + /// Wrapper around ZigTerminal that tracks additional state for C API usage, /// such as the persistent VT stream needed to handle escape sequences split /// across multiple vt_write calls. const TerminalWrapper = struct { - const IoImpl = if (builtin.os.tag != .freestanding) *std.Io.Threaded else void; - terminal: *ZigTerminal, - /// We need to keep an I/O instance here as part of the terminal since we - /// have no way of taking it in the C API. This is set up in `new` and - /// destroyed on `free`. - /// - /// This is set to null on freestanding platforms, which get std.Io.failing - /// instead. - io_impl: IoImpl, + /// C construction has no I/O argument, so the wrapper retains the owner + /// created by `new` or transferred from snapshot decoding until `free`. + /// Freestanding owners contain no native allocation and expose failing I/O. + io: Io, /// We also need to store a temp dir path for some operations (e.g., kitty /// graphics). This provides stable storage for the API calls. tmp_dir_path: [max_path_bytes]u8, @@ -373,6 +417,135 @@ pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal { return (terminal_ orelse return null).terminal; } +/// Attach the persistent C stream and I/O owner to a heap-stable terminal. +/// The caller retains ownership of both inputs if wrapper allocation fails. +fn wrap( + alloc: std.mem.Allocator, + t: *ZigTerminal, + io: Io, + continuation_max_bytes: usize, +) error{OutOfMemory}!Terminal { + const wrapper = alloc.create(TerminalWrapper) catch + return error.OutOfMemory; + + // Trampolines are always installed so setting C callbacks later takes + // effect immediately. + var handler: Stream.Handler = t.vtHandler(); + handler.effects = .{ + .write_pty = &Effects.writePtyTrampoline, + .bell = &Effects.bellTrampoline, + .color_scheme = &Effects.colorSchemeTrampoline, + .desktop_notification = &Effects.desktopNotificationTrampoline, + .device_attributes = &Effects.deviceAttributesTrampoline, + .enquiry = &Effects.enquiryTrampoline, + .xtversion = &Effects.xtversionTrampoline, + .title_changed = &Effects.titleChangedTrampoline, + .pwd_changed = &Effects.pwdChangedTrampoline, + .progress_report = &Effects.progressReportTrampoline, + .size = &Effects.sizeTrampoline, + .clipboard_write = &Effects.clipboardWriteTrampoline, + }; + + wrapper.* = .{ + .terminal = t, + .io = io, + .tmp_dir_path = undefined, + .stream = Stream.init(.{ + .allocator = alloc, + .handler = handler, + .continuation_max_bytes = continuation_max_bytes, + }), + }; + return wrapper; +} + +pub const RestoreContinuationError = error{ + OutOfMemory, + ContinuationDisabled, + ContinuationUnavailable, + InvalidContinuation, +}; + +/// Replay a decoded continuation exactly once into a newly created C terminal +/// and verify that the persistent stream exports the identical canonical +/// bytes. The terminal remains valid on error and may be freed normally. +pub fn restoreContinuation( + terminal_: Terminal, + continuation: []const u8, +) RestoreContinuationError!void { + const wrapper = terminal_ orelse return error.InvalidContinuation; + if (continuation.len > 0) wrapper.stream.nextSlice(continuation); + + var exported: std.Io.Writer.Allocating = .init(wrapper.terminal.gpa()); + defer exported.deinit(); + wrapper.stream.writeContinuation(&exported.writer) catch |err| switch (err) { + error.WriteFailed => return error.OutOfMemory, + error.ContinuationDisabled => return error.ContinuationDisabled, + error.ContinuationUnavailable => return error.ContinuationUnavailable, + }; + if (!std.mem.eql(u8, continuation, exported.written())) { + return error.InvalidContinuation; + } +} + +pub const FromDecodedError = error{ + OutOfMemory, + InvalidContinuation, +}; + +/// Transfer a core snapshot result into a caller-owned C terminal. +/// +/// This function consumes `io` on every path. The decoded terminal is +/// transferred only after its final heap address has been allocated; its +/// continuation remains in `decoded` and is replayed before returning. +/// Replay uses a temporary exact-size tracker which is disabled before the +/// terminal crosses the C ABI, restoring the ordinary C default policy. +pub fn fromDecoded( + alloc: std.mem.Allocator, + io: Io, + decoded: *snapshot_core.Decoded, +) FromDecodedError!Terminal { + const native = alloc.create(ZigTerminal) catch { + io.deinit(alloc); + return error.OutOfMemory; + }; + native.* = decoded.toOwned(); + + const continuation = switch (decoded.continuation) { + .ground => "", + .bytes => |bytes| bytes, + }; + + // Non-ground state needs tracking only long enough to verify that replay + // reconstructed the exact canonical continuation. Ground state needs no + // tracker at all. + const terminal = wrap(alloc, native, io, continuation.len) catch |err| { + native.deinit(alloc); + alloc.destroy(native); + io.deinit(alloc); + return err; + }; + errdefer free(terminal); + + if (continuation.len > 0) { + restoreContinuation(terminal, continuation) catch |err| return switch (err) { + // The decoded bytes exactly fit this fresh tracker's cap, so + // losing them while replaying can only be an allocation failure. + error.OutOfMemory, + error.ContinuationUnavailable, + => error.OutOfMemory, + error.ContinuationDisabled, + error.InvalidContinuation, + => error.InvalidContinuation, + }; + } + + // Decoder validation limits are not terminal runtime policy. A restored + // terminal always starts with the same disabled tracking default as new. + setContinuationMaxBytes(terminal.?, default_continuation_max_bytes); + return terminal; +} + const NewError = error{ InvalidValue, OutOfMemory, @@ -399,7 +572,7 @@ fn new_( alloc_: ?*const CAllocator, cols: size.CellCountInt, rows: size.CellCountInt, -) NewError!*TerminalWrapper { +) NewError!Terminal { if (cols == 0 or rows == 0) return error.InvalidValue; const alloc = lib.alloc.default(alloc_); @@ -407,21 +580,12 @@ fn new_( return error.OutOfMemory; errdefer alloc.destroy(t); - const wrapper = alloc.create(TerminalWrapper) catch - return error.OutOfMemory; - errdefer alloc.destroy(wrapper); - - const has_nonfailing_io = builtin.os.tag != .freestanding; - const io_impl: TerminalWrapper.IoImpl = if (has_nonfailing_io) io_impl: { - const ptr = try alloc.create(std.Io.Threaded); - ptr.* = .init_single_threaded; - break :io_impl ptr; - } else {}; - errdefer if (has_nonfailing_io) alloc.destroy(io_impl); + const io = try Io.init(alloc); + errdefer io.deinit(alloc); // Setup our terminal t.* = try .init( - if (has_nonfailing_io) io_impl.io() else std.Io.failing, + io.io(), alloc, .{ .cols = cols, @@ -435,35 +599,12 @@ fn new_( // Shells can still opt in with OSC 133;A;redraw=1. t.flags.shell_redraws_prompt = .false; - // Setup our stream with trampolines always installed so that - // setting C callbacks at any time takes effect immediately. - var handler: Stream.Handler = t.vtHandler(); - handler.effects = .{ - .write_pty = &Effects.writePtyTrampoline, - .bell = &Effects.bellTrampoline, - .color_scheme = &Effects.colorSchemeTrampoline, - .desktop_notification = &Effects.desktopNotificationTrampoline, - .device_attributes = &Effects.deviceAttributesTrampoline, - .enquiry = &Effects.enquiryTrampoline, - .xtversion = &Effects.xtversionTrampoline, - .title_changed = &Effects.titleChangedTrampoline, - .pwd_changed = &Effects.pwdChangedTrampoline, - .progress_report = &Effects.progressReportTrampoline, - .size = &Effects.sizeTrampoline, - .clipboard_write = &Effects.clipboardWriteTrampoline, - }; - - wrapper.* = .{ - .terminal = t, - .io_impl = io_impl, - .tmp_dir_path = undefined, // Only used if temporary directory is set with API calls - .stream = Stream.init(.{ - .allocator = alloc, - .handler = handler, - }), - }; - - return wrapper; + return try wrap( + alloc, + t, + io, + default_continuation_max_bytes, + ); } pub fn vt_write( @@ -475,6 +616,223 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +pub const ContinuationWriteError = error{ + InvalidValue, + WriteFailed, + ContinuationDisabled, + ContinuationUnavailable, +}; + +/// Write the exact replay-safe continuation for a C terminal to a Zig writer. +/// Snapshot encoding uses this helper so it can preflight continuation state +/// without converting its allocator or writer through the C ABI. +pub fn continuationWriteIo( + terminal_: Terminal, + writer: *std.Io.Writer, +) ContinuationWriteError!void { + // Keep handle validation here so the three public output forms and the + // snapshot encoder all use exactly the same continuation preflight. + const wrapper = terminal_ orelse return error.InvalidValue; + + // Stream owns the tracker and distinguishes disabled tracking from a + // tracker that lost bytes after allocation failure or limit overflow. + try wrapper.stream.writeContinuation(writer); +} + +pub const ContinuationAllocError = error{ + InvalidValue, + OutOfMemory, + ContinuationDisabled, + ContinuationUnavailable, +}; + +/// Return an allocator-owned copy of the terminal's replay-safe continuation. +/// The caller owns the returned slice and must free it with `alloc`. +/// +/// If `allow_untracked_ground` is true, disabled tracking is accepted only +/// when the stream is provably at ground and an owned empty slice is returned. +pub fn continuationAllocIo( + terminal_: Terminal, + alloc: std.mem.Allocator, + allow_untracked_ground: bool, +) ContinuationAllocError![]u8 { + const wrapper = terminal_ orelse return error.InvalidValue; + if (allow_untracked_ground and + wrapper.stream.continuation == null and + wrapper.stream.ground()) + { + return alloc.dupe(u8, "") catch error.OutOfMemory; + } + + // The allocating writer gives snapshot encoding and the public allocation + // API one common way to obtain an exact owned continuation. + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + // A write failure from an Allocating writer can only be allocation failure; + // no external callback participates in this path. + continuationWriteIo(terminal_, &aw.writer) catch |err| switch (err) { + error.InvalidValue => return error.InvalidValue, + error.WriteFailed => return error.OutOfMemory, + error.ContinuationDisabled => return error.ContinuationDisabled, + error.ContinuationUnavailable => return error.ContinuationUnavailable, + }; + + // Transfer the buffer out before the deferred writer cleanup runs. + return aw.toOwnedSlice() catch error.OutOfMemory; +} + +/// Map errors that are intrinsic to continuation export. Public callback +/// failures receive finer classification in `continuation_write` below. +fn continuationErrorResult(err: ContinuationWriteError) Result { + return switch (err) { + error.InvalidValue => .invalid_value, + error.WriteFailed => .io_error, + error.ContinuationDisabled => .invalid_value, + error.ContinuationUnavailable => .invalid_value, + }; +} + +pub fn continuation_write( + terminal_: Terminal, + writer: c_io.Writer, +) callconv(lib.calling_conv) Result { + // Reject the missing callback before invoking the common helper so this is + // classified as a bad argument rather than a write failure. + if (writer.write == null) return .invalid_value; + + // The callback was validated above, so invalid_write can only mean output + // accounting overflow. Keep that distinct from callback rejection. + var adapter: c_io.WriterAdapter = .init(writer); + continuationWriteIo(terminal_, &adapter.interface) catch |err| { + if (err == error.WriteFailed) { + if (adapter.invalid_write) return .limit_exceeded; + if (adapter.callback_failed) return .io_error; + } + return continuationErrorResult(err); + }; + return .success; +} + +pub fn continuation_buf( + terminal_: Terminal, + out_: ?[*]u8, + out_len: usize, + out_written_: ?*usize, +) callconv(lib.calling_conv) Result { + const out_written = out_written_ orelse return .invalid_value; + // All failure paths leave deterministic output metadata. + out_written.* = 0; + if (out_ == null and out_len != 0) return .invalid_value; + + if (out_ == null) { + // A null/zero destination is the explicit size-query form. Discarding + // runs the real exporter, so disabled or unavailable tracking is still + // detected before a required length is reported. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + continuationWriteIo(terminal_, &discarding.writer) catch |err| + return continuationErrorResult(err); + out_written.* = @intCast(discarding.count); + return .out_of_space; + } + + // Fixed writers report WriteFailed when capacity is exhausted. The stream + // exporter itself remains all-or-nothing from the API's perspective. + var writer: std.Io.Writer = .fixed(out_.?[0..out_len]); + continuationWriteIo(terminal_, &writer) catch |err| switch (err) { + error.WriteFailed => { + // Re-run against a counter to return the full required capacity, + // not merely the prefix that fit in the caller's buffer. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + continuationWriteIo(terminal_, &discarding.writer) catch |count_err| + return continuationErrorResult(count_err); + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + else => return continuationErrorResult(err), + }; + + // `end` is the initialized prefix of the fixed destination. + out_written.* = writer.end; + return .success; +} + +pub fn continuation_alloc( + terminal_: Terminal, + alloc_: ?*const CAllocator, + out_ptr_: ?*?[*]u8, + out_len_: ?*usize, +) callconv(lib.calling_conv) Result { + const out_ptr = out_ptr_ orelse return .invalid_value; + const out_len = out_len_ orelse return .invalid_value; + // Make ownership unambiguous even if validation or allocation fails. + out_ptr.* = null; + out_len.* = 0; + + // Resolve NULL to libghostty-vt's default allocator before entering the + // shared Zig allocation path. + const bytes = continuationAllocIo( + terminal_, + lib.alloc.default(alloc_), + false, + ) catch |err| return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + error.ContinuationDisabled => .invalid_value, + error.ContinuationUnavailable => .invalid_value, + }; + + // Ownership crosses the ABI here; callers release this exact pointer and + // length with ghostty_free and the same allocator selection. + out_ptr.* = bytes.ptr; + out_len.* = bytes.len; + return .success; +} + +fn continuationMaxBytes(wrapper: *const TerminalWrapper) usize { + // Absence of a tracker is the public representation of disabled tracking. + return if (wrapper.stream.continuation) |tracker| + tracker.max_bytes + else + 0; +} + +/// Change continuation tracking policy without disturbing normal VT parser +/// state. Bytes which were not retained while disabled or after exceeding an +/// earlier cap cannot be reconstructed: export remains unavailable until a +/// later feed reaches ground or contains a new replay start. +fn setContinuationMaxBytes(wrapper: *TerminalWrapper, max_bytes: usize) void { + if (max_bytes == 0) { + // Disabling releases retained bytes immediately. Parser and UTF-8 state + // continue normally; only future replay/export information is lost. + if (wrapper.stream.continuation) |*tracker| tracker.deinit(); + wrapper.stream.continuation = null; + return; + } + + if (wrapper.stream.continuation) |*tracker| { + // Changing a live cap preserves retained bytes when they still fit. + tracker.max_bytes = max_bytes; + if (tracker.bytes.items.len > max_bytes) { + // Once a retained prefix is discarded it cannot be reconstructed + // from parser state alone. Mark it broken until Stream observes a + // ground state or a new replay-safe sequence start. + tracker.bytes.clearRetainingCapacity(); + tracker.broken = true; + } + return; + } + + // Enabling from zero starts an empty tracker owned by the terminal's + // allocator. It can immediately track only if no earlier bytes are needed. + wrapper.stream.continuation = .init(wrapper.terminal.gpa(), max_bytes); + if (!wrapper.stream.ground()) { + // The parser was already mid-sequence while tracking was disabled, so + // exporting now would omit an unknown prefix. + wrapper.stream.continuation.?.broken = true; + } +} + pub fn compression_activity( terminal_: Terminal, out_activity_: ?*u64, @@ -530,6 +888,7 @@ pub const Option = enum(c_int) { scrollback_max_lines = 28, desktop_notification = 29, progress_report = 30, + continuation_max_bytes = 31, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -560,6 +919,7 @@ pub const Option = enum(c_int) { .apc_max_bytes_kitty, .scrollback_max_bytes, .scrollback_max_lines, + .continuation_max_bytes, => ?*const usize, .selection => ?*const selection_c.CSelection, .default_cursor_style => ?*const TerminalCursorStyle, @@ -720,6 +1080,10 @@ fn setTyped( .scrollback_max_lines => wrapper.terminal.setScrollbackMaxLines( if (value) |ptr| ptr.* else null, ), + .continuation_max_bytes => setContinuationMaxBytes( + wrapper, + if (value) |ptr| ptr.* else default_continuation_max_bytes, + ), } return .success; } @@ -861,6 +1225,7 @@ pub const TerminalData = enum(c_int) { vt_processing_error = 33, scrollback_max_bytes = 34, scrollback_max_lines = 35, + continuation_max_bytes = 36, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -882,6 +1247,7 @@ pub const TerminalData = enum(c_int) { .scrollback_rows, .scrollback_max_bytes, .scrollback_max_lines, + .continuation_max_bytes, => usize, .width_px, .height_px => u32, .color_foreground, @@ -1028,6 +1394,7 @@ fn getTyped( if (max == std.math.maxInt(usize)) return .no_value; out.* = max; }, + .continuation_max_bytes => out.* = continuationMaxBytes(wrapper), } return .success; @@ -1112,15 +1479,19 @@ pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void { wrapper.tracked_grid_refs.deinit(alloc); wrapper.stream.deinit(); t.deinit(alloc); - if (builtin.os.tag != .freestanding) { - // Deinit is always safe to call, even for single-threaded instances - wrapper.io_impl.deinit(); - alloc.destroy(wrapper.io_impl); - } + wrapper.io.deinit(alloc); alloc.destroy(t); alloc.destroy(wrapper); } +fn testEnableContinuation(terminal: Terminal) !void { + const limit: usize = 1024; + try testing.expectEqual( + Result.success, + set(terminal, .continuation_max_bytes, &limit), + ); +} + test "new/free" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -1154,6 +1525,318 @@ test "new invalid value" { try testing.expect(t == null); } +test "continuation option and data" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + var value: usize = 0; + try testing.expectEqual( + Result.success, + get(t, .continuation_max_bytes, @ptrCast(&value)), + ); + try testing.expectEqual(default_continuation_max_bytes, value); + + const custom: usize = 1234; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &custom), + ); + try testing.expectEqual( + Result.success, + get(t, .continuation_max_bytes, @ptrCast(&value)), + ); + try testing.expectEqual(custom, value); + + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, null), + ); + try testing.expectEqual( + Result.success, + get(t, .continuation_max_bytes, @ptrCast(&value)), + ); + try testing.expectEqual(default_continuation_max_bytes, value); + + const disabled: usize = 0; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &disabled), + ); + try testing.expectEqual( + Result.success, + get(t, .continuation_max_bytes, @ptrCast(&value)), + ); + try testing.expectEqual(@as(usize, 0), value); + + var written: usize = 99; + try testing.expectEqual( + Result.invalid_value, + continuation_buf(t, null, 0, &written), + ); + try testing.expectEqual(@as(usize, 0), written); +} + +test "continuation buffer and allocator export exact suffix" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + try testEnableContinuation(t); + + vt_write(t, "A\x1b[31", 5); + + var required: usize = 0; + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, null, 0, &required), + ); + try testing.expectEqual(@as(usize, 4), required); + + var short: [2]u8 = undefined; + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, &short, short.len, &required), + ); + try testing.expectEqual(@as(usize, 4), required); + + var buf: [8]u8 = undefined; + var written: usize = 0; + try testing.expectEqual( + Result.success, + continuation_buf(t, &buf, buf.len, &written), + ); + try testing.expectEqualStrings("\x1b[31", buf[0..written]); + + var out_ptr: ?[*]u8 = null; + var out_len: usize = 0; + try testing.expectEqual(Result.success, continuation_alloc( + t, + &lib.alloc.test_allocator, + &out_ptr, + &out_len, + )); + const allocated = out_ptr orelse return error.TestExpectedEqual; + defer lib.alloc.default(&lib.alloc.test_allocator).free(allocated[0..out_len]); + try testing.expectEqualStrings("\x1b[31", allocated[0..out_len]); + + vt_write(t, "m", 1); + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, null, 0, &required), + ); + try testing.expectEqual(@as(usize, 0), required); +} + +test "continuation export tracks split UTF-8" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + try testEnableContinuation(t); + + const prefix = [_]u8{ 0xF0, 0x9F }; + vt_write(t, &prefix, prefix.len); + + var buf: [4]u8 = undefined; + var written: usize = 0; + try testing.expectEqual( + Result.success, + continuation_buf(t, &buf, buf.len, &written), + ); + try testing.expectEqualSlices(u8, &prefix, buf[0..written]); + + const suffix = [_]u8{ 0x98, 0x80 }; + vt_write(t, &suffix, suffix.len); + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, null, 0, &written), + ); + try testing.expectEqual(@as(usize, 0), written); +} + +test "continuation callback writer reports success and failure" { + const Sink = struct { + bytes: [8]u8 = undefined, + len: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + if (len > self.bytes.len - self.len) return false; + @memcpy(self.bytes[self.len..][0..len], data[0..len]); + self.len += len; + return true; + } + + fn fail( + _: ?*anyopaque, + _: [*]const u8, + _: usize, + ) callconv(lib.calling_conv) bool { + return false; + } + }; + + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + try testEnableContinuation(t); + vt_write(t, "\x1b[31", 4); + + var sink: Sink = .{}; + try testing.expectEqual(Result.success, continuation_write(t, .{ + .write = &Sink.write, + .userdata = &sink, + })); + try testing.expectEqualStrings("\x1b[31", sink.bytes[0..sink.len]); + + try testing.expectEqual(Result.io_error, continuation_write(t, .{ + .write = &Sink.fail, + })); + try testing.expectEqual( + Result.invalid_value, + continuation_write(t, .{}), + ); + try testing.expectEqual(Result.invalid_value, continuation_write(null, .{ + .write = &Sink.fail, + })); +} + +test "continuation runtime reconfiguration recovers safely" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const enough: usize = 8; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &enough), + ); + vt_write(t, "\x1b[123", 5); + const too_small: usize = 2; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &too_small), + ); + + var written: usize = 99; + try testing.expectEqual( + Result.invalid_value, + continuation_buf(t, null, 0, &written), + ); + try testing.expectEqual(@as(usize, 0), written); + + // Raising the limit cannot reconstruct discarded bytes, but a new ESC is + // a complete replay start and repairs tracking without first grounding. + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &enough), + ); + vt_write(t, "\x1b[", 2); + var buf: [8]u8 = undefined; + try testing.expectEqual( + Result.success, + continuation_buf(t, &buf, buf.len, &written), + ); + try testing.expectEqualStrings("\x1b[", buf[0..written]); + + const disabled: usize = 0; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &disabled), + ); + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &enough), + ); + try testing.expectEqual( + Result.invalid_value, + continuation_buf(t, null, 0, &written), + ); + + // Completing the sequence reaches ground and resets the broken marker. + vt_write(t, "m", 1); + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, null, 0, &written), + ); + try testing.expectEqual(@as(usize, 0), written); +} + +test "continuation internal allocation and restoration" { + var source: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &source, + 80, + 24, + )); + defer free(source); + try testEnableContinuation(source); + vt_write(source, "\x1b[31", 4); + + const alloc = lib.alloc.default(&lib.alloc.test_allocator); + const bytes = try continuationAllocIo(source, alloc, false); + defer alloc.free(bytes); + try testing.expectEqualStrings("\x1b[31", bytes); + + var restored: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &restored, + 80, + 24, + )); + defer free(restored); + try testEnableContinuation(restored); + try restoreContinuation(restored, bytes); + + const reexported = try continuationAllocIo(restored, alloc, false); + defer alloc.free(reexported); + try testing.expectEqualStrings(bytes, reexported); + + var invalid: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &invalid, + 80, + 24, + )); + defer free(invalid); + try testEnableContinuation(invalid); + try testing.expectError( + error.InvalidContinuation, + restoreContinuation(invalid, "A"), + ); +} + test "set scrollback limits" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 60f97c342..43f553d8b 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -20,6 +20,7 @@ const render = @import("render.zig"); const style_c = @import("style.zig"); const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); +const io = @import("io.zig"); /// C: GhosttySurfacePosition pub const SurfacePosition = extern struct { @@ -60,6 +61,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, + .{ "GhosttyReader", StructInfo.init(io.Reader) }, .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, .{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) }, @@ -72,12 +74,13 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) }, .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, + .{ "GhosttyWriter", StructInfo.init(io.Writer) }, }); }; /// The comptime-generated JSON string of all structs. pub const json: [:0]const u8 = json: { - @setEvalBranchQuota(100000); + @setEvalBranchQuota(200_000); var counter: std.Io.Writer.Discarding = .init(&.{}); jsonWriteAll(&counter.writer) catch unreachable; @@ -218,6 +221,8 @@ test "json parses" { try std.testing.expect(root.contains("GhosttyClipboardContent")); try std.testing.expect(root.contains("GhosttyClipboardWrite")); try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions")); + try std.testing.expect(root.contains("GhosttyReader")); + try std.testing.expect(root.contains("GhosttyWriter")); const clipboard_content = root.get("GhosttyClipboardContent").?.object; const clipboard_content_fields = clipboard_content.get("fields").?.object; @@ -231,6 +236,16 @@ test "json parses" { try std.testing.expect(clipboard_write_fields.contains("contents")); try std.testing.expect(clipboard_write_fields.contains("contents_len")); + const reader_fields = root.get("GhosttyReader").?.object + .get("fields").?.object; + try std.testing.expect(reader_fields.contains("read")); + try std.testing.expect(reader_fields.contains("userdata")); + + const writer_fields = root.get("GhosttyWriter").?.object + .get("fields").?.object; + try std.testing.expect(writer_fields.contains("write")); + try std.testing.expect(writer_fields.contains("userdata")); + try std.testing.expect(!root.contains("GhosttyTerminalOptions")); } diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig index f9d6f6be7..4cb226516 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -233,11 +233,14 @@ const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const test_fixture = @import("fixture.zig"); const io = @import("io.zig"); -const kitty = @import("../kitty.zig"); const terminal_hyperlink = @import("../hyperlink.zig"); const terminal_page = @import("../page.zig"); const terminal_style = @import("../style.zig"); +/// The Kitty virtual-placement placeholder remains wire-relevant even when +/// runtime Kitty graphics support is compiled out. +const kitty_virtual_placeholder: u21 = 0x10EEEE; + const TerminalCell = terminal_page.Cell; const TerminalHyperlinkId = terminal_hyperlink.Id; const TerminalPage = terminal_page.Page; @@ -957,7 +960,7 @@ fn applyCell( // version, but the placeholder is still a valid Unicode scalar. // Preserve it and derive the native row hint so later row // operations remain correct. - if (wire.content == kitty.graphics.unicode.placeholder) { + if (wire.content == kitty_virtual_placeholder) { row.kitty_virtual_placeholder = true; } }, @@ -2020,7 +2023,7 @@ test "grid four-byte cells run full normalization" { // The Kitty placeholder does not fit two-byte cells but fits here // and must still derive the native row hint. try io.writeInt(&writer, u32, @truncate(@as(u64, @bitCast(Cell{ - .content = kitty.graphics.unicode.placeholder, + .content = kitty_virtual_placeholder, })))); // An unknown small style reference degrades to the default style. @@ -2042,7 +2045,7 @@ test "grid four-byte cells run full normalization" { const first = page.getRowAndCell(0, 0); try testing.expectEqual( - @as(u21, kitty.graphics.unicode.placeholder), + kitty_virtual_placeholder, first.cell.codepoint(), ); try testing.expect(first.row.kitty_virtual_placeholder); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index dcdc1adbe..65c7cc5e0 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -553,7 +553,7 @@ pub fn Stream(comptime H: type) type { /// True when no continuation suffix is needed to reproduce the /// stream's current parsing state. - inline fn ground(self: *const Self) bool { + pub inline fn ground(self: *const Self) bool { // Parser ground alone is not sufficient because the UTF-8 // decoder may have some state. return self.parser.state == .ground and self.utf8decoder.state == 0;