mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-25 06:18:37 +00:00
The terminal.Stream next/nextSlice functions can now no longer fail. All prior failure modes were fully isolated in the handler `vt` callbacks. As such, vt callbacks are now required to not return an error and handle their own errors somehow. Allowing streams to be fallible before was an incorrect design. It caused problematic scenarios like in `nextSlice` early terminating processing due to handler errors. This should not be possible. There is no safe way to bubble up vt errors through the stream because if nextSlice is called and multiple errors are returned, we can't coalesce them. We could modify that to return a partial result but its just more work for stream that is unnecessary. The handler can do all of this. This work was discovered due to cleanups to prepare for more C APIs. Less errors make C APIs easier to implement! And, it helps clean up our Zig, too.
162 lines
4.9 KiB
Zig
162 lines
4.9 KiB
Zig
//! This benchmark tests the performance of the terminal stream
|
|
//! handler from input to terminal state update. This is useful to
|
|
//! test general throughput of VT parsing and handling.
|
|
//!
|
|
//! Note that the handler used for this benchmark isn't the full
|
|
//! terminal handler, since that requires a significant amount of
|
|
//! state. This is a simplified version that only handles specific
|
|
//! terminal operations like printing characters. We should expand
|
|
//! this to include more operations to improve the accuracy of the
|
|
//! benchmark.
|
|
//!
|
|
//! It is a fairly broad benchmark that can be used to determine
|
|
//! if we need to optimize something more specific (e.g. the parser).
|
|
const TerminalStream = @This();
|
|
|
|
const std = @import("std");
|
|
const assert = std.debug.assert;
|
|
const Allocator = std.mem.Allocator;
|
|
const terminalpkg = @import("../terminal/main.zig");
|
|
const Benchmark = @import("Benchmark.zig");
|
|
const options = @import("options.zig");
|
|
const Terminal = terminalpkg.Terminal;
|
|
const Stream = terminalpkg.Stream(*Handler);
|
|
|
|
const log = std.log.scoped(.@"terminal-stream-bench");
|
|
|
|
opts: Options,
|
|
terminal: Terminal,
|
|
handler: Handler,
|
|
stream: Stream,
|
|
|
|
/// The file, opened in the setup function.
|
|
data_f: ?std.fs.File = null,
|
|
|
|
pub const Options = struct {
|
|
/// The size of the terminal. This affects benchmarking when
|
|
/// dealing with soft line wrapping and the memory impact
|
|
/// of page sizes.
|
|
@"terminal-rows": u16 = 80,
|
|
@"terminal-cols": u16 = 120,
|
|
|
|
/// The data to read as a filepath. If this is "-" then
|
|
/// we will read stdin. If this is unset, then we will
|
|
/// do nothing (benchmark is a noop). It'd be more unixy to
|
|
/// use stdin by default but I find that a hanging CLI command
|
|
/// with no interaction is a bit annoying.
|
|
data: ?[]const u8 = null,
|
|
};
|
|
|
|
/// Create a new terminal stream handler for the given arguments.
|
|
pub fn create(
|
|
alloc: Allocator,
|
|
opts: Options,
|
|
) !*TerminalStream {
|
|
const ptr = try alloc.create(TerminalStream);
|
|
errdefer alloc.destroy(ptr);
|
|
|
|
ptr.* = .{
|
|
.opts = opts,
|
|
.terminal = try .init(alloc, .{
|
|
.rows = opts.@"terminal-rows",
|
|
.cols = opts.@"terminal-cols",
|
|
}),
|
|
.handler = .{ .t = &ptr.terminal },
|
|
.stream = .init(&ptr.handler),
|
|
};
|
|
|
|
return ptr;
|
|
}
|
|
|
|
pub fn destroy(self: *TerminalStream, alloc: Allocator) void {
|
|
self.terminal.deinit(alloc);
|
|
alloc.destroy(self);
|
|
}
|
|
|
|
pub fn benchmark(self: *TerminalStream) Benchmark {
|
|
return .init(self, .{
|
|
.stepFn = step,
|
|
.setupFn = setup,
|
|
.teardownFn = teardown,
|
|
});
|
|
}
|
|
|
|
fn setup(ptr: *anyopaque) Benchmark.Error!void {
|
|
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
|
|
|
|
// Always reset our terminal state
|
|
self.terminal.fullReset();
|
|
|
|
// Open our data file to prepare for reading. We can do more
|
|
// validation here eventually.
|
|
assert(self.data_f == null);
|
|
self.data_f = options.dataFile(self.opts.data) catch |err| {
|
|
log.warn("error opening data file err={}", .{err});
|
|
return error.BenchmarkFailed;
|
|
};
|
|
}
|
|
|
|
fn teardown(ptr: *anyopaque) void {
|
|
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
|
|
if (self.data_f) |f| {
|
|
f.close();
|
|
self.data_f = null;
|
|
}
|
|
}
|
|
|
|
fn step(ptr: *anyopaque) Benchmark.Error!void {
|
|
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
|
|
|
|
// Get our buffered reader so we're not predominantly
|
|
// waiting on file IO. It'd be better to move this fully into
|
|
// memory. If we're IO bound though that should show up on
|
|
// the benchmark results and... I know writing this that we
|
|
// aren't currently IO bound.
|
|
const f = self.data_f orelse return;
|
|
|
|
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
|
|
var f_reader = f.reader(&read_buf);
|
|
const r = &f_reader.interface;
|
|
|
|
var buf: [4096]u8 = undefined;
|
|
while (true) {
|
|
const n = r.readSliceShort(&buf) catch {
|
|
log.warn("error reading data file err={?}", .{f_reader.err});
|
|
return error.BenchmarkFailed;
|
|
};
|
|
if (n == 0) break; // EOF reached
|
|
self.stream.nextSlice(buf[0..n]);
|
|
}
|
|
}
|
|
|
|
/// Implements the handler interface for the terminal.Stream.
|
|
/// We should expand this to include more operations to make
|
|
/// our benchmark more realistic.
|
|
const Handler = struct {
|
|
t: *Terminal,
|
|
|
|
pub fn vt(
|
|
self: *Handler,
|
|
comptime action: Stream.Action.Tag,
|
|
value: Stream.Action.Value(action),
|
|
) void {
|
|
switch (action) {
|
|
.print => self.t.print(value.cp) catch |err| {
|
|
log.warn("error processing benchmark print err={}", .{err});
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
};
|
|
|
|
test TerminalStream {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
const impl: *TerminalStream = try .create(alloc, .{});
|
|
defer impl.destroy(alloc);
|
|
|
|
const bench = impl.benchmark();
|
|
_ = try bench.run(.once);
|
|
}
|