terminal: various VT processing optimizations (~1.5x to ~6x throughput increase) (#13220)

This is a series of five commits that optimizes VT processing throughput
in various ways. Each commit is isolated, individually benchmarked, and
carries a detailed commit message so please read each for details about
each change.

After #13209 made IO fully parser-bound, these gains should translate
directly into end-to-end IO throughput (until some other stage becomes
the new bottleneck). Plain ASCII processing went from ~128 MB/s to ~725
MB/s. `time cat ascii_150MB.txt` went from 1.5s before 13209 to 1.2s on
main to 566ms on this branch.

## The changes

1. **batch printed codepoint runs into direct row fills**. Profiling
showed ~85% of plain-text time inside `Terminal.print`, re-answering the
same questions (margins, modes, width, charset, style) for every single
character. A new `print_slice` stream action delivers runs of decoded
codepoints to `Terminal.printSlice`, which hoists the invariants and
fills rows with a masked-compare + branch-free store loop, falling back
to `print()` for anything complex. **Result: 2.2x–5.7x on ascii plus
unicode text.**
2. **dispatch CSI finals directly from stream fast paths**. Every byte
through `Parser.next` copies a ~240 byte `[3]?Action` and a typical CSI
copied it twice. New `csi_entry/final` fast paths dispatch directly
without the action array. **Result: +17-18% on CSI streams.**
3. **bulk-parse CSI parameter bytes at the slice level**. Parameter
digits/separators are consumed in a tight slice loop with parser state
in locals instead of re-entering the per-byte path. **Result: +29-41% on
escape-heavy streams.**
4. **skip style map update when SGR leaves style unchanged**. Skip the
release/hash/probe/use churn when an SGR attribute is a no-op. **Result:
+4-7% on TUI-refresh patterns, -2-3% on adversarial random-color
streams** (tradeoff detailed in the commit message). This one is more
questionable, but willing to measure on real workloads.

## Benchmarks

Measured with `ghostty-bench +terminal-stream` (full terminal handler,
100 MB deterministic synthetic corpora, 120x80 terminal, M4 Max, macOS
26, ReleaseFast, hyperfine means of 10 runs, ~15 ms process startup
included in all numbers). These are parser-stage numbers, not end-to-end
app numbers.

| stream | before | after | throughput | change |

|----------------------------|--------|--------|------------------|--------|
| ascii (no newlines) | 784 ms | 138 ms | 128 → 725 MB/s | 5.7x |
| ascii lines | 833 ms | 198 ms | 120 → 505 MB/s | 4.2x |
| unicode mixed-script | 779 ms | 320 ms | 128 → 313 MB/s | 2.4x |
| CJK (all wide) | 424 ms | 126 ms | 236 → 794 MB/s | 3.4x |
| unicode, mode 2027 on | 807 ms | 367 ms | 124 → 273 MB/s | 2.2x |
| CJK, mode 2027 on | 495 ms | 198 ms | 202 → 505 MB/s | 2.5x |
| csi mix (SGR/CUP/EL/modes) | 648 ms | 414 ms | 154 → 242 MB/s | 1.6x |
| sgr fire (doom-fire-like) | 495 ms | 303 ms | 202 → 330 MB/s | 1.6x |
| TUI redraw (repeat styles) | 642 ms | 291 ms | 156 → 344 MB/s | 2.2x |
| osc | 8.26 s | 8.20 s | (untouched path) | ~1.0x |

**End-to-end note:** #13209 measured the parse thread pegged while the
gather thread used ~33% of a core, so parser gains of this size may make
gather (or the renderer lock) the new bottleneck for plain text before
the full 5.7x shows up end to end. I'll take a look at that soon...

## LLM Notes

These findings were almost all found by Fable 5. I went through each
change and simplified quite a lot, read every single line, re-ran
verifications by hand. Fable in particular isn't good at writing elegant
Zig code, so there's a lot of style stuff. Ultimately though, I
understand all of this and feel comfortable with the changes.
This commit is contained in:
Mitchell Hashimoto
2026-07-06 09:07:57 -07:00
committed by GitHub
6 changed files with 907 additions and 35 deletions

View File

@@ -2,15 +2,13 @@
//! 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.
//! This uses the full readonly terminal stream handler
//! (terminal.TerminalStream) so every escape sequence updates real
//! terminal state (styles, cursor movement, erases, modes, etc.).
//! This closely mirrors the work done by the real IO thread.
//!
//! It is a fairly broad benchmark that can be used to determine
//! if we need to optimize something more specific (e.g. the parser).
//! For more isolated measurements see the terminal-parser and
//! osc-parser benchmarks.
const TerminalStream = @This();
const std = @import("std");
@@ -20,13 +18,12 @@ 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 Stream = terminalpkg.TerminalStream;
const log = std.log.scoped(.@"terminal-stream-bench");
opts: Options,
terminal: Terminal,
handler: Handler,
stream: Stream,
/// The file, opened in the setup function.
@@ -61,14 +58,15 @@ pub fn create(
.rows = opts.@"terminal-rows",
.cols = opts.@"terminal-cols",
}),
.handler = .{ .t = &ptr.terminal },
.stream = .init(&ptr.handler),
.stream = undefined,
};
ptr.stream = .initAlloc(alloc, .init(&ptr.terminal));
return ptr;
}
pub fn destroy(self: *TerminalStream, alloc: Allocator) void {
self.stream.deinit();
self.terminal.deinit(alloc);
alloc.destroy(self);
}
@@ -129,26 +127,6 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
}
}
/// 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;

