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" {