Files
ghostty/src/terminal/c/io.zig
Mitchell Hashimoto d7bb4b8639 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);
```
2026-08-03 13:09:04 -07:00

595 lines
22 KiB
Zig

//! 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);
}