View File

@@ -1992,6 +1992,12 @@ pub fn setAttribute(
.unknown => return,
}
// If the attribute didn't change our style then we can skip the
// style update entirely: our current style ID is already correct.
// This is a common case in the wild where programs re-assert the
// same style repeatedly (e.g. per span or per line).
if (self.cursor.style.eql(old_style)) return;
try self.manualStyleUpdate();
}

View File

@@ -321,6 +321,422 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void {
}
}
/// Print multiple codepoints to the terminal at once. This is
/// semantically identical to calling `print` for each codepoint in
/// order, but is much faster because it can batch cell writes and
/// hoist per-codepoint checks out of the hot loop.
///
/// The codepoints must all be printable: it is illegal for any
/// codepoint in this slice to be a C0 control character. Therefore,
/// this should only be called as a result of a proper VT parser
/// (like our own).
///
/// This is optimized for the common case: ASCII, soft-wrap, etc.
/// Sequences of codepoints that require special handling (e.g. wide characters,
/// grapheme clustering) are handled correctly but fall back to the
/// slower per-codepoint path. They're less common and this is optimized
/// for the aforementioned cases.
pub fn printSlice(self: *Terminal, cps: []const u32) !void {
var i: usize = 0;
while (i < cps.len) {
// Try the fast-path print first. This will return the number of
// codepoints it consumed.
const consumed = try self.printSliceFast(cps[i..]);
if (consumed > 0) {
i += consumed;
continue;
}
// Consuming zero bytes means that the fast path can't handle
// the next codepoint or the terminal is in a state we can't
// fast-path. Fall back to the slow cp-by-cp print then try
// fast paths again.
try self.print(@intCast(cps[i]));
i += 1;
}
}
/// Attempt to print a prefix of `cps` using a batched fast path that
/// writes cells directly. Returns the number of codepoints consumed.
/// A return value of zero means the caller must print the first
/// codepoint via the normal `print` path.
///
/// The fast path handles runs of narrow (width 1) and wide (width 2)
/// codepoints being written to simple cells. Everything else (zero
/// width codepoints, grapheme cluster continuations, insert mode,
/// charset mapping, hyperlinks, complex cells, etc.) is rejected so
/// `print` can handle it with full generality.
fn printSliceFast(self: *Terminal, cps: []const u32) !usize {
// Only the main display is supported.
if (self.status_display != .main) return 0;
// Modes that require per-codepoint handling in print(). Wraparound
// is required (its the default) so that our row-fill logic below can
// assume soft-wrap semantics. Insert mode shifts cells per print.
if (self.modes.get(.insert)) return 0;
if (!self.modes.get(.wraparound)) return 0;
const screen: *Screen = self.screens.active;
// Charset must map ASCII as-is (true unless a DEC special charset
// is actively invoked, which is rare).
if (screen.charset.single_shift != null) return 0;
switch (screen.charset.charsets.get(screen.charset.gl)) {
.utf8, .ascii => {},
else => return 0,
}
// Hyperlinks require per-cell map bookkeeping.
if (screen.cursor.hyperlink_id != 0) return 0;
// Codepoints in [0x10, 0xFF] are always narrow (width 1, matching
// the c <= 0xFF fast path in print) and can never interact with
// grapheme clustering (which requires a codepoint > 0xFF).
//
// Codepoints above 0xFF are batchable if their width is 1 or 2
// (excluding zero-width characters such as combining marks, ZWJ,
// and variation selectors) and, when grapheme clustering (mode
// 2027) is enabled, if they are a grapheme break from the
// previously printed codepoint (so print would never attach them
// to the previous cell).
const grapheme_cluster = self.modes.get(.grapheme_cluster);
// When grapheme clustering is enabled and a left margin is set,
// print() consults the cell left of the margin after wrapping,
// which we can't reason about here. Restrict the fast path to
// the [0x10, 0xFF] range in that case (those never cluster).
const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0;
// Codepoints in [0x10, 0xFF] are always narrow: print()
// hardcodes width 1 for c <= 0xFF (no width table lookup).
// They also can never interact with grapheme clustering,
// which print() only performs for c > 0xFF, so they're
// immediately eligible for the narrow fill with no further
// checks.
const cp0 = cps[0];
if (cp0 <= 0xFF) {
// C0 control characters (0x00-0x0F) aren't printable. The
// stream never sends these (they're routed to execute), but
// printSlice is a public API so defer to print() for safety.
if (cp0 < 0x10) return 0;
return self.printSliceFill(
.narrow,
cps,
grapheme_cluster,
allow_unicode,
);
}
if (!allow_unicode) return 0;
if (comptime build_options.kitty_graphics) {
// The Kitty graphics placeholder requires row bookkeeping.
if (cp0 == kitty.graphics.unicode.placeholder) return 0;
}
// The first codepoint requires care when grapheme clustering is
// enabled: print() examines the previous *cell* which can hold
// state (grapheme data) that we can't cheaply reason about here.
// Note this includes the pending-wrap state: print() may attach
// to the pending cell *instead of wrapping*. We only take the
// first codepoint if the cursor is at column zero with no pending
// wrap, where print() skips clustering entirely.
if (grapheme_cluster) {
if (screen.cursor.pending_wrap or screen.cursor.x != 0) return 0;
}
// The width lookup is a runtime value while printSliceFill is
// specialized at comptime by width class, so this switch selects
// between the two instantiations rather than passing the width
// through as an argument.
return switch (unicode.table.get(@intCast(cp0)).width) {
1 => self.printSliceFill(
.narrow,
cps,
grapheme_cluster,
allow_unicode,
),
2 => self.printSliceFill(
.wide,
cps,
grapheme_cluster,
allow_unicode,
),
else => 0,
};
}
/// The width class of a printSlice batch. Each batch contains only
/// codepoints of a single width class because they fill cells
/// differently: wide codepoints occupy a (wide, spacer_tail) cell
/// pair while narrow codepoints occupy a single cell.
const PrintSliceWidth = enum(u1) {
narrow,
wide,
/// The number of cells each codepoint of this width class occupies.
fn cellsPerCp(comptime self: PrintSliceWidth) usize {
return switch (self) {
.narrow => 1,
.wide => 2,
};
}
};
/// Whether a codepoint above 0xFF is eligible for the batched print
/// fast path with the given width class.
inline fn printSliceEligible(cp: u32, comptime width: PrintSliceWidth) bool {
assert(cp > 0xFF);
if (comptime build_options.kitty_graphics) {
if (cp == kitty.graphics.unicode.placeholder) return false;
}
return unicode.table.get(@intCast(cp)).width == comptime @as(u2, switch (width) {
.narrow => 1,
.wide => 2,
});
}
/// The row-filling portion of the printSlice fast path, specialized by
/// width class. The first codepoint must already be validated by the
/// caller (printSliceFast).
fn printSliceFill(
self: *Terminal,
comptime width: PrintSliceWidth,
cps: []const u32,
grapheme_cluster: bool,
allow_unicode: bool,
) !usize {
const screen: *Screen = self.screens.active;
// Our fast path can only handle "simple" cells. A simple cell is
// a codepoint cell (no grapheme data or bg-color tag), narrow, and
// not a hyperlink. The mask covers every field that must match
// the expected value (see printSliceCheckExpected) exactly.
const simple_mask = comptime fieldMask(Cell, &.{
"content_tag",
"style_id",
"wide",
"hyperlink",
});
// The bit offset of the codepoint content within a Cell, used to
// construct cell values from a template without field assignments.
const cp_shift = @bitOffsetOf(Cell, "content");
// Determine the run of codepoints in the same width class that we
// can batch. For codepoints after the first, the previous codepoint
// in the run is always written as a fresh, single-codepoint cell,
// so the grapheme break check against it is exact.
const run_len: usize = run: {
for (1..cps.len) |idx| {
const cp = cps[idx];
if (comptime width == .narrow) {
if (cp >= 0x10 and cp <= 0xFF) continue;
}
if (cp > 0xFF and allow_unicode and printSliceEligible(cp, width)) {
if (!grapheme_cluster) continue;
var state: uucode.grapheme.BreakState = .default;
if (unicode.graphemeBreak(@intCast(cps[idx - 1]), @intCast(cp), &state)) continue;
}
break :run idx;
}
break :run cps.len;
};
assert(run_len > 0);
// After doing any printing, wrapping, scrolling, etc. we want to
// ensure that our screen remains in a consistent state.
defer screen.assertIntegrity();
// The number of cells each codepoint occupies.
const cells_per_cp: usize = comptime width.cellsPerCp();
var printed: usize = 0;
outer: while (printed < run_len) {
// If we're soft-wrapping, handle that first so that our cursor
// is in the row/column that will receive the next codepoint.
if (screen.cursor.pending_wrap) try self.printWrap();
// Our right margin depends on where our cursor is now,
// matching the logic in print().
const right_limit: usize = if (screen.cursor.x > self.scrolling_region.right)
self.cols
else
self.scrolling_region.right + 1;
// A degenerate 1-wide region can't hold a wide char; print()
// has special handling so fall back to it.
if (comptime width == .wide) {
if (right_limit - self.scrolling_region.left <= 1) break;
}
const cursor = &screen.cursor;
const avail: usize = right_limit - cursor.x;
assert(avail > 0);
const page = &cursor.page_pin.node.data;
const cells: [*]Cell = @ptrCast(cursor.page_cell);
const style_id = cursor.style_id;
const template: Cell = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 0 },
.style_id = style_id,
.wide = .narrow,
.protected = cursor.protected,
.semantic_content = cursor.semantic_content,
};
const template_bits: u64 = @bitCast(template);
const check_expected: u64 = printSliceCheckExpected(style_id);
if (comptime width == .wide) {
if (avail == 1) {
// Only one cell left in the row: print() writes a
// spacer head (or a blank narrow cell if we're inside
// a right margin) and wraps. We require a simple cell,
// otherwise fall back to print() for the cleanup.
const bits: u64 = @bitCast(cells[0]);
if ((bits & simple_mask) != check_expected) break;
var spacer = template;
if (right_limit == self.cols) {
cursor.page_row.wrap = true;
spacer.wide = .spacer_head;
}
cursor.page_row.dirty = true;
if (style_id != style.default_id) cursor.page_row.styled = true;
cells[0] = spacer;
try self.printWrap();
continue :outer;
}
}
// Number of codepoints and cells we're writing to this row.
const count = @min(avail / cells_per_cp, run_len - printed);
assert(count > 0);
const cell_count = count * cells_per_cp;
// Wide cells always come in (wide, spacer_tail) pairs.
const spacer_bits: u64 = if (comptime width == .wide) spacer: {
var spacer = template;
spacer.wide = .spacer_tail;
break :spacer @bitCast(spacer);
} else undefined;
const wide_bits: u64 = if (comptime width == .wide) wb: {
var w = template;
w.wide = .wide;
break :wb @bitCast(w);
} else undefined;
var k: usize = 0; // cells written
fill: while (k < cell_count) {
// Find the run of simple cells so the store loop below is
// branch-free (and vectorizable).
var simple = k;
while (simple < cell_count) : (simple += 1) {
const bits: u64 = @bitCast(cells[simple]);
if ((bits & simple_mask) != check_expected) break;
}
if (comptime width == .wide) {
// We can only write whole (wide, spacer) pairs.
const pair_end = k + (simple - k) / 2 * 2;
var idx = k;
while (idx < pair_end) : (idx += 2) {
cells[idx] = @bitCast(
wide_bits | (@as(u64, cps[printed + idx / 2]) << cp_shift),
);
cells[idx + 1] = @bitCast(spacer_bits);
}
// If the simple run ended mid-pair we stop at the pair
// boundary and handle the offending cell below.
k = pair_end;
if (simple != pair_end) {
// The first cell of the next pair is simple but the
// second isn't; handle both via the general path.
simple = pair_end;
}
} else {
for (k..simple) |idx| {
cells[idx] = @bitCast(
template_bits | (@as(u64, cps[printed + idx]) << cp_shift),
);
}
k = simple;
}
if (k >= cell_count) break;
// General path for cells that failed the masked check:
// style-only mismatches are handled inline; anything that
// needs cleanup (wide chars and their spacers, grapheme
// data, hyperlinks) falls back to print().
const general_count: usize = cells_per_cp;
for (0..general_count) |offset| {
const cell = &cells[k + offset];
if (cell.wide != .narrow or
cell.hasGrapheme() or
cell.hyperlink) break :fill;
}
for (0..general_count) |offset| {
const cell = &cells[k + offset];
if (cell.style_id != style_id) {
if (cell.style_id != style.default_id) {
page.styles.release(page.memory, cell.style_id);
}
if (style_id != style.default_id) {
page.styles.use(page.memory, style_id);
}
}
}
if (comptime width == .wide) {
cells[k] = @bitCast(
wide_bits | (@as(u64, cps[printed + k / 2]) << cp_shift),
);
cells[k + 1] = @bitCast(spacer_bits);
} else {
cells[k] = @bitCast(
template_bits | (@as(u64, cps[printed + k]) << cp_shift),
);
}
k += cells_per_cp;
}
if (k > 0) {
assert(k % cells_per_cp == 0);
cursor.page_row.dirty = true;
if (style_id != style.default_id) cursor.page_row.styled = true;
self.previous_char = @intCast(cps[printed + k / cells_per_cp - 1]);
printed += k / cells_per_cp;
// Advance the cursor. If we filled through the right limit
// then the cursor stays on the last cell with the pending
// wrap flag set, matching print().
if (cursor.x + k >= right_limit) {
assert(cursor.x + k == right_limit);
screen.cursorRight(@intCast(k - 1));
cursor.pending_wrap = true;
} else {
screen.cursorRight(@intCast(k));
}
}
// We hit a cell that requires the slow path. The cursor is
// exactly at that cell so return and let the caller print the
// next codepoint via print().
if (k < cell_count) break;
}
return printed;
}
/// The expected value of a simple cell (per the check mask built from
/// fieldMask in printSliceFill) that already has the given style
/// (so no ref-counting is needed).
inline fn printSliceCheckExpected(style_id: style.Id) u64 {
var e: Cell = @bitCast(@as(u64, 0));
e.style_id = style_id;
return @bitCast(e);
}
pub fn print(self: *Terminal, c: u21) !void {
// log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x });
@@ -3202,6 +3618,30 @@ fn clearDirty(t: *Terminal) void {
t.screens.active.pages.clearDirty();
}
/// Returns a mask with all bits set for the given fields of the packed
/// struct T, used for masked compares of raw backing-integer values.
fn fieldMask(
comptime T: type,
comptime fields: []const []const u8,
) @typeInfo(T).@"struct".backing_integer.? {
// Backing int of the packed struct
const Int = @typeInfo(T).@"struct".backing_integer.?;
var mask: Int = 0;
inline for (fields) |field| {
// The type that fits all the bits we need to set.
const Ones = std.meta.Int(
.unsigned,
@bitSizeOf(@FieldType(T, field)),
);
// Mask out the ones
mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field);
}
return mask;
}
test "Terminal: input with no control characters" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 40, .rows = 40 });
@@ -11592,6 +12032,241 @@ test "Terminal: printRepeat no previous character" {
}
}
test "Terminal: printSlice simple ascii" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
try t.printSlice(&.{ 'h', 'e', 'l', 'l', 'o' });
try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x);
try testing.expectEqual(@as(u21, 'o'), t.previous_char.?);
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("hello", str);
}
}
test "Terminal: printSlice wraps and scrolls" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 5, .rows = 2 });
defer t.deinit(alloc);
// 12 chars: fills row 1 (5), row 2 (5), wraps+scrolls, 2 more.
try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l' });
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("fghij\nkl", str);
}
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expect(!t.screens.active.cursor.pending_wrap);
}
test "Terminal: printSlice pending wrap state" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 5, .rows = 2 });
defer t.deinit(alloc);
try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e' });
try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x);
try testing.expect(t.screens.active.cursor.pending_wrap);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("abcde", str);
}
}
/// Differential testing helper: applies the same logical print
/// operations to two terminals, one using per-codepoint print() and
/// the other using printSlice() with random chunking, verifying that
/// the results are identical.
fn testPrintSliceDifferential(
alloc: Allocator,
rand: std.Random,
ops: usize,
cols: size.CellCountInt,
rows: size.CellCountInt,
) !void {
var t1 = try init(alloc, .{
.cols = cols,
.rows = rows,
});
defer t1.deinit(alloc);
var t2 = try init(alloc, .{
.cols = cols,
.rows = rows,
});
defer t2.deinit(alloc);
// Alphabet of interesting codepoints: ascii, latin-1, combining
// marks, CJK (wide), emoji (wide), ZWJ, variation selectors.
const alphabet = [_]u21{
'a', 'b', 'Z', '0', ' ', 0x10, 0x1F, 0x7F,
'é', 0xFF, 0x301, 0x4E00, 0x4E01, 0x1F600, 0x200D, 0xFE0F,
'x', 'y', 0x1F9D1, 0x0308, 0xAD, 0x3042, 0xAC00, 'q',
'r', 's', 't', 'u', 'v', 'w', '1', '2',
0x1F1E6, 0x1F1E7, 0x1100, 0x1161, 0x11A8, 0x200C, 0x0430, 0x03B1,
};
var cps_buf: [64]u32 = undefined;
var last_n: usize = 0;
for (0..ops) |_| {
switch (rand.intRangeAtMost(u8, 0, 20)) {
// Print a run of codepoints (most common op).
0...9 => {
const n = rand.intRangeAtMost(usize, 1, cps_buf.len);
last_n = n;
for (cps_buf[0..n]) |*cp| {
cp.* = alphabet[rand.intRangeLessThan(usize, 0, alphabet.len)];
}
// t1: per-codepoint print
for (cps_buf[0..n]) |cp| try t1.print(@intCast(cp));
// t2: printSlice with random chunking
var i: usize = 0;
while (i < n) {
const chunk = rand.intRangeAtMost(usize, 1, n - i);
try t2.printSlice(cps_buf[i..][0..chunk]);
i += chunk;
}
},
10 => {
t1.carriageReturn();
t2.carriageReturn();
try t1.linefeed();
try t2.linefeed();
},
11 => {
const row = rand.intRangeAtMost(usize, 1, rows);
const col = rand.intRangeAtMost(usize, 1, cols);
t1.setCursorPos(row, col);
t2.setCursorPos(row, col);
},
12 => {
const attr: sgr.Attribute = switch (rand.intRangeAtMost(u8, 0, 3)) {
0 => .{ .unset = {} },
1 => .{ .bold = {} },
2 => .{ .direct_color_fg = .{
.r = rand.int(u8),
.g = rand.int(u8),
.b = rand.int(u8),
} },
3 => .{ .@"8_fg" = .red },
else => unreachable,
};
try t1.setAttribute(attr);
try t2.setAttribute(attr);
},
13 => {
const v = rand.boolean();
t1.modes.set(.insert, v);
t2.modes.set(.insert, v);
},
14 => {
const v = rand.boolean();
t1.modes.set(.wraparound, v);
t2.modes.set(.wraparound, v);
},
15 => {
const v = rand.boolean();
// Erase the display first: grapheme clusters created
// while mode 2027 was off can trip a pre-existing
// debug assert in print()'s cluster walk when the mode
// is toggled on (unrelated to printSlice; it reproduces
// with per-codepoint print alone).
t1.eraseDisplay(.complete, false);
t2.eraseDisplay(.complete, false);
t1.modes.set(.grapheme_cluster, v);
t2.modes.set(.grapheme_cluster, v);
},
16 => {
// Margins.
t1.modes.set(.enable_left_and_right_margin, true);
t2.modes.set(.enable_left_and_right_margin, true);
const left = rand.intRangeAtMost(usize, 1, cols / 2);
const right = rand.intRangeAtMost(usize, cols / 2, cols);
t1.setLeftAndRightMargin(left, right);
t2.setLeftAndRightMargin(left, right);
},
17 => {
t1.setLeftAndRightMargin(0, 0);
t2.setLeftAndRightMargin(0, 0);
},
18 => {
try t1.screens.active.startHyperlink("http://example.com", null);
try t2.screens.active.startHyperlink("http://example.com", null);
},
19 => {
t1.screens.active.endHyperlink();
t2.screens.active.endHyperlink();
},
20 => {
const set: charsets.Charset = if (rand.boolean())
.dec_special
else
.utf8;
t1.configureCharset(.G0, set);
t2.configureCharset(.G0, set);
},
else => unreachable,
}
// Cursor state must match exactly after every op.
try testing.expectEqual(t1.screens.active.cursor.x, t2.screens.active.cursor.x);
try testing.expectEqual(t1.screens.active.cursor.y, t2.screens.active.cursor.y);
try testing.expectEqual(
t1.screens.active.cursor.pending_wrap,
t2.screens.active.cursor.pending_wrap,
);
// Full screen contents must match after every op. On failure,
// dump diagnostics that make the failure reproducible.
{
const str1 = try t1.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str1);
const str2 = try t2.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str2);
testing.expectEqualStrings(str1, str2) catch |err| {
std.debug.print("last print cps: {any}\n", .{cps_buf[0..last_n]});
std.debug.print("modes: 2027={} insert={} wrap={} sr.left={} sr.right={} cols={}\n", .{
t1.modes.get(.grapheme_cluster),
t1.modes.get(.insert),
t1.modes.get(.wraparound),
t1.scrolling_region.left,
t1.scrolling_region.right,
cols,
});
return err;
};
}
}
// Page integrity (styles refcounts, grapheme maps, etc.) must hold.
try t1.screens.active.cursor.page_pin.node.data.verifyIntegrity(alloc);
try t2.screens.active.cursor.page_pin.node.data.verifyIntegrity(alloc);
}
test "Terminal: printSlice differential fuzz vs print" {
const alloc = testing.allocator;
// Multiple seeds and terminal sizes for coverage, including a
// tiny terminal to stress wrap/scroll edge cases.
var prng = std.Random.DefaultPrng.init(0xC0FFEE);
const rand = prng.random();
try testPrintSliceDifferential(alloc, rand, 500, 80, 24);
try testPrintSliceDifferential(alloc, rand, 500, 10, 4);
try testPrintSliceDifferential(alloc, rand, 500, 5, 2);
try testPrintSliceDifferential(alloc, rand, 200, 2, 2);
}
test "Terminal: printAttributes" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });

