From 89b103dd5ec669318de53fa361195d155b9e7155 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Jul 2026 08:20:16 -0700 Subject: [PATCH 1/3] terminal: more full featured resize (cell geo, sync rendering, etc.) This makes the Terminal.resize handle more of the common elements that a core terminal emulator should: cell geoemtry handling (if exists), updates synchronized output modes. This adds a new TerminalStream.resize that also handles the side effects for more easy integration into downstream libghostty-vt consumers, namely mode 2048 in-band signaling handling. --- src/terminal/Terminal.zig | 193 ++++++++++++++++++++++----- src/terminal/c/terminal.zig | 49 +++---- src/terminal/stream_terminal.zig | 217 +++++++++++++++++++++++++++++++ src/termio/Termio.zig | 18 ++- src/termio/stream_handler.zig | 6 +- 5 files changed, 408 insertions(+), 75 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 089201276..f81ff5356 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3519,35 +3519,80 @@ pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { self.modes.set(.@"132_column", mode == .@"132_cols"); // Resize to the requested size - try self.resize( - alloc, - switch (mode) { + try self.resize(alloc, .{ + .cols = switch (mode) { .@"132_cols" => 132, .@"80_cols" => 80, }, - self.rows, - ); + .rows = self.rows, + }); // Erase our display and move our cursor. self.eraseDisplay(.complete, false); self.setCursorPos(1, 1); } +/// A terminal resize expressed in cells with optional per-cell pixel +/// geometry. It is highly recommended that callers supply cell geometry +/// but a terminal can technically function without it (but some reports +/// like certain mouse reporting modes and Kitty image protocol will +/// not be functional). +/// +/// Cell pixel dimensions must already be scaled for the current display DPI. +pub const Resize = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + cell_size_px: ?struct { + width: u32, + height: u32, + } = null, +}; + +pub const ResizeError = error{ + /// Resize requires allocation + OutOfMemory, + + /// Input value was invalid, such as a 0-sized dimension. + InvalidValue, +}; + /// Resize the underlying terminal. +/// +/// This has follow-on impacts: +/// +/// - If the column count changes, tabstops are reset. +/// - The scroll region is always reset +/// - Synchronized output mode is reset before grid resize work. +/// +/// If this returns an error, the terminal is in a bad state currently. +/// We will make this safe soon. pub fn resize( self: *Terminal, alloc: Allocator, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - // If our cols/rows didn't change then we're done - if (self.cols == cols and self.rows == rows) return; + opts: Resize, +) ResizeError!void { + // Screen and scrolling-region invariants require non-zero dimensions. + // Validate before changing any terminal state. + if (opts.cols == 0 or opts.rows == 0) return error.InvalidValue; + + // If we have cell geometry, set that now. + if (opts.cell_size_px) |cell_size| { + self.width_px = std.math.mul(u32, opts.cols, cell_size.width) catch + std.math.maxInt(u32); + self.height_px = std.math.mul(u32, opts.rows, cell_size.height) catch + std.math.maxInt(u32); + } + + self.modes.set(.synchronized_output, false); + + // If our cols/rows didn't change, skip grid work but still apply pixels. + if (self.cols == opts.cols and self.rows == opts.rows) return; // Resize our tabstops - if (self.cols != cols) { + if (self.cols != opts.cols) { const tabstops: Tabstops = try .init( alloc, - cols, + opts.cols, TABSTOP_INTERVAL, ); self.tabstops.deinit(alloc); @@ -3557,16 +3602,16 @@ pub fn resize( // Resize primary screen, which supports reflow const primary = self.screens.get(.primary).?; try primary.resize(.{ - .cols = cols, - .rows = rows, + .cols = opts.cols, + .rows = opts.rows, .reflow = self.modes.get(.wraparound), .prompt_redraw = self.flags.shell_redraws_prompt, }); // Alternate screen, if it exists, doesn't reflow if (self.screens.get(.alternate)) |alt| try alt.resize(.{ - .cols = cols, - .rows = rows, + .cols = opts.cols, + .rows = opts.rows, .reflow = false, }); @@ -3574,18 +3619,101 @@ pub fn resize( self.flags.dirty.clear = true; // Set our size - self.cols = cols; - self.rows = rows; + self.cols = opts.cols; + self.rows = opts.rows; // Reset the scrolling region self.scrolling_region = .{ .top = 0, - .bottom = rows - 1, + .bottom = opts.rows - 1, .left = 0, - .right = cols - 1, + .right = opts.cols - 1, }; } +test "Terminal: resize resets synchronized output" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.modes.set(.synchronized_output, true); + try t.resize(alloc, .{ .cols = 10, .rows = 5 }); + try testing.expect(!t.modes.get(.synchronized_output)); +} + +test "Terminal: resize rejects zero dimensions before mutation" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.width_px = 100; + t.height_px = 100; + t.flags.dirty.clear = false; + + try testing.expectError(error.InvalidValue, t.resize(alloc, .{ + .cols = 0, + .rows = 5, + .cell_size_px = .{ .width = 9, .height = 18 }, + })); + try testing.expectError(error.InvalidValue, t.resize(alloc, .{ + .cols = 10, + .rows = 0, + .cell_size_px = .{ .width = 9, .height = 18 }, + })); + + try testing.expectEqual(@as(size.CellCountInt, 10), t.cols); + try testing.expectEqual(@as(size.CellCountInt, 5), t.rows); + try testing.expectEqual(@as(u32, 100), t.width_px); + try testing.expectEqual(@as(u32, 100), t.height_px); + try testing.expect(!t.flags.dirty.clear); +} + +test "Terminal: resize preserves pixel dimensions when omitted" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.width_px = 90; + t.height_px = 90; + try t.resize(alloc, .{ .cols = 20, .rows = 10 }); + + try testing.expectEqual(@as(u32, 90), t.width_px); + try testing.expectEqual(@as(u32, 90), t.height_px); +} + +test "Terminal: resize updates pixels without changing cell dimensions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + try t.resize(alloc, .{ + .cols = 10, + .rows = 5, + .cell_size_px = .{ .width = 9, .height = 18 }, + }); + + try testing.expectEqual(@as(u32, 90), t.width_px); + try testing.expectEqual(@as(u32, 90), t.height_px); +} + +test "Terminal: resize pixel dimensions saturate" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 3 }); + defer t.deinit(alloc); + + try t.resize(alloc, .{ + .cols = 2, + .rows = 3, + .cell_size_px = .{ + .width = std.math.maxInt(u32), + .height = std.math.maxInt(u32), + }, + }); + + try testing.expectEqual(std.math.maxInt(u32), t.width_px); + try testing.expectEqual(std.math.maxInt(u32), t.height_px); +} + test "Terminal: resize preserves tabstops on allocation failure" { var failing = testing.FailingAllocator.init(testing.allocator, .{}); const alloc = failing.allocator(); @@ -3593,7 +3721,10 @@ test "Terminal: resize preserves tabstops on allocation failure" { defer t.deinit(alloc); failing.fail_index = failing.alloc_index; - try testing.expectError(error.OutOfMemory, t.resize(alloc, 513, 1)); + try testing.expectError(error.OutOfMemory, t.resize(alloc, .{ + .cols = 513, + .rows = 1, + })); try testing.expectEqual(@as(size.CellCountInt, 10), t.cols); try testing.expect(t.tabstops.get(8)); @@ -11819,7 +11950,7 @@ test "Terminal: saveCursor resize" { t.setCursorPos(1, 10); t.saveCursor(); - try t.resize(alloc, 5, 5); + try t.resize(alloc, .{ .cols = 5, .rows = 5 }); t.restoreCursor(); try t.print('X'); @@ -13837,7 +13968,7 @@ test "Terminal: resize less cols with wide char then print" { try t.print('x'); try t.print('😀'); // 0x1F600 - try t.resize(alloc, 2, 3); + try t.resize(alloc, .{ .cols = 2, .rows = 3 }); t.setCursorPos(1, 2); try t.print('😀'); // 0x1F600 } @@ -13854,11 +13985,11 @@ test "Terminal: resize with left and right margin set" { t.modes.set(.enable_left_and_right_margin, true); try t.print('0'); t.modes.set(.enable_mode_3, true); - try t.resize(alloc, cols, rows); + try t.resize(alloc, .{ .cols = cols, .rows = rows }); t.setLeftAndRightMargin(2, 0); try t.printRepeat(1850); _ = t.modes.restore(.enable_mode_3); - try t.resize(alloc, cols, rows); + try t.resize(alloc, .{ .cols = cols, .rows = rows }); } // https://github.com/mitchellh/ghostty/issues/1343 @@ -13875,7 +14006,7 @@ test "Terminal: resize with wraparound off" { try t.print('2'); try t.print('3'); const new_cols = 2; - try t.resize(alloc, new_cols, rows); + try t.resize(alloc, .{ .cols = new_cols, .rows = rows }); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -13895,7 +14026,7 @@ test "Terminal: resize with wraparound on" { try t.print('2'); try t.print('3'); const new_cols = 2; - try t.resize(alloc, new_cols, rows); + try t.resize(alloc, .{ .cols = new_cols, .rows = rows }); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -13919,7 +14050,7 @@ test "Terminal: resize with high unique style per cell" { } } - try t.resize(alloc, 60, 30); + try t.resize(alloc, .{ .cols = 60, .rows = 30 }); } test "Terminal: resize with high unique style per cell with wrapping" { @@ -13940,7 +14071,7 @@ test "Terminal: resize with high unique style per cell with wrapping" { try t.print('x'); } - try t.resize(alloc, 60, 30); + try t.resize(alloc, .{ .cols = 60, .rows = 30 }); } test "Terminal: resize with reflow and saved cursor" { @@ -13965,7 +14096,7 @@ test "Terminal: resize with reflow and saved cursor" { } t.saveCursor(); - try t.resize(alloc, 5, 3); + try t.resize(alloc, .{ .cols = 5, .rows = 3 }); t.restoreCursor(); { @@ -14006,7 +14137,7 @@ test "Terminal: resize with reflow and saved cursor pending wrap" { } t.saveCursor(); - try t.resize(alloc, 5, 3); + try t.resize(alloc, .{ .cols = 5, .rows = 3 }); t.restoreCursor(); { diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 1c3b9f0c5..da48ab73c 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -651,34 +651,17 @@ pub fn resize( cell_height_px: u32, ) callconv(lib.calling_conv) Result { const wrapper = terminal_ orelse return .invalid_value; - const t = wrapper.terminal; - if (cols == 0 or rows == 0) return .invalid_value; - t.resize(t.gpa(), cols, rows) catch return .out_of_memory; - - // Update pixel sizes - t.width_px = std.math.mul(u32, cols, cell_width_px) catch std.math.maxInt(u32); - t.height_px = std.math.mul(u32, rows, cell_height_px) catch std.math.maxInt(u32); - - // Disable synchronized output mode so that we show changes - // immediately for a resize. This is allowed by the spec. - t.modes.set(.synchronized_output, false); - - // If we have in-band size reporting enabled, send a report. - if (t.modes.get(.in_band_size_reports)) in_band: { - const func = wrapper.effects.write_pty orelse break :in_band; - - var buf: [1024]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); - size_report.encode(&writer, .mode_2048, .{ - .rows = rows, - .columns = cols, - .cell_width = cell_width_px, - .cell_height = cell_height_px, - }) catch break :in_band; - - const data = writer.buffered(); - func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); - } + wrapper.stream.handler.resize(.{ + .cols = cols, + .rows = rows, + .cell_size_px = .{ + .width = cell_width_px, + .height = cell_height_px, + }, + }) catch |err| return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; return .success; } @@ -3368,11 +3351,12 @@ test "resize updates pixel dimensions" { )); defer free(t); - try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18)); + // Pixel geometry must still be applied when the cell dimensions match. + try testing.expectEqual(Result.success, resize(t, 80, 24, 9, 18)); const zt = t.?.terminal; - try testing.expectEqual(@as(u32, 100 * 9), zt.width_px); - try testing.expectEqual(@as(u32, 40 * 18), zt.height_px); + try testing.expectEqual(@as(u32, 80 * 9), zt.width_px); + try testing.expectEqual(@as(u32, 24 * 18), zt.height_px); } test "resize pixel overflow saturates" { @@ -3411,7 +3395,8 @@ test "resize disables synchronized output" { const zt = t.?.terminal; zt.modes.set(.synchronized_output, true); - try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18)); + // The terminal-level reset must run even if grid work is unnecessary. + try testing.expectEqual(Result.success, resize(t, 80, 24, 9, 18)); try testing.expect(!zt.modes.get(.synchronized_output)); } diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 91cfb21cf..d7baf528b 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -138,6 +138,38 @@ pub const Handler = struct { self.apc_handler.deinit(); } + /// Resize the terminal and apply any side effects (if supported) + /// as a result of that. + /// + /// This is different than a direct `Terminal.resize` operation + /// because it also handles the side effects like mode 2048 in-band + /// size reports if write_pty is set. + pub fn resize(self: *Handler, value: Terminal.Resize) !void { + try self.terminal.resize(self.terminal.gpa(), value); + + // Mode 2048 reports require complete, current cell pixel geometry. + const cell_size = value.cell_size_px orelse return; + + // If we have no in-band size reports enabled then do nothing. + if (!self.terminal.modes.get(.in_band_size_reports)) return; + + // If we have no write_pty effect, do nothing. + const write_pty = self.effects.write_pty orelse return; + + // The maximum mode-2048 response is covered by size_report's maximum + // value test. Reserve the final byte for the callback's sentinel. + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(buf[0 .. buf.len - 1]); + size_report.encode(&writer, .mode_2048, .{ + .rows = value.rows, + .columns = value.cols, + .cell_width = cell_size.width, + .cell_height = cell_size.height, + }) catch unreachable; + buf[writer.end] = 0; + write_pty(self, buf[0..writer.end :0]); + } + pub fn vt( self: *Handler, comptime action: Action.Tag, @@ -881,6 +913,191 @@ pub const Handler = struct { } }; +test "resize clears synchronized output on unchanged cell dimensions" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + t.modes.set(.synchronized_output, true); + try s.handler.resize(.{ + .cols = 80, + .rows = 24, + .cell_size_px = .{ .width = 9, .height = 18 }, + }); + + try testing.expect(!t.modes.get(.synchronized_output)); + try testing.expectEqual(@as(u32, 720), t.width_px); + try testing.expectEqual(@as(u32, 432), t.height_px); +} + +test "resize reports mode 2048 geometry" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var response: [128]u8 = undefined; + var response_len: usize = 0; + + fn writePty(_: *Handler, data: [:0]const u8) void { + @memcpy(response[0..data.len], data); + response_len = data.len; + } + }; + S.response_len = 0; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + t.modes.set(.in_band_size_reports, true); + try s.handler.resize(.{ + .cols = 100, + .rows = 40, + .cell_size_px = .{ .width = 9, .height = 18 }, + }); + + try testing.expectEqualStrings( + "\x1B[48;40;100;720;900t", + S.response[0..S.response_len], + ); +} + +test "resize suppresses mode 2048 reports" { + const S = struct { + var calls: usize = 0; + + fn writePty(_: *Handler, _: [:0]const u8) void { + calls += 1; + } + }; + S.calls = 0; + + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Disabled mode suppresses a report even with pixels and a callback. + try s.handler.resize(.{ + .cols = 80, + .rows = 24, + .cell_size_px = .{ .width = 9, .height = 18 }, + }); + try testing.expectEqual(@as(usize, 0), S.calls); + + // Missing pixel geometry suppresses a report even with the mode enabled. + t.modes.set(.in_band_size_reports, true); + try s.handler.resize(.{ .cols = 80, .rows = 24 }); + try testing.expectEqual(@as(usize, 0), S.calls); + + // A read-only stream has no write effect and remains successful. + var readonly_terminal: Terminal = try .init( + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + 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), + ); + defer readonly_stream.deinit(); + try readonly_stream.handler.resize(.{ + .cols = 80, + .rows = 24, + .cell_size_px = .{ .width = 9, .height = 18 }, + }); + try testing.expectEqual(@as(usize, 0), S.calls); +} + +test "resize failure resets synchronized output but does not write" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 1 }); + defer t.deinit(alloc); + + const S = struct { + var called: bool = false; + + fn writePty(_: *Handler, _: [:0]const u8) void { + called = true; + } + }; + S.called = false; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .initAlloc(alloc, handler); + defer s.deinit(); + + t.modes.set(.synchronized_output, true); + t.modes.set(.in_band_size_reports, true); + failing.fail_index = failing.alloc_index; + try testing.expectError(error.OutOfMemory, s.handler.resize(.{ + .cols = 513, + .rows = 1, + .cell_size_px = .{ .width = 9, .height = 18 }, + })); + + try testing.expect(!t.modes.get(.synchronized_output)); + try testing.expect(!S.called); + try testing.expectEqual(@as(@TypeOf(t.cols), 10), t.cols); +} + +test "resize effects do not change canonical terminal state" { + var authoritative: Terminal = try .init( + testing.allocator, + .{ .cols = 10, .rows = 5 }, + ); + defer authoritative.deinit(testing.allocator); + var readonly: Terminal = try .init( + testing.allocator, + .{ .cols = 10, .rows = 5 }, + ); + defer readonly.deinit(testing.allocator); + + const S = struct { + fn writePty(_: *Handler, _: [:0]const u8) void {} + }; + var authoritative_handler: Handler = .init(&authoritative); + authoritative_handler.effects.write_pty = &S.writePty; + var authoritative_stream: Stream = .initAlloc( + testing.allocator, + authoritative_handler, + ); + defer authoritative_stream.deinit(); + var readonly_stream: Stream = .initAlloc( + testing.allocator, + .init(&readonly), + ); + defer readonly_stream.deinit(); + + authoritative.modes.set(.in_band_size_reports, true); + readonly.modes.set(.in_band_size_reports, true); + const value: Terminal.Resize = .{ + .cols = 20, + .rows = 10, + .cell_size_px = .{ .width = 9, .height = 18 }, + }; + try authoritative_stream.handler.resize(value); + try readonly_stream.handler.resize(value); + + try testing.expectEqual(authoritative.cols, readonly.cols); + try testing.expectEqual(authoritative.rows, readonly.rows); + try testing.expectEqual(authoritative.width_px, readonly.width_px); + try testing.expectEqual(authoritative.height_px, readonly.height_px); + try testing.expect(std.meta.eql(authoritative.modes, readonly.modes)); + try testing.expect(std.meta.eql( + authoritative.scrolling_region, + readonly.scrolling_region, + )); +} + test "basic print" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 1b08c5e9a..f5d9580eb 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -479,18 +479,16 @@ pub fn resize( // Update the size of our terminal state try self.terminal.resize( self.alloc, - grid_size.columns, - grid_size.rows, + .{ + .cols = grid_size.columns, + .rows = grid_size.rows, + .cell_size_px = .{ + .width = self.size.cell.width, + .height = self.size.cell.height, + }, + }, ); - // Update our pixel sizes - self.terminal.width_px = grid_size.columns * self.size.cell.width; - self.terminal.height_px = grid_size.rows * self.size.cell.height; - - // Disable synchronized output mode so that we show changes - // immediately for a resize. This is allowed by the spec. - self.terminal.modes.set(.synchronized_output, false); - // If we have size reporting enabled we need to send a report. if (self.terminal.modes.get(.in_band_size_reports)) { try self.sizeReportLocked(td, .mode_2048); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 43c5d6117..2438003f5 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -747,8 +747,10 @@ pub const StreamHandler = struct { const grid_size = self.size.grid(); self.terminal.resize( self.alloc, - grid_size.columns, - grid_size.rows, + .{ + .cols = grid_size.columns, + .rows = grid_size.rows, + }, ) catch |err| { log.err("error updating terminal size: {}", .{err}); }; From dde3d4d6b05338e1860a7c45fd17990a6d634e8b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Jul 2026 09:37:02 -0700 Subject: [PATCH 2/3] terminal: screen resize failures are now safe --- src/terminal/Screen.zig | 491 ++++++++++++++++++++++++++++++---------- 1 file changed, 377 insertions(+), 114 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 6c4db3237..5e953bc50 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1851,50 +1851,80 @@ pub const Resize = struct { prompt_redraw: osc.semantic_prompt.Redraw = .false, }; -/// Resize the screen. The rows or cols can be bigger or smaller. -/// -/// If this returns an error, the screen is left in a likely garbage state. -/// It is very hard to undo this operation without blowing up our memory -/// usage. The only way to recover is to reset the screen. The only way -/// this really fails is if page allocation is required and fails, which -/// probably means the system is in trouble anyways. I'd like to improve this -/// in the future but it is not a priority particularly because this scenario -/// (resize) is difficult. +const resize_tw = tripwire.module(enum { + saved_cursor_pin, + pages, +}, resize); + +/// Resize the screen. The rows or cols can be bigger or smaller. If this +/// returns an error, the screen is unchanged. pub inline fn resize( self: *Screen, opts: Resize, -) !void { +) Allocator.Error!void { + const tw = resize_tw; defer self.assertIntegrity(); - if (comptime build_options.kitty_graphics) { - // No matter what we mark our image state as dirty - self.kitty_images.dirty = true; + // We need to insert a tracked pin for our saved cursor so we can + // modify its X/Y for reflow. Do this before changing any state since + // tracking the pin can fail. + const saved_cursor_pin: ?*Pin = saved_cursor: { + const sc = self.saved_cursor orelse break :saved_cursor null; + const pin = self.pages.pin(.{ .active = .{ + .x = sc.x, + .y = sc.y, + } }) orelse break :saved_cursor null; + try tw.check(.saved_cursor_pin); + break :saved_cursor try self.pages.trackPin(pin); + }; + defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); + + // A cursor style and hyperlink are partly stored in the page containing + // the cursor. Their IDs only have meaning within that page, and the page + // keeps reference counts for them. Resizing can replace or destroy the + // page, so below we temporarily remove both values from the cursor before + // asking PageList to resize. + // + // Save everything first so we can put the cursor back together afterward, + // or restore it unchanged if the resize fails. We also save the original + // node and serial so after a successful resize we can tell whether that + // page survived and still needs its temporary references released. + const cursor_node = self.cursor.page_pin.node; + const cursor_node_serial = cursor_node.serial; + const cursor_style = self.cursor.style; + const cursor_style_id = self.cursor.style_id; + const cursor_hyperlink = self.cursor.hyperlink; + const cursor_hyperlink_id = self.cursor.hyperlink_id; + + // Keep an extra reference to the cursor style and hyperlink while the + // resize is in progress. Releasing the cursor references below therefore + // can't delete either entry, and an error can restore the cursor without + // any allocation. + { + const page = cursor_node.page(); + if (cursor_style_id != style.default_id) { + page.styles.use(page.memory, cursor_style_id); + } + if (cursor_hyperlink_id != 0) { + page.hyperlink_set.use(page.memory, cursor_hyperlink_id); + } + } + errdefer { + self.cursor.style = cursor_style; + self.cursor.style_id = cursor_style_id; + self.cursor.hyperlink = cursor_hyperlink; + self.cursor.hyperlink_id = cursor_hyperlink_id; } - // Release the cursor style while resizing just - // in case the cursor ends up on a different page. - const cursor_style = self.cursor.style; + // Release the cursor style while resizing just in case the cursor ends + // up on a different page. self.cursor.style = .{}; self.manualStyleUpdate() catch unreachable; - defer { - // Restore the cursor style. - self.cursor.style = cursor_style; - self.manualStyleUpdate() catch |err| { - // This failure should not happen because manualStyleUpdate - // handles page splitting, overflow, and more. This should only - // happen if we're out of RAM. In this case, we'll just degrade - // gracefully back to the default style. - log.err("failed to update style on cursor reload err={}", .{err}); - self.cursor.style = .{}; - self.cursor.style_id = 0; - }; - } // If we have a hyperlink, release it from the old page // and then we need to re-add it to the new page. This needs // to happen because resize below typically reallocates a // new page so the old hyperlink is invalid. - const hyperlink_ = self.cursor.hyperlink; if (self.cursor.hyperlink_id != 0) { // Note we do NOT use endHyperlink because we want to keep // our allocated self.cursor.hyperlink valid. @@ -1904,20 +1934,138 @@ pub inline fn resize( self.cursor.hyperlink = null; } - // We need to insert a tracked pin for our saved cursor so we can - // modify its X/Y for reflow. - const saved_cursor_pin: ?*Pin = saved_cursor: { - const sc = self.saved_cursor orelse break :saved_cursor null; - const pin = self.pages.pin(.{ .active = .{ - .x = sc.x, - .y = sc.y, - } }) orelse break :saved_cursor null; - break :saved_cursor try self.pages.trackPin(pin); - }; - defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); + // Perform the resize operation. + try tw.check(.pages); + try self.pages.resize(.{ + .rows = opts.rows, + .cols = opts.cols, + .reflow = opts.reflow, + .cursor = .{ + .x = self.cursor.x, + .y = self.cursor.y, + .pin = self.cursor.page_pin, + }, + }); + // No more failures are possible after this. Enforced by compiler + // because we do NO cleanup of the state below. + errdefer comptime unreachable; + + // Resizing moves image placements, so mark their geometry as dirty. + if (comptime build_options.kitty_graphics) self.kitty_images.dirty = true; + + // If we have no scrollback and we shrunk our rows, we must explicitly + // erase our history. This is because PageList always keeps at least + // a page size of history. + if (self.no_scrollback) self.pages.eraseHistory(null); + + // If our cursor was updated, we do a full reload so all our cursor + // state is correct. + self.cursorReload(); + + // Clear any redrawable prompt after the fallible resize but before + // restoring the cursor style and hyperlink, so cleared cells retain the + // same default styling they had with the previous ordering. + self.clearPromptForRedraw(opts.prompt_redraw); + + // Restore the cursor style. + self.cursor.style = cursor_style; + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate handles + // page splitting, overflow, and more. This should only happen if + // we're out of RAM. In this case, we'll just degrade gracefully back + // to the default style. + log.err("failed to update style on cursor reload err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = 0; + }; + + // If we reflowed a saved cursor, update it. + if (saved_cursor_pin) |p| { + // This should never fail because a non-null saved_cursor_pin + // implies a non-null saved_cursor. + const sc = &self.saved_cursor.?; + if (self.pages.pointFromPin(.active, p.*)) |pt| { + sc.x = @intCast(pt.active.x); + sc.y = @intCast(pt.active.y); + + // If we had pending wrap set and we're no longer at the end of + // the line, we unset the pending wrap and move the cursor to + // reflect the correct next position. + if (sc.pending_wrap and sc.x != opts.cols - 1) { + sc.pending_wrap = false; + sc.x += 1; + } + } else { + // I think this can happen if the screen is resized to be + // less rows or less cols and our saved cursor moves outside + // the active area. In this case, there isn't anything really + // reasonable we can do so we just move the cursor to the + // top-left. It may be reasonable to also move the cursor to + // match the primary cursor. Any behavior is fine since this is + // totally unspecified. + sc.x = 0; + sc.y = 0; + sc.pending_wrap = false; + } + } + + // Fix up our hyperlink if we had one. + if (cursor_hyperlink) |link| { + self.startHyperlink(link.uri, switch (link.id) { + .explicit => |v| v, + .implicit => null, + }) catch |err| { + // This shouldn't happen because startHyperlink should handle + // resizing. This only happens if we're truly out of RAM. Degrade + // to forgetting the hyperlink. + log.err("failed to update hyperlink on resize err={}", .{err}); + }; + + // Remove our old link + link.deinit(self.alloc); + self.alloc.destroy(link); + } + + // A tracked pin follows its content when PageList moves or replaces a + // page. If the cursor's pin still points to the node we started with, + // that node survived the resize and still owns the temporary style and + // hyperlink references we added above. + // + // Comparing pointers is not enough by itself. PageList keeps a pool of + // nodes, so it can destroy a node and create a replacement at the same + // memory address. Every new use of a node gets a different serial number, + // making the pointer-and-serial pair an O(1) identity check. + const cursor_node_survived = + self.cursor.page_pin.node == cursor_node and + self.cursor.page_pin.node.serial == cursor_node_serial; + if (comptime build_options.slow_runtime_safety) { + assert(cursor_node_survived == + self.pages.nodeIsValid(cursor_node, cursor_node_serial)); + } + + // A surviving node still contains our temporary references, so remove + // them now. If the node was replaced, its destruction removed those + // references along with the rest of the old page so we don't need to + // fix it up. + if (cursor_node_survived) { + const page = cursor_node.page(); + if (cursor_style_id != style.default_id) { + page.styles.release(page.memory, cursor_style_id); + } + if (cursor_hyperlink_id != 0) { + page.hyperlink_set.release(page.memory, cursor_hyperlink_id); + } + } +} + +fn clearPromptForRedraw( + self: *Screen, + redraw: osc.semantic_prompt.Redraw, +) void { // If our cursor is on a prompt or input line, clear it so the shell can - // redraw it. This works with OSC 133 semantic prompts. + // redraw it. This works with OSC 133 semantic prompts. We do this after + // the fallible resize so an error leaves the original prompt untouched. // // We check cursor.semantic_content rather than page_row.semantic_prompt // because some shells (e.g., Nu) mark input areas with OSC 133 B but don't @@ -1926,10 +2074,10 @@ pub inline fn resize( // would miss them. By checking semantic_content, we assume that if the // cursor is on anything other than command output, we're at a prompt/input // line and should clear from there. - if (opts.prompt_redraw != .false and + if (redraw != .false and self.cursor.semantic_content != .output) prompt: { - switch (opts.prompt_redraw) { + switch (redraw) { .false => unreachable, // For `.last`, only clear the current line where the cursor is. @@ -1969,76 +2117,6 @@ pub inline fn resize( }, } } - - // Perform the resize operation. - try self.pages.resize(.{ - .rows = opts.rows, - .cols = opts.cols, - .reflow = opts.reflow, - .cursor = .{ - .x = self.cursor.x, - .y = self.cursor.y, - .pin = self.cursor.page_pin, - }, - }); - - // If we have no scrollback and we shrunk our rows, we must explicitly - // erase our history. This is because PageList always keeps at least - // a page size of history. - if (self.no_scrollback) { - self.pages.eraseHistory(null); - } - - // If our cursor was updated, we do a full reload so all our cursor - // state is correct. - self.cursorReload(); - - // If we reflowed a saved cursor, update it. - if (saved_cursor_pin) |p| { - // This should never fail because a non-null saved_cursor_pin - // implies a non-null saved_cursor. - const sc = &self.saved_cursor.?; - if (self.pages.pointFromPin(.active, p.*)) |pt| { - sc.x = @intCast(pt.active.x); - sc.y = @intCast(pt.active.y); - - // If we had pending wrap set and we're no longer at the end of - // the line, we unset the pending wrap and move the cursor to - // reflect the correct next position. - if (sc.pending_wrap and sc.x != opts.cols - 1) { - sc.pending_wrap = false; - sc.x += 1; - } - } else { - // I think this can happen if the screen is resized to be - // less rows or less cols and our saved cursor moves outside - // the active area. In this case, there isn't anything really - // reasonable we can do so we just move the cursor to the - // top-left. It may be reasonable to also move the cursor to - // match the primary cursor. Any behavior is fine since this is - // totally unspecified. - sc.x = 0; - sc.y = 0; - sc.pending_wrap = false; - } - } - - // Fix up our hyperlink if we had one. - if (hyperlink_) |link| { - self.startHyperlink(link.uri, switch (link.id) { - .explicit => |v| v, - .implicit => null, - }) catch |err| { - // This shouldn't happen because startHyperlink should handle - // resizing. This only happens if we're truly out of RAM. Degrade - // to forgetting the hyperlink. - log.err("failed to update hyperlink on resize err={}", .{err}); - }; - - // Remove our old link - link.deinit(self.alloc); - self.alloc.destroy(link); - } } /// Set a style attribute for the current cursor. @@ -7236,6 +7314,191 @@ test "Screen: resize more cols with reflow" { try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); } +test "Screen: resize errors preserve state" { + const testing = std.testing; + const alloc = testing.allocator; + + for (std.meta.tags(resize_tw.FailPoint)) |tag| { + const tw = resize_tw; + defer tw.end(.reset) catch unreachable; + + var s = try init(alloc, .{ + .cols = 10, + .rows = 3, + .max_scrollback = 0, + }); + defer s.deinit(); + + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("echo"); + try s.setAttribute(.{ .bold = {} }); + try s.startHyperlink("https://example.com", "resize"); + s.saved_cursor = .{ + .x = 1, + .y = 0, + .style = s.cursor.style, + .protected = s.cursor.protected, + .pending_wrap = s.cursor.pending_wrap, + .origin = false, + .charset = s.charset, + }; + + if (comptime build_options.kitty_graphics) { + s.kitty_images.dirty = false; + } + + // Keep a shallow copy for all non-page state and a byte-for-byte + // copy of the sole page so reference counts and prompt contents are + // covered as well. + try testing.expectEqual(s.pages.pages.first, s.pages.pages.last); + const before = s; + const before_viewport_pin = s.pages.viewport_pin.*; + const before_tracked_pins = s.pages.countTrackedPins(); + const before_page = try alloc.dupe( + u8, + s.pages.pages.first.?.page().memory, + ); + defer alloc.free(before_page); + + tw.errorAlways(tag, error.OutOfMemory); + try testing.expectError(error.OutOfMemory, s.resize(.{ + .cols = 20, + .rows = 4, + .prompt_redraw = .true, + })); + + try testing.expect(std.meta.eql(before.cursor, s.cursor)); + try testing.expect(std.meta.eql(before.saved_cursor, s.saved_cursor)); + try testing.expect(std.meta.eql(before.selection, s.selection)); + try testing.expect(std.meta.eql(before.charset, s.charset)); + try testing.expectEqual(before.protected_mode, s.protected_mode); + try testing.expect(std.meta.eql(before.kitty_keyboard, s.kitty_keyboard)); + try testing.expect(std.meta.eql(before.semantic_prompt, s.semantic_prompt)); + try testing.expectEqual(before.dirty, s.dirty); + try testing.expectEqual(before.pages.pages.first, s.pages.pages.first); + try testing.expectEqual(before.pages.pages.last, s.pages.pages.last); + try testing.expectEqual(before.pages.cols, s.pages.cols); + try testing.expectEqual(before.pages.rows, s.pages.rows); + try testing.expectEqual(before.pages.total_rows, s.pages.total_rows); + try testing.expectEqual(before.pages.viewport, s.pages.viewport); + try testing.expectEqual(before_viewport_pin, s.pages.viewport_pin.*); + try testing.expectEqual(before_tracked_pins, s.pages.countTrackedPins()); + try testing.expectEqualSlices( + u8, + before_page, + s.pages.pages.first.?.page().memory, + ); + if (comptime build_options.kitty_graphics) { + try testing.expectEqual( + before.kitty_images.dirty, + s.kitty_images.dirty, + ); + } + } +} + +test "Screen: resize cursor references when node survives" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ + .cols = 5, + .rows = 3, + .max_scrollback = 1000, + }); + defer s.deinit(); + + try s.setAttribute(.bold); + try s.startHyperlink("https://example.com/", "resize"); + try s.testWriteString("abc"); + + const original_node = s.cursor.page_pin.node; + const original_serial = original_node.serial; + { + const page = original_node.page(); + try testing.expectEqual( + 4, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + try testing.expectEqual( + 4, + page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id), + ); + } + + // A row-only resize grows the existing page without replacing the + // cursor's node. The temporary resize references must be released from + // this page after the cursor state is restored. + try s.resize(.{ .cols = 5, .rows = 4 }); + + try testing.expectEqual(original_node, s.cursor.page_pin.node); + try testing.expectEqual(original_serial, s.cursor.page_pin.node.serial); + { + const page = s.cursor.page_pin.node.page(); + try testing.expectEqual( + 4, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + try testing.expectEqual( + 4, + page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id), + ); + } +} + +test "Screen: resize cursor references when node is replaced" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ + .cols = 5, + .rows = 3, + .max_scrollback = 1000, + }); + defer s.deinit(); + + try s.setAttribute(.bold); + try s.startHyperlink("https://example.com/", "resize"); + try s.testWriteString("abc"); + + const original_node = s.cursor.page_pin.node; + const original_serial = original_node.serial; + { + const page = original_node.page(); + try testing.expectEqual( + 4, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + try testing.expectEqual( + 4, + page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id), + ); + } + + // A column resize with reflow replaces the page and remaps the tracked + // cursor pin. The old page owns the temporary references, so destroying + // it must account for them without attempting to release them afterward. + try s.resize(.{ .cols = 10, .rows = 3 }); + + try testing.expect( + s.cursor.page_pin.node != original_node or + s.cursor.page_pin.node.serial != original_serial, + ); + { + const page = s.cursor.page_pin.node.page(); + try testing.expectEqual( + 4, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + try testing.expectEqual( + 4, + page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id), + ); + } +} + test "Screen: resize more rows and cols with wrapping" { const testing = std.testing; const alloc = testing.allocator; From a3c1caba545e7894b21fa359701d245b82b82083 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Jul 2026 10:18:26 -0700 Subject: [PATCH 3/3] terminal: resize failures are now almost fully safe Terminal resize could leave tab stops, pixel geometry, synchronized output, or screen dimensions partially updated when a later allocation failed. This is now safe. There is one exception, see the code comments. --- src/terminal/Terminal.zig | 330 +++++++++++++++++++++++++++++-- src/terminal/stream_terminal.zig | 6 +- 2 files changed, 315 insertions(+), 21 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index f81ff5356..3cf34cc4c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -7,6 +7,7 @@ const std = @import("std"); const build_options = @import("terminal_options"); const lib = @import("lib.zig"); const assert = @import("../quirks.zig").inlineAssert; +const tripwire = @import("../tripwire.zig"); const testing = std.testing; const Allocator = std.mem.Allocator; const unicode = @import("../unicode/main.zig"); @@ -3556,31 +3557,67 @@ pub const ResizeError = error{ InvalidValue, }; +const resize_tw = tripwire.module(enum { + tabstops, + primary_screen, + alternate_screen, + alternate_screen_init, +}, resize); + /// Resize the underlying terminal. /// /// This has follow-on impacts: /// /// - If the column count changes, tabstops are reset. /// - The scroll region is always reset -/// - Synchronized output mode is reset before grid resize work. +/// - Synchronized output mode is reset for every successful resize. /// -/// If this returns an error, the terminal is in a bad state currently. -/// We will make this safe soon. +/// This handles errors gracefully and recovers the terminal back to +/// a clean usable state. +/// +/// The only error handling edge case is in the highly exceptional scenario +/// where the primary screen can be resized but the alternate screen cannot. +/// In this scenario, we attempt to clear the alt screen at the desired +/// new size. If that fails, we unconditionally deallocate the alt screen +/// and move to primary screen. This can break terminal programs but it +/// requires a really particular scenario where memory exists for one +/// but not the other and we do our best. pub fn resize( self: *Terminal, alloc: Allocator, opts: Resize, ) ResizeError!void { + const tw = resize_tw; + // Screen and scrolling-region invariants require non-zero dimensions. // Validate before changing any terminal state. if (opts.cols == 0 or opts.rows == 0) return error.InvalidValue; - // If we have cell geometry, set that now. + // Pixel geometry and synchronized output are updated on every valid + // resize attempt, including one that doesn't change the grid dimensions. + // Save their old values so later allocation failures can roll them back. + const old_width_px = self.width_px; + const old_height_px = self.height_px; + const old_synchronized_output = self.modes.get(.synchronized_output); + errdefer { + self.width_px = old_width_px; + self.height_px = old_height_px; + self.modes.set(.synchronized_output, old_synchronized_output); + } + + // If our pixel geometry was set, then we set it even if our rows/cols + // didn't change. if (opts.cell_size_px) |cell_size| { - self.width_px = std.math.mul(u32, opts.cols, cell_size.width) catch - std.math.maxInt(u32); - self.height_px = std.math.mul(u32, opts.rows, cell_size.height) catch - std.math.maxInt(u32); + self.width_px = std.math.mul( + u32, + opts.cols, + cell_size.width, + ) catch std.math.maxInt(u32); + self.height_px = std.math.mul( + u32, + opts.rows, + cell_size.height, + ) catch std.math.maxInt(u32); } self.modes.set(.synchronized_output, false); @@ -3588,18 +3625,23 @@ pub fn resize( // If our cols/rows didn't change, skip grid work but still apply pixels. if (self.cols == opts.cols and self.rows == opts.rows) return; - // Resize our tabstops + // Build replacement tabstops without touching the current table. Keep + // ownership here until every fallible resize operation has succeeded. + var new_tabstops: ?Tabstops = null; + errdefer if (new_tabstops) |*v| v.deinit(alloc); if (self.cols != opts.cols) { - const tabstops: Tabstops = try .init( + try tw.check(.tabstops); + new_tabstops = try .init( alloc, opts.cols, TABSTOP_INTERVAL, ); - self.tabstops.deinit(alloc); - self.tabstops = tabstops; } - // Resize primary screen, which supports reflow + // Resize primary screen, which supports reflow. We do this first + // because the cleanup situation is a lot better if this succeeds + // and alt fails than the reverse. + try tw.check(.primary_screen); const primary = self.screens.get(.primary).?; try primary.resize(.{ .cols = opts.cols, @@ -3608,12 +3650,78 @@ pub fn resize( .prompt_redraw = self.flags.shell_redraws_prompt, }); - // Alternate screen, if it exists, doesn't reflow - if (self.screens.get(.alternate)) |alt| try alt.resize(.{ - .cols = opts.cols, - .rows = opts.rows, - .reflow = false, - }); + // Alternate screen, if it exists, doesn't reflow. The primary resize + // above can't be losslessly undone, so if the alternate resize fails we + // replace it with an empty screen at the requested size. If that + // also fails, we fall back to the primary screen. + if (self.screens.get(.alternate)) |alt| alt: { + const err: ResizeError = resize: { + tw.check(.alternate_screen) catch |err| break :resize err; + alt.resize(.{ + .cols = opts.cols, + .rows = opts.rows, + .reflow = false, + }) catch |err| break :resize err; + + // Resize succeeded. + break :alt; + }; + + log.warn("alternate screen resize failed, replacing it err={}", .{err}); + + // If the alternate screen isn't active, then we just free it + // and move on. It'll be reallocated when it gets reinitialized lazily. + // In this case, we just lose the prior data if the terminal program + // expected it to be saved. + if (self.screens.active_key != .alternate) { + self.screens.remove(alloc, .alternate); + break :alt; + } + + // The alt screen is active, so we temporarily switch to primary + // so we can safely remove the alt and recreate it blank. This loses + // the data but hopefully keeps us on the alt screen. + const charset = alt.charset; + self.screens.switchTo(.primary); + self.screens.remove(alloc, .alternate); + + // Replace the alt screen with an empty version. If this fails + // we just go back to the primary screen. Not great, but best + // we can do. + tw.check(.alternate_screen_init) catch break :alt; + const replacement = self.screens.getInit(alloc, .alternate, .{ + .cols = opts.cols, + .rows = opts.rows, + .max_scrollback = 0, + .kitty_image_storage_limit = if (comptime build_options.kitty_graphics) + primary.kitty_images.total_limit + else + 0, + .kitty_image_loading_limits = if (comptime build_options.kitty_graphics) + primary.kitty_images.image_limits + else {}, + }) catch |init_err| { + log.warn( + "alternate screen replacement failed, falling back to primary err={}", + .{init_err}, + ); + break :alt; + }; + + replacement.charset = charset; + self.screens.switchTo(.alternate); + } + + // No more failures are allowed after this point because the screens have + // committed their new sizes and the remaining Terminal state must follow. + errdefer comptime unreachable; + + // All fallible work is complete. Replace the old tabstop table only now. + if (new_tabstops) |v| { + self.tabstops.deinit(alloc); + self.tabstops = v; + new_tabstops = null; + } // Whenever we resize we just mark it as a screen clear self.flags.dirty.clear = true; @@ -3730,6 +3838,190 @@ test "Terminal: resize preserves tabstops on allocation failure" { try testing.expect(t.tabstops.get(8)); } +test "Terminal: resize failure paths preserve consistent state" { + const alloc = testing.allocator; + + for ([_]resize_tw.FailPoint{ + .tabstops, + .primary_screen, + .alternate_screen, + }) |tag| { + const tw = resize_tw; + defer tw.end(.reset) catch unreachable; + + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + try t.printString("primary"); + _ = try t.switchScreen(.alternate); + try t.printString("alternate"); + _ = try t.switchScreen(.primary); + + t.width_px = 100; + t.height_px = 50; + t.modes.set(.synchronized_output, true); + t.flags.dirty.clear = false; + t.scrolling_region = .{ + .top = 1, + .bottom = 2, + .left = 1, + .right = 8, + }; + t.tabstops.unset(8); + t.tabstops.set(3); + + const primary = t.screens.get(.primary).?; + const alternate = t.screens.get(.alternate).?; + const alternate_generation = t.screens.generation(.alternate); + try testing.expectEqual(primary.pages.pages.first, primary.pages.pages.last); + try testing.expectEqual(alternate.pages.pages.first, alternate.pages.pages.last); + + const before = t; + const before_primary = primary.*; + const before_alternate = alternate.*; + const before_primary_page = try alloc.dupe( + u8, + primary.pages.pages.first.?.page().memory, + ); + defer alloc.free(before_primary_page); + const before_alternate_page = try alloc.dupe( + u8, + alternate.pages.pages.first.?.page().memory, + ); + defer alloc.free(before_alternate_page); + tw.errorAlways(tag, error.OutOfMemory); + + // A failure after the primary screen has resized is recovered by + // dropping the inactive alternate and completing the resize. This + // also proves the alternate tripwire is after the primary resize + // rather than acting as a preflight check. + if (tag == .alternate_screen) { + try t.resize(alloc, .{ + .cols = 513, + .rows = 4, + .cell_size_px = .{ .width = 9, .height = 18 }, + }); + + try testing.expectEqual(@as(size.CellCountInt, 513), t.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), t.rows); + try testing.expectEqual(@as(u32, 4617), t.width_px); + try testing.expectEqual(@as(u32, 72), t.height_px); + try testing.expect(!t.modes.get(.synchronized_output)); + try testing.expect(t.flags.dirty.clear); + try testing.expectEqual(@as(size.CellCountInt, 513), primary.pages.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), primary.pages.rows); + try testing.expectEqual(@as(?*Screen, null), t.screens.get(.alternate)); + try testing.expectEqual( + alternate_generation +% 1, + t.screens.generation(.alternate), + ); + try testing.expectEqual(.primary, t.screens.active_key); + try testing.expectEqual(primary, t.screens.active); + try testing.expect(t.tabstops.get(8)); + try testing.expect(!t.tabstops.get(3)); + + // The alternate is recreated lazily at the terminal's new size. + _ = try t.switchScreen(.alternate); + const replacement = t.screens.get(.alternate).?; + try testing.expectEqual(@as(size.CellCountInt, 513), replacement.pages.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), replacement.pages.rows); + try testing.expect(replacement.pages.getCell(.{ .active = .{} }).?.cell.isEmpty()); + continue; + } + + try testing.expectError(error.OutOfMemory, t.resize(alloc, .{ + .cols = 513, + .rows = 4, + .cell_size_px = .{ .width = 9, .height = 18 }, + })); + + try testing.expectEqual(before.width_px, t.width_px); + try testing.expectEqual(before.height_px, t.height_px); + try testing.expect(std.meta.eql(before.modes, t.modes)); + try testing.expectEqual(before.cols, t.cols); + try testing.expectEqual(before.rows, t.rows); + try testing.expectEqual(before.scrolling_region, t.scrolling_region); + try testing.expectEqual(before.flags, t.flags); + try testing.expect(std.meta.eql(before.tabstops, t.tabstops)); + try testing.expectEqual(before.screens.active_key, t.screens.active_key); + try testing.expectEqual(before.screens.active, t.screens.active); + + try testing.expectEqual(before_primary.pages.cols, primary.pages.cols); + try testing.expectEqual(before_primary.pages.rows, primary.pages.rows); + try testing.expectEqual( + before_primary.pages.total_rows, + primary.pages.total_rows, + ); + try testing.expect(std.meta.eql(before_primary.cursor, primary.cursor)); + try testing.expectEqualSlices( + u8, + before_primary_page, + primary.pages.pages.first.?.page().memory, + ); + + try testing.expectEqual(before_alternate.pages.cols, alternate.pages.cols); + try testing.expectEqual(before_alternate.pages.rows, alternate.pages.rows); + try testing.expectEqual( + before_alternate.pages.total_rows, + alternate.pages.total_rows, + ); + try testing.expect(std.meta.eql(before_alternate.cursor, alternate.cursor)); + try testing.expectEqualSlices( + u8, + before_alternate_page, + alternate.pages.pages.first.?.page().memory, + ); + } +} + +test "Terminal: alternate resize failure replaces active alternate screen" { + const alloc = testing.allocator; + const tw = resize_tw; + defer tw.end(.reset) catch unreachable; + + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + _ = try t.switchScreen(.alternate); + try testing.expectEqual(.alternate, t.screens.active_key); + t.screens.active.charset.gl = .G1; + try t.printString("alternate"); + const generation = t.screens.generation(.alternate); + + tw.errorAlways(.alternate_screen, error.OutOfMemory); + try t.resize(alloc, .{ .cols = 20, .rows = 4 }); + + const alternate = t.screens.get(.alternate).?; + try testing.expectEqual(.alternate, t.screens.active_key); + try testing.expectEqual(alternate, t.screens.active); + try testing.expectEqual(@as(size.CellCountInt, 20), alternate.pages.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), alternate.pages.rows); + try testing.expect(alternate.pages.getCell(.{ .active = .{} }).?.cell.isEmpty()); + try testing.expectEqual(.G1, alternate.charset.gl); + try testing.expectEqual(generation +% 1, t.screens.generation(.alternate)); +} + +test "Terminal: alternate resize replacement failure falls back to primary" { + const alloc = testing.allocator; + const tw = resize_tw; + defer tw.end(.reset) catch unreachable; + + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + _ = try t.switchScreen(.alternate); + tw.errorAlways(.alternate_screen, error.OutOfMemory); + tw.errorAlways(.alternate_screen_init, error.OutOfMemory); + try t.resize(alloc, .{ .cols = 20, .rows = 4 }); + + const primary = t.screens.get(.primary).?; + try testing.expectEqual(@as(?*Screen, null), t.screens.get(.alternate)); + try testing.expectEqual(.primary, t.screens.active_key); + try testing.expectEqual(primary, t.screens.active); + try testing.expectEqual(@as(size.CellCountInt, 20), primary.pages.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), primary.pages.rows); +} + /// Set the pwd for the terminal. pub fn setPwd(self: *Terminal, pwd: []const u8) !void { if (pwd.len == 0) { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index d7baf528b..e0aeb61d7 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -1015,7 +1015,7 @@ test "resize suppresses mode 2048 reports" { try testing.expectEqual(@as(usize, 0), S.calls); } -test "resize failure resets synchronized output but does not write" { +test "resize failure preserves terminal state and does not write" { var failing = testing.FailingAllocator.init(testing.allocator, .{}); const alloc = failing.allocator(); var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 1 }); @@ -1044,9 +1044,11 @@ test "resize failure resets synchronized output but does not write" { .cell_size_px = .{ .width = 9, .height = 18 }, })); - try testing.expect(!t.modes.get(.synchronized_output)); + try testing.expect(t.modes.get(.synchronized_output)); try testing.expect(!S.called); try testing.expectEqual(@as(@TypeOf(t.cols), 10), t.cols); + try testing.expectEqual(@as(u32, 0), t.width_px); + try testing.expectEqual(@as(u32, 0), t.height_px); } test "resize effects do not change canonical terminal state" {