From 439d35e27ca2ef7ae62af5d3a386dad4f7ad0bdf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Jul 2026 12:20:38 -0700 Subject: [PATCH] libghostty: mark semantic stream failures The libghostty-vt stream is made to be infallible: in the case of any error it just logs and moves on. That's because a terminal can't really... stop, under normal operations. But, under special operations (fuzzing, replays, etc.) it can and should stop! Rather than make the operation fallible, its simply enough for me at least to know that something went wrong. This is a simple change that adds a simple flag that is flagged to true when such a scenario happens. For normal Ghostty GUI operations, this isn't used at all. For libghostty consumers they can choose to read it if they want, but don't have to. This also adds a C API to read it. --- include/ghostty/vt/terminal.h | 16 ++++++ src/terminal/c/terminal.zig | 60 ++++++++++++++++++++- src/terminal/stream_terminal.zig | 93 ++++++++++++++++++++++++++------ 3 files changed, 151 insertions(+), 18 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 26ee0c0b8..9ae4e6c45 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1197,6 +1197,22 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: bool * */ GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE = 32, + + /** + * Whether VT processing encountered a non-gracefully handled error that may + * have prevented a terminal-owned semantic update. + * + * Processing remains best-effort, and ghostty_terminal_reset() does not + * clear it. Gracefully handled protocol failures, configured limits, + * malformed or unsupported input, and failures limited to external effects + * or query responses do not set it. + * + * This can't currently be unset. This is purely informational to consumers + * if there was some error that happened at some point during VT processing. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_VT_PROCESSING_ERROR = 33, GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index da48ab73c..741496231 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -739,13 +739,19 @@ pub const TerminalData = enum(c_int) { kitty_graphics = 30, selection = 31, viewport_active = 32, + vt_processing_error = 33, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { return switch (self) { .invalid => void, .cols, .rows, .cursor_x, .cursor_y => size.CellCountInt, - .cursor_pending_wrap, .cursor_visible, .mouse_tracking, .viewport_active => bool, + .cursor_pending_wrap, + .cursor_visible, + .mouse_tracking, + .viewport_active, + .vt_processing_error, + => bool, .active_screen => TerminalScreen, .kitty_keyboard_flags => u8, .scrollbar => TerminalScrollbar, @@ -820,7 +826,8 @@ fn getTyped( comptime data: TerminalData, out: *data.OutType(), ) Result { - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const wrapper = terminal_ orelse return .invalid_value; + const t: *ZigTerminal = wrapper.terminal; switch (data) { .invalid => return .invalid_value, .cols => out.* = t.cols, @@ -881,6 +888,7 @@ fn getTyped( t.screens.active.selection orelse return .no_value, ), .viewport_active => out.* = t.screens.active.pages.viewport == .active, + .vt_processing_error => out.* = wrapper.stream.handler.semantic_failure, } return .success; @@ -1565,6 +1573,54 @@ test "get cursor position" { try testing.expectEqual(0, y); } +test "get vt_processing_error" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var processing_error: bool = true; + try testing.expectEqual(Result.success, get( + t, + .vt_processing_error, + @ptrCast(&processing_error), + )); + try testing.expect(!processing_error); + + // Force a non-graceful terminal-owned update failure through the public + // VT write path. + { + const alloc = t.?.terminal.screens.active.alloc; + t.?.terminal.screens.active.alloc = testing.failing_allocator; + defer t.?.terminal.screens.active.alloc = alloc; + + const input = "\x1B]2;unavailable\x1B\\"; + vt_write(t, input, input.len); + } + + try testing.expectEqual(Result.success, get( + t, + .vt_processing_error, + @ptrCast(&processing_error), + )); + try testing.expect(processing_error); + + reset(t); + try testing.expectEqual(Result.success, get( + t, + .vt_processing_error, + @ptrCast(&processing_error), + )); + try testing.expect(processing_error); +} + test "get null" { var cols: size.CellCountInt = undefined; try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols))); diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index e0aeb61d7..1c27c5c08 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -34,6 +34,20 @@ pub const Handler = struct { /// The terminal state to modify. terminal: *Terminal, + /// True after an error prevented a terminal-owned semantic update. + /// + /// When an error happens during terminal processing, streams continue + /// forward and remain best-effort. A terminal can't really stop in + /// the middle it must go on. But this is flagged to true to let + /// consumers know some sort of unhandle-able error state happened + /// (e.g. an allocation failure). + /// + /// Only non-handled outcomes set this. Gracefully handled outcomes + /// that don't meaningfully negatively impact the terminal state + /// such as hitting Kitty image limits, failure to write a response, + /// do not flag this. + semantic_failure: bool = false, + /// Callbacks for certain effects that handlers may have. These /// may or may not fully replace internal handling of certain effects, /// but they allow for the handler to trigger or query external @@ -176,6 +190,7 @@ pub const Handler = struct { value: Action.Value(action), ) void { self.vtFallible(action, value) catch |err| { + self.semantic_failure = true; log.warn("error handling VT action action={} err={}", .{ action, err }); }; } @@ -277,7 +292,7 @@ pub const Handler = struct { .configure_charset => self.terminal.configureCharset(value.slot, value.charset), .set_attribute => switch (value) { .unknown => {}, - else => self.terminal.setAttribute(value) catch {}, + else => try self.terminal.setAttribute(value), }, .protected_mode_off => self.terminal.setProtectedMode(.off), .protected_mode_iso => self.terminal.setProtectedMode(.iso), @@ -307,8 +322,13 @@ pub const Handler = struct { .end_hyperlink => self.terminal.screens.active.endHyperlink(), .semantic_prompt => try self.terminal.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, - .color_operation => try self.colorOperation(&value.requests, value.terminator), - .kitty_color_report => try self.kittyColorOperation(value), + .color_operation => self.colorOperation( + &value.requests, + value.terminator, + ) catch |err| log.warn("error reporting OSC color err={}", .{err}), + .kitty_color_report => self.kittyColorOperation(value) catch |err| { + log.warn("error reporting Kitty colors err={}", .{err}); + }, // APC .apc_start => self.apc_handler.start(), @@ -325,10 +345,16 @@ pub const Handler = struct { .request_mode => self.requestMode(value.mode), .request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi), .size_report => self.reportSize(value), - .window_title => self.windowTitle(value.title), - .report_pwd => self.reportPwd(value.url), + .window_title => try self.windowTitle(value.title), + .report_pwd => try self.reportPwd(value.url), .xtversion => self.reportXtversion(), - .clipboard_contents => try self.clipboardContents(value.kind, value.data), + .clipboard_contents => self.clipboardContents( + value.kind, + value.data, + ) catch |err| { + // Clipboard writes are external effects, not terminal state. + log.warn("error handling clipboard write err={}", .{err}); + }, // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. @@ -514,7 +540,7 @@ pub const Handler = struct { self.writePty(resp); } - fn windowTitle(self: *Handler, title_raw: []const u8) void { + fn windowTitle(self: *Handler, title_raw: []const u8) !void { // Prevent DoS attacks by limiting title length. const max_title_len = 1024; const title = if (title_raw.len > max_title_len) title: { @@ -525,16 +551,13 @@ pub const Handler = struct { break :title title_raw[0..max_title_len]; } else title_raw; - self.terminal.setTitle(title) catch |err| { - log.warn("error setting title err={}", .{err}); - return; - }; + try self.terminal.setTitle(title); const func = self.effects.title_changed orelse return; func(self); } - fn reportPwd(self: *Handler, url_raw: []const u8) void { + fn reportPwd(self: *Handler, url_raw: []const u8) !void { // Prevent DoS attacks by limiting url length. Headroom for // Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding. const max_url_len = 4096; @@ -548,10 +571,7 @@ pub const Handler = struct { // We store the raw payload unparsed. Embedders read it via // getPwd() and are responsible for decoding any URI scheme. - self.terminal.setPwd(url) catch |err| { - log.warn("error setting pwd err={}", .{err}); - return; - }; + try self.terminal.setPwd(url); const func = self.effects.pwd_changed orelse return; func(self); @@ -1116,6 +1136,39 @@ test "basic print" { try testing.expectEqualStrings("Hello", str); } +test "semantic failure is sticky while processing continues" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s: Stream = .initAlloc(alloc, .init(&t)); + defer s.deinit(); + try testing.expect(!s.handler.semantic_failure); + + // Setting the title is a terminal-owned semantic update. Force its + // allocation to fail at the central vtFallible boundary. + failing.fail_index = failing.alloc_index; + s.nextSlice("\x1B]2;unavailable\x1B\\"); + try testing.expect(s.handler.semantic_failure); + + // Later input and RIS remain best-effort and never clear the diagnostic. + failing.fail_index = std.math.maxInt(usize); + s.nextSlice("ignored"); + s.nextSlice("\x1Bc"); + s.nextSlice("OK"); + try testing.expect(s.handler.semantic_failure); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("OK", str); + + // A new execution root starts without inheriting the diagnostic. + var fresh = Handler.init(&t); + defer fresh.deinit(); + try testing.expect(!fresh.semantic_failure); +} + test "cursor movement" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); @@ -1373,6 +1426,13 @@ test "ignores query actions" { s.nextSlice("\x1B[6n"); // Cursor position report s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query + s.nextSlice("\x1B]52;c;%%%invalid-base64%%%\x1B\\"); + s.nextSlice("\x1B_Ga=p,i=999\x1B\\"); // Missing Kitty image + s.nextSlice("\x1B_25a1;r;cp=41;%%%invalid%%%\x1B\\"); // Rejected glyph + + // Query, malformed input, protocol failure responses, and external-effect + // failures do not imply that terminal-owned semantic state diverged. + try testing.expect(!s.handler.semantic_failure); // Terminal should still be functional s.nextSlice("Test"); @@ -2015,6 +2075,7 @@ test "clipboard_write allocation failure is ignored" { s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); } try testing.expectEqual(@as(usize, 0), S.count); + try testing.expect(!s.handler.semantic_failure); } test "request mode DECRQM with write_pty callback" {