From f214ad2b75274cd7a613d1433acb6c6f5c801412 Mon Sep 17 00:00:00 2001 From: LordAizen1 Date: Tue, 14 Jul 2026 16:33:10 +0530 Subject: [PATCH 1/2] Parser: stop OSC at the first BEL or ST parseOsc tried skipUntilST before checking for BEL, and skipUntilST grabs the first ESC byte from index 2 and assumes the next byte is \. If an OSC ends with BEL and another escape sequence follows it in the same buffer, that first ESC belongs to the next sequence, so the OSC reads past its own BEL and into it. Depending on the OSC type this dropped the next sequence or, for a color or paste reply, returned an error out of parse() that Loop.zig throws away with _ttyRun(...) catch {}, quietly killing the read thread. Scan for the first real terminator instead: BEL (0x07) or ST (ESC \). A bare ESC that is not part of an ST means the OSC was never terminated and a new sequence started, so return {null, 0} rather than reading past it. Adds tests for an OSC 11 color reply followed by DA1 and a BEL-terminated OSC followed by a CSI cursor-up. --- src/Parser.zig | 67 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/src/Parser.zig b/src/Parser.zig index a7980c30..ca3a564e 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -243,16 +243,29 @@ inline fn parseOsc(input: []const u8, paste_allocator: ?std.mem.Allocator) !Resu // end is the index of the terminating byte(s) (either the last byte of an // ST or BEL) const end: usize = blk: { - const esc_result = skipUntilST(input); - if (esc_result.n > 0) break :blk esc_result.n; - - // No escape, could be BEL terminated - const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07) orelse return .{ - .event = null, - .n = 0, - }; - bel_terminated = true; - break :blk bel + 1; + // Find the first terminator: BEL (0x07) or ST (ESC \), whichever comes + // first. A bare ESC that is not part of an ST begins a new sequence, so + // we must stop rather than consume into it. + var i: usize = 2; + while (i < input.len) : (i += 1) { + switch (input[i]) { + 0x07 => { + bel_terminated = true; + break :blk i + 1; + }, + 0x1b => { + if (i + 1 >= input.len) return .{ .event = null, .n = 0 }; + if (input[i + 1] == 0x5c) break :blk i + 2; + // ESC not followed by \: the OSC was never terminated and a + // new sequence has begun. Treat as incomplete/malformed + // rather than over-consuming the following sequence. + return .{ .event = null, .n = 0 }; + }, + else => {}, + } + } + // No terminator yet: wait for more input. + return .{ .event = null, .n = 0 }; }; // The complete OSC sequence @@ -1330,3 +1343,37 @@ test "parse: disambiguate shift + space" { test { std.testing.refAllDecls(@This()); } + +test "parse: bel-terminated osc color report followed by da1" { + // A BEL-terminated OSC 11 (background color) reply immediately followed by + // a DA1 reply, as they can arrive together in one read(). The OSC must stop + // at the BEL and must not consume into the following sequence. + var parser: Parser = .{}; + const input = "\x1b]11;rgb:0000/0000/0000\x07\x1b[?1;2c"; + const result = try parser.parse(input, null); + try testing.expectEqual(24, result.n); + switch (result.event.?) { + .color_report => {}, + else => try testing.expect(false), + } + // The DA1 that follows must survive intact. + const rest = try parser.parse(input[result.n..], null); + switch (rest.event.?) { + .cap_da1 => {}, + else => try testing.expect(false), + } +} + +test "parse: bel-terminated osc does not consume the following sequence" { + // An OSC (BEL-terminated) directly followed by a CSI cursor-up. The OSC must + // stop at the BEL and leave the CSI intact. + var parser: Parser = .{}; + const input = "\x1b]0;title\x07\x1b[A"; + const result = try parser.parse(input, null); + try testing.expectEqual(10, result.n); + const rest = try parser.parse(input[result.n..], null); + switch (rest.event.?) { + .key_press => |key| try testing.expectEqual(Key.up, key.codepoint), + else => try testing.expect(false), + } +} From 7fff2e1d7e862b92e48a19f9a39a53f0edc407f1 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Tue, 14 Jul 2026 06:21:00 -0500 Subject: [PATCH 2/2] Parser: handle interrupted OSC sequences ESC is an anywhere transition in the DEC parser state machine. When an OSC sees ESC followed by a byte other than backslash, the OSC has been interrupted by a new escape sequence rather than remaining incomplete. Consume the malformed OSC prefix and leave the ESC byte for the next parse call, so the following sequence can still be parsed. Keep waiting when the buffer ends at ESC because it may still become ST on the next read. Add regression tests for an OSC interrupted by CSI and for the trailing-ESC case. --- src/Parser.zig | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/Parser.zig b/src/Parser.zig index ca3a564e..d16f1a21 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -257,9 +257,9 @@ inline fn parseOsc(input: []const u8, paste_allocator: ?std.mem.Allocator) !Resu if (i + 1 >= input.len) return .{ .event = null, .n = 0 }; if (input[i + 1] == 0x5c) break :blk i + 2; // ESC not followed by \: the OSC was never terminated and a - // new sequence has begun. Treat as incomplete/malformed - // rather than over-consuming the following sequence. - return .{ .event = null, .n = 0 }; + // new escape sequence has begun. Consume the malformed OSC + // prefix and leave the ESC for the next parse call. + return .{ .event = null, .n = i }; }, else => {}, } @@ -1377,3 +1377,30 @@ test "parse: bel-terminated osc does not consume the following sequence" { else => try testing.expect(false), } } + +test "parse: osc interrupted by escape leaves the next sequence intact" { + // Per the DEC parser, ESC is an anywhere transition out of OSC. If it is not + // the ST sequence (ESC \\), the malformed OSC should be discarded up to the + // ESC and the new escape sequence should be parsed next. + var parser: Parser = .{}; + const input = "\x1b]0;title\x1b[A"; + const result = try parser.parse(input, null); + try testing.expectEqual(9, result.n); + try testing.expectEqual(@as(?Event, null), result.event); + + const rest = try parser.parse(input[result.n..], null); + switch (rest.event.?) { + .key_press => |key| try testing.expectEqual(Key.up, key.codepoint), + else => try testing.expect(false), + } +} + +test "parse: osc ending with escape waits for a possible ST" { + // If the buffer ends at ESC, it may still become an ST when more bytes + // arrive, so the parser should wait instead of discarding the OSC. + var parser: Parser = .{}; + const input = "\x1b]0;title\x1b"; + const result = try parser.parse(input, null); + try testing.expectEqual(0, result.n); + try testing.expectEqual(@as(?Event, null), result.event); +}