View File

@@ -33,6 +33,7 @@ const debug = false;
/// function for handling.
pub const Action = union(Key) {
print: Print,
print_slice: PrintSlice,
print_repeat: usize,
bell,
backspace,
@@ -130,6 +131,7 @@ pub const Action = union(Key) {
lib.target,
&.{
"print",
"print_slice",
"print_repeat",
"bell",
"backspace",
@@ -252,6 +254,27 @@ pub const Action = union(Key) {
}
};
/// A run of printable codepoints. This is emitted instead of
/// individual print actions when the stream can decode multiple
/// printable codepoints at once, so handlers can process them in
/// batch with per-run rather than per-codepoint overhead (see
/// Terminal.printSlice). A naive handler can simply loop and
/// handle each codepoint like a print action.
///
/// The slice is only valid for the duration of the handler call.
pub const PrintSlice = struct {
cps: []const u32,
pub const C = extern struct {
cps: [*]const u32,
len: usize,
};
pub fn cval(self: PrintSlice) PrintSlice.C {
return .{ .cps = self.cps.ptr, .len = self.cps.len };
}
};
pub const InvokeCharset = lib.Struct(lib.target, struct {
bank: charsets.ActiveSlot,
charset: charsets.Slots,
@@ -411,6 +434,11 @@ pub const Action = union(Key) {
/// about in its pursuit of implementing a terminal emulator or other
/// functionality.
///
/// Note that printable text is delivered via `print_slice` actions
/// (runs of codepoints) whenever the stream can decode multiple
/// codepoints at once, and via `print` actions otherwise. Handlers
/// that care about text must handle both.
///
/// The Handler type must also have a `deinit` function.
///
/// The "comptime" key is on purpose (vs. a standard Zig tagged union)
@@ -521,12 +549,25 @@ pub fn Stream(comptime H: type) type {
// up to that point are just UTF-8.
while (self.parser.state == .ground and offset < input.len) {
const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf);
for (cp_buf[0..res.decoded]) |cp| {
const cps = cp_buf[0..res.decoded];
// Hand runs of printable codepoints to the handler as
// print_slice actions so it can process them with
// per-run rather than per-codepoint overhead.
var i: usize = 0;
while (i < cps.len) {
const cp = cps[i];
if (cp <= 0xF) {
@branchHint(.unlikely);
self.execute(@intCast(cp));
} else {
self.print(@intCast(cp));
i += 1;
continue;
}
var end = i + 1;
while (end < cps.len and cps[end] > 0xF) end += 1;
self.handler.vt(.print_slice, .{ .cps = cps[i..end] });
i = end;
}
// Consume the bytes we just processed.
offset += res.consumed;
@@ -570,12 +611,88 @@ pub fn Stream(comptime H: type) type {
var offset: usize = 0;
while (self.parser.state != .ground) {
if (offset >= input.len) return input.len;
// Bulk-consume CSI parameter bytes. This can't be used
// for handlers with a vtRaw hook because it dispatches
// the CSI directly (see nextNonUtf8).
if (comptime !@hasDecl(T, "vtRaw")) {
if (self.parser.state == .csi_param) {
offset += self.consumeCsiParams(input[offset..]);
if (offset >= input.len) return input.len;
// If we're still in csi_param then the next byte
// isn't a parameter byte; let nextNonUtf8 below
// handle it. Otherwise re-check our state.
if (self.parser.state != .csi_param) continue;
}
}
self.nextNonUtf8(input[offset]);
offset += 1;
}
return offset;
}
/// Bulk-consume CSI parameter bytes (digits and separators)
/// and, if reached, the final byte (dispatching the CSI).
/// Returns the number of bytes consumed. Stops at the first
/// byte that isn't handled here, leaving the parser in the
/// csi_param state so the caller can process that byte.
fn consumeCsiParams(self: *Self, input: []const u8) usize {
const p = &self.parser;
assert(p.state == .csi_param);
// Accumulate parser state in locals for the hot loop.
var acc = p.param_acc;
var acc_idx = p.param_acc_idx;
var idx = p.params_idx;
var offset: usize = 0;
while (offset < input.len) {
const c = input[offset];
switch (c) {
// A parameter digit.
'0'...'9' => {
if (idx < Parser.MAX_PARAMS) {
acc *|= 10;
acc +|= c - '0';
acc_idx |= 1;
}
offset += 1;
},
// A parameter separator.
':', ';' => {
if (idx < Parser.MAX_PARAMS) {
p.params[idx] = acc;
if (c == ':') p.params_sep.set(idx);
idx += 1;
acc = 0;
acc_idx = 0;
}
offset += 1;
},
// A final byte: dispatch the CSI.
0x40...0x7E => {
p.param_acc = acc;
p.param_acc_idx = acc_idx;
p.params_idx = idx;
self.csiDispatchFinal(c);
return offset + 1;
},
// Anything else (C0 controls, intermediates, etc.)
// is handled by the caller.
else => break,
}
}
p.param_acc = acc;
p.param_acc_idx = acc_idx;
p.params_idx = idx;
return offset;
}
/// 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.
@@ -654,6 +771,13 @@ pub fn Stream(comptime H: type) type {
self.parser.state = .csi_entry;
return;
}
// The fast paths below dispatch actions directly rather than
// going through Parser.next, so they'd bypass a handler's
// vtRaw hook. Handlers with vtRaw (e.g. the inspector) use
// the general path for anything that produces an action.
const has_vt_raw = comptime @hasDecl(T, "vtRaw");
// Fast path for CSI params.
if (self.parser.state == .csi_param) csi_param: {
// csi_param is the most common parser state
@@ -690,6 +814,10 @@ pub fn Stream(comptime H: type) type {
self.parser.param_acc = 0;
self.parser.param_acc_idx = 0;
},
// A final byte: dispatch the CSI directly.
0x40...0x7E => if (comptime !has_vt_raw) {
self.csiDispatchFinal(c);
} else break :csi_param,
// Explicitly ignored:
0x7F => {},
// Defer to the state machine to
@@ -699,6 +827,42 @@ pub fn Stream(comptime H: type) type {
return;
}
// Fast path for CSI entry, the state right after "ESC [".
// Virtually every CSI sequence spends exactly one byte in
// this state, on either a digit, a private marker, or a
// final byte.
if (comptime !has_vt_raw) {
if (self.parser.state == .csi_entry) csi_entry: {
switch (c) {
// First parameter digit.
'0'...'9' => {
self.parser.state = .csi_param;
// param_acc is zero (cleared on escape entry)
// so accumulating is just the digit value.
self.parser.param_acc = c - '0';
self.parser.param_acc_idx = 1;
},
// An empty first parameter.
';' => {
self.parser.state = .csi_param;
self.parser.params[0] = 0;
self.parser.params_idx = 1;
},
// Private marker (e.g. '?' in "ESC [ ? 2004 h").
0x3C...0x3F => {
self.parser.state = .csi_param;
self.parser.collect(c);
},
// A final byte: a parameterless CSI.
0x40...0x7E => self.csiDispatchFinal(c),
// Defer to the state machine for anything else
// (C0 controls, intermediates, colon).
else => break :csi_entry,
}
return;
}
}
// We explicitly inline this call here for performance reasons.
//
// We do this rather than mark Parser.next as inline because doing
@@ -744,6 +908,48 @@ pub fn Stream(comptime H: type) type {
}
}
/// Finalize and dispatch a CSI directly from parser state for
/// the fast paths in nextNonUtf8, without going through
/// Parser.next. This must match the behavior of the parser's
/// csi_dispatch action.
fn csiDispatchFinal(self: *Self, c: u8) void {
const p = &self.parser;
p.state = .ground;
// Ignore sequences with too many parameters, matching the
// parser's behavior of dropping the dispatch entirely.
if (p.params_idx >= Parser.MAX_PARAMS) {
@branchHint(.unlikely);
return;
}
// Finalize the last parameter if we have one.
if (p.param_acc_idx > 0) {
p.params[p.params_idx] = p.param_acc;
p.params_idx += 1;
}
const action: Parser.Action.CSI = .{
.intermediates = p.intermediates[0..p.intermediates_idx],
.params = p.params[0..p.params_idx],
.params_sep = p.params_sep,
.final = c,
};
// We only allow colon or mixed separators for the 'm' command.
if (c != 'm' and p.params_sep.count() > 0) {
@branchHint(.cold);
log.warn(
"CSI colon or mixed separators only allowed for 'm' command, got: {f}",
.{action},
);
return;
}
if (comptime debug) log.info("action: {f}", .{Parser.Action{ .csi_dispatch = action }});
self.csiDispatch(action);
}
inline fn print(self: *Self, c: u21) void {
self.handler.vt(.print, .{ .cp = c });
}
@@ -2415,6 +2621,7 @@ test "simd: print invalid utf-8" {
) void {
switch (action) {
.print => self.c = value.cp,
.print_slice => self.c = @intCast(value.cps[value.cps.len - 1]),
else => {},
}
}
@@ -2436,6 +2643,7 @@ test "simd: complete incomplete utf-8" {
) void {
switch (action) {
.print => self.c = value.cp,
.print_slice => self.c = @intCast(value.cps[value.cps.len - 1]),
else => {},
}
}

View File

@@ -137,6 +137,7 @@ pub const Handler = struct {
) !void {
switch (action) {
.print => try self.terminal.print(value.cp),
.print_slice => try self.terminal.printSlice(value.cps),
.print_repeat => try self.terminal.printRepeat(value),
.backspace => self.terminal.backspace(),
.carriage_return => self.terminal.carriageReturn(),

View File

@@ -205,6 +205,10 @@ pub const StreamHandler = struct {
@branchHint(.likely);
try self.terminal.print(value.cp);
},
.print_slice => {
@branchHint(.likely);
try self.terminal.printSlice(value.cps);
},
.print_repeat => try self.terminal.printRepeat(value),
.bell => self.bell(),
.backspace => self.terminal.backspace(),