From cbc9f360b1d6be9305f25cd6e68c49884fa24187 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 2 Aug 2026 20:48:22 -0700 Subject: [PATCH] terminal/snapshot: report invalid decoder states Decoder.next treated calls before READY and calls after any prior decode error as unreachable. Network or mux glue that retried after a truncated history record, or invoked next before setup completed, could therefore turn a recoverable protocol misuse into a process panic. Add DecoderNotReady and DecoderFailed to NextError and return them for the start and failed states. Keep finished calls idempotent, and cover both an early call and a retry after FINISH truncation. --- src/terminal/snapshot/snapshot.zig | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index a6b3146aa..2153802b4 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -413,6 +413,12 @@ pub const Decoder = struct { page.DecodeError || Allocator.Error || error{ + /// `next` was called before `ready` completed successfully. + DecoderNotReady, + + /// A prior `next` call failed and invalidated the stream position. + DecoderFailed, + /// A HISTORY names a key not declared by TERMINAL. UnexpectedHistoryKey, @@ -457,7 +463,8 @@ pub const Decoder = struct { switch (self.state) { // `ready` must succeed before history exists to decode, and a // failed decoder no longer knows its stream position. - .start, .failed => unreachable, + .start => return error.DecoderNotReady, + .failed => return error.DecoderFailed, .finished => return null, .history => {}, } @@ -1774,6 +1781,23 @@ test "incremental decode restores a renderable terminal at READY" { try testing.expectEqualStrings(&test_complete_fixture, reencoded.written()); } +test "incremental next reports invalid decoder states" { + const testing = std.testing; + var source: std.Io.Reader = .fixed(""); + var decoder: Decoder = .init(&source); + var terminal_value = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 1, .rows = 1 }, + ); + defer terminal_value.deinit(testing.allocator); + + try testing.expectError( + error.DecoderNotReady, + decoder.next(testing.allocator, &terminal_value), + ); +} + test "incremental decode applies history below live PTY output" { const testing = std.testing; @@ -2065,6 +2089,10 @@ test "incremental decode failure leaves applied history usable" { error.EndOfStream, decoder.next(testing.allocator, &restored), ); + try testing.expectError( + error.DecoderFailed, + decoder.next(testing.allocator, &restored), + ); // The transferred terminal was never owned by the decoder: it remains // valid with the contiguous history prefix that did apply, and only