mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-10 11:19:45 +00:00
terminal: dispatch APC string bytes in bulk slices
APC payloads such as Kitty graphics images can be megabytes of base64 data, but every byte was dispatched individually: through the VT state machine table, an apc_put action, the stream handler, the APC protocol handler, and finally a per-byte ArrayList append in the Kitty command parser. Five layers of dispatch per byte made large image transfers far slower than they needed to be. Add a bulk fast path alongside the existing CSI fast paths in consumeUntilGround: scan the longest run of apc_put bytes (stopping at any byte the parse table doesn't treat as APC payload: CAN, SUB, ESC, and most C1 bytes exit or abort the string state, and 0xA0-0xFF are ignored by it) and dispatch the run as a single new apc_put_slice action. The APC handler identifies the protocol from the first few bytes as before, then passes the remainder of each slice to the protocol parser in bulk; the Kitty parser appends payload data with a single appendSlice. Ignored/unknown APC sequences now drop each slice in O(1) instead of per-byte dispatch. The fast path is guarded the same way as the CSI fast paths: handlers with a vtRaw hook (the inspector) keep receiving per-byte apc_put actions, and the scalar next() path is unchanged. Also add benchmark support: a `ghostty-gen +kitty` synthetic generator emitting well-formed Kitty graphics transmit commands with 4 KiB random base64 payloads (not valid image data; the corpus exercises the parsing paths, not image decoding), and a `ghostty-bench +apc-parser` benchmark that measures the stream -> APC -> Kitty parse path without image decode/storage. Benchmarks on a 64 MiB corpus (hyperfine, ReleaseFast, x86_64 Linux, baseline is identical source with only the fast path disabled): apc-parser: 1.061 s -> 43 ms (~25x) terminal-stream (kitty): 1.163 s -> 72 ms (~16x) terminal-stream (ascii): no change The ascii case was verified with retired instruction counts (perf stat, pinned to one core) since wall time on the test machine has 4-7 ms of noise: 988,030,458 vs 988,045,833 instructions (+0.0016%), a fixed startup-size delta; the ground-state hot loop never reaches the new branch.
This commit is contained in:
143
src/benchmark/ApcParser.zig
Normal file
143
src/benchmark/ApcParser.zig
Normal file
@@ -0,0 +1,143 @@
|
||||
//! This benchmark tests the throughput of APC sequence parsing
|
||||
//! through the terminal stream: VT state machine dispatch, APC
|
||||
//! protocol identification, and the protocol command parsers
|
||||
//! (e.g. Kitty graphics). Completed commands are parsed and then
|
||||
//! discarded; command execution (image decoding, storage) is not
|
||||
//! included so this isolates the parsing path.
|
||||
const ApcParser = @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 log = std.log.scoped(.@"apc-parser-bench");
|
||||
|
||||
opts: Options,
|
||||
stream: Stream,
|
||||
|
||||
/// The file, opened in the setup function.
|
||||
data_f: ?std.fs.File = null,
|
||||
|
||||
pub const Options = struct {
|
||||
/// 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,
|
||||
};
|
||||
|
||||
const Stream = terminalpkg.Stream(Handler);
|
||||
|
||||
/// A stream handler that only processes APC actions, parsing and
|
||||
/// immediately discarding completed commands.
|
||||
const Handler = struct {
|
||||
alloc: Allocator,
|
||||
apc: terminalpkg.apc.Handler = .{},
|
||||
|
||||
pub fn deinit(self: *Handler) void {
|
||||
self.apc.deinit();
|
||||
}
|
||||
|
||||
pub fn vt(
|
||||
self: *Handler,
|
||||
comptime action: Stream.Action.Tag,
|
||||
value: Stream.Action.Value(action),
|
||||
) void {
|
||||
switch (action) {
|
||||
.apc_start => self.apc.start(),
|
||||
.apc_put => self.apc.feed(self.alloc, value),
|
||||
.apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes),
|
||||
.apc_end => if (self.apc.end()) |cmd| {
|
||||
var c = cmd;
|
||||
std.mem.doNotOptimizeAway(&c);
|
||||
c.deinit(self.alloc);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Create a new APC parser benchmark for the given arguments.
|
||||
pub fn create(
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
) !*ApcParser {
|
||||
const ptr = try alloc.create(ApcParser);
|
||||
errdefer alloc.destroy(ptr);
|
||||
ptr.* = .{
|
||||
.opts = opts,
|
||||
.stream = .init(.{ .alloc = alloc }),
|
||||
};
|
||||
return ptr;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *ApcParser, alloc: Allocator) void {
|
||||
self.stream.deinit();
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
pub fn benchmark(self: *ApcParser) Benchmark {
|
||||
return .init(self, .{
|
||||
.stepFn = step,
|
||||
.setupFn = setup,
|
||||
.teardownFn = teardown,
|
||||
});
|
||||
}
|
||||
|
||||
fn setup(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ApcParser = @ptrCast(@alignCast(ptr));
|
||||
|
||||
// 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: *ApcParser = @ptrCast(@alignCast(ptr));
|
||||
if (self.data_f) |f| {
|
||||
f.close();
|
||||
self.data_f = null;
|
||||
}
|
||||
}
|
||||
|
||||
fn step(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ApcParser = @ptrCast(@alignCast(ptr));
|
||||
|
||||
const f = self.data_f orelse return;
|
||||
|
||||
var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined;
|
||||
var f_reader = f.reader(&read_buf);
|
||||
const r = &f_reader.interface;
|
||||
|
||||
// This buffer size matches the read buffer size used by the
|
||||
// real IO thread (see termio Exec.zig buffer_capacity) so that
|
||||
// the benchmark exercises the stream with realistic chunk sizes.
|
||||
var buf: [64 * 1024]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]);
|
||||
}
|
||||
}
|
||||
|
||||
test ApcParser {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const impl: *ApcParser = try .create(alloc, .{});
|
||||
defer impl.destroy(alloc);
|
||||
|
||||
const bench = impl.benchmark();
|
||||
_ = try bench.run(.once);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const cli = @import("../cli.zig");
|
||||
/// benchmarks. View docs for each individual one in the predictably
|
||||
/// named files.
|
||||
pub const Action = enum {
|
||||
@"apc-parser",
|
||||
@"codepoint-width",
|
||||
@"grapheme-break",
|
||||
@"page-compression",
|
||||
@@ -26,6 +27,7 @@ pub const Action = enum {
|
||||
/// See TerminalStream for an example.
|
||||
pub fn Struct(comptime action: Action) type {
|
||||
return switch (action) {
|
||||
.@"apc-parser" => @import("ApcParser.zig"),
|
||||
.@"screen-clone" => @import("ScreenClone.zig"),
|
||||
.@"page-compression" => @import("PageCompression.zig"),
|
||||
.@"scrollback-compression" => @import("ScrollbackCompression.zig"),
|
||||
|
||||
88
src/synthetic/Kitty.zig
Normal file
88
src/synthetic/Kitty.zig
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Generates random Kitty graphics protocol APC sequences.
|
||||
//!
|
||||
//! The payload is random base64, NOT a valid image: decoding it as
|
||||
//! PNG will fail. This corpus is meant for benchmarking the stream,
|
||||
//! APC, and Kitty command parsing paths, which never decode the
|
||||
//! image data. It does not exercise successful image loading.
|
||||
const Kitty = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Generator = @import("Generator.zig");
|
||||
const Bytes = @import("Bytes.zig");
|
||||
|
||||
/// Random number generator.
|
||||
rand: std.Random,
|
||||
|
||||
/// The base64 payload length for each generated command. This is
|
||||
/// rounded down to a multiple of four so the payload is always valid
|
||||
/// base64 without requiring padding. Kitty clients typically chunk
|
||||
/// payloads at 4096 bytes.
|
||||
data_len: usize = 4096,
|
||||
|
||||
fn checkBase64Alphabet(c: u8) bool {
|
||||
return switch (c) {
|
||||
'A'...'Z', 'a'...'z', '0'...'9', '+', '/' => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// The base64 alphabet, without the padding character.
|
||||
pub const base64_alphabet = Bytes.generateAlphabet(checkBase64Alphabet);
|
||||
|
||||
pub fn generator(self: *Kitty) Generator {
|
||||
return .init(self, next);
|
||||
}
|
||||
|
||||
const prefix = "\x1b_G";
|
||||
const st = "\x1b\\";
|
||||
|
||||
/// Get the next Kitty graphics APC sequence: a well-formed transmit
|
||||
/// command with a random base64 payload (not a valid image; see the
|
||||
/// module comment), including the APC prefix and the ST terminator.
|
||||
pub fn next(
|
||||
self: *Kitty,
|
||||
writer: *std.Io.Writer,
|
||||
max_len: usize,
|
||||
) Generator.Error!void {
|
||||
var control_buf: [64]u8 = undefined;
|
||||
const control = std.fmt.bufPrint(
|
||||
&control_buf,
|
||||
"a=t,f=100,i={d};",
|
||||
.{self.rand.intRangeAtMost(u32, 1, 1_000_000)},
|
||||
) catch unreachable;
|
||||
|
||||
const overhead = prefix.len + control.len + st.len;
|
||||
assert(max_len > overhead);
|
||||
|
||||
const avail = @min(self.data_len, max_len - overhead);
|
||||
const payload_len = avail - (avail % 4);
|
||||
|
||||
try writer.writeAll(prefix);
|
||||
try writer.writeAll(control);
|
||||
if (payload_len > 0) {
|
||||
const bytes: Bytes = .{
|
||||
.rand = self.rand,
|
||||
.alphabet = base64_alphabet,
|
||||
.min_len = payload_len,
|
||||
.max_len = payload_len,
|
||||
};
|
||||
_ = try bytes.write(writer);
|
||||
}
|
||||
try writer.writeAll(st);
|
||||
}
|
||||
|
||||
test "kitty" {
|
||||
const testing = std.testing;
|
||||
var prng = std.Random.DefaultPrng.init(0);
|
||||
|
||||
var buf: [8192]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
var v: Kitty = .{ .rand = prng.random() };
|
||||
const gen = v.generator();
|
||||
try gen.next(&writer, buf.len);
|
||||
|
||||
const data = writer.buffered();
|
||||
try testing.expect(std.mem.startsWith(u8, data, "\x1b_Ga=t,f=100,i="));
|
||||
try testing.expect(std.mem.endsWith(u8, data, "\x1b\\"));
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const cli = @import("../cli.zig");
|
||||
/// predictably named files under `cli/`.
|
||||
pub const Action = enum {
|
||||
ascii,
|
||||
kitty,
|
||||
osc,
|
||||
utf8,
|
||||
|
||||
@@ -21,6 +22,7 @@ pub const Action = enum {
|
||||
pub fn Struct(comptime action: Action) type {
|
||||
return switch (action) {
|
||||
.ascii => @import("cli/Ascii.zig"),
|
||||
.kitty => @import("cli/Kitty.zig"),
|
||||
.osc => @import("cli/Osc.zig"),
|
||||
.utf8 => @import("cli/Utf8.zig"),
|
||||
};
|
||||
|
||||
70
src/synthetic/cli/Kitty.zig
Normal file
70
src/synthetic/cli/Kitty.zig
Normal file
@@ -0,0 +1,70 @@
|
||||
const Kitty = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const synthetic = @import("../main.zig");
|
||||
|
||||
const log = std.log.scoped(.@"kitty-gen");
|
||||
|
||||
pub const Options = struct {
|
||||
/// The base64 payload length of each command in bytes, rounded
|
||||
/// down to a multiple of four. Kitty clients typically chunk
|
||||
/// payloads at 4096 bytes.
|
||||
@"data-len": usize = 4096,
|
||||
};
|
||||
|
||||
opts: Options,
|
||||
|
||||
/// The buffer a single generated sequence is written into. Sized to
|
||||
/// the payload length plus room for the control data and terminator.
|
||||
buf: []u8,
|
||||
|
||||
/// Create a new Kitty graphics sequence generator for the given arguments.
|
||||
pub fn create(
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
) !*Kitty {
|
||||
const ptr = try alloc.create(Kitty);
|
||||
errdefer alloc.destroy(ptr);
|
||||
|
||||
const buf = try alloc.alloc(u8, opts.@"data-len" + 128);
|
||||
errdefer alloc.free(buf);
|
||||
|
||||
ptr.* = .{ .opts = opts, .buf = buf };
|
||||
return ptr;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Kitty, alloc: Allocator) void {
|
||||
alloc.free(self.buf);
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
pub fn run(self: *Kitty, writer: *std.Io.Writer, rand: std.Random) !void {
|
||||
var gen: synthetic.Kitty = .{
|
||||
.rand = rand,
|
||||
.data_len = self.opts.@"data-len",
|
||||
};
|
||||
|
||||
while (true) {
|
||||
var fixed: std.Io.Writer = .fixed(self.buf);
|
||||
try gen.next(&fixed, self.buf.len);
|
||||
writer.writeAll(fixed.buffered()) catch |err| switch (err) {
|
||||
error.WriteFailed => return,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test Kitty {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const impl: *Kitty = try .create(alloc, .{});
|
||||
defer impl.destroy(alloc);
|
||||
|
||||
var prng = std.Random.DefaultPrng.init(1);
|
||||
const rand = prng.random();
|
||||
|
||||
var buf: [8192]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try impl.run(&writer, rand);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub const cli = @import("cli.zig");
|
||||
pub const Generator = @import("Generator.zig");
|
||||
pub const Bytes = @import("Bytes.zig");
|
||||
pub const Utf8 = @import("Utf8.zig");
|
||||
pub const Kitty = @import("Kitty.zig");
|
||||
pub const Osc = @import("Osc.zig");
|
||||
|
||||
test {
|
||||
|
||||
@@ -113,6 +113,47 @@ pub const Handler = struct {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a slice of bytes to the handler. This is equivalent to
|
||||
/// calling feed for each byte in order, but protocol payload bytes
|
||||
/// are passed through in bulk so large payloads (e.g. Kitty graphics
|
||||
/// images) avoid per-byte dispatch overhead.
|
||||
pub fn feedSlice(self: *Handler, alloc: Allocator, bytes: []const u8) void {
|
||||
var rem = bytes;
|
||||
while (rem.len > 0) {
|
||||
switch (self.state) {
|
||||
.inactive => unreachable,
|
||||
|
||||
// We're ignoring this APC command; drop the whole slice.
|
||||
.ignore => return,
|
||||
|
||||
// Identification consumes at most a few bytes; step
|
||||
// through them one at a time until the state changes.
|
||||
.identify => {
|
||||
self.feed(alloc, rem[0]);
|
||||
rem = rem[1..];
|
||||
},
|
||||
|
||||
.kitty => |*p| if (comptime build_options.kitty_graphics) {
|
||||
p.feedSlice(rem) catch |err| {
|
||||
log.warn("kitty graphics protocol error: {}", .{err});
|
||||
p.deinit();
|
||||
self.state = .ignore;
|
||||
};
|
||||
return;
|
||||
} else unreachable,
|
||||
|
||||
.glyph => |*p| {
|
||||
p.feedSlice(rem) catch |err| {
|
||||
log.warn("glyph protocol error: {}", .{err});
|
||||
p.deinit();
|
||||
self.state = .ignore;
|
||||
};
|
||||
return;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end(self: *Handler) ?Command {
|
||||
defer {
|
||||
self.state.deinit();
|
||||
@@ -390,6 +431,85 @@ test "valid glyph command" {
|
||||
try testing.expect(cmd.glyph == .query);
|
||||
}
|
||||
|
||||
test "feedSlice valid Kitty command" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
h.start();
|
||||
h.feedSlice(alloc, "Gf=24,s=10,v=20;aGVsbG8=");
|
||||
|
||||
var cmd = h.end().?;
|
||||
defer cmd.deinit(alloc);
|
||||
try testing.expect(cmd == .kitty);
|
||||
|
||||
// The payload is base64-decoded by the parser on completion.
|
||||
try testing.expectEqualStrings("hello", cmd.kitty.data);
|
||||
}
|
||||
|
||||
test "feedSlice identify split across slices" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
h.start();
|
||||
h.feedSlice(alloc, "G");
|
||||
h.feedSlice(alloc, "f=24,s=10,");
|
||||
h.feedSlice(alloc, "v=20;aGVsbG8=");
|
||||
|
||||
var cmd = h.end().?;
|
||||
defer cmd.deinit(alloc);
|
||||
try testing.expect(cmd == .kitty);
|
||||
|
||||
// The payload is base64-decoded by the parser on completion.
|
||||
try testing.expectEqualStrings("hello", cmd.kitty.data);
|
||||
}
|
||||
|
||||
test "feedSlice unknown APC command is ignored" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
h.start();
|
||||
h.feedSlice(alloc, "Xabcdef1234");
|
||||
try testing.expect(h.state == .ignore);
|
||||
h.feedSlice(alloc, "more data that is dropped");
|
||||
try testing.expect(h.end() == null);
|
||||
}
|
||||
|
||||
test "feedSlice valid glyph command" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
h.start();
|
||||
h.feedSlice(alloc, "25a1;q;cp=E0A0");
|
||||
|
||||
var cmd = h.end().?;
|
||||
defer cmd.deinit(alloc);
|
||||
try testing.expect(cmd == .glyph);
|
||||
try testing.expect(cmd.glyph == .query);
|
||||
}
|
||||
|
||||
test "feedSlice kitty max bytes exceeded" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{ .max_bytes = .init(.{ .kitty = 4 }) };
|
||||
defer h.deinit();
|
||||
h.start();
|
||||
h.feedSlice(alloc, "Ga=t;abcd");
|
||||
try testing.expect(h.state != .ignore);
|
||||
h.feedSlice(alloc, "e");
|
||||
try testing.expect(h.state == .ignore);
|
||||
}
|
||||
|
||||
test "disabled glyph command is ignored" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -34,6 +34,13 @@ pub const CommandParser = struct {
|
||||
try self.data.append(self.alloc, byte);
|
||||
}
|
||||
|
||||
/// Append a slice of APC payload bytes to the buffered command.
|
||||
/// Equivalent to calling feed for each byte, but appends in bulk.
|
||||
pub fn feedSlice(self: *CommandParser, bytes: []const u8) Allocator.Error!void {
|
||||
if (self.data.items.len + bytes.len > self.max_bytes) return error.OutOfMemory;
|
||||
try self.data.appendSlice(self.alloc, bytes);
|
||||
}
|
||||
|
||||
/// Finish parsing and return an owned request that can outlive the parser.
|
||||
pub fn complete(self: *CommandParser, alloc: Allocator) Error!Request {
|
||||
// Normalize bare single-byte verbs like `s` into `s;` so the parsed
|
||||
|
||||
@@ -149,6 +149,26 @@ pub const Parser = struct {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a slice of bytes to the parser. This is equivalent to
|
||||
/// calling feed for each byte in order, but once we're in the data
|
||||
/// state the remainder of the slice is appended in bulk, avoiding
|
||||
/// per-byte overhead for large payloads.
|
||||
pub fn feedSlice(self: *Parser, bytes: []const u8) !void {
|
||||
var rem = bytes;
|
||||
while (rem.len > 0) {
|
||||
if (self.state == .data) {
|
||||
if (self.data.items.len + rem.len > self.max_bytes) {
|
||||
return error.OutOfMemory;
|
||||
}
|
||||
try self.data.appendSlice(self.arena.child_allocator, rem);
|
||||
return;
|
||||
}
|
||||
|
||||
try self.feed(rem[0]);
|
||||
rem = rem[1..];
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete the parsing. This must be called after all the
|
||||
/// bytes have been fed to the parser.
|
||||
///
|
||||
@@ -987,6 +1007,57 @@ test "transmission command" {
|
||||
try testing.expectEqual(@as(u32, 20), v.height);
|
||||
}
|
||||
|
||||
test "feedSlice matches per-byte feed" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const input = "f=24,s=10,v=20;aGVsbG8gd29ybGQ=";
|
||||
|
||||
var p1 = Parser.init(alloc, 1024 * 1024);
|
||||
defer p1.deinit();
|
||||
for (input) |c| try p1.feed(c);
|
||||
const c1 = try p1.complete(alloc);
|
||||
defer c1.deinit(alloc);
|
||||
|
||||
var p2 = Parser.init(alloc, 1024 * 1024);
|
||||
defer p2.deinit();
|
||||
try p2.feedSlice(input);
|
||||
const c2 = try p2.complete(alloc);
|
||||
defer c2.deinit(alloc);
|
||||
|
||||
try testing.expect(c1.control == .transmit);
|
||||
try testing.expect(c2.control == .transmit);
|
||||
try testing.expectEqualStrings(c1.data, c2.data);
|
||||
}
|
||||
|
||||
test "feedSlice across slice boundaries" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var p = Parser.init(alloc, 1024 * 1024);
|
||||
defer p.deinit();
|
||||
|
||||
try p.feedSlice("f=24,s=10");
|
||||
try p.feedSlice(",v=20;aGVsbG8g");
|
||||
try p.feedSlice("d29ybGQ=");
|
||||
const command = try p.complete(alloc);
|
||||
defer command.deinit(alloc);
|
||||
|
||||
try testing.expect(command.control == .transmit);
|
||||
|
||||
// The payload is base64-decoded on completion.
|
||||
try testing.expectEqualStrings("hello world", command.data);
|
||||
}
|
||||
|
||||
test "feedSlice respects max_bytes" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var p = Parser.init(alloc, 4);
|
||||
defer p.deinit();
|
||||
|
||||
try p.feedSlice("f=24;ab");
|
||||
try testing.expectError(error.OutOfMemory, p.feedSlice("cde"));
|
||||
}
|
||||
|
||||
test "transmission ignores 'm' if medium is not direct" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -111,6 +111,7 @@ pub const Action = union(Key) {
|
||||
apc_start,
|
||||
apc_end,
|
||||
apc_put: u8,
|
||||
apc_put_slice: ApcPutSlice,
|
||||
end_hyperlink,
|
||||
active_status_display: ansi.StatusDisplay,
|
||||
decaln,
|
||||
@@ -209,6 +210,7 @@ pub const Action = union(Key) {
|
||||
"apc_start",
|
||||
"apc_end",
|
||||
"apc_put",
|
||||
"apc_put_slice",
|
||||
"end_hyperlink",
|
||||
"active_status_display",
|
||||
"decaln",
|
||||
@@ -275,6 +277,19 @@ pub const Action = union(Key) {
|
||||
}
|
||||
};
|
||||
|
||||
pub const ApcPutSlice = struct {
|
||||
bytes: []const u8,
|
||||
|
||||
pub const C = extern struct {
|
||||
bytes: [*]const u8,
|
||||
len: usize,
|
||||
};
|
||||
|
||||
pub fn cval(self: ApcPutSlice) ApcPutSlice.C {
|
||||
return .{ .bytes = self.bytes.ptr, .len = self.bytes.len };
|
||||
}
|
||||
};
|
||||
|
||||
pub const InvokeCharset = lib.Struct(lib.target, struct {
|
||||
bank: charsets.ActiveSlot,
|
||||
charset: charsets.Slots,
|
||||
@@ -641,6 +656,18 @@ pub fn Stream(comptime H: type) type {
|
||||
// handle it. Otherwise re-check our state.
|
||||
if (self.parser.state != .csi_param) continue;
|
||||
}
|
||||
|
||||
// Bulk-consume APC string bytes into a single slice.
|
||||
// APC payloads (e.g. Kitty graphics) can be megabytes
|
||||
// of base64 data, so per-byte dispatch is far too slow.
|
||||
// This can't be used for handlers with a vtRaw hook
|
||||
// because it dispatches the slice directly.
|
||||
if (self.parser.state == .sos_pm_apc_string) {
|
||||
offset += self.consumeApcString(input[offset..]);
|
||||
if (offset >= input.len) return input.len;
|
||||
// The next byte exits the string state; let
|
||||
// nextNonUtf8 below handle it.
|
||||
}
|
||||
}
|
||||
|
||||
self.nextNonUtf8(input[offset]);
|
||||
@@ -751,6 +778,38 @@ pub fn Stream(comptime H: type) type {
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// Bulk-consume APC string bytes and dispatch them as a single
|
||||
/// apc_put_slice action. Returns the number of bytes consumed.
|
||||
/// Stops at the first byte that is not an apc_put byte in the
|
||||
/// parse table, leaving it for the caller to process through
|
||||
/// the state machine. CAN, SUB, ESC, and most C1 bytes exit
|
||||
/// or abort the string state; 0xA0-0xFF are ignored by the
|
||||
/// table (not payload), so they can't be bulk-consumed either.
|
||||
///
|
||||
/// Must not be used by handlers with a vtRaw hook because it
|
||||
/// dispatches the slice directly.
|
||||
fn consumeApcString(self: *Self, input: []const u8) usize {
|
||||
comptime assert(!@hasDecl(T, "vtRaw"));
|
||||
assert(self.parser.state == .sos_pm_apc_string);
|
||||
|
||||
var end: usize = 0;
|
||||
while (end < input.len) {
|
||||
switch (input[end]) {
|
||||
// Not apc_put bytes: CAN/SUB/ESC and most C1 exit
|
||||
// or abort the state; 0xA0-0xFF are ignored by it.
|
||||
0x18, 0x1A, 0x1B, 0x80...0xFF => break,
|
||||
// Everything else is an apc_put byte.
|
||||
else => end += 1,
|
||||
}
|
||||
}
|
||||
|
||||
if (end > 0) self.handler.vt(
|
||||
.apc_put_slice,
|
||||
.{ .bytes = input[0..end] },
|
||||
);
|
||||
return end;
|
||||
}
|
||||
|
||||
/// Like nextSlice but takes one byte and is necessarily a scalar
|
||||
/// operation that can't use SIMD. Prefer nextSlice if you can and
|
||||
/// try to get multiple bytes at once.
|
||||
@@ -3760,3 +3819,101 @@ test "stream: tab clear with overflowing param" {
|
||||
// CSI with a huge numeric param that saturates to 65535, followed by 'g'.
|
||||
s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m");
|
||||
}
|
||||
|
||||
/// A test handler that accumulates APC bytes regardless of whether they
|
||||
/// arrive per-byte (apc_put) or in bulk (apc_put_slice).
|
||||
const ApcTestHandler = struct {
|
||||
buf: [256]u8 = undefined,
|
||||
len: usize = 0,
|
||||
slices: usize = 0,
|
||||
puts: usize = 0,
|
||||
started: usize = 0,
|
||||
ended: usize = 0,
|
||||
|
||||
pub fn vt(
|
||||
self: *@This(),
|
||||
comptime action: Action.Tag,
|
||||
value: Action.Value(action),
|
||||
) void {
|
||||
switch (action) {
|
||||
.apc_start => self.started += 1,
|
||||
.apc_end => self.ended += 1,
|
||||
.apc_put => {
|
||||
self.buf[self.len] = value;
|
||||
self.len += 1;
|
||||
self.puts += 1;
|
||||
},
|
||||
.apc_put_slice => {
|
||||
@memcpy(self.buf[self.len..][0..value.bytes.len], value.bytes);
|
||||
self.len += value.bytes.len;
|
||||
self.slices += 1;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "stream: apc bulk slice" {
|
||||
var s: Stream(ApcTestHandler) = .init(.{});
|
||||
s.nextSlice("\x1b_Gf=24,s=10,v=20;aGVsbG8=\x1b\\");
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.started);
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.ended);
|
||||
try testing.expectEqualStrings(
|
||||
"Gf=24,s=10,v=20;aGVsbG8=",
|
||||
s.handler.buf[0..s.handler.len],
|
||||
);
|
||||
|
||||
// With SIMD enabled the body must arrive as a single slice.
|
||||
if (comptime build_options.simd and !debug) {
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.slices);
|
||||
try testing.expectEqual(@as(usize, 0), s.handler.puts);
|
||||
}
|
||||
}
|
||||
|
||||
test "stream: apc bulk slice split across inputs" {
|
||||
var s: Stream(ApcTestHandler) = .init(.{});
|
||||
s.nextSlice("\x1b_Gf=24,s=10");
|
||||
s.nextSlice(",v=20;aGVs");
|
||||
s.nextSlice("bG8=\x1b\\");
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.started);
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.ended);
|
||||
try testing.expectEqualStrings(
|
||||
"Gf=24,s=10,v=20;aGVsbG8=",
|
||||
s.handler.buf[0..s.handler.len],
|
||||
);
|
||||
}
|
||||
|
||||
test "stream: apc bulk slice keeps C0 bytes as data" {
|
||||
var s: Stream(ApcTestHandler) = .init(.{});
|
||||
// BEL does not terminate an APC string; it is payload data.
|
||||
s.nextSlice("\x1b_Gx\x07y\x1b\\");
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.ended);
|
||||
try testing.expectEqualStrings("Gx\x07y", s.handler.buf[0..s.handler.len]);
|
||||
}
|
||||
|
||||
test "stream: apc aborted by CAN" {
|
||||
var s: Stream(ApcTestHandler) = .init(.{});
|
||||
// CAN (0x18) aborts the APC string via the anywhere => ground
|
||||
// transition. Exiting the sos_pm_apc_string state emits apc_end,
|
||||
// and the trailing bytes are printed, not treated as APC data.
|
||||
s.nextSlice("\x1b_Gabc\x18def");
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.started);
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.ended);
|
||||
try testing.expectEqualStrings("Gabc", s.handler.buf[0..s.handler.len]);
|
||||
}
|
||||
|
||||
test "stream: apc scalar path matches" {
|
||||
var s: Stream(ApcTestHandler) = .init(.{});
|
||||
for ("\x1b_Gf=24;aGVsbG8=\x1b\\") |c| s.next(c);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.started);
|
||||
try testing.expectEqual(@as(usize, 1), s.handler.ended);
|
||||
try testing.expectEqualStrings(
|
||||
"Gf=24;aGVsbG8=",
|
||||
s.handler.buf[0..s.handler.len],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ pub const Handler = struct {
|
||||
// APC
|
||||
.apc_start => self.apc_handler.start(),
|
||||
.apc_put => self.apc_handler.feed(self.terminal.gpa(), value),
|
||||
.apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes),
|
||||
.apc_end => self.apcEnd(),
|
||||
|
||||
// Effect-based handlers
|
||||
|
||||
@@ -361,6 +361,7 @@ pub const StreamHandler = struct {
|
||||
.apc_start => self.apc.start(),
|
||||
.apc_end => try self.apcEnd(),
|
||||
.apc_put => self.apc.feed(self.alloc, value),
|
||||
.apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes),
|
||||
|
||||
// Unimplemented
|
||||
.title_push,
|
||||
|
||||
Reference in New Issue
Block a user