Fix tmux control parser premature %end/%error block termination (#11597)

Fixes [#11935.](https://github.com/ghostty-org/ghostty/issues/11395)

I’m new to Zig, so I used AI assistance (Codex) while preparing this
change. Before opening this PR, I manually reviewed every line of the
final patch and stepped through the parser in LLDB to verify the
behavior. Happy to make any changes.

To better understand the parser, I also built a small model-checker
model
[here](https://gist.github.com/wyounas/284036272ba5893b6e413cafe2fe2a24).

Separately from this fix, I think formal verification and modeling could
be useful for parser work in Ghostty. The model is written in FizzBee,
which uses a Python-like Starlark syntax and is fairly readable. If that
seems useful, I’d be happy to open a separate discussion about whether
something like that belongs in the repository as executable
documentation or an additional safety net for future parser changes.
This commit is contained in:
Mitchell Hashimoto
2026-03-17 10:32:14 -07:00
committed by GitHub

View File

@@ -116,19 +116,23 @@ pub const Parser = struct {
)) |v| v + 1 else 0;
const line = written[idx..];
if (std.mem.startsWith(u8, line, "%end") or
std.mem.startsWith(u8, line, "%error"))
{
const err = std.mem.startsWith(u8, line, "%error");
const output = std.mem.trimRight(u8, written[0..idx], "\r\n");
// If it is an error then log it.
if (err) log.warn("tmux control mode error={s}", .{output});
if (parseBlockTerminator(line)) |terminator| {
const output = std.mem.trimRight(
u8,
written[0..idx],
"\r\n",
);
// Important: do not clear buffer since the notification
// contains it.
self.state = .idle;
return if (err) .{ .block_err = output } else .{ .block_end = output };
switch (terminator) {
.end => return .{ .block_end = output },
.err => {
log.warn("tmux control mode error={s}", .{output});
return .{ .block_err = output };
},
}
}
// Didn't end the block, continue accumulating.
@@ -144,6 +148,41 @@ pub const Parser = struct {
const ParseError = error{RegexError};
const BlockTerminator = enum { end, err };
/// Block payload is raw data, so a line only terminates a block if it
/// exactly matches tmux's `%end`/`%error` guard-line shape.
fn parseBlockTerminator(line_raw: []const u8) ?BlockTerminator {
var line = line_raw;
if (line.len > 0 and line[line.len - 1] == '\r') {
line = line[0 .. line.len - 1];
}
var fields = std.mem.tokenizeScalar(u8, line, ' ');
const cmd = fields.next() orelse return null;
const terminator: BlockTerminator = if (std.mem.eql(u8, cmd, "%end"))
.end
else if (std.mem.eql(u8, cmd, "%error"))
.err
else
return null;
const time = fields.next() orelse return null;
const command_id = fields.next() orelse return null;
const flags = fields.next() orelse return null;
const extra = fields.next();
// In the future, we should compare these to the %begin block
// because the tmux source guarantees that these always match and
// that is a more robust way to match.
_ = std.fmt.parseInt(usize, time, 10) catch return null;
_ = std.fmt.parseInt(usize, command_id, 10) catch return null;
_ = std.fmt.parseInt(usize, flags, 10) catch return null;
if (extra != null) return null;
return terminator;
}
fn parseNotification(self: *Parser) ParseError!?Notification {
assert(self.state == .notification);
@@ -597,6 +636,81 @@ test "tmux begin/end data" {
try testing.expectEqualStrings("hello\nworld", n.block_end);
}
test "tmux block payload may start with %end" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end not really\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("%end not really\nhello", n.block_end);
}
test "tmux block payload may start with %error" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("%error not really\nhello", n.block_end);
}
test "tmux block may terminate with real %error after misleading payload" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%error 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_err);
try testing.expectEqualStrings("%error not really\nhello", n.block_err);
}
test "tmux block terminator requires exact token count" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1 1 1 trailing\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("%end 1 1 1 trailing\nhello", n.block_end);
}
test "tmux block terminator requires numeric metadata" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end foo bar baz\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("%end foo bar baz\nhello", n.block_end);
}
test "tmux output" {
const testing = std.testing;
const alloc = testing.allocator;