diff --git a/include/ghostty/vt/snapshot.h b/include/ghostty/vt/snapshot.h index fb09d2415..89cb53983 100644 --- a/include/ghostty/vt/snapshot.h +++ b/include/ghostty/vt/snapshot.h @@ -7,6 +7,7 @@ #ifndef GHOSTTY_VT_SNAPSHOT_H #define GHOSTTY_VT_SNAPSHOT_H +#include #include #include @@ -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 diff --git a/src/terminal/c/snapshot.zig b/src/terminal/c/snapshot.zig index e978ca49b..75cbc1773 100644 --- a/src/terminal/c/snapshot.zig +++ b/src/terminal/c/snapshot.zig @@ -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, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 8732c5dc8..552ee5e33 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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; }