mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 20:59:02 +00:00
terminal/snapshot: format doesn't require EOF
Treat FINISH as the self-delimiting snapshot boundary instead of peeking for end-of-file. Normal decoding now leaves continuation bytes unread, allowing snapshots and live protocol data to share a stream without waiting for closure. Add decodeExact for bounded files that still require strict exhaustion, and update the Kaitai schema, documentation, and tests for continuation and sequential snapshot decoding.
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
//!
|
||||
//! READY covers the envelope, TERMINAL, and all SCREEN/PAGE sequences. FINISH
|
||||
//! covers that same prefix plus the complete READY record and all HISTORY/PAGE
|
||||
//! sequences. Neither digest includes its own record. FINISH must be followed
|
||||
//! immediately by end-of-file; READY may be followed by history.
|
||||
//! sequences. Neither digest includes its own record. FINISH terminates one
|
||||
//! snapshot; bytes after it belong to the containing transport.
|
||||
//!
|
||||
//! The digest does not replace record framing. Its 32-byte payload is still
|
||||
//! protected by the record's CRC32C.
|
||||
@@ -76,9 +76,6 @@ pub const DecodeError = record.Reader.InitError ||
|
||||
|
||||
/// The checkpoint does not describe the preceding snapshot bytes.
|
||||
InvalidDigest,
|
||||
|
||||
/// FINISH was followed by additional bytes.
|
||||
TrailingData,
|
||||
};
|
||||
|
||||
/// Decode and validate one checkpoint against the running prefix digest.
|
||||
@@ -105,14 +102,6 @@ pub fn decode(
|
||||
try record_reader.payloadReader().readSliceAll(&actual);
|
||||
try record_reader.finish();
|
||||
if (!std.mem.eql(u8, &expected, &actual)) return error.InvalidDigest;
|
||||
|
||||
if (kind == .finish) {
|
||||
_ = source.peekByte() catch |err| switch (err) {
|
||||
error.EndOfStream => return,
|
||||
else => return err,
|
||||
};
|
||||
return error.TrailingData;
|
||||
}
|
||||
}
|
||||
|
||||
const test_ready_fixture = test_fixture.parse(
|
||||
@@ -212,7 +201,7 @@ test "READY and FINISH checkpoint coverage" {
|
||||
);
|
||||
}
|
||||
|
||||
test "checkpoint rejects wrong tags, digests, and FINISH trailing data" {
|
||||
test "checkpoint rejects wrong tags and digests" {
|
||||
const testing = std.testing;
|
||||
|
||||
var snapshot: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
@@ -260,6 +249,10 @@ test "checkpoint rejects wrong tags, digests, and FINISH trailing data" {
|
||||
error.InvalidDigest,
|
||||
decode(.ready, &invalid_digest_stream),
|
||||
);
|
||||
}
|
||||
|
||||
test "FINISH leaves continuation bytes unread" {
|
||||
const testing = std.testing;
|
||||
|
||||
var finished: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer finished.deinit();
|
||||
@@ -277,8 +270,6 @@ test "checkpoint rejects wrong tags, digests, and FINISH trailing data" {
|
||||
&trailing_source,
|
||||
);
|
||||
try trailing_stream.reader().discardAll("prefix".len);
|
||||
try testing.expectError(
|
||||
error.TrailingData,
|
||||
decode(.finish, &trailing_stream),
|
||||
);
|
||||
try decode(.finish, &trailing_stream);
|
||||
try testing.expectEqual(@as(u8, 0), try trailing_source.takeByte());
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
//! restore each active area. HISTORY contains the older complete pages for its
|
||||
//! screen in newest-to-oldest order so they can be prepended as they arrive.
|
||||
//! Every SCREEN has one corresponding HISTORY, even when its history page count
|
||||
//! is zero. FINISH is followed by end-of-file.
|
||||
//! is zero. FINISH terminates the snapshot. Bytes after FINISH belong to the
|
||||
//! containing transport and are not consumed by snapshot decoding.
|
||||
//!
|
||||
//! READY and FINISH contain BLAKE3-256 digests of all preceding snapshot bytes.
|
||||
//! READY therefore validates the renderable active-state prefix. FINISH covers
|
||||
@@ -100,6 +101,18 @@
|
||||
//!
|
||||
//! Each record type usually exposes an `encode` function that encodes
|
||||
//! a complete record, such as `screen.encode`.
|
||||
//!
|
||||
//! ## Decoding
|
||||
//!
|
||||
//! `snapshot.decode` consumes exactly one snapshot through FINISH and leaves
|
||||
//! any following bytes unread. This permits multiple snapshots or live protocol
|
||||
//! data to share a stream without waiting for the peer to close it. Transports
|
||||
//! that deliver live PTY data before history finishes must multiplex that data
|
||||
//! outside this ordered snapshot record sequence.
|
||||
//!
|
||||
//! Use `snapshot.decodeExact` for a bounded file or buffer that must contain
|
||||
//! only one snapshot. It preserves the stricter end-of-file check, which may
|
||||
//! block when used with a live stream.
|
||||
|
||||
pub const checkpoint = @import("checkpoint.zig");
|
||||
pub const envelope = @import("envelope.zig");
|
||||
|
||||
@@ -10,7 +10,8 @@ doc: |
|
||||
A complete snapshot contains an envelope, terminal-wide state, one or two
|
||||
renderable screen sequences, a READY checkpoint, matching history sequences,
|
||||
and a FINISH checkpoint. SCREEN pages are oldest-to-newest. HISTORY pages are
|
||||
newest-to-oldest.
|
||||
newest-to-oldest. FINISH terminates the snapshot; bytes that follow belong to
|
||||
the containing transport and are outside this schema.
|
||||
|
||||
Record CRC32C values and checkpoint BLAKE3-256 digests are represented here
|
||||
but cannot be calculated by portable Kaitai Struct expressions. The adjacent
|
||||
@@ -32,10 +33,6 @@ seq:
|
||||
repeat-expr: terminal.payload.header.screen_count
|
||||
- id: finish
|
||||
type: checkpoint_record(6)
|
||||
- id: trailing_data
|
||||
size-eos: true
|
||||
valid:
|
||||
expr: _.size == 0
|
||||
enums:
|
||||
record_tag:
|
||||
1: terminal
|
||||
|
||||
@@ -100,11 +100,12 @@ pub const DecodeError = envelope.DecodeError ||
|
||||
|
||||
/// Restore one complete snapshot into a native terminal.
|
||||
///
|
||||
/// The reader must contain exactly one snapshot through FINISH. Restoration is
|
||||
/// transactional: the returned terminal is either complete and ready or
|
||||
/// not (error return). Individual record codecs normalize optional semantic
|
||||
/// state, while framing, checkpoints, declared sequence counts, and unique
|
||||
/// cross-record screen routing remain strict.
|
||||
/// This consumes one snapshot through FINISH and leaves any following bytes in
|
||||
/// the reader for the containing transport. Restoration is transactional: the
|
||||
/// returned terminal is either complete and ready or not (error return).
|
||||
/// Individual record codecs normalize optional semantic state, while framing,
|
||||
/// checkpoints, declared sequence counts, and unique cross-record screen
|
||||
/// routing remain strict.
|
||||
pub fn decode(
|
||||
source: *std.Io.Reader,
|
||||
io_: std.Io,
|
||||
@@ -218,6 +219,31 @@ pub fn decode(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Errors possible while restoring a snapshot that must end at end-of-file.
|
||||
pub const DecodeExactError = DecodeError || std.Io.Reader.Error || error{
|
||||
/// FINISH was followed by additional bytes.
|
||||
TrailingData,
|
||||
};
|
||||
|
||||
/// Restore one snapshot and require FINISH to be followed by end-of-file.
|
||||
///
|
||||
/// This is intended for bounded snapshot files and buffers. On a live stream,
|
||||
/// checking for end-of-file may block; use `decode` to stop at FINISH instead.
|
||||
pub fn decodeExact(
|
||||
source: *std.Io.Reader,
|
||||
io_: std.Io,
|
||||
alloc: Allocator,
|
||||
) DecodeExactError!Terminal {
|
||||
var result = try decode(source, io_, alloc);
|
||||
errdefer result.deinit(alloc);
|
||||
|
||||
_ = source.peekByte() catch |err| switch (err) {
|
||||
error.EndOfStream => return result,
|
||||
else => return err,
|
||||
};
|
||||
return error.TrailingData;
|
||||
}
|
||||
|
||||
test "complete snapshot round trip with history and alternate screen" {
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -543,7 +569,7 @@ test "complete snapshot encoding streams from the current writer position" {
|
||||
);
|
||||
}
|
||||
|
||||
test "complete snapshot rejects ordering checkpoints and trailing data" {
|
||||
test "complete snapshot rejects ordering and invalid checkpoints" {
|
||||
const testing = std.testing;
|
||||
|
||||
var t = try Terminal.init(testing.io, testing.allocator, .{
|
||||
@@ -598,17 +624,6 @@ test "complete snapshot rejects ordering checkpoints and trailing data" {
|
||||
decode(&invalid_ready_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
// FINISH is the exact end of a complete snapshot.
|
||||
var trailing: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer trailing.deinit();
|
||||
try encode(testing.allocator, &trailing.writer, &t);
|
||||
try trailing.writer.writeByte(0);
|
||||
var trailing_source: std.Io.Reader = .fixed(trailing.written());
|
||||
try testing.expectError(
|
||||
error.TrailingData,
|
||||
decode(&trailing_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
// A SCREEN key must name one of the slots declared by TERMINAL.
|
||||
var undeclared: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer undeclared.deinit();
|
||||
@@ -694,3 +709,65 @@ test "complete snapshot rejects ordering checkpoints and trailing data" {
|
||||
decode(&duplicate_history_source, testing.io, testing.allocator),
|
||||
);
|
||||
}
|
||||
|
||||
test "complete snapshot leaves continuation bytes unread" {
|
||||
const testing = std.testing;
|
||||
|
||||
var t = try Terminal.init(testing.io, testing.allocator, .{
|
||||
.cols = 2,
|
||||
.rows = 1,
|
||||
});
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer encoded.deinit();
|
||||
try encode(testing.allocator, &encoded.writer, &t);
|
||||
const snapshot_len = encoded.written().len;
|
||||
try encoded.writer.writeAll("pty");
|
||||
|
||||
var source: std.Io.Reader = .fixed(encoded.written());
|
||||
var restored = try decode(&source, testing.io, testing.allocator);
|
||||
defer restored.deinit(testing.allocator);
|
||||
|
||||
var continuation: [3]u8 = undefined;
|
||||
try source.readSliceAll(&continuation);
|
||||
try testing.expectEqualStrings("pty", &continuation);
|
||||
|
||||
var exact_source: std.Io.Reader = .fixed(encoded.written());
|
||||
try testing.expectError(
|
||||
error.TrailingData,
|
||||
decodeExact(&exact_source, testing.io, testing.allocator),
|
||||
);
|
||||
|
||||
var bounded_source: std.Io.Reader = .fixed(
|
||||
encoded.written()[0..snapshot_len],
|
||||
);
|
||||
var bounded = try decodeExact(
|
||||
&bounded_source,
|
||||
testing.io,
|
||||
testing.allocator,
|
||||
);
|
||||
defer bounded.deinit(testing.allocator);
|
||||
}
|
||||
|
||||
test "complete snapshots decode sequentially from one reader" {
|
||||
const testing = std.testing;
|
||||
|
||||
var t = try Terminal.init(testing.io, testing.allocator, .{
|
||||
.cols = 2,
|
||||
.rows = 1,
|
||||
});
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer encoded.deinit();
|
||||
try encode(testing.allocator, &encoded.writer, &t);
|
||||
try encode(testing.allocator, &encoded.writer, &t);
|
||||
|
||||
var source: std.Io.Reader = .fixed(encoded.written());
|
||||
var first = try decode(&source, testing.io, testing.allocator);
|
||||
defer first.deinit(testing.allocator);
|
||||
var second = try decode(&source, testing.io, testing.allocator);
|
||||
defer second.deinit(testing.allocator);
|
||||
try testing.expectError(error.EndOfStream, source.takeByte());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user