From f6f79acce6c2805baeaa1b3a6bc4ab918b096c25 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Thu, 9 Jul 2026 16:54:30 -0500 Subject: [PATCH] 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. --- src/benchmark/ApcParser.zig | 143 +++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/synthetic/Kitty.zig | 88 +++++++++++++ src/synthetic/cli.zig | 2 + src/synthetic/cli/Kitty.zig | 70 +++++++++++ src/synthetic/main.zig | 1 + src/terminal/apc.zig | 120 ++++++++++++++++++ src/terminal/apc/glyph/request.zig | 7 ++ src/terminal/kitty/graphics_command.zig | 71 +++++++++++ src/terminal/stream.zig | 157 ++++++++++++++++++++++++ src/terminal/stream_terminal.zig | 1 + src/termio/stream_handler.zig | 1 + 12 files changed, 663 insertions(+) create mode 100644 src/benchmark/ApcParser.zig create mode 100644 src/synthetic/Kitty.zig create mode 100644 src/synthetic/cli/Kitty.zig diff --git a/src/benchmark/ApcParser.zig b/src/benchmark/ApcParser.zig new file mode 100644 index 000000000..73b7c57a2 --- /dev/null +++ b/src/benchmark/ApcParser.zig @@ -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); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index b48a35b4b..d51fd871e 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -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"), diff --git a/src/synthetic/Kitty.zig b/src/synthetic/Kitty.zig new file mode 100644 index 000000000..c2c649d71 --- /dev/null +++ b/src/synthetic/Kitty.zig @@ -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\\")); +} diff --git a/src/synthetic/cli.zig b/src/synthetic/cli.zig index d9b6a659d..d6ec028f7 100644 --- a/src/synthetic/cli.zig +++ b/src/synthetic/cli.zig @@ -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"), }; diff --git a/src/synthetic/cli/Kitty.zig b/src/synthetic/cli/Kitty.zig new file mode 100644 index 000000000..99a92798e --- /dev/null +++ b/src/synthetic/cli/Kitty.zig @@ -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); +} diff --git a/src/synthetic/main.zig b/src/synthetic/main.zig index 85f9f7d35..1c26090db 100644 --- a/src/synthetic/main.zig +++ b/src/synthetic/main.zig @@ -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 { diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 61d30822f..121f906a0 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -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; diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig index b33a28413..71a2dd4bf 100644 --- a/src/terminal/apc/glyph/request.zig +++ b/src/terminal/apc/glyph/request.zig @@ -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 diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index aa35fbeb9..bc0ffb5ba 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -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; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 6c5a5f365..0572015f2 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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], + ); +} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 38d59bcd2..c635e91d9 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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 diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 307c72686..43c5d6117 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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,