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, }; }