From 68beeeb3f6c9d296d99d821b17a477623d98b035 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 1 Aug 2026 06:46:02 -0700 Subject: [PATCH] terminal: add stream continuation tracking for replay This adds opt-in continuation tracking to `terminal.Stream` that allows any caller to call `writeContinuation` in order to get the minimum bytes necessary from a grounded parser state to the identical state. This enables reliable stream restart across serialization states, which could be used for local restart, networked terminals, etc. For me, this is used for multiplexers. :) ## Implementation The implementation of this was really carefully done to avoid any negative performance impact particularly when continuation tracking is _off_. The way this work is simple: 1. ESC is the only char that leaves the ground state and most ESC sequences are short. So if we're in a non-ground state, we do a backwards vectorized search to find the last `ESC` in the input slice. If one doesn't exist, we assume we found it previously and store the whole slice (rare, since ESC sequences are usually short like I said). 2. If we're in the ground state that means we only have a potential incomplete UTF-8 codepoint, so we find the lead UTF-8 byte. 3. When writing, we normalize the suffix to drop things like BEL commands that would've already been handled to avoid double-calling. ## Performance Via `ghostty-bench +terminal-stream` Corpus main tracking off tracking on plain ASCII (256 MiB) 175.4ms 175.8ms 175.5ms UTF-8 (32 MiB) 268.4ms 270.0ms 269.9ms 5% invalid UTF-8 (32 MiB) 316.4ms 317.8ms 320.2ms CSI-heavy (32 MiB) 145.0ms 146.3ms 145.9ms OSC (32 MiB) 1621.0ms 1627.5ms 1638.3ms Kitty APC (128 MiB) 95.5ms 96.9ms 96.3ms mixed traffic (32 MiB) 172.4ms 172.0ms 172.3ms giant APC (128 MiB) 38.3ms 38.3ms 40.8ms --- src/benchmark/ApcParser.zig | 5 +- src/benchmark/TerminalStream.zig | 30 +- src/inspector/widgets/termio.zig | 5 +- src/terminal/Terminal.zig | 5 +- src/terminal/c/terminal.zig | 5 +- src/terminal/stream.zig | 781 ++++++++++++++++++++++++--- src/terminal/stream_continuation.zig | 540 ++++++++++++++++++ src/terminal/stream_terminal.zig | 344 ++++++++---- src/termio/Termio.zig | 5 +- 9 files changed, 1551 insertions(+), 169 deletions(-) create mode 100644 src/terminal/stream_continuation.zig diff --git a/src/benchmark/ApcParser.zig b/src/benchmark/ApcParser.zig index 8b5f883fd..84378f1a2 100644 --- a/src/benchmark/ApcParser.zig +++ b/src/benchmark/ApcParser.zig @@ -71,7 +71,10 @@ pub fn create( errdefer alloc.destroy(ptr); ptr.* = .{ .opts = opts, - .stream = .init(.{ .alloc = alloc }), + .stream = .init(.{ + .allocator = alloc, + .handler = .{ .alloc = alloc }, + }), }; return ptr; } diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index a521c5b7a..a16e36e07 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -37,7 +37,13 @@ pub const Options = struct { @"terminal-rows": u16 = 80, @"terminal-cols": u16 = 120, - /// The data to read as a filepath. If this is "-" then + /// Enable opt-in continuation tracking on the stream. + @"continuation-enabled": bool = false, + + /// Maximum continuation suffix retained when tracking is enabled. + @"continuation-max-bytes": usize = 1024 * 1024, + + /// Pre-generated data from ghostty-gen. 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 @@ -61,7 +67,15 @@ pub fn create( }), .stream = undefined, }; - ptr.stream = .initAlloc(alloc, .init(&ptr.terminal)); + errdefer ptr.terminal.deinit(alloc); + ptr.stream = .init(.{ + .allocator = alloc, + .handler = .init(&ptr.terminal), + .continuation_max_bytes = if (opts.@"continuation-enabled") + opts.@"continuation-max-bytes" + else + null, + }); return ptr; } @@ -86,8 +100,8 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { // Always reset our terminal state self.terminal.fullReset(); - // Open our data file to prepare for reading. We can do more - // validation here eventually. + // 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}); @@ -142,4 +156,12 @@ test TerminalStream { const bench = impl.benchmark(); _ = try bench.run(.once); + + const tracked: *TerminalStream = try .create(alloc, .{ + .@"continuation-enabled" = true, + }); + defer tracked.destroy(alloc); + + const tracked_bench = tracked.benchmark(); + _ = try tracked_bench.run(.once); } diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index 04574f4ce..6e3408beb 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -30,7 +30,10 @@ pub const Stream = struct { return .{ .events = events, - .parser_stream = .initAlloc(alloc, handler), + .parser_stream = .init(.{ + .allocator = alloc, + .handler = handler, + }), }; } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d47fd474f..b73b0a53d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -371,7 +371,10 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { /// for handling escape sequences split across write boundaries), you /// must store and reuse the returned stream. pub fn vtStream(self: *Terminal) Stream { - return .initAlloc(self.gpa(), self.vtHandler()); + return Stream.init(.{ + .allocator = self.gpa(), + .handler = self.vtHandler(), + }); } /// This is the handler-side only for vtStream. diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 85f4dbcf4..e81d1f106 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -457,7 +457,10 @@ fn new_( .terminal = t, .io_impl = io_impl, .tmp_dir_path = undefined, // Only used if temporary directory is set with API calls - .stream = .initAlloc(alloc, handler), + .stream = Stream.init(.{ + .allocator = alloc, + .handler = handler, + }), }; return wrapper; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 791d0c55d..dcdc1adbe 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -16,6 +16,7 @@ const kitty = @import("kitty.zig"); const modes = @import("modes.zig"); const osc = @import("osc.zig"); const sgr = @import("sgr.zig"); +const continuationpkg = @import("stream_continuation.zig"); const UTF8Decoder = @import("UTF8Decoder.zig"); const MouseShape = @import("mouse.zig").Shape; @@ -476,46 +477,124 @@ pub fn Stream(comptime H: type) type { handler: Handler, parser: Parser, utf8decoder: UTF8Decoder, + continuation: ?continuationpkg.Tracker, - /// Initialize an allocation-free stream. This will preallocate various - /// sizes as necessary and anything over that will be dropped. If you - /// want to support more dynamic behavior use initAlloc instead. + pub const Options = struct { + /// The handler initial value, must be set. + handler: Handler, + + /// Allocator to use. If this is not set then the stream + /// will be fully allocation free. There are some operations + /// that will be dropped in this case such as OSC 52 clipboard + /// ops. + allocator: ?Allocator = null, + + /// Maximum size in bytes of the continuation suffix. If this is + /// null or zero then continuation tracking is disabled. This is + /// only applied when `allocator` is non-null; without an allocator + /// continuation tracking is disabled. Feeding this continuation + /// suffix into an equivalent stream at ground reconstructs the + /// unfinished state without repeating committed terminal effects. + /// Continuation tracking is only supported by TerminalStream. + continuation_max_bytes: ?usize = null, + }; + + /// Initialize a stream. Without an allocator, operations that require + /// heap allocation are dropped. /// /// As a concrete example of something that requires heap allocation, /// consider OSC 52 (clipboard operations) which can be arbitrarily /// large. /// - /// If you want to limit allocation size, use an allocator with - /// a size limit with initAlloc. - /// /// This takes ownership of the handler and will call deinit /// when the stream is deinitialized. - pub fn init(h: Handler) Self { + pub fn init(options: Options) Self { + // Initialize the parser + var parser: Parser = .init(); + if (options.allocator) |alloc| parser.osc_parser.alloc = alloc; + + // Initialize the continuation tracker if one is requested. + var tracker: ?continuationpkg.Tracker = null; + if (options.allocator) |alloc| { + if (options.continuation_max_bytes) |max_bytes| { + if (max_bytes > 0) tracker = .init(alloc, max_bytes); + } + } + return .{ - .handler = h, - .parser = .init(), + .handler = options.handler, + .parser = parser, .utf8decoder = .{}, + .continuation = tracker, }; } - /// Initialize the stream that supports heap allocation as necessary. - pub fn initAlloc(alloc: Allocator, h: Handler) Self { - var self: Self = .init(h); - self.parser.osc_parser.alloc = alloc; - return self; - } - pub fn deinit(self: *Self) void { + if (self.continuation) |*tracker| tracker.deinit(); self.parser.deinit(); self.handler.deinit(); } + /// Write the current continuation suffix directly to a caller-owned + /// writer. The caller must pause and serialize access to this Stream. + pub fn writeContinuation( + self: *const Self, + writer: *std.Io.Writer, + ) (std.Io.Writer.Error || error{ + ContinuationDisabled, + ContinuationUnavailable, + })!void { + const tracker = self.continuation orelse + return error.ContinuationDisabled; + if (tracker.broken) + return error.ContinuationUnavailable; + try tracker.write(writer); + } + + /// True when no continuation suffix is needed to reproduce the + /// stream's current parsing state. + inline fn ground(self: *const Self) bool { + // Parser ground alone is not sufficient because the UTF-8 + // decoder may have some state. + return self.parser.state == .ground and self.utf8decoder.state == 0; + } + + /// Update the continuation suffix after one complete feed call. + /// Must only be called when tracking is enabled. + fn trackContinuation(self: *Self, input: []const u8) void { + const tracker = &self.continuation.?; + + // If we're in a ground state, we have no continuation suffix + // to track by definition. + if (self.ground()) { + tracker.reset(); + return; + } + + // Retain the part of this feed needed to replay the unfinished + // state. When the parser is grounded here, the feed must have + // ended inside a UTF-8 codepoint instead, because the ground + // check above covers both state machines. + tracker.append( + if (self.parser.state != .ground) .vt else .utf8, + input, + ); + } + /// Process a string of characters. pub inline fn nextSlice(self: *Self, input: []const u8) void { + self.nextSliceUntracked(input); + + // Continuation tracking is opt-in and this branch predicts + // perfectly, so disabled streams pay nothing else here. + if (self.continuation != null) self.trackContinuation(input); + } + + inline fn nextSliceUntracked(self: *Self, input: []const u8) void { // Disable SIMD optimizations if build requests it or if our // manual debug mode is on. if (comptime debug or !build_options.simd) { - for (input) |c| self.next(c); + for (input) |c| self.nextUntracked(c); return; } @@ -848,6 +927,11 @@ pub fn Stream(comptime H: type) type { /// operation that can't use SIMD. Prefer nextSlice if you can and /// try to get multiple bytes at once. pub inline fn next(self: *Self, c: u8) void { + self.nextUntracked(c); + if (self.continuation != null) self.trackContinuation(&.{c}); + } + + inline fn nextUntracked(self: *Self, c: u8) void { // The scalar path can be responsible for decoding UTF-8. if (self.parser.state == .ground) { self.nextUtf8(c); @@ -893,7 +977,7 @@ pub fn Stream(comptime H: type) type { // We need to increase the eval branch limit because a lot of // tests end up running almost completely at comptime due to // a chain of inline functions. - @setEvalBranchQuota(100_000); + @setEvalBranchQuota(200_000); // C0 control if (c <= 0xF) { @@ -2802,7 +2886,7 @@ test "stream: print" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.next('x'); try testing.expectEqual(@as(u21, 'x'), s.handler.c.?); } @@ -2824,7 +2908,7 @@ test "simd: print invalid utf-8" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice(&.{0xFF}); try testing.expectEqual(@as(u21, 0xFFFD), s.handler.c.?); } @@ -2846,7 +2930,7 @@ test "simd: complete incomplete utf-8" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice(&.{0xE0}); // 3 byte try testing.expect(s.handler.c == null); s.nextSlice(&.{0xA0}); // still incomplete @@ -2871,7 +2955,7 @@ test "stream: cursor right (CUF)" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[C"); try testing.expectEqual(@as(u16, 1), s.handler.amount); @@ -2904,7 +2988,7 @@ test "stream: dec set mode (SM) and reset mode (RM)" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[?6h"); try testing.expectEqual(@as(modes.Mode, .origin), s.handler.mode); @@ -2933,7 +3017,7 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[4h"); try testing.expectEqual(@as(modes.Mode, .insert), s.handler.mode.?); @@ -2964,7 +3048,7 @@ test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[6h"); try testing.expect(s.handler.mode == null); @@ -2990,7 +3074,7 @@ test "stream: restore mode" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[?42r") |c| s.next(c); try testing.expect(!s.handler.called); } @@ -3012,7 +3096,7 @@ test "stream: pop kitty keyboard with no params defaults to 1" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[2s"); try testing.expect(s.handler.escape == null); @@ -3340,13 +3424,13 @@ test "stream: change window title with invalid utf-8" { }; { - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b]2;abc\x1b\\"); try testing.expect(s.handler.seen); } { - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b]2;abc\xc0\x1b\\"); try testing.expect(!s.handler.seen); } @@ -3370,7 +3454,7 @@ test "stream: insert characters" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[42@") |c| s.next(c); try testing.expect(s.handler.called); @@ -3396,7 +3480,7 @@ test "stream: insert characters explicit zero clamps to 1" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[0@") |c| s.next(c); try testing.expectEqual(@as(usize, 1), s.handler.value.?); } @@ -3420,7 +3504,7 @@ test "stream: SCOSC" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[s") |c| s.next(c); try testing.expect(s.handler.called); } @@ -3443,7 +3527,7 @@ test "stream: SCORC" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[u") |c| s.next(c); try testing.expect(s.handler.called); } @@ -3464,7 +3548,7 @@ test "stream: too many csi params" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); } @@ -3481,7 +3565,7 @@ test "stream: csi param too long" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); } @@ -3501,7 +3585,7 @@ test "stream: send report with CSI t" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[14t"); try testing.expectEqual(csi.SizeReportStyle.csi_14_t, s.handler.style); @@ -3535,7 +3619,7 @@ test "stream: invalid CSI t" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[19t"); try testing.expectEqual(null, s.handler.style); @@ -3557,7 +3641,7 @@ test "stream: CSI t push title" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[22;0t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); @@ -3579,7 +3663,7 @@ test "stream: CSI t push title with explicit window" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[22;2t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); @@ -3601,7 +3685,7 @@ test "stream: CSI t push title with explicit icon" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[22;1t"); try testing.expectEqual(null, s.handler.index); @@ -3623,7 +3707,7 @@ test "stream: CSI t push title with index" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[22;0;5t"); try testing.expectEqual(@as(u16, 5), s.handler.index.?); @@ -3645,7 +3729,7 @@ test "stream: CSI t pop title" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[23;0t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); @@ -3667,7 +3751,7 @@ test "stream: CSI t pop title with explicit window" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[23;2t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); @@ -3689,7 +3773,7 @@ test "stream: CSI t pop title with explicit icon" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[23;1t"); try testing.expectEqual(null, s.handler.index); @@ -3711,7 +3795,7 @@ test "stream: CSI t pop title with index" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[23;0;5t"); try testing.expectEqual(@as(u16, 5), s.handler.index.?); @@ -3731,7 +3815,7 @@ test "stream CSI W clear tab stops" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[2W"); try testing.expectEqual(Action.Key.tab_clear_current, s.handler.action.?); @@ -3754,7 +3838,7 @@ test "stream CSI W tab set" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[W"); try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); @@ -3786,7 +3870,7 @@ test "stream CSI ? W reset tab stops" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[?2W"); try testing.expect(s.handler.action == null); @@ -3820,7 +3904,7 @@ test "stream: SGR with 17+ parameters for underline color" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); // Kakoune-style SGR with underline color as 17th parameter // This tests the fix where param 17 was being dropped @@ -3848,7 +3932,7 @@ test "stream: tab clear with overflowing param" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); // This is the exact input from the fuzz crash (minus the mode byte): // CSI with a huge numeric param that saturates to 65535, followed by 'g'. s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m"); @@ -3888,7 +3972,7 @@ const ApcTestHandler = struct { }; test "stream: apc bulk slice" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); s.nextSlice("\x1b_Gf=24,s=10,v=20;aGVsbG8=\x1b\\"); try testing.expectEqual(@as(usize, 1), s.handler.started); @@ -3906,7 +3990,7 @@ test "stream: apc bulk slice" { } test "stream: apc bulk slice split across inputs" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); s.nextSlice("\x1b_Gf=24,s=10"); s.nextSlice(",v=20;aGVs"); s.nextSlice("bG8=\x1b\\"); @@ -3920,7 +4004,7 @@ test "stream: apc bulk slice split across inputs" { } test "stream: apc bulk slice keeps C0 bytes as data" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); // BEL does not terminate an APC string; it is payload data. s.nextSlice("\x1b_Gx\x07y\x1b\\"); @@ -3929,7 +4013,7 @@ test "stream: apc bulk slice keeps C0 bytes as data" { } test "stream: apc aborted by CAN" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); // 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. @@ -3944,7 +4028,7 @@ test "stream: apc aborted by CAN" { } test "stream: apc scalar path matches" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); for ("\x1b_Gf=24;aGVsbG8=\x1b\\") |c| s.next(c); try testing.expectEqual(@as(usize, 1), s.handler.started); @@ -3967,9 +4051,9 @@ test "stream: apc vector boundaries match scalar path" { input[4 + position] = '\\'; const bytes = input[0 .. 5 + position]; - var bulk: Stream(ApcTestHandler) = .init(.{}); + var bulk: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); bulk.nextSlice(bytes); - var scalar: Stream(ApcTestHandler) = .init(.{}); + var scalar: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); for (bytes) |byte| scalar.next(byte); try testing.expectEqual(scalar.handler.started, bulk.handler.started); @@ -3980,3 +4064,574 @@ test "stream: apc vector boundaries match scalar path" { ); }; } + +const ContinuationTestHandler = struct { + committed: usize = 0, + apc_active: bool = false, + apc_buf: [256]u8 = undefined, + apc_len: usize = 0, + dcs_active: bool = false, + + pub fn deinit(_: *@This()) void {} + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .apc_start => self.apc_active = true, + .apc_put => { + self.apc_buf[self.apc_len] = value; + self.apc_len += 1; + }, + .apc_put_slice => { + @memcpy( + self.apc_buf[self.apc_len..][0..value.bytes.len], + value.bytes, + ); + self.apc_len += value.bytes.len; + }, + .dcs_hook => self.dcs_active = true, + .dcs_put => {}, + .apc_end => { + self.apc_active = false; + self.apc_len = 0; + self.committed += 1; + }, + .dcs_unhook => { + self.dcs_active = false; + self.committed += 1; + }, + .print => self.committed += 1, + .print_slice => self.committed += value.cps.len, + .print_repeat => self.committed += value, + else => self.committed += 1, + } + } +}; + +const ContinuationNullHandler = struct { + pub fn deinit(_: *@This()) void {} + + pub fn vt( + _: *@This(), + comptime _: Action.Tag, + _: anytype, + ) void {} +}; + +test "stream: continuation lifecycle" { + const S = Stream(ContinuationTestHandler); + + var disabled: S = .init(.{ .handler = .{} }); + defer disabled.deinit(); + var disabled_buf: [1]u8 = undefined; + var disabled_writer: std.Io.Writer = .fixed(&disabled_buf); + try testing.expectError( + error.ContinuationDisabled, + disabled.writeContinuation(&disabled_writer), + ); + + var zero_capacity: S = .init(.{ + .handler = .{}, + .continuation_max_bytes = 0, + }); + defer zero_capacity.deinit(); + var zero_capacity_buf: [1]u8 = undefined; + var zero_capacity_writer: std.Io.Writer = .fixed(&zero_capacity_buf); + try testing.expectError( + error.ContinuationDisabled, + zero_capacity.writeContinuation(&zero_capacity_writer), + ); + var no_allocator: S = .init(.{ + .handler = .{}, + .continuation_max_bytes = 64, + }); + defer no_allocator.deinit(); + var no_allocator_buf: [1]u8 = undefined; + var no_allocator_writer: std.Io.Writer = .fixed(&no_allocator_buf); + try testing.expectError( + error.ContinuationDisabled, + no_allocator.writeContinuation(&no_allocator_writer), + ); + + var tracked = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 64, + }); + defer tracked.deinit(); + + tracked.nextSlice("complete input"); + var empty_buf: [1]u8 = undefined; + var empty_writer: std.Io.Writer = .fixed(&empty_buf); + try tracked.writeContinuation(&empty_writer); + try testing.expectEqual(@as(usize, 0), empty_writer.end); + + tracked.nextSlice("\x1b["); + var short_buf: [1]u8 = undefined; + var short_writer: std.Io.Writer = .fixed(&short_buf); + try testing.expectError( + error.WriteFailed, + tracked.writeContinuation(&short_writer), + ); + + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + var failing_stream = Stream(ContinuationNullHandler).init(.{ + .allocator = failing.allocator(), + .handler = .{}, + .continuation_max_bytes = 64, + }); + defer failing_stream.deinit(); + failing_stream.nextSlice("\x1b["); + var unavailable_buf: [1]u8 = undefined; + var unavailable_writer: std.Io.Writer = .fixed(&unavailable_buf); + try testing.expectError( + error.ContinuationUnavailable, + failing_stream.writeContinuation(&unavailable_writer), + ); +} + +test "stream: continuation suffixes are replay safe" { + const Case = struct { + input: []const u8, + expected: []const u8, + }; + const cases = [_]Case{ + .{ .input = "text\x1b", .expected = "\x1b" }, + .{ .input = "text\x1b[12;", .expected = "\x1b[12;" }, + .{ .input = "text\x1b[1\x07;2", .expected = "\x1b[1;2" }, + .{ .input = "text\x1b]2;hello", .expected = "\x1b]2;hello" }, + .{ .input = "text\x1b_Gabc", .expected = "\x1b_Gabc" }, + .{ .input = "text\x1bP+qabc", .expected = "\x1bP+qabc" }, + .{ .input = "text\xE0\xA0\xF0", .expected = "\xF0" }, + .{ .input = "text\x1b[12\x1b", .expected = "\x1b" }, + .{ + .input = "text\x1b[12\x9D2;title", + .expected = "\x1b[12\x9D2;title", + }, + }; + + const S = Stream(ContinuationTestHandler); + for (cases) |case| { + var stream = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer stream.deinit(); + stream.nextSlice(case.input); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try stream.writeContinuation(&writer); + try testing.expectEqualStrings(case.expected, writer.buffered()); + } +} + +test "stream: continuation reconstructs every unfinished VT state" { + const Case = struct { + input: []const u8, + state: Parser.State, + }; + const cases = [_]Case{ + .{ .input = "\x1b", .state = .escape }, + .{ .input = "\x1b(", .state = .escape_intermediate }, + .{ .input = "\x1b[", .state = .csi_entry }, + .{ .input = "\x1b[1", .state = .csi_param }, + .{ .input = "\x1b[1$", .state = .csi_intermediate }, + .{ .input = "\x1b[:", .state = .csi_ignore }, + .{ .input = "\x1bP", .state = .dcs_entry }, + .{ .input = "\x1bP1", .state = .dcs_param }, + .{ .input = "\x1bP1$", .state = .dcs_intermediate }, + .{ .input = "\x1bP1q", .state = .dcs_passthrough }, + .{ .input = "\x1bP:", .state = .dcs_ignore }, + .{ .input = "\x1b]2;title", .state = .osc_string }, + .{ .input = "\x1b_Gpayload", .state = .sos_pm_apc_string }, + }; + const S = Stream(ContinuationTestHandler); + + for (cases) |case| { + var source = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer source.deinit(); + source.nextSlice(case.input); + try testing.expectEqual(case.state, source.parser.state); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try source.writeContinuation(&writer); + + var restored = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer restored.deinit(); + restored.nextSlice(writer.buffered()); + try testing.expectEqual(@as(usize, 0), restored.handler.committed); + try testing.expectEqual(source.parser.state, restored.parser.state); + try testing.expectEqual(source.utf8decoder.state, restored.utf8decoder.state); + } + + // Parser ground is still unfinished while the UTF-8 decoder is waiting + // for the remaining bytes of a codepoint. + var utf8 = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 4, + }); + defer utf8.deinit(); + utf8.next(0xF0); + try testing.expectEqual(Parser.State.ground, utf8.parser.state); + try testing.expect(utf8.utf8decoder.state != 0); + var utf8_buf: [4]u8 = undefined; + var utf8_writer: std.Io.Writer = .fixed(&utf8_buf); + try utf8.writeContinuation(&utf8_writer); + try testing.expectEqualSlices(u8, &.{0xF0}, utf8_writer.buffered()); +} + +test "stream: continuation is chunking-independent and idempotent" { + const S = Stream(ContinuationTestHandler); + const input = "committed\x1b[1\x07;2"; + + var bulk = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer bulk.deinit(); + bulk.nextSlice(input); + + var scalar = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer scalar.deinit(); + for (input) |c| scalar.next(c); + + var bulk_buf: [1024]u8 = undefined; + var bulk_writer: std.Io.Writer = .fixed(&bulk_buf); + try bulk.writeContinuation(&bulk_writer); + var scalar_buf: [1024]u8 = undefined; + var scalar_writer: std.Io.Writer = .fixed(&scalar_buf); + try scalar.writeContinuation(&scalar_writer); + try testing.expectEqualStrings( + bulk_writer.buffered(), + scalar_writer.buffered(), + ); + + var restored = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer restored.deinit(); + restored.nextSlice(bulk_writer.buffered()); + try testing.expectEqual(@as(usize, 0), restored.handler.committed); + + var restored_buf: [1024]u8 = undefined; + var restored_writer: std.Io.Writer = .fixed(&restored_buf); + try restored.writeContinuation(&restored_writer); + try testing.expectEqualStrings( + bulk_writer.buffered(), + restored_writer.buffered(), + ); + + bulk.handler.committed = 0; + restored.handler.committed = 0; + bulk.nextSlice("mZ"); + restored.next('m'); + restored.next('Z'); + try testing.expectEqual(bulk.handler.committed, restored.handler.committed); +} + +test "stream: continuation rebuilds APC handler input" { + const S = Stream(ContinuationTestHandler); + var source = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer source.deinit(); + source.nextSlice("committed\x1b_Gabc"); + + var continuation: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&continuation); + try source.writeContinuation(&writer); + + var restored = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer restored.deinit(); + restored.nextSlice(writer.buffered()); + try testing.expectEqual(@as(usize, 0), restored.handler.committed); + try testing.expect(restored.handler.apc_active); + try testing.expectEqualStrings( + source.handler.apc_buf[0..source.handler.apc_len], + restored.handler.apc_buf[0..restored.handler.apc_len], + ); + + source.handler.committed = 0; + restored.handler.committed = 0; + source.nextSlice("\x1b\\"); + restored.nextSlice("\x1b\\"); + try testing.expectEqual(source.handler.committed, restored.handler.committed); + try testing.expect(!source.handler.apc_active); + try testing.expect(!restored.handler.apc_active); +} + +test "stream: continuation cap and recovery" { + const S = Stream(ContinuationTestHandler); + + // The raw feed exceeds the cap, but only the unfinished three-byte + // CSI suffix is retained. + var seeded = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 4, + }); + defer seeded.deinit(); + seeded.nextSlice("committed text\x1b[1"); + var seeded_buf: [4]u8 = undefined; + var seeded_writer: std.Io.Writer = .fixed(&seeded_buf); + try seeded.writeContinuation(&seeded_writer); + try testing.expectEqualStrings("\x1b[1", seeded_writer.buffered()); + + var exceeded = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 4, + }); + defer exceeded.deinit(); + exceeded.nextSlice("\x1b[123"); + var unavailable_buf: [1]u8 = undefined; + var unavailable_writer: std.Io.Writer = .fixed(&unavailable_buf); + try testing.expectError( + error.ContinuationUnavailable, + exceeded.writeContinuation(&unavailable_writer), + ); + + // Completing the CSI reaches ground and recovers without rebuilding the + // Stream. A later unfinished sequence is tracked normally. + exceeded.nextSlice("mtext\x1b["); + var recovered_buf: [4]u8 = undefined; + var recovered_writer: std.Io.Writer = .fixed(&recovered_buf); + try exceeded.writeContinuation(&recovered_writer); + try testing.expectEqualStrings("\x1b[", recovered_writer.buffered()); + + // A fresh ESC seed also recovers broken tracking even when the stream + // never reaches ground: the ESC abandons the previous unfinished + // state and everything after it is retained. + exceeded.nextSlice("\x1b[123"); + var reexceeded_buf: [1]u8 = undefined; + var reexceeded_writer: std.Io.Writer = .fixed(&reexceeded_buf); + try testing.expectError( + error.ContinuationUnavailable, + exceeded.writeContinuation(&reexceeded_writer), + ); + exceeded.nextSlice("\x1b]0;"); + var seed_buf: [4]u8 = undefined; + var seed_writer: std.Io.Writer = .fixed(&seed_buf); + try exceeded.writeContinuation(&seed_writer); + try testing.expectEqualStrings("\x1b]0;", seed_writer.buffered()); +} + +test "stream: continuation spans multiple bulk feeds" { + const S = Stream(ContinuationTestHandler); + + // An unfinished APC grows across feeds that contain no new seed. + var apc = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer apc.deinit(); + apc.nextSlice("text\x1b_Gab"); + apc.nextSlice("cd"); + apc.nextSlice("ef"); + var apc_buf: [16]u8 = undefined; + var apc_writer: std.Io.Writer = .fixed(&apc_buf); + try apc.writeContinuation(&apc_writer); + try testing.expectEqualStrings("\x1b_Gabcdef", apc_writer.buffered()); + + // An incomplete UTF-8 sequence grows across feeds of its + // continuation bytes. + var utf8 = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer utf8.deinit(); + utf8.nextSlice("text\xF0"); + utf8.nextSlice("\x9F"); + var utf8_buf: [4]u8 = undefined; + var utf8_writer: std.Io.Writer = .fixed(&utf8_buf); + try utf8.writeContinuation(&utf8_writer); + try testing.expectEqualSlices(u8, "\xF0\x9F", utf8_writer.buffered()); + + // A later feed with its own seed drops everything retained earlier. + utf8.nextSlice("\x98\x84 done \x1b[38;5"); + var seed_buf: [8]u8 = undefined; + var seed_writer: std.Io.Writer = .fixed(&seed_buf); + try utf8.writeContinuation(&seed_writer); + try testing.expectEqualStrings("\x1b[38;5", seed_writer.buffered()); +} + +test "stream: continuation exact cap and large unfinished string" { + const S = Stream(ContinuationNullHandler); + + var exact = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 5, + }); + defer exact.deinit(); + exact.nextSlice("\x1b[123"); + var exact_buf: [5]u8 = undefined; + var exact_writer: std.Io.Writer = .fixed(&exact_buf); + try exact.writeContinuation(&exact_writer); + try testing.expectEqualStrings("\x1b[123", exact_writer.buffered()); + + const payload_len = 12 * 1024; + const input = try testing.allocator.alloc(u8, payload_len); + defer testing.allocator.free(input); + input[0..3].* = "\x1b_G".*; + @memset(input[3..], 'A'); + + var large = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = payload_len, + }); + defer large.deinit(); + large.nextSlice(input); + const large_buf = try testing.allocator.alloc(u8, payload_len); + defer testing.allocator.free(large_buf); + var large_writer: std.Io.Writer = .fixed(large_buf); + try large.writeContinuation(&large_writer); + try testing.expectEqualSlices(u8, input, large_writer.buffered()); +} + +test "stream: continuation allocation failure recovers" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + const S = Stream(ContinuationNullHandler); + var stream = S.init(.{ + .allocator = alloc, + .handler = .{}, + .continuation_max_bytes = 16 * 1024, + }); + defer stream.deinit(); + + var input = try testing.allocator.alloc(u8, 12 * 1024); + defer testing.allocator.free(input); + input[0..2].* = "\x1b[".*; + @memset(input[2..], '1'); + + failing.fail_index = failing.alloc_index; + stream.nextSlice(input); + var unavailable_buf: [1]u8 = undefined; + var unavailable_writer: std.Io.Writer = .fixed(&unavailable_buf); + try testing.expectError( + error.ContinuationUnavailable, + stream.writeContinuation(&unavailable_writer), + ); + + failing.fail_index = std.math.maxInt(usize); + stream.next('m'); + var recovered_buf: [1]u8 = undefined; + var recovered_writer: std.Io.Writer = .fixed(&recovered_buf); + try stream.writeContinuation(&recovered_writer); + try testing.expectEqual(@as(usize, 0), recovered_writer.end); +} + +test "stream: continuation every-byte cuts preserve future behavior" { + const corpora = [_][]const u8{ + "plain \xF0\x9F\x98\x84 utf8", + "bad \xE0\xA0\xF0\x9F\x98\x84 utf8", + "\x1b[1\x07;2mstyled\x1b[0m", + "\x1b]2;window title\x1b\\text", + "\x1bP$qm\x1b\\text", + "\x1b_Ga=q;payload\x1b\\text", + "\x1b_25a1;s\x1b\\text", + "\x1b]2;first\x1b\\\x1b_Gsecond", + "\x1b[12\x9D2;title\x1b\\text", + "\x1b[12\x18text\x1b[1\x1Atext", + }; + const S = Stream(ContinuationTestHandler); + + for (corpora) |corpus| for (0..corpus.len + 1) |cut| { + var source = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 64 * 1024, + }); + defer source.deinit(); + source.nextSlice(corpus[0..cut]); + + var continuation: [64 * 1024]u8 = undefined; + var continuation_writer: std.Io.Writer = .fixed(&continuation); + try source.writeContinuation(&continuation_writer); + + var restored = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 64 * 1024, + }); + defer restored.deinit(); + restored.nextSlice(continuation_writer.buffered()); + try testing.expectEqual(@as(usize, 0), restored.handler.committed); + try testing.expectEqual(source.handler.apc_active, restored.handler.apc_active); + try testing.expectEqual(source.handler.dcs_active, restored.handler.dcs_active); + if (source.handler.apc_active) { + try testing.expectEqualStrings( + source.handler.apc_buf[0..source.handler.apc_len], + restored.handler.apc_buf[0..restored.handler.apc_len], + ); + } + + var reexport: [64 * 1024]u8 = undefined; + var reexport_writer: std.Io.Writer = .fixed(&reexport); + try restored.writeContinuation(&reexport_writer); + try testing.expectEqualStrings( + continuation_writer.buffered(), + reexport_writer.buffered(), + ); + + source.handler.committed = 0; + restored.handler.committed = 0; + source.nextSlice(corpus[cut..]); + var offset = cut; + var partition = cut +% corpus.len +% 1; + while (offset < corpus.len) { + partition = partition *% 1664525 +% 1013904223; + const len = @min(1 + partition % 7, corpus.len - offset); + restored.nextSlice(corpus[offset..][0..len]); + offset += len; + } + try testing.expectEqual(source.handler.committed, restored.handler.committed); + try testing.expectEqual(source.handler.apc_active, restored.handler.apc_active); + try testing.expectEqual(source.handler.dcs_active, restored.handler.dcs_active); + + var source_final: [64]u8 = undefined; + var source_final_writer: std.Io.Writer = .fixed(&source_final); + try source.writeContinuation(&source_final_writer); + var restored_final: [64]u8 = undefined; + var restored_final_writer: std.Io.Writer = .fixed(&restored_final); + try restored.writeContinuation(&restored_final_writer); + try testing.expectEqualStrings( + source_final_writer.buffered(), + restored_final_writer.buffered(), + ); + }; +} diff --git a/src/terminal/stream_continuation.zig b/src/terminal/stream_continuation.zig new file mode 100644 index 000000000..4512947de --- /dev/null +++ b/src/terminal/stream_continuation.zig @@ -0,0 +1,540 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const Parser = @import("Parser.zig"); +const UTF8Decoder = @import("UTF8Decoder.zig"); + +/// Retains the input needed to reconstruct unfinished Stream parser state. +/// +/// A feed is one chunk of bytes given to a Stream. It can end in the middle of +/// a VT sequence or a UTF-8 codepoint. To continue in another Stream, that +/// Stream starts from ground (with no sequence in progress) and reads the +/// saved bytes again. The first saved byte is called the replay start. +/// +/// Stream updates this tracker once per feed based on where that replay start +/// is: +/// +/// - Ground (parser and UTF-8 decoder): `reset`, no suffix is needed. +/// - Anything unfinished: `append`, which replaces the suffix with the +/// bytes from the replay start onward when that start is inside this +/// feed, and otherwise extends the suffix with this whole feed because +/// the unfinished state began in an earlier one. +/// +/// This keeps the retained bytes minimal: they always begin at the replay +/// start, so the feed path never needs to parse or trim old input. The suffix +/// may still contain a byte whose visible terminal effect already happened, +/// such as BEL inside an unfinished CSI sequence. `write` leaves out those +/// bytes so replay does not perform the same effect twice. +/// +/// If the suffix exceeds `max_bytes`, or retaining it fails, `broken` +/// is set until a later feed ends at ground (`reset`) or contains a new +/// replay start (`replace`), both of which need nothing that was lost. +/// +/// Replay semantics are guaranteed for the standard TerminalStream handler. +/// Custom handlers, including handlers that intercept vtRaw, are unsupported. +pub const Tracker = struct { + const initial_capacity = 4096; + + alloc: Allocator, + max_bytes: usize, + bytes: std.ArrayList(u8) = .empty, + broken: bool = false, + + /// Initialize a tracker. + pub fn init(alloc: Allocator, max_bytes: usize) Tracker { + var result: Tracker = .{ + .alloc = alloc, + .max_bytes = max_bytes, + }; + result.bytes.ensureTotalCapacity( + alloc, + @min(max_bytes, initial_capacity), + ) catch { + // We ignore memory errors here. They'll mark the tracker + // as broken in a future append. + + }; + return result; + } + + pub fn deinit(self: *Tracker) void { + self.bytes.deinit(self.alloc); + } + + /// Clear the current continuation suffix and broken state while keeping + /// its allocation. Stream calls this after reaching ground because no + /// earlier input is needed to reconstruct the next unfinished state. + pub fn reset(self: *Tracker) void { + self.broken = false; + self.bytes.clearRetainingCapacity(); + } + + /// Which state machine is unfinished at the end of a feed. When the VT + /// parser is outside ground the unfinished state is an escape sequence. + /// When it is grounded, only the UTF-8 decoder can be unfinished, with + /// an incomplete codepoint ending the feed. + pub const Pending = enum { vt, utf8 }; + + /// Retain the part of `input` needed to replay a feed that ended with + /// `pending` state unfinished. + /// + /// When the input contains the replay start for that state, it replaces + /// the retained suffix outright because nothing earlier is needed. The + /// new suffix is complete on its own, so this also repairs a broken + /// tracker. Otherwise the unfinished state began in an earlier feed and + /// this whole input extends the current suffix. + pub fn append(self: *Tracker, pending: Pending, input: []const u8) void { + const start = switch (pending) { + .vt => findVTReplayStart(input), + .utf8 => findUtf8ReplayStart(input), + } orelse { + self.extend(input); + return; + }; + self.replace(input[start..]); + } + + /// Replace the continuation suffix with one that has a new replay start. + fn replace(self: *Tracker, suffix: []const u8) void { + self.bytes.clearRetainingCapacity(); + if (suffix.len > self.max_bytes) { + self.markBroken(); + return; + } + self.bytes.appendSlice(self.alloc, suffix) catch { + self.markBroken(); + return; + }; + self.broken = false; + } + + /// Extend the continuation suffix with a fragment that contains no replay + /// start of its own. The stream stayed unfinished for the whole fragment, + /// so the existing suffix plus this fragment reproduces the current state. + fn extend(self: *Tracker, fragment: []const u8) void { + if (self.broken) return; + if (fragment.len > self.max_bytes -| self.bytes.items.len) { + self.markBroken(); + return; + } + self.bytes.appendSlice(self.alloc, fragment) catch self.markBroken(); + } + + /// Write the replay-safe continuation suffix to `writer`. The retained + /// bytes already begin at the replay start; this omits only bytes that + /// would repeat committed terminal effects without contributing to the + /// unfinished parser state (e.g. a BEL inside an unfinished CSI). The + /// tracker must not be broken. + pub fn write( + self: *const Tracker, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + var scanner: BoundaryScanner = .init(); + for (self.bytes.items) |c| { + if (scanner.next(c) == .omittable) continue; + try writer.writeByte(c); + } + } + + fn markBroken(self: *Tracker) void { + self.broken = true; + self.bytes.clearRetainingCapacity(); + } +}; + +/// Find where replay must begin when a feed ends inside a VT sequence. +/// +/// VT parsers treat ESC specially: it abandons the previous parser state and +/// starts a fresh escape state no matter what was being parsed. Because of +/// that rule, feeding the bytes from the last ESC onward into a grounded +/// Stream recreates the unfinished state at the end of `input`. +/// +/// The caller must only use this when the VT parser ended outside ground. +/// The returned value is the index of the last ESC in `input`. A null result +/// means the sequence began in an earlier feed, so this entire input must be +/// appended to the continuation bytes already retained. +fn findVTReplayStart(input: []const u8) ?usize { + const esc: u8 = 0x1B; + var rem: usize = input.len; + + // Escape sequences are short, so when the stream ends inside one the + // replay start is usually within the last few bytes. Scan backward in + // vector chunks; pure payload inputs (no ESC at all) still scan quickly. + if (comptime std.simd.suggestVectorLength(u8)) |lanes| { + const V = @Vector(lanes, u8); + const Bits = std.meta.Int(.unsigned, lanes); + const needle: V = @splat(esc); + + // Process several vectors per iteration so long inputs without ESC + // (e.g. huge string payloads) stay memory-bound rather than + // loop-bound. On a match, rescan the group precisely. + const unroll = 4; + while (rem >= lanes * unroll) { + const start = rem - lanes * unroll; + var match: @Vector(lanes, bool) = @splat(false); + inline for (0..unroll) |block| { + const v: V = input[start + block * lanes ..][0..lanes].*; + match = match | (v == needle); + } + if (@reduce(.Or, match)) { + inline for (0..unroll) |i| { + const block = unroll - 1 - i; + const v: V = input[start + block * lanes ..][0..lanes].*; + const bits: Bits = @bitCast(v == needle); + if (bits != 0) { + return start + block * lanes + (lanes - 1 - @clz(bits)); + } + } + unreachable; + } + rem = start; + } + + while (rem >= lanes) { + const start = rem - lanes; + const v: V = input[start..][0..lanes].*; + const bits: Bits = @bitCast(v == needle); + if (bits != 0) return start + (lanes - 1 - @clz(bits)); + rem = start; + } + } + + while (rem > 0) { + rem -= 1; + if (input[rem] == esc) return rem; + } + return null; +} + +/// Find where replay must begin when a feed ends inside a UTF-8 codepoint. +/// +/// A UTF-8 codepoint starts with a lead byte and may have up to three +/// continuation bytes. If the lead byte is in this input, replay must begin +/// there so the decoder sees the complete partial codepoint again. Since an +/// incomplete codepoint can contain at most three bytes, only the final three +/// input bytes need to be searched. +/// +/// The caller must only use this when the VT parser is at ground and the +/// UTF-8 decoder ended mid-codepoint. The returned value is the lead byte's +/// index. A null result means the lead byte was in an earlier feed, so this +/// entire input must be appended to the continuation bytes already retained. +fn findUtf8ReplayStart(input: []const u8) ?usize { + const max_pending = 3; + var idx = input.len; + while (idx > 0 and input.len - idx < max_pending) { + idx -= 1; + if (input[idx] >= 0xC0) return idx; + } + return null; +} + +/// Classifies bytes of a continuation suffix for export. +/// +/// The retained suffix reconstructs unfinished state exactly, but it can +/// contain bytes whose handler-visible work already committed the first +/// time (e.g. a C0 control executed inside an unfinished CSI sequence). +/// This implements a minimal VT stream processor (to avoid circular imports +/// with stream.zig) so `Tracker.write` can omit those bytes and replay +/// never repeats a terminal effect. It only runs at export time, never on +/// the feed path. +/// +/// This is allocation-free. +const BoundaryScanner = struct { + parser: Parser, + utf8decoder: UTF8Decoder, + + /// Initialize both state machines at runtime. Parser initialization may + /// inspect the runtime environment, so it cannot be a struct field default + /// that Zig requires to be comptime-known. + fn init() BoundaryScanner { + return .{ + .parser = .init(), + .utf8decoder = .{}, + }; + } + + /// How one byte affects committed work and the continuation suffix. + const Effect = enum { + /// The byte commits no handler-visible work. It must remain because it + /// may still build the unfinished VT, UTF-8, APC, or DCS state. + uncommitted, + + /// The byte commits handler-visible work but also changes a parser + /// state tag or reaches ground. It cannot be omitted in isolation; + /// boundary analysis decides whether the surrounding prefix is safe. + committed, + + /// The byte commits handler-visible work without changing either + /// state-machine tag or reaching ground. It can be omitted without + /// changing the unfinished state, avoiding a duplicate effect. + omittable, + }; + + /// True only when neither state machine needs prior bytes to continue. + fn ground(self: *const BoundaryScanner) bool { + return self.parser.state == .ground and self.utf8decoder.state == 0; + } + + /// Consume one byte using the same scalar UTF-8 retry and VT transitions + /// as Stream, then classify its effect on continuation construction. + fn next(self: *BoundaryScanner, c: u8) Effect { + // Preserve the state tags so we can recognize committed controls that + // do not contribute to the unfinished sequence. + const parser_state = self.parser.state; + const utf8_state = self.utf8decoder.state; + + // A byte is committed when replaying it would repeat handler-visible + // work already captured outside the continuation suffix. Actions that + // only build unfinished APC or DCS state are not committed because + // replay must reconstruct that state. + const committed = committed: { + if (self.parser.state == .ground) { + // Match Stream's scalar UTF-8 path, including retrying a byte that + // follows a malformed sequence. + var committed = false; + const res = self.utf8decoder.next(c); + if (res[0]) |cp| committed = self.codepoint(cp) or committed; + if (!res[1]) { + const retry = self.utf8decoder.next(c); + assert(retry[1]); + if (retry[0]) |cp| committed = self.codepoint(cp) or committed; + } + + break :committed committed; + } + + const actions = self.parser.next(c); + for (actions) |action_opt| { + const action = action_opt orelse continue; + switch (action) { + // These actions only build standard handler state. Their + // matching end/unhook actions are committed work. + .dcs_hook, + .dcs_put, + .apc_start, + .apc_put, + => {}, + else => break :committed true, + } + } + + break :committed false; + }; + + if (!committed) return .uncommitted; + + // A committed byte is independently omittable only while the stream + // remains unfinished and neither state-machine tag changes. + const state_changed = parser_state != self.parser.state or + utf8_state != self.utf8decoder.state; + return if (!state_changed and !self.ground()) + .omittable + else + .committed; + } + + /// Apply the Stream.handleCodepoint behavior relevant to replay. Returns + /// whether the accepted codepoint would commit handler-visible work. + fn codepoint(self: *BoundaryScanner, cp: u21) bool { + if (cp == 0x1B) { + self.parser.state = .escape; + self.parser.clear(); + return false; + } + + // Match Stream.handleCodepoint: all other accepted codepoints have + // already caused a handler-visible action (including supported C0s). + return true; + } +}; + +test "boundary scanner classifies effects and ground" { + const testing = std.testing; + var scanner: BoundaryScanner = .init(); + + try testing.expect(scanner.ground()); + try testing.expectEqual(BoundaryScanner.Effect.committed, scanner.next('A')); + try testing.expect(scanner.ground()); + + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, scanner.next(0x1B)); + try testing.expect(!scanner.ground()); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, scanner.next('[')); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, scanner.next('1')); + + // BEL commits an execute action without changing the unfinished CSI. + try testing.expectEqual(BoundaryScanner.Effect.omittable, scanner.next(0x07)); + try testing.expect(!scanner.ground()); + + try testing.expectEqual(BoundaryScanner.Effect.committed, scanner.next('m')); + try testing.expect(scanner.ground()); +} + +test "boundary scanner preserves unfinished builder actions" { + const testing = std.testing; + + var apc: BoundaryScanner = .init(); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, apc.next(0x1B)); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, apc.next('_')); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, apc.next('G')); + try testing.expectEqual(BoundaryScanner.Effect.committed, apc.next(0x1B)); + + var dcs: BoundaryScanner = .init(); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, dcs.next(0x1B)); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, dcs.next('P')); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, dcs.next('q')); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, dcs.next('x')); + try testing.expectEqual(BoundaryScanner.Effect.committed, dcs.next(0x1B)); +} + +test "boundary scanner handles UTF-8 and malformed retries" { + const testing = std.testing; + + var valid: BoundaryScanner = .init(); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, valid.next(0xF0)); + try testing.expect(!valid.ground()); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, valid.next(0x9F)); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, valid.next(0x98)); + try testing.expectEqual(BoundaryScanner.Effect.committed, valid.next(0x84)); + try testing.expect(valid.ground()); + + var malformed: BoundaryScanner = .init(); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, malformed.next(0xE0)); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, malformed.next(0xA0)); + try testing.expect(malformed.next(0xF0) != .uncommitted); + try testing.expect(!malformed.ground()); +} + +test "findVTReplayStart finds the latest ESC across vector boundaries" { + const testing = std.testing; + + try testing.expectEqual(@as(?usize, null), findVTReplayStart("")); + try testing.expectEqual(@as(?usize, null), findVTReplayStart("no escapes here")); + try testing.expectEqual(@as(?usize, 0), findVTReplayStart("\x1b")); + try testing.expectEqual(@as(?usize, 5), findVTReplayStart("\x1b[1m\x20\x1b[2")); + + // Exercise both the vector loop and the scalar remainder with replay + // starts at every position of a buffer larger than any vector width. + var buf: [193]u8 = undefined; + @memset(&buf, 'a'); + try testing.expectEqual(@as(?usize, null), findVTReplayStart(&buf)); + for (0..buf.len) |idx| { + @memset(&buf, 'a'); + buf[idx] = 0x1B; + try testing.expectEqual(@as(?usize, idx), findVTReplayStart(&buf)); + + // The latest ESC wins. + if (idx > 0) { + buf[idx - 1] = 0x1B; + try testing.expectEqual(@as(?usize, idx), findVTReplayStart(&buf)); + } + } +} + +test "findUtf8ReplayStart finds the pending lead byte" { + const testing = std.testing; + + try testing.expectEqual(@as(?usize, null), findUtf8ReplayStart("")); + try testing.expectEqual(@as(?usize, 4), findUtf8ReplayStart("text\xF0")); + try testing.expectEqual(@as(?usize, 4), findUtf8ReplayStart("text\xF0\x9F")); + try testing.expectEqual(@as(?usize, 4), findUtf8ReplayStart("text\xF0\x9F\x98")); + try testing.expectEqual(@as(?usize, 0), findUtf8ReplayStart("\xE0\xA0")); + + // A malformed prefix rejected earlier doesn't hide the pending lead. + try testing.expectEqual(@as(?usize, 2), findUtf8ReplayStart("\xE0\xA0\xF0")); + + // Continuation bytes of a sequence that started in an earlier input have + // no replay start of their own. + try testing.expectEqual(@as(?usize, null), findUtf8ReplayStart("\x9F")); + try testing.expectEqual(@as(?usize, null), findUtf8ReplayStart("\x9F\x98")); +} + +test "tracker retains and normalizes replay-safe bytes" { + const testing = std.testing; + var tracker = Tracker.init(testing.allocator, 64); + defer tracker.deinit(); + + tracker.append(.vt, "committed\x1b[1\x07"); + tracker.append(.vt, ";2"); + try testing.expect(!tracker.broken); + + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try tracker.write(&writer); + try testing.expectEqualStrings("\x1b[1;2", writer.buffered()); + + // A feed with a later replay start drops the previous suffix entirely. + tracker.append(.vt, "committed\x1b]0;t"); + var replaced_buf: [64]u8 = undefined; + var replaced_writer: std.Io.Writer = .fixed(&replaced_buf); + try tracker.write(&replaced_writer); + try testing.expectEqualStrings("\x1b]0;t", replaced_writer.buffered()); + + // An incomplete UTF-8 codepoint seeds at its lead byte and grows with + // continuation bytes from later feeds. + tracker.append(.utf8, "committed\xF0"); + tracker.append(.utf8, "\x9F"); + var utf8_buf: [4]u8 = undefined; + var utf8_writer: std.Io.Writer = .fixed(&utf8_buf); + try tracker.write(&utf8_writer); + try testing.expectEqualSlices(u8, "\xF0\x9F", utf8_writer.buffered()); +} + +test "tracker cap, reset, and broken recovery" { + const testing = std.testing; + var tracker = Tracker.init(testing.allocator, 4); + defer tracker.deinit(); + + tracker.append(.vt, "\x1b[123"); + try testing.expect(tracker.broken); + + // Feeds without a replay start are dropped while broken. + tracker.append(.vt, "4"); + try testing.expect(tracker.broken); + try testing.expectEqual(@as(usize, 0), tracker.bytes.items.len); + + // Suffixes that grow past the cap break tracking. + tracker.reset(); + tracker.append(.vt, "\x1b[1"); + tracker.append(.vt, "23"); + try testing.expect(tracker.broken); + + // A new replay start that fits recovers without a reset. + tracker.append(.vt, "\x1b["); + try testing.expect(!tracker.broken); + var buf: [4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try tracker.write(&writer); + try testing.expectEqualStrings("\x1b[", writer.buffered()); + + tracker.append(.vt, "\x1b[123"); + try testing.expect(tracker.broken); + tracker.reset(); + try testing.expect(!tracker.broken); + var empty_buf: [1]u8 = undefined; + var empty_writer: std.Io.Writer = .fixed(&empty_buf); + try tracker.write(&empty_writer); + try testing.expectEqual(@as(usize, 0), empty_writer.end); +} + +test "tracker reports writer failure and defers allocation failure" { + const testing = std.testing; + var tracker = Tracker.init(testing.allocator, 64); + defer tracker.deinit(); + tracker.append(.vt, "\x1b["); + + var buf: [1]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try testing.expectError(error.WriteFailed, tracker.write(&writer)); + + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + var failing_tracker = Tracker.init(failing.allocator(), 64); + defer failing_tracker.deinit(); + try testing.expect(!failing_tracker.broken); + + // The best-effort initial reservation failed, so the first required + // retention retries allocation and marks the tracker broken. + failing_tracker.append(.vt, "\x1b["); + try testing.expect(failing_tracker.broken); +} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 7586711a4..a2db0c7ee 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -993,7 +993,7 @@ test "resize clears synchronized output on unchanged cell dimensions" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); t.modes.set(.synchronized_output, true); @@ -1025,7 +1025,7 @@ test "resize reports mode 2048 geometry" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); t.modes.set(.in_band_size_reports, true); @@ -1055,7 +1055,7 @@ test "resize suppresses mode 2048 reports" { defer t.deinit(testing.allocator); var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Disabled mode suppresses a report even with pixels and a callback. @@ -1079,10 +1079,10 @@ test "resize suppresses mode 2048 reports" { ); defer readonly_terminal.deinit(testing.allocator); readonly_terminal.modes.set(.in_band_size_reports, true); - var readonly_stream: Stream = .initAlloc( - testing.allocator, - .init(&readonly_terminal), - ); + var readonly_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = .init(&readonly_terminal), + }); defer readonly_stream.deinit(); try readonly_stream.handler.resize(.{ .cols = 80, @@ -1109,7 +1109,7 @@ test "resize failure preserves terminal state and does not write" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(alloc, handler); + var s: Stream = .init(.{ .allocator = alloc, .handler = handler }); defer s.deinit(); t.modes.set(.synchronized_output, true); @@ -1147,15 +1147,15 @@ test "resize effects do not change canonical terminal state" { }; var authoritative_handler: Handler = .init(&authoritative); authoritative_handler.effects.write_pty = &S.writePty; - var authoritative_stream: Stream = .initAlloc( - testing.allocator, - authoritative_handler, - ); + var authoritative_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = authoritative_handler, + }); defer authoritative_stream.deinit(); - var readonly_stream: Stream = .initAlloc( - testing.allocator, - .init(&readonly), - ); + var readonly_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = .init(&readonly), + }); defer readonly_stream.deinit(); authoritative.modes.set(.in_band_size_reports, true); @@ -1183,7 +1183,7 @@ test "basic print" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("Hello"); @@ -1201,7 +1201,7 @@ test "semantic failure is sticky while processing continues" { var t: Terminal = try .init(testing.io, alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); - var s: Stream = .initAlloc(alloc, .init(&t)); + var s: Stream = .init(.{ .allocator = alloc, .handler = .init(&t) }); defer s.deinit(); try testing.expect(!s.handler.semantic_failure); @@ -1232,7 +1232,7 @@ test "cursor movement" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Move cursor using escape sequences @@ -1250,7 +1250,7 @@ test "erase operations" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 20, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Print some text @@ -1271,7 +1271,7 @@ test "tabs" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("A\tB"); @@ -1286,7 +1286,7 @@ test "modes" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Test wraparound mode @@ -1301,7 +1301,7 @@ test "scrolling regions" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set scrolling region from line 5 to 20 @@ -1316,7 +1316,7 @@ test "charsets" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Configure G0 as DEC special graphics @@ -1332,7 +1332,7 @@ test "alt screen" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write to primary screen @@ -1359,7 +1359,7 @@ test "cursor save and restore" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Move cursor to 10,15 @@ -1385,7 +1385,7 @@ test "attributes" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set bold and write text @@ -1434,7 +1434,7 @@ test "DECRQSS responses" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // SGR @@ -1458,7 +1458,7 @@ test "DECRQSS without write effect is ignored" { ); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1BP$qm\x1B\\"); @@ -1473,7 +1473,7 @@ test "DCS command memory is released" { ); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); // A completed, unsupported command transfers its allocation to Command; // dcsCommand must release it even though stream_terminal ignores it. @@ -1489,7 +1489,7 @@ test "DECALN screen alignment" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 3 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Run DECALN @@ -1509,7 +1509,7 @@ test "full reset" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Make some changes @@ -1549,7 +1549,7 @@ test "glyph protocol APC with write_pty callback" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B_25a1;s\x1B\\"); @@ -1564,7 +1564,7 @@ test "ignores query actions" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // These should be ignored without error @@ -1592,7 +1592,7 @@ test "OSC 4 set and reset palette" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Save default color @@ -1615,7 +1615,7 @@ test "OSC 104 reset all palette colors" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set multiple colors @@ -1640,7 +1640,7 @@ test "OSC 10 set and reset foreground color" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Initially unset @@ -1662,7 +1662,7 @@ test "OSC 11 set and reset background color" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set background to green @@ -1681,7 +1681,7 @@ test "OSC 12 set and reset cursor color" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set cursor to blue @@ -1719,7 +1719,7 @@ test "OSC color query responses" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1b]10;?\x1b\\"); @@ -1757,7 +1757,7 @@ test "kitty color protocol set palette" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set palette color 5 to magenta using kitty protocol @@ -1773,7 +1773,7 @@ test "kitty color protocol reset palette" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set and then reset palette color @@ -1790,7 +1790,7 @@ test "kitty color protocol set foreground" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set foreground using kitty protocol @@ -1805,7 +1805,7 @@ test "kitty color protocol set background" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set background using kitty protocol @@ -1820,7 +1820,7 @@ test "kitty color protocol set cursor" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set cursor using kitty protocol @@ -1835,7 +1835,7 @@ test "kitty color protocol reset foreground" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set and reset foreground @@ -1870,7 +1870,7 @@ test "kitty color protocol query responses" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1b]21;background=?\x1b\\"); @@ -1891,7 +1891,7 @@ test "palette dirty flag set on color change" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Clear dirty flag @@ -1916,7 +1916,7 @@ test "semantic prompt fresh line" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("Hello"); @@ -1929,7 +1929,7 @@ test "semantic prompt fresh line new prompt" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write some text and then send OSC 133;A (fresh_line_new_prompt) @@ -1953,7 +1953,7 @@ test "semantic prompt end of input, then start output" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write some text and then send OSC 133;A (fresh_line_new_prompt) @@ -1970,7 +1970,7 @@ test "semantic prompt prompt_start" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write some text @@ -1987,7 +1987,7 @@ test "semantic prompt new_command" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write some text @@ -2005,7 +2005,7 @@ test "semantic prompt new_command at column zero" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // OSC 133;N when already at column 0 should stay on same line @@ -2019,7 +2019,7 @@ test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set input terminated by EOL @@ -2037,7 +2037,7 @@ test "bell effect callback" { // Test bell with null callback (default readonly effects) doesn't crash { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x07"); @@ -2064,7 +2064,7 @@ test "bell effect callback" { var handler: Handler = .init(&t); handler.effects.bell = &S.bell; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x07"); @@ -2082,7 +2082,7 @@ test "desktop_notification effect callback" { // A null callback (the default readonly effects) silently ignores // notifications and leaves the terminal usable. { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1B]9;Ignored\x1B\\AfterNotification"); @@ -2114,7 +2114,7 @@ test "desktop_notification effect callback" { var handler: Handler = .init(&t); handler.effects.desktop_notification = &S.desktopNotification; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // OSC 9 is split across writes and carries only a body. @@ -2138,7 +2138,7 @@ test "progress_report effect callback" { // A null callback (the default readonly effects) silently ignores reports. { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1B]9;4;1;25\x1B\\"); } @@ -2161,7 +2161,7 @@ test "progress_report effect callback" { var handler: Handler = .init(&t); handler.effects.progress_report = &S.progressReport; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); const cases = [_]struct { @@ -2194,7 +2194,7 @@ test "clipboard_write effect callback" { // A null callback (the default readonly effects) silently ignores writes. { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); @@ -2246,7 +2246,7 @@ test "clipboard_write effect callback" { var handler: Handler = .init(&t); handler.effects.clipboard_write = &S.clipboardWrite; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Selectors are normalized and payloads are decoded before the callback. @@ -2323,7 +2323,7 @@ test "clipboard_write allocation failure is ignored" { var handler: Handler = .init(&t); handler.effects.clipboard_write = &S.clipboardWrite; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Only the decoded scratch data uses the terminal allocator here. Swap in @@ -2344,7 +2344,7 @@ test "request mode DECRQM with write_pty callback" { // Without callback, DECRQM should not crash { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // DECRQM for mode 7 (wraparound) — should be silently ignored @@ -2368,7 +2368,7 @@ test "request mode DECRQM with write_pty callback" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Wraparound mode (7) is set by default @@ -2396,7 +2396,7 @@ test "stream: CSI W with intermediate but no params" { }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1b[?W"); @@ -2417,7 +2417,7 @@ test "window_title effect is called" { var handler: Handler = .init(&t); handler.effects.title_changed = &S.titleChanged; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Set window title via OSC 2 @@ -2430,7 +2430,7 @@ test "window_title effect not called without callback" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Should not crash when no callback is set @@ -2461,7 +2461,7 @@ test "window_title effect with empty title" { var handler: Handler = .init(&t); handler.effects.title_changed = &S.titleChanged; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Set empty window title @@ -2485,7 +2485,7 @@ test "kitty_keyboard_query" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Default kitty keyboard flags should be 0 @@ -2514,7 +2514,7 @@ test "xtversion default" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Without xtversion effect set, should report "libghostty" @@ -2541,7 +2541,7 @@ test "xtversion with effect" { handler.effects.write_pty = &S.writePty; handler.effects.xtversion = &S.xtversion; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1b[>0q"); @@ -2567,7 +2567,7 @@ test "xtversion with empty string effect" { handler.effects.write_pty = &S.writePty; handler.effects.xtversion = &S.xtversion; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Empty string from effect should fall back to "libghostty" @@ -2594,7 +2594,7 @@ test "size report csi_14_t with effect" { handler.effects.write_pty = &S.writePty; handler.effects.size = &S.getSize; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI 14 t - report text area size in pixels @@ -2622,7 +2622,7 @@ test "size report csi_16_t with effect" { handler.effects.write_pty = &S.writePty; handler.effects.size = &S.getSize; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI 16 t - report cell size in pixels @@ -2650,7 +2650,7 @@ test "size report csi_18_t with effect" { handler.effects.write_pty = &S.writePty; handler.effects.size = &S.getSize; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI 18 t - report text area size in characters @@ -2674,7 +2674,7 @@ test "size report no effect callback" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Without size effect, size reports should be silently ignored @@ -2697,7 +2697,7 @@ test "size report csi_21_t title" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Set a title first @@ -2724,7 +2724,7 @@ test "enquiry no effect" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // ENQ without enquiry effect should not write anything @@ -2751,7 +2751,7 @@ test "enquiry with effect" { handler.effects.write_pty = &S.writePty; handler.effects.enquiry = &S.enquiry; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x05"); @@ -2778,7 +2778,7 @@ test "enquiry with empty response" { handler.effects.write_pty = &S.writePty; handler.effects.enquiry = &S.enquiry; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Empty enquiry response should not write anything @@ -2803,7 +2803,7 @@ test "device status: operating status" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI 5 n — operating status report @@ -2828,7 +2828,7 @@ test "device status: cursor position" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Default position is 0,0 — reported as 1,1 @@ -2858,7 +2858,7 @@ test "device status: cursor position with origin mode" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Set scroll region rows 5-20 @@ -2894,7 +2894,7 @@ test "device status: color scheme dark" { handler.effects.write_pty = &S.writePty; handler.effects.color_scheme = &S.colorScheme; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI ? 996 n — color scheme query @@ -2923,7 +2923,7 @@ test "device status: color scheme light" { handler.effects.write_pty = &S.writePty; handler.effects.color_scheme = &S.colorScheme; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI ? 996 n — color scheme query @@ -2948,7 +2948,7 @@ test "device status: color scheme without callback" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Without color_scheme effect, query should be silently ignored @@ -2977,7 +2977,7 @@ test "visibility reports" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Mode 2033 is supported and initially disabled. @@ -3011,7 +3011,7 @@ test "device status: readonly ignores all" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // All device status queries should be silently ignored without effects @@ -3048,7 +3048,7 @@ test "device attributes: primary DA" { handler.effects.write_pty = &S.writePty; handler.effects.device_attributes = &S.da; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B[c"); @@ -3076,7 +3076,7 @@ test "device attributes: secondary DA" { handler.effects.write_pty = &S.writePty; handler.effects.device_attributes = &S.da; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B[>c"); @@ -3104,7 +3104,7 @@ test "device attributes: tertiary DA" { handler.effects.write_pty = &S.writePty; handler.effects.device_attributes = &S.da; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B[=c"); @@ -3115,7 +3115,7 @@ test "device attributes: readonly ignores" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // All DA queries should be silently ignored without effects @@ -3160,7 +3160,7 @@ test "device attributes: custom response" { handler.effects.write_pty = &S.writePty; handler.effects.device_attributes = &S.da; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B[c"); @@ -3189,7 +3189,7 @@ test "kitty graphics APC response" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Send a kitty graphics transmit command with image id 1 @@ -3206,7 +3206,7 @@ test "kitty graphics via APC" { defer t.deinit(testing.allocator); const handler: Handler = .init(&t); - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Send a kitty graphics transmit command via APC: @@ -3218,3 +3218,153 @@ test "kitty graphics via APC" { const img = storage.imageById(1).?; try testing.expectEqual(.rgb, img.format); } + +test "continuation reconstructs standard stream without duplicate effects" { + const S = struct { + var bell_count: usize = 0; + var title_count: usize = 0; + var write_count: usize = 0; + var notification_count: usize = 0; + var clipboard_count: usize = 0; + + fn bell(_: *Handler) void { + bell_count += 1; + } + + fn titleChanged(_: *Handler) void { + title_count += 1; + } + + fn writePty(_: *Handler, _: [:0]const u8) void { + write_count += 1; + } + + fn desktopNotification( + _: *Handler, + _: Action.ShowDesktopNotification, + ) void { + notification_count += 1; + } + + fn clipboardWrite( + _: *Handler, + _: clipboard.Write, + ) clipboard.WriteResult { + clipboard_count += 1; + return .success; + } + + fn reset() void { + bell_count = 0; + title_count = 0; + write_count = 0; + notification_count = 0; + clipboard_count = 0; + } + }; + S.reset(); + + const committed = "A\n\x07" ++ + "\x1b]2;title\x1b\\" ++ + "\x1b[5n" ++ + "\x1b]9;body\x1b\\" ++ + "\x1b]52;c;aA==\x1b\\"; + + var source_terminal: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer source_terminal.deinit(testing.allocator); + + var source_handler: Handler = .init(&source_terminal); + source_handler.effects.bell = &S.bell; + source_handler.effects.title_changed = &S.titleChanged; + source_handler.effects.write_pty = &S.writePty; + source_handler.effects.desktop_notification = &S.desktopNotification; + source_handler.effects.clipboard_write = &S.clipboardWrite; + var source = Stream.init(.{ + .allocator = testing.allocator, + .handler = source_handler, + .continuation_max_bytes = 1024, + }); + defer source.deinit(); + + // Terminal mutation and all callbacks have already committed. The + // unfinished CSI is the only input needed to recreate the stream state. + source.nextSlice(committed ++ "\x1b[31"); + try testing.expectEqual(@as(usize, 1), S.bell_count); + try testing.expectEqual(@as(usize, 1), S.title_count); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 1), S.notification_count); + try testing.expectEqual(@as(usize, 1), S.clipboard_count); + + var continuation_buf: [1024]u8 = undefined; + var continuation_writer: std.Io.Writer = .fixed(&continuation_buf); + try source.writeContinuation(&continuation_writer); + try testing.expectEqualStrings("\x1b[31", continuation_writer.buffered()); + + var restored_terminal: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer restored_terminal.deinit(testing.allocator); + + // Stand in for restoring the already-committed terminal snapshot. + { + var snapshot_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = .init(&restored_terminal), + }); + defer snapshot_stream.deinit(); + snapshot_stream.nextSlice(committed); + } + + const before = try restored_terminal.plainString(testing.allocator); + defer testing.allocator.free(before); + const before_x = restored_terminal.screens.active.cursor.x; + const before_y = restored_terminal.screens.active.cursor.y; + const before_style = restored_terminal.screens.active.cursor.style_id; + const before_title = restored_terminal.getTitle().?; + + var restored_handler: Handler = .init(&restored_terminal); + restored_handler.effects.bell = &S.bell; + restored_handler.effects.title_changed = &S.titleChanged; + restored_handler.effects.write_pty = &S.writePty; + restored_handler.effects.desktop_notification = &S.desktopNotification; + restored_handler.effects.clipboard_write = &S.clipboardWrite; + var restored = Stream.init(.{ + .allocator = testing.allocator, + .handler = restored_handler, + .continuation_max_bytes = 1024, + }); + defer restored.deinit(); + + S.reset(); + restored.nextSlice(continuation_writer.buffered()); + try testing.expectEqual(@as(usize, 0), S.bell_count); + try testing.expectEqual(@as(usize, 0), S.title_count); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), S.notification_count); + try testing.expectEqual(@as(usize, 0), S.clipboard_count); + const after = try restored_terminal.plainString(testing.allocator); + defer testing.allocator.free(after); + try testing.expectEqualStrings(before, after); + try testing.expectEqual(before_x, restored_terminal.screens.active.cursor.x); + try testing.expectEqual(before_y, restored_terminal.screens.active.cursor.y); + try testing.expectEqual(before_style, restored_terminal.screens.active.cursor.style_id); + try testing.expectEqualStrings(before_title, restored_terminal.getTitle().?); + + source.nextSlice("mB"); + restored.nextSlice("mB"); + const source_text = try source_terminal.plainString(testing.allocator); + defer testing.allocator.free(source_text); + const restored_text = try restored_terminal.plainString(testing.allocator); + defer testing.allocator.free(restored_text); + try testing.expectEqualStrings(source_text, restored_text); + try testing.expectEqual( + source_terminal.screens.active.cursor.style_id, + restored_terminal.screens.active.cursor.style_id, + ); +} diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index e3b564bc9..cdcab9532 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -307,7 +307,10 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .size = opts.size, .backend = backend, .mailbox = opts.mailbox, - .terminal_stream = .initAlloc(alloc, handler), + .terminal_stream = .init(.{ + .allocator = alloc, + .handler = handler, + }), .thread_enter_state = thread_enter_state, }; }