libghostty: option to retain continuations on snapshot decode (#13878)

Add a snapshot decoder option that leaves continuation tracking enabled
on decoded terminals. This lets caller access the continuation bytes (if
any) that were applied to the terminal.

This lets replay callers export an unfinished parser or UTF-8 sequence
from the returned terminal.

This defaults to off.
This commit is contained in:
Mitchell Hashimoto
2026-08-17 13:39:27 -07:00
committed by GitHub
3 changed files with 384 additions and 24 deletions

View File

@@ -7,6 +7,7 @@
#ifndef GHOSTTY_VT_SNAPSHOT_H
#define GHOSTTY_VT_SNAPSHOT_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
@@ -131,13 +132,34 @@ typedef enum GHOSTTY_ENUM_TYPED {
* 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.
* This is primarily an input validation limit. When
* GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the same value
* also becomes the continuation tracking limit on the returned terminal.
*
* Input type: size_t *
*/
GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES = 0,
/**
* Retain the decoded continuation on the returned terminal.
*
* When true, terminals returned by ghostty_snapshot_decoder_ready() and
* ghostty_snapshot_decoder_decode() use
* GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES as their continuation
* tracking limit. The existing ghostty_terminal_continuation_* APIs can then
* export the exact unfinished VT or UTF-8 input restored from the snapshot.
*
* This is false by default. A maximum continuation size of zero leaves
* tracking disabled. With a nonzero maximum, tracking remains enabled even
* when the decoded continuation is empty. Exporting an empty continuation
* does not disable it. Callers that do not need ongoing tracking must still
* set GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and
* before writing post-snapshot input.
*
* Input type: bool *
*/
GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION = 1,
GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySnapshotDecoderOption;
@@ -222,6 +244,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING = 7,
/**
* Whether decoded continuation tracking is retained on returned terminals.
*
* This value is available in every non-failed decoder state.
*
* Output type: bool *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_RETAIN_CONTINUATION = 8,
GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySnapshotDecoderData;
@@ -397,10 +428,16 @@ GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_set(
* 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 restored parser state may be unfinished. By default, terminal
* continuation tracking is disabled and
* GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns zero. When
* GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the decoder's
* maximum continuation size is applied to the terminal, and the terminal
* continuation APIs export the exact current continuation when that limit is
* nonzero. Tracking remains enabled even if the exported continuation is
* empty. Callers that do not need ongoing tracking must set
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and before
* writing any post-snapshot bytes, because later input may change it.
*
* The caller must keep the returned terminal alive until FINISH validates or
* the decoder is freed. The decoder borrows this terminal handle while it
@@ -455,8 +492,15 @@ GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_next(
* 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.
* terminal is disabled by default. When
* GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the decoder's
* maximum continuation size is applied to the terminal, and the terminal
* continuation APIs export the exact current continuation when that limit is
* nonzero. Tracking remains enabled even if the exported continuation is
* empty. Callers that do not need ongoing tracking must set
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and before
* writing any post-snapshot bytes, because later input may change it.
* 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

View File

@@ -11,15 +11,18 @@ 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.
/// Opt-in decoded-terminal retention reuses this configured validation bound
/// as its runtime tracking limit.
const default_max_continuation_bytes = apc.Protocol.maxDefaultBytes();
const terminal_c = @import("terminal.zig");
/// C: GhosttySnapshotDecoderOption.
///
/// Options configure validation policy before snapshot decoding begins.
/// Options configure validation and returned-terminal policy before snapshot
/// decoding begins.
pub const DecoderOption = enum(c_int) {
max_continuation_bytes = 0,
retain_continuation = 1,
_,
};
@@ -35,6 +38,7 @@ pub const DecoderData = enum(c_int) {
progress_screen = 5,
progress_rows = 6,
progress_remaining = 7,
retain_continuation = 8,
_,
/// Return the Zig type stored through the C API output pointer for a key.
@@ -46,6 +50,7 @@ pub const DecoderData = enum(c_int) {
.progress_screen => terminal_c.TerminalScreen,
.progress_rows => usize,
.progress_remaining => u32,
.retain_continuation => bool,
_ => void,
};
}
@@ -102,6 +107,7 @@ const DecoderWrapper = struct {
decoder: snapshot_core.Decoder,
state: State,
max_continuation_bytes: usize,
retain_continuation: bool,
};
/// C: GhosttySnapshotDecoder, an opaque nullable decoder handle.
@@ -164,6 +170,7 @@ fn decoderNewSource(
wrapper.source = source;
wrapper.state = .configuring;
wrapper.max_continuation_bytes = default_max_continuation_bytes;
wrapper.retain_continuation = false;
wrapper.decoder = .init(wrapper.source.reader());
out.* = wrapper;
return .success;
@@ -194,6 +201,8 @@ pub fn decoder_set(
switch (option) {
.max_continuation_bytes => decoder.max_continuation_bytes =
@as(*const usize, @ptrCast(@alignCast(value))).*,
.retain_continuation => decoder.retain_continuation =
@as(*const bool, @ptrCast(@alignCast(value))).*,
_ => return .invalid_value,
}
return .success;
@@ -215,6 +224,7 @@ pub fn decoder_get(
.progress_screen,
.progress_rows,
.progress_remaining,
.retain_continuation,
=> |comptime_data| decoderGetTyped(
decoder_,
comptime_data,
@@ -242,6 +252,10 @@ fn decoderGetTyped(
if (decoderFailed(decoder)) return .no_value;
out.* = decoder.source.offset();
},
.retain_continuation => {
if (decoderFailed(decoder)) return .no_value;
out.* = decoder.retain_continuation;
},
// READY metadata remains available after history decoding completes.
.history_rows_primary => {
@@ -340,7 +354,6 @@ pub fn decoder_ready(
.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 };
@@ -405,7 +418,6 @@ pub fn decoder_decode(
.configuring => {},
else => return .invalid_value,
}
// Build the terminal from the renderable READY prefix.
const ready = decoderReadyTerminal(decoder) catch |err| {
decoder.state = .{ .failed = null };
@@ -461,6 +473,10 @@ fn decoderReadyTerminal(decoder: *DecoderWrapper) anyerror!ReadyTerminal {
decoder.alloc,
io,
&decoded,
if (decoder.retain_continuation)
decoder.max_continuation_bytes
else
0,
);
return .{ .terminal = terminal, .metadata = metadata };
}
@@ -705,6 +721,27 @@ test "decoder option and empty source" {
));
try testing.expectEqual(1234, limit);
var retain = true;
try testing.expectEqual(Result.success, decoder_get(
decoder,
.retain_continuation,
&retain,
));
try testing.expect(!retain);
retain = true;
try testing.expectEqual(Result.success, decoder_set(
decoder,
.retain_continuation,
&retain,
));
retain = false;
try testing.expectEqual(Result.success, decoder_get(
decoder,
.retain_continuation,
&retain,
));
try testing.expect(retain);
var written: usize = 99;
try testing.expectEqual(Result.invalid_value, decoder_get_multi(
decoder,
@@ -800,6 +837,81 @@ test "snapshot C API full round trip restores continuation" {
try testing.expectEqual(encoded.len, offset);
try testing.expectEqual(Result.no_value, decoder_next(decoder));
var retained_decoder: Decoder = null;
try testing.expectEqual(Result.success, decoder_new_buf(
&lib.alloc.test_allocator,
&retained_decoder,
encoded.ptr,
encoded.len,
));
defer if (retained_decoder != null) decoder_free(retained_decoder);
const retained_limit: usize = 1024;
try testing.expectEqual(Result.success, decoder_set(
retained_decoder,
.max_continuation_bytes,
&retained_limit,
));
const retain = true;
try testing.expectEqual(Result.success, decoder_set(
retained_decoder,
.retain_continuation,
&retain,
));
// The returned tracker owns the exact decoded continuation, so the
// decoder can be freed before callers export those terminal-owned bytes.
var retained: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, decoder_decode(
retained_decoder,
&retained,
));
decoder_free(retained_decoder);
retained_decoder = null;
defer terminal_c.free(retained);
var retained_terminal_limit: usize = 0;
try testing.expectEqual(Result.success, terminal_c.get(
retained,
.continuation_max_bytes,
&retained_terminal_limit,
));
try testing.expectEqual(retained_limit, retained_terminal_limit);
var continuation_buf: [16]u8 = undefined;
var continuation_len: usize = 0;
try testing.expectEqual(Result.success, terminal_c.continuation_buf(
retained,
&continuation_buf,
continuation_buf.len,
&continuation_len,
));
try testing.expectEqualStrings(
"\x1b[31",
continuation_buf[0..continuation_len],
);
// Disabling tracking releases the exported prefix without changing the
// unfinished parser state. The final byte still completes the SGR.
const disabled: usize = 0;
try testing.expectEqual(Result.success, terminal_c.set(
retained,
.continuation_max_bytes,
&disabled,
));
try testing.expectEqual(Result.invalid_value, terminal_c.continuation_buf(
retained,
&continuation_buf,
continuation_buf.len,
&continuation_len,
));
terminal_c.vt_write(retained, "m", 1);
var retained_ground = false;
try testing.expectEqual(Result.success, terminal_c.get(
retained,
.vt_ground,
&retained_ground,
));
try testing.expect(retained_ground);
var limited: Decoder = null;
try testing.expectEqual(Result.success, decoder_new_buf(
&lib.alloc.test_allocator,
@@ -832,7 +944,6 @@ test "snapshot C API full round trip restores continuation" {
&failed_offset,
));
const disabled: usize = 0;
try testing.expectEqual(Result.success, terminal_c.set(
source,
.continuation_max_bytes,
@@ -847,6 +958,158 @@ test "snapshot C API full round trip restores continuation" {
));
}
test "snapshot decoder retains every supported continuation class" {
const cases = [_]struct {
name: []const u8,
prefix: []const u8,
suffix: []const u8,
}{
.{ .name = "ground", .prefix = "", .suffix = "X" },
.{ .name = "CSI", .prefix = "\x1b[31", .suffix = "mX" },
.{ .name = "OSC", .prefix = "\x1b]2;title", .suffix = "\x07" },
.{ .name = "DCS", .prefix = "\x1bP$qm", .suffix = "\x1b\\" },
.{ .name = "APC", .prefix = "\x1b_Ga=q;payload", .suffix = "\x1b\\" },
.{ .name = "UTF-8", .prefix = "\xF0\x9F", .suffix = "\x98\x84" },
};
const limit: usize = 4096;
const disabled: usize = 0;
const retain = true;
for (cases) |case| {
errdefer std.log.err("retained continuation case failed: {s}", .{case.name});
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 testing.expectEqual(Result.success, terminal_c.set(
source,
.continuation_max_bytes,
&limit,
));
terminal_c.vt_write(source, case.prefix.ptr, case.prefix.len);
var snapshot_ptr: ?[*]u8 = null;
var snapshot_len: usize = 0;
try testing.expectEqual(Result.success, encode_alloc(
source,
&lib.alloc.test_allocator,
&snapshot_ptr,
&snapshot_len,
));
const snapshot = snapshot_ptr.?[0..snapshot_len];
defer lib.alloc.default(&lib.alloc.test_allocator).free(snapshot);
var decoder: Decoder = null;
try testing.expectEqual(Result.success, decoder_new_buf(
&lib.alloc.test_allocator,
&decoder,
snapshot.ptr,
snapshot.len,
));
defer if (decoder != null) decoder_free(decoder);
try testing.expectEqual(Result.success, decoder_set(
decoder,
.max_continuation_bytes,
&limit,
));
try testing.expectEqual(Result.success, decoder_set(
decoder,
.retain_continuation,
&retain,
));
var restored: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, decoder_decode(
decoder,
&restored,
));
defer terminal_c.free(restored);
decoder_free(decoder);
decoder = null;
var restored_limit: usize = 0;
try testing.expectEqual(Result.success, terminal_c.get(
restored,
.continuation_max_bytes,
&restored_limit,
));
try testing.expectEqual(limit, restored_limit);
var continuation: [4096]u8 = undefined;
var continuation_len: usize = 0;
try testing.expectEqual(Result.success, terminal_c.continuation_buf(
restored,
&continuation,
continuation.len,
&continuation_len,
));
try testing.expectEqualStrings(
case.prefix,
continuation[0..continuation_len],
);
// Releasing the retained bytes must not reset the parser. Both the
// source and replica should reach the same fully serializable state
// after consuming the suffix that followed the snapshot cut.
try testing.expectEqual(Result.success, terminal_c.set(
restored,
.continuation_max_bytes,
&disabled,
));
try testing.expectEqual(Result.invalid_value, terminal_c.continuation_buf(
restored,
&continuation,
continuation.len,
&continuation_len,
));
terminal_c.vt_write(source, case.suffix.ptr, case.suffix.len);
terminal_c.vt_write(restored, case.suffix.ptr, case.suffix.len);
var source_ground = false;
var restored_ground = false;
try testing.expectEqual(Result.success, terminal_c.get(
source,
.vt_ground,
&source_ground,
));
try testing.expectEqual(Result.success, terminal_c.get(
restored,
.vt_ground,
&restored_ground,
));
try testing.expect(source_ground);
try testing.expect(restored_ground);
var source_final_ptr: ?[*]u8 = null;
var source_final_len: usize = 0;
try testing.expectEqual(Result.success, encode_alloc(
source,
&lib.alloc.test_allocator,
&source_final_ptr,
&source_final_len,
));
const source_final = source_final_ptr.?[0..source_final_len];
defer lib.alloc.default(&lib.alloc.test_allocator).free(source_final);
var restored_final_ptr: ?[*]u8 = null;
var restored_final_len: usize = 0;
try testing.expectEqual(Result.success, encode_alloc(
restored,
&lib.alloc.test_allocator,
&restored_final_ptr,
&restored_final_len,
));
const restored_final = restored_final_ptr.?[0..restored_final_len];
defer lib.alloc.default(&lib.alloc.test_allocator).free(restored_final);
try testing.expectEqualSlices(u8, source_final, restored_final);
}
}
test "snapshot encoding accepts an untracked ground terminal" {
var source: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
@@ -1112,6 +1375,12 @@ test "snapshot incremental decoder exposes READY and page progress" {
.get(.primary).?.pages.pages.first.?.capacity().rows;
for (0..@as(usize, first_page_rows) * 3) |_|
terminal_c.vt_write(source, "x\r\n", 3);
const ready_continuation = "\x1b]2;ready";
terminal_c.vt_write(
source,
ready_continuation,
ready_continuation.len,
);
var encoded_ptr: ?[*]u8 = null;
var encoded_len: usize = 0;
@@ -1132,11 +1401,45 @@ test "snapshot incremental decoder exposes READY and page progress" {
encoded.len,
));
defer decoder_free(decoder);
const retained_limit: usize = 1024;
try testing.expectEqual(Result.success, decoder_set(
decoder,
.max_continuation_bytes,
&retained_limit,
));
const retain = true;
try testing.expectEqual(Result.success, decoder_set(
decoder,
.retain_continuation,
&retain,
));
var restored: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, decoder_ready(decoder, &restored));
defer terminal_c.free(restored);
// READY applies the same retention policy as one-shot decode and makes
// the unfinished snapshot continuation immediately exportable.
var restored_limit: usize = 0;
try testing.expectEqual(Result.success, terminal_c.get(
restored,
.continuation_max_bytes,
&restored_limit,
));
try testing.expectEqual(retained_limit, restored_limit);
var ready_buf: [32]u8 = undefined;
var ready_len: usize = 0;
try testing.expectEqual(Result.success, terminal_c.continuation_buf(
restored,
&ready_buf,
ready_buf.len,
&ready_len,
));
try testing.expectEqualStrings(
ready_continuation,
ready_buf[0..ready_len],
);
var history_rows: u64 = 0;
try testing.expectEqual(Result.success, decoder_get(
decoder,

View File

@@ -579,12 +579,15 @@ pub const FromDecodedError = error{
/// 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.
/// `continuation_max_bytes` selects the returned terminal's tracking policy:
/// zero uses a temporary exact-size tracker and restores the ordinary C
/// default before returning, while a nonzero value leaves tracking enabled
/// with that limit.
pub fn fromDecoded(
alloc: std.mem.Allocator,
io: Io,
decoded: *snapshot_core.Decoded,
continuation_max_bytes: usize,
) FromDecodedError!Terminal {
const native = alloc.create(ZigTerminal) catch {
io.deinit(alloc);
@@ -596,11 +599,18 @@ pub fn fromDecoded(
.ground => "",
.bytes => |bytes| bytes,
};
assert(continuation_max_bytes == 0 or
continuation.len <= continuation_max_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| {
// Without opt-in retention, non-ground state needs tracking only long
// enough to verify that replay reconstructed the exact canonical
// continuation. With retention, even ground state needs a tracker so its
// continuation can be exported successfully as an empty slice.
const tracker_max_bytes = if (continuation_max_bytes > 0)
continuation_max_bytes
else
continuation.len;
const terminal = wrap(alloc, native, io, tracker_max_bytes) catch |err| {
native.deinit(alloc);
alloc.destroy(native);
io.deinit(alloc);
@@ -610,8 +620,8 @@ pub fn fromDecoded(
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.
// The decoded bytes fit this fresh tracker's cap, so losing them
// while replaying can only be an allocation failure.
error.OutOfMemory,
error.ContinuationUnavailable,
=> error.OutOfMemory,
@@ -621,9 +631,12 @@ pub fn fromDecoded(
};
}
// 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);
// Unless the decoder opted into retention, restore the same disabled
// tracking default used by newly created C terminals. Disabling tracking
// does not alter the parser or UTF-8 state reconstructed above.
if (continuation_max_bytes == 0) {
setContinuationMaxBytes(terminal.?, default_continuation_max_bytes);
}
return terminal;
}