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:
Tim Culverhouse
2026-07-09 16:54:30 -05:00
parent 7e02af8798
commit f6f79acce6
12 changed files with 663 additions and 0 deletions

143
src/benchmark/ApcParser.zig Normal file
View 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);
}

View File

@@ -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
View 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\\"));
}

View File

@@ -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"),
};

View 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);
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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],
);
}

View File

@@ -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

View File

@@ -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,