mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 05:20:29 +00:00
Introduce Tripwire to test errdefer and fix some found issues (#10401)
This adds a new single-file library called "Tripwire" in `src/tripwire.zig`. This library helps inject failures around `try` cases for the purpose of testing `errdefer`. It is fully optimized away in non-test builds (even debug), turning into zero space and zero assembly. From this, I've verified (via unit tests w/ tripwire) and fixed a number of errdefer issues: * PageList init with non-standard pages that requires more than 1 page can leak on allocation error on the 2nd+ loop * Tabstop allocation failure on resize corrupts the internal state (invalid cols) * `Screen.selectionString` would leak memory on late allocation failures * Screen search could leak memory on late allocation failures * `SharedGrid.renderGlyph` in our font subsystem would corrupt the glyph cache if failure occurred * `SharedGrid.init` could leak memory if loading font metrics failed In addition to the bugs found, there is now tripwire coverage around more of our core and we should continue to add more. I've also added significantly more explicit error sets as I found them. **AI disclosure:** AI wrote some of the tests, but tripwire itself is all handwritten and everything was reviewed.
This commit is contained in:
@@ -20,6 +20,7 @@ const assert = @import("../quirks.zig").inlineAssert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const testing = std.testing;
|
||||
const fastmem = @import("../fastmem.zig");
|
||||
const tripwire = @import("../tripwire.zig");
|
||||
|
||||
const log = std.log.scoped(.atlas);
|
||||
|
||||
@@ -91,7 +92,15 @@ pub const Region = extern struct {
|
||||
/// TODO: figure out optimal prealloc based on real world usage
|
||||
const node_prealloc: usize = 64;
|
||||
|
||||
pub const init_tw = tripwire.module(enum {
|
||||
alloc_data,
|
||||
alloc_nodes,
|
||||
}, init);
|
||||
|
||||
pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas {
|
||||
const tw = init_tw;
|
||||
|
||||
try tw.check(.alloc_data);
|
||||
var result = Atlas{
|
||||
.data = try alloc.alloc(u8, size * size * format.depth()),
|
||||
.size = size,
|
||||
@@ -101,6 +110,7 @@ pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas {
|
||||
errdefer result.deinit(alloc);
|
||||
|
||||
// Prealloc some nodes.
|
||||
try tw.check(.alloc_nodes);
|
||||
result.nodes = try .initCapacity(alloc, node_prealloc);
|
||||
|
||||
// This sets up our initial state
|
||||
@@ -115,6 +125,10 @@ pub fn deinit(self: *Atlas, alloc: Allocator) void {
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
pub const reserve_tw = tripwire.module(enum {
|
||||
insert_node,
|
||||
}, reserve);
|
||||
|
||||
/// Reserve a region within the atlas with the given width and height.
|
||||
///
|
||||
/// May allocate to add a new rectangle into the internal list of rectangles.
|
||||
@@ -125,6 +139,8 @@ pub fn reserve(
|
||||
width: u32,
|
||||
height: u32,
|
||||
) (Allocator.Error || Error)!Region {
|
||||
const tw = reserve_tw;
|
||||
|
||||
// x, y are populated within :best_idx below
|
||||
var region: Region = .{ .x = 0, .y = 0, .width = width, .height = height };
|
||||
|
||||
@@ -162,11 +178,13 @@ pub fn reserve(
|
||||
};
|
||||
|
||||
// Insert our new node for this rectangle at the exact best index
|
||||
try tw.check(.insert_node);
|
||||
try self.nodes.insert(alloc, best_idx, .{
|
||||
.x = region.x,
|
||||
.y = region.y + height,
|
||||
.width = width,
|
||||
});
|
||||
errdefer comptime unreachable;
|
||||
|
||||
// Optimize our rectangles
|
||||
var i: usize = best_idx + 1;
|
||||
@@ -287,15 +305,24 @@ pub fn setFromLarger(
|
||||
_ = self.modified.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
pub const grow_tw = tripwire.module(enum {
|
||||
ensure_node_capacity,
|
||||
alloc_data,
|
||||
}, grow);
|
||||
|
||||
// Grow the texture to the new size, preserving all previously written data.
|
||||
pub fn grow(self: *Atlas, alloc: Allocator, size_new: u32) Allocator.Error!void {
|
||||
const tw = grow_tw;
|
||||
|
||||
assert(size_new >= self.size);
|
||||
if (size_new == self.size) return;
|
||||
|
||||
// We reserve space ahead of time for the new node, so that we
|
||||
// won't have to handle any errors after allocating our new data.
|
||||
try tw.check(.ensure_node_capacity);
|
||||
try self.nodes.ensureUnusedCapacity(alloc, 1);
|
||||
|
||||
try tw.check(.alloc_data);
|
||||
const data_new = try alloc.alloc(
|
||||
u8,
|
||||
size_new * size_new * self.format.depth(),
|
||||
@@ -355,7 +382,7 @@ pub fn clear(self: *Atlas) void {
|
||||
/// swapped because PPM expects RGB. This would be
|
||||
/// easy enough to fix so next time someone needs
|
||||
/// to debug a color atlas they should fix it.
|
||||
pub fn dump(self: Atlas, writer: *std.Io.Writer) !void {
|
||||
pub fn dump(self: Atlas, writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
try writer.print(
|
||||
\\P{c}
|
||||
\\{d} {d}
|
||||
@@ -795,3 +822,68 @@ test "grow OOM" {
|
||||
try testing.expectEqual(@as(u8, 3), atlas.data[9]);
|
||||
try testing.expectEqual(@as(u8, 4), atlas.data[10]);
|
||||
}
|
||||
|
||||
test "init error" {
|
||||
// Test every failure point in `init` and ensure that we don't
|
||||
// leak memory (testing.allocator verifies) since we're exiting early.
|
||||
for (std.meta.tags(init_tw.FailPoint)) |tag| {
|
||||
const tw = init_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
try tw.errorAlways(tag, error.OutOfMemory);
|
||||
try testing.expectError(
|
||||
error.OutOfMemory,
|
||||
init(testing.allocator, 32, .grayscale),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "reserve error" {
|
||||
// Test every failure point in `reserve` and ensure that we don't
|
||||
// leak memory (testing.allocator verifies) since we're exiting early.
|
||||
for (std.meta.tags(reserve_tw.FailPoint)) |tag| {
|
||||
const tw = reserve_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
|
||||
var atlas = try init(testing.allocator, 32, .grayscale);
|
||||
defer atlas.deinit(testing.allocator);
|
||||
|
||||
try tw.errorAlways(tag, error.OutOfMemory);
|
||||
try testing.expectError(
|
||||
error.OutOfMemory,
|
||||
atlas.reserve(testing.allocator, 2, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "grow error" {
|
||||
// Test every failure point in `grow` and ensure that we don't
|
||||
// leak memory (testing.allocator verifies) since we're exiting early.
|
||||
for (std.meta.tags(grow_tw.FailPoint)) |tag| {
|
||||
const tw = grow_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
|
||||
var atlas = try init(testing.allocator, 4, .grayscale);
|
||||
defer atlas.deinit(testing.allocator);
|
||||
|
||||
// Write some data to verify it's preserved after failed grow
|
||||
const reg = try atlas.reserve(testing.allocator, 2, 2);
|
||||
atlas.set(reg, &[_]u8{ 1, 2, 3, 4 });
|
||||
|
||||
const old_modified = atlas.modified.load(.monotonic);
|
||||
const old_resized = atlas.resized.load(.monotonic);
|
||||
|
||||
try tw.errorAlways(tag, error.OutOfMemory);
|
||||
try testing.expectError(
|
||||
error.OutOfMemory,
|
||||
atlas.grow(testing.allocator, atlas.size + 1),
|
||||
);
|
||||
|
||||
// Verify atlas state is unchanged after failed grow
|
||||
try testing.expectEqual(old_modified, atlas.modified.load(.monotonic));
|
||||
try testing.expectEqual(old_resized, atlas.resized.load(.monotonic));
|
||||
try testing.expectEqual(@as(u8, 1), atlas.data[5]);
|
||||
try testing.expectEqual(@as(u8, 2), atlas.data[6]);
|
||||
try testing.expectEqual(@as(u8, 3), atlas.data[9]);
|
||||
try testing.expectEqual(@as(u8, 4), atlas.data[10]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const SharedGrid = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
const tripwire = @import("../tripwire.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const renderer = @import("../renderer.zig");
|
||||
const font = @import("main.zig");
|
||||
@@ -61,6 +62,12 @@ metrics: Metrics,
|
||||
/// to review call sites to ensure they are using the lock correctly.
|
||||
lock: std.Thread.RwLock,
|
||||
|
||||
pub const init_tw = tripwire.module(enum {
|
||||
codepoints_capacity,
|
||||
glyphs_capacity,
|
||||
reload_metrics,
|
||||
}, init);
|
||||
|
||||
/// Initialize the grid.
|
||||
///
|
||||
/// The resolver must have a collection that supports deferred loading
|
||||
@@ -74,6 +81,8 @@ pub fn init(
|
||||
alloc: Allocator,
|
||||
resolver: CodepointResolver,
|
||||
) !SharedGrid {
|
||||
const tw = init_tw;
|
||||
|
||||
// We need to support loading options since we use the size data
|
||||
assert(resolver.collection.load_options != null);
|
||||
|
||||
@@ -92,10 +101,15 @@ pub fn init(
|
||||
|
||||
// We set an initial capacity that can fit a good number of characters.
|
||||
// This number was picked empirically based on my own terminal usage.
|
||||
try tw.check(.codepoints_capacity);
|
||||
try result.codepoints.ensureTotalCapacity(alloc, 128);
|
||||
errdefer result.codepoints.deinit(alloc);
|
||||
try tw.check(.glyphs_capacity);
|
||||
try result.glyphs.ensureTotalCapacity(alloc, 128);
|
||||
errdefer result.glyphs.deinit(alloc);
|
||||
|
||||
// Initialize our metrics.
|
||||
try tw.check(.reload_metrics);
|
||||
try result.reloadMetrics();
|
||||
|
||||
return result;
|
||||
@@ -232,6 +246,10 @@ pub fn renderCodepoint(
|
||||
return try self.renderGlyph(alloc, index, glyph_index, opts);
|
||||
}
|
||||
|
||||
pub const renderGlyph_tw = tripwire.module(enum {
|
||||
get_presentation,
|
||||
}, renderGlyph);
|
||||
|
||||
/// Render a glyph index. This automatically determines the correct texture
|
||||
/// atlas to use and caches the result.
|
||||
pub fn renderGlyph(
|
||||
@@ -241,6 +259,8 @@ pub fn renderGlyph(
|
||||
glyph_index: u32,
|
||||
opts: RenderOptions,
|
||||
) !Render {
|
||||
const tw = renderGlyph_tw;
|
||||
|
||||
const key: GlyphKey = .{ .index = index, .glyph = glyph_index, .opts = opts };
|
||||
|
||||
// Fast path: the cache has the value. This is almost always true and
|
||||
@@ -257,8 +277,10 @@ pub fn renderGlyph(
|
||||
|
||||
const gop = try self.glyphs.getOrPut(alloc, key);
|
||||
if (gop.found_existing) return gop.value_ptr.*;
|
||||
errdefer self.glyphs.removeByPtr(gop.key_ptr);
|
||||
|
||||
// Get the presentation to determine what atlas to use
|
||||
try tw.check(.get_presentation);
|
||||
const p = try self.resolver.getPresentation(index, glyph_index);
|
||||
const atlas: *font.Atlas = switch (p) {
|
||||
.text => &self.atlas_grayscale,
|
||||
@@ -426,3 +448,93 @@ test getIndex {
|
||||
try testing.expectEqual(@as(Collection.Index.IndexInt, 0), idx.idx);
|
||||
}
|
||||
}
|
||||
|
||||
test "renderGlyph error after cache insert rolls back cache entry" {
|
||||
// This test verifies that when renderGlyph fails after inserting a cache
|
||||
// entry (via getOrPut), the errdefer properly removes the entry, preventing
|
||||
// corrupted/uninitialized data from remaining in the cache.
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var lib = try Library.init(alloc);
|
||||
defer lib.deinit();
|
||||
|
||||
var grid = try testGrid(.normal, alloc, lib);
|
||||
defer grid.deinit(alloc);
|
||||
|
||||
// Get the font index for 'A'
|
||||
const idx = (try grid.getIndex(alloc, 'A', .regular, null)).?;
|
||||
|
||||
// Get the glyph index for 'A'
|
||||
const glyph_index = glyph_index: {
|
||||
grid.lock.lockShared();
|
||||
defer grid.lock.unlockShared();
|
||||
const face = try grid.resolver.collection.getFace(idx);
|
||||
break :glyph_index face.glyphIndex('A').?;
|
||||
};
|
||||
|
||||
const render_opts: RenderOptions = .{ .grid_metrics = grid.metrics };
|
||||
const key: GlyphKey = .{ .index = idx, .glyph = glyph_index, .opts = render_opts };
|
||||
|
||||
// Verify the cache is empty for this glyph
|
||||
try testing.expect(grid.glyphs.get(key) == null);
|
||||
|
||||
// Set up tripwire to fail after cache insert.
|
||||
// We use OutOfMemory as it's a valid error in the renderGlyph error set.
|
||||
const tw = renderGlyph_tw;
|
||||
defer tw.end(.reset) catch {};
|
||||
try tw.errorAlways(.get_presentation, error.OutOfMemory);
|
||||
|
||||
// This should fail due to the tripwire
|
||||
try testing.expectError(
|
||||
error.OutOfMemory,
|
||||
grid.renderGlyph(alloc, idx, glyph_index, render_opts),
|
||||
);
|
||||
|
||||
// The errdefer should have removed the cache entry, leaving the cache clean.
|
||||
// Without the errdefer fix, this would contain garbage/uninitialized data.
|
||||
try testing.expect(grid.glyphs.get(key) == null);
|
||||
}
|
||||
|
||||
test "init error" {
|
||||
// Test every failure point in `init` and ensure that we don't
|
||||
// leak memory (testing.allocator verifies) since we're exiting early.
|
||||
//
|
||||
// BUG: Currently this test will fail because init() is missing errdefer
|
||||
// cleanup for codepoints and glyphs when late operations fail
|
||||
// (ensureTotalCapacity, reloadMetrics).
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
for (std.meta.tags(init_tw.FailPoint)) |tag| {
|
||||
const tw = init_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
try tw.errorAlways(tag, error.OutOfMemory);
|
||||
|
||||
// Create a resolver for testing - we need to set up a minimal one.
|
||||
// The caller is responsible for cleaning up the resolver if init fails.
|
||||
var lib = try Library.init(alloc);
|
||||
defer lib.deinit();
|
||||
|
||||
var c = Collection.init();
|
||||
c.load_options = .{ .library = lib };
|
||||
_ = try c.add(alloc, try .init(
|
||||
lib,
|
||||
font.embedded.regular,
|
||||
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
|
||||
), .{
|
||||
.style = .regular,
|
||||
.fallback = false,
|
||||
.size_adjustment = .none,
|
||||
});
|
||||
|
||||
var resolver: CodepointResolver = .{ .collection = c };
|
||||
defer resolver.deinit(alloc); // Caller cleans up on init failure
|
||||
|
||||
try testing.expectError(
|
||||
error.OutOfMemory,
|
||||
init(alloc, resolver),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ test {
|
||||
_ = @import("surface_mouse.zig");
|
||||
|
||||
// Libraries
|
||||
_ = @import("tripwire.zig");
|
||||
_ = @import("benchmark/main.zig");
|
||||
_ = @import("crash/main.zig");
|
||||
_ = @import("datastruct/main.zig");
|
||||
|
||||
@@ -9,6 +9,7 @@ const build_options = @import("terminal_options");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
const fastmem = @import("../fastmem.zig");
|
||||
const tripwire = @import("../tripwire.zig");
|
||||
const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList;
|
||||
const color = @import("color.zig");
|
||||
const kitty = @import("kitty.zig");
|
||||
@@ -84,7 +85,7 @@ pub const MemoryPool = struct {
|
||||
gen_alloc: Allocator,
|
||||
page_alloc: Allocator,
|
||||
preheat: usize,
|
||||
) !MemoryPool {
|
||||
) Allocator.Error!MemoryPool {
|
||||
var node_pool = try NodePool.initPreheated(gen_alloc, preheat);
|
||||
errdefer node_pool.deinit();
|
||||
var page_pool = try PagePool.initPreheated(page_alloc, preheat);
|
||||
@@ -330,6 +331,13 @@ inline fn pageAllocator() Allocator {
|
||||
return mach.taggedPageAllocator(.application_specific_1);
|
||||
}
|
||||
|
||||
const init_tw = tripwire.module(enum {
|
||||
init_memory_pool,
|
||||
init_pages,
|
||||
viewport_pin,
|
||||
viewport_pin_track,
|
||||
}, init);
|
||||
|
||||
/// Initialize the page. The top of the first page in the list is always the
|
||||
/// top of the active area of the screen (important knowledge for quickly
|
||||
/// setting up cursors in Screen).
|
||||
@@ -351,16 +359,21 @@ pub fn init(
|
||||
cols: size.CellCountInt,
|
||||
rows: size.CellCountInt,
|
||||
max_size: ?usize,
|
||||
) !PageList {
|
||||
) Allocator.Error!PageList {
|
||||
const tw = init_tw;
|
||||
|
||||
// The screen starts with a single page that is the entire viewport,
|
||||
// and we'll split it thereafter if it gets too large and add more as
|
||||
// necessary.
|
||||
try tw.check(.init_memory_pool);
|
||||
var pool = try MemoryPool.init(
|
||||
alloc,
|
||||
pageAllocator(),
|
||||
page_preheat,
|
||||
);
|
||||
errdefer pool.deinit();
|
||||
|
||||
try tw.check(.init_pages);
|
||||
var page_serial: u64 = 0;
|
||||
const page_list, const page_size = try initPages(
|
||||
&pool,
|
||||
@@ -373,12 +386,16 @@ pub fn init(
|
||||
const min_max_size = minMaxSize(cols, rows);
|
||||
|
||||
// We always track our viewport pin to ensure this is never an allocation
|
||||
try tw.check(.viewport_pin);
|
||||
const viewport_pin = try pool.pins.create();
|
||||
viewport_pin.* = .{ .node = page_list.first.? };
|
||||
var tracked_pins: PinSet = .{};
|
||||
errdefer tracked_pins.deinit(pool.alloc);
|
||||
|
||||
try tw.check(.viewport_pin_track);
|
||||
try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {});
|
||||
|
||||
errdefer comptime unreachable;
|
||||
const result: PageList = .{
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
@@ -399,12 +416,20 @@ pub fn init(
|
||||
return result;
|
||||
}
|
||||
|
||||
const initPages_tw = tripwire.module(enum {
|
||||
page_node,
|
||||
page_buf_std,
|
||||
page_buf_non_std,
|
||||
}, initPages);
|
||||
|
||||
fn initPages(
|
||||
pool: *MemoryPool,
|
||||
serial: *u64,
|
||||
cols: size.CellCountInt,
|
||||
rows: size.CellCountInt,
|
||||
) Allocator.Error!struct { List, usize } {
|
||||
const tw = initPages_tw;
|
||||
|
||||
var page_list: List = .{};
|
||||
var page_size: usize = 0;
|
||||
|
||||
@@ -418,17 +443,34 @@ fn initPages(
|
||||
// redundant here for safety.
|
||||
assert(layout.total_size <= size.max_page_size);
|
||||
|
||||
// If we have an error, we need to clean up our non-standard pages
|
||||
// since they're not in the pool.
|
||||
errdefer {
|
||||
var it = page_list.first;
|
||||
while (it) |node| : (it = node.next) {
|
||||
if (node.data.memory.len > std_size) {
|
||||
page_alloc.free(node.data.memory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var rem = rows;
|
||||
while (rem > 0) {
|
||||
try tw.check(.page_node);
|
||||
const node = try pool.nodes.create();
|
||||
const page_buf = if (pooled)
|
||||
try pool.pages.create()
|
||||
else
|
||||
try page_alloc.alignedAlloc(
|
||||
errdefer pool.nodes.destroy(node);
|
||||
|
||||
const page_buf = if (pooled) buf: {
|
||||
try tw.check(.page_buf_std);
|
||||
break :buf try pool.pages.create();
|
||||
} else buf: {
|
||||
try tw.check(.page_buf_non_std);
|
||||
break :buf try page_alloc.alignedAlloc(
|
||||
u8,
|
||||
.fromByteUnits(std.heap.page_size_min),
|
||||
layout.total_size,
|
||||
);
|
||||
};
|
||||
errdefer if (pooled)
|
||||
pool.pages.destroy(page_buf)
|
||||
else
|
||||
@@ -451,6 +493,7 @@ fn initPages(
|
||||
// Add the page to the list
|
||||
page_list.append(node);
|
||||
page_size += page_buf.len;
|
||||
errdefer comptime unreachable;
|
||||
|
||||
// Increment our serial
|
||||
serial.* += 1;
|
||||
@@ -738,7 +781,11 @@ pub fn clone(
|
||||
alloc: Allocator,
|
||||
opts: Clone,
|
||||
) !PageList {
|
||||
var it = self.pageIterator(.right_down, opts.top, opts.bot);
|
||||
var it = self.pageIterator(
|
||||
.right_down,
|
||||
opts.top,
|
||||
opts.bot,
|
||||
);
|
||||
|
||||
// First, count our pages so our preheat is exactly what we need.
|
||||
var it_copy = it;
|
||||
@@ -2655,7 +2702,7 @@ fn scrollPrompt(self: *PageList, delta: isize) void {
|
||||
|
||||
/// Clear the screen by scrolling written contents up into the scrollback.
|
||||
/// This will not update the viewport.
|
||||
pub fn scrollClear(self: *PageList) !void {
|
||||
pub fn scrollClear(self: *PageList) Allocator.Error!void {
|
||||
defer self.assertIntegrity();
|
||||
|
||||
// Go through the active area backwards to find the first non-empty
|
||||
@@ -3967,7 +4014,10 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell {
|
||||
/// 1 | etc.| | 4
|
||||
/// +-----+ :
|
||||
/// +--------+
|
||||
pub fn diagram(self: *const PageList, writer: *std.Io.Writer) !void {
|
||||
pub fn diagram(
|
||||
self: *const PageList,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
const active_pin = self.getTopLeft(.active);
|
||||
|
||||
var active = false;
|
||||
@@ -4604,7 +4654,7 @@ pub fn totalPages(self: *const PageList) usize {
|
||||
|
||||
/// Grow the number of rows available in the page list by n.
|
||||
/// This is only used for testing so it isn't optimized in any way.
|
||||
fn growRows(self: *PageList, n: usize) !void {
|
||||
fn growRows(self: *PageList, n: usize) Allocator.Error!void {
|
||||
for (0..n) |_| _ = try self.grow();
|
||||
}
|
||||
|
||||
@@ -5114,6 +5164,62 @@ test "PageList" {
|
||||
}, s.scrollbar());
|
||||
}
|
||||
|
||||
test "PageList init error" {
|
||||
// Test every failure point in `init` and ensure that we don't
|
||||
// leak memory (testing.allocator verifies) since we're exiting early.
|
||||
for (std.meta.tags(init_tw.FailPoint)) |tag| {
|
||||
const tw = init_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
try tw.errorAlways(tag, error.OutOfMemory);
|
||||
try std.testing.expectError(
|
||||
error.OutOfMemory,
|
||||
init(
|
||||
std.testing.allocator,
|
||||
80,
|
||||
24,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// init calls initPages transitively, so let's check that if
|
||||
// any failures happen in initPages, we also don't leak memory.
|
||||
for (std.meta.tags(initPages_tw.FailPoint)) |tag| {
|
||||
const tw = initPages_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
try tw.errorAlways(tag, error.OutOfMemory);
|
||||
|
||||
const cols: size.CellCountInt = if (tag == .page_buf_std) 80 else std_capacity.maxCols().? + 1;
|
||||
try std.testing.expectError(
|
||||
error.OutOfMemory,
|
||||
init(
|
||||
std.testing.allocator,
|
||||
cols,
|
||||
24,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Try non-standard pages since they don't go in our pool.
|
||||
for ([_]initPages_tw.FailPoint{
|
||||
.page_buf_non_std,
|
||||
}) |tag| {
|
||||
const tw = initPages_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
try tw.errorAfter(tag, error.OutOfMemory, 1);
|
||||
try std.testing.expectError(
|
||||
error.OutOfMemory,
|
||||
init(
|
||||
std.testing.allocator,
|
||||
std_capacity.maxCols().? + 1,
|
||||
std_capacity.rows + 1,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "PageList init rows across two pages" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -9,6 +9,7 @@ const charsets = @import("charsets.zig");
|
||||
const fastmem = @import("../fastmem.zig");
|
||||
const kitty = @import("kitty.zig");
|
||||
const sgr = @import("sgr.zig");
|
||||
const tripwire = @import("../tripwire.zig");
|
||||
const unicode = @import("../unicode/main.zig");
|
||||
const Selection = @import("Selection.zig");
|
||||
const PageList = @import("PageList.zig");
|
||||
@@ -241,7 +242,7 @@ pub const Options = struct {
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
) !Screen {
|
||||
) Allocator.Error!Screen {
|
||||
// Initialize our backing pages.
|
||||
var pages = try PageList.init(
|
||||
alloc,
|
||||
@@ -2324,7 +2325,7 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void {
|
||||
}
|
||||
|
||||
/// Set the selection to the given selection. If this is a tracked selection
|
||||
/// then the screen will take overnship of the selection. If this is untracked
|
||||
/// then the screen will take ownership of the selection. If this is untracked
|
||||
/// then the screen will convert it to tracked internally. This will automatically
|
||||
/// untrack the prior selection (if any).
|
||||
///
|
||||
@@ -2333,7 +2334,7 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void {
|
||||
/// This is always recommended over setting `selection` directly. Beyond
|
||||
/// managing memory for you, it also performs safety checks that the selection
|
||||
/// is always tracked.
|
||||
pub fn select(self: *Screen, sel_: ?Selection) !void {
|
||||
pub fn select(self: *Screen, sel_: ?Selection) Allocator.Error!void {
|
||||
const sel = sel_ orelse {
|
||||
self.clearSelection();
|
||||
return;
|
||||
@@ -2371,6 +2372,10 @@ pub const SelectionString = struct {
|
||||
map: ?*StringMap = null,
|
||||
};
|
||||
|
||||
const selectionString_tw = tripwire.module(enum {
|
||||
copy_map,
|
||||
}, selectionString);
|
||||
|
||||
/// Returns the raw text associated with a selection. This will unwrap
|
||||
/// soft-wrapped edges. The returned slice is owned by the caller and allocated
|
||||
/// using alloc, not the allocator associated with the screen (unless they match).
|
||||
@@ -2380,7 +2385,7 @@ pub fn selectionString(
|
||||
self: *Screen,
|
||||
alloc: Allocator,
|
||||
opts: SelectionString,
|
||||
) ![:0]const u8 {
|
||||
) Allocator.Error![:0]const u8 {
|
||||
// We'll use this as our buffer to build our string.
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
@@ -2404,19 +2409,23 @@ pub fn selectionString(
|
||||
.map = &pins,
|
||||
};
|
||||
|
||||
// Emit
|
||||
try formatter.format(&aw.writer);
|
||||
// Emit. Since this is an allocating writer, a failed write
|
||||
// just becomes an OOM.
|
||||
formatter.format(&aw.writer) catch return error.OutOfMemory;
|
||||
|
||||
// Build our final text and if we have a string map set that up.
|
||||
const text = try aw.toOwnedSliceSentinel(0);
|
||||
errdefer alloc.free(text);
|
||||
if (opts.map) |map| {
|
||||
const map_string = try alloc.dupeZ(u8, text);
|
||||
errdefer alloc.free(map_string);
|
||||
try selectionString_tw.check(.copy_map);
|
||||
const map_pins = try pins.toOwnedSlice(alloc);
|
||||
map.* = .{
|
||||
.string = try alloc.dupeZ(u8, text),
|
||||
.map = try pins.toOwnedSlice(alloc),
|
||||
.string = map_string,
|
||||
.map = map_pins,
|
||||
};
|
||||
}
|
||||
errdefer if (opts.map) |m| m.deinit(alloc);
|
||||
|
||||
return text;
|
||||
}
|
||||
@@ -9464,3 +9473,34 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" {
|
||||
s.cursor.page_pin.node != original_node;
|
||||
try testing.expect(page_was_split);
|
||||
}
|
||||
|
||||
test "selectionString map allocation failure cleanup" {
|
||||
// This test verifies that if toOwnedSlice fails when building
|
||||
// the StringMap, we don't leak the already-allocated map.string.
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var s = try Screen.init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 });
|
||||
defer s.deinit();
|
||||
|
||||
try s.testWriteString("hello");
|
||||
|
||||
// Get a selection
|
||||
const sel = Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?,
|
||||
false,
|
||||
);
|
||||
|
||||
// Trigger allocation failure on toOwnedSlice
|
||||
var map: StringMap = undefined;
|
||||
try selectionString_tw.errorAlways(.copy_map, error.OutOfMemory);
|
||||
const result = s.selectionString(alloc, .{
|
||||
.sel = sel,
|
||||
.map = &map,
|
||||
});
|
||||
try testing.expectError(error.OutOfMemory, result);
|
||||
try selectionString_tw.end(.reset);
|
||||
|
||||
// If this test passes without memory leaks (when run with testing.allocator),
|
||||
// it means the errdefer properly cleaned up map.string when toOwnedSlice failed.
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ all: std.EnumMap(Key, *Screen),
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
opts: Screen.Options,
|
||||
) !ScreenSet {
|
||||
) Allocator.Error!ScreenSet {
|
||||
// We need to initialize our initial primary screen
|
||||
const screen = try alloc.create(Screen);
|
||||
errdefer alloc.destroy(screen);
|
||||
@@ -64,7 +64,7 @@ pub fn getInit(
|
||||
alloc: Allocator,
|
||||
key: Key,
|
||||
opts: Screen.Options,
|
||||
) !*Screen {
|
||||
) Allocator.Error!*Screen {
|
||||
if (self.get(key)) |screen| return screen;
|
||||
const screen = try alloc.create(Screen);
|
||||
errdefer alloc.destroy(screen);
|
||||
|
||||
@@ -3,6 +3,7 @@ const Selection = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const page = @import("page.zig");
|
||||
const point = @import("point.zig");
|
||||
const PageList = @import("PageList.zig");
|
||||
@@ -126,7 +127,7 @@ pub fn tracked(self: *const Selection) bool {
|
||||
|
||||
/// Convert this selection a tracked selection. It is asserted this is
|
||||
/// an untracked selection. The tracked selection is returned.
|
||||
pub fn track(self: *const Selection, s: *Screen) !Selection {
|
||||
pub fn track(self: *const Selection, s: *Screen) Allocator.Error!Selection {
|
||||
assert(!self.tracked());
|
||||
|
||||
// Track our pins
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
const Tabstops = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const tripwire = @import("../tripwire.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const testing = std.testing;
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
@@ -58,7 +59,11 @@ inline fn index(col: usize) usize {
|
||||
return @mod(col, unit_bits);
|
||||
}
|
||||
|
||||
pub fn init(alloc: Allocator, cols: usize, interval: usize) !Tabstops {
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
cols: usize,
|
||||
interval: usize,
|
||||
) Allocator.Error!Tabstops {
|
||||
var res: Tabstops = .{};
|
||||
try res.resize(alloc, cols);
|
||||
res.reset(interval);
|
||||
@@ -114,21 +119,36 @@ pub fn get(self: Tabstops, col: usize) bool {
|
||||
return unit & mask == mask;
|
||||
}
|
||||
|
||||
const resize_tw = tripwire.module(enum {
|
||||
dynamic_alloc,
|
||||
}, resize);
|
||||
|
||||
/// Resize this to support up to cols columns.
|
||||
// TODO: needs interval to set new tabstops
|
||||
pub fn resize(self: *Tabstops, alloc: Allocator, cols: usize) !void {
|
||||
// Set our new value
|
||||
self.cols = cols;
|
||||
pub fn resize(
|
||||
self: *Tabstops,
|
||||
alloc: Allocator,
|
||||
cols: usize,
|
||||
) Allocator.Error!void {
|
||||
const tw = resize_tw;
|
||||
|
||||
// Do nothing if it fits.
|
||||
if (cols <= prealloc_columns) return;
|
||||
if (cols <= prealloc_columns) {
|
||||
self.cols = cols;
|
||||
return;
|
||||
}
|
||||
|
||||
// What we need in the dynamic size
|
||||
const size = cols - prealloc_columns;
|
||||
if (size < self.dynamic_stops.len) return;
|
||||
if (size < self.dynamic_stops.len) {
|
||||
self.cols = cols;
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: we can probably try to realloc here but I'm not sure it matters.
|
||||
try tw.check(.dynamic_alloc);
|
||||
const new = try alloc.alloc(Unit, size);
|
||||
errdefer comptime unreachable;
|
||||
@memset(new, 0);
|
||||
if (self.dynamic_stops.len > 0) {
|
||||
fastmem.copy(Unit, new, self.dynamic_stops);
|
||||
@@ -136,6 +156,7 @@ pub fn resize(self: *Tabstops, alloc: Allocator, cols: usize) !void {
|
||||
}
|
||||
|
||||
self.dynamic_stops = new;
|
||||
self.cols = cols;
|
||||
}
|
||||
|
||||
/// Return the maximum number of columns this can support currently.
|
||||
@@ -230,3 +251,21 @@ test "Tabstops: count on 80" {
|
||||
|
||||
try testing.expectEqual(@as(usize, 9), count);
|
||||
}
|
||||
|
||||
test "Tabstops: resize alloc failure preserves state" {
|
||||
// This test verifies that if resize() fails during allocation,
|
||||
// the original cols value is preserved (not corrupted).
|
||||
var t: Tabstops = try init(testing.allocator, 80, 8);
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const original_cols = t.cols;
|
||||
|
||||
// Trigger allocation failure when resizing beyond prealloc
|
||||
try resize_tw.errorAlways(.dynamic_alloc, error.OutOfMemory);
|
||||
const result = t.resize(testing.allocator, prealloc_columns * 2);
|
||||
try testing.expectError(error.OutOfMemory, result);
|
||||
try resize_tw.end(.reset);
|
||||
|
||||
// cols should be unchanged after failed resize
|
||||
try testing.expectEqual(original_cols, t.cols);
|
||||
}
|
||||
|
||||
@@ -1118,7 +1118,7 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool {
|
||||
|
||||
/// Horizontal tab moves the cursor to the next tabstop, clearing
|
||||
/// the screen to the left the tabstop.
|
||||
pub fn horizontalTab(self: *Terminal) !void {
|
||||
pub fn horizontalTab(self: *Terminal) void {
|
||||
while (self.screens.active.cursor.x < self.scrolling_region.right) {
|
||||
// Move the cursor right
|
||||
self.screens.active.cursorRight(1);
|
||||
@@ -1131,7 +1131,7 @@ pub fn horizontalTab(self: *Terminal) !void {
|
||||
}
|
||||
|
||||
// Same as horizontalTab but moves to the previous tabstop instead of the next.
|
||||
pub fn horizontalTabBack(self: *Terminal) !void {
|
||||
pub fn horizontalTabBack(self: *Terminal) void {
|
||||
// With origin mode enabled, our leftmost limit is the left margin.
|
||||
const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0;
|
||||
|
||||
@@ -4736,17 +4736,17 @@ test "Terminal: horizontal tabs" {
|
||||
|
||||
// HT
|
||||
try t.print('1');
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x);
|
||||
|
||||
// HT
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x);
|
||||
|
||||
// HT at the end
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x);
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x);
|
||||
}
|
||||
|
||||
@@ -4758,7 +4758,7 @@ test "Terminal: horizontal tabs starting on tabstop" {
|
||||
t.setCursorPos(t.screens.active.cursor.y, 9);
|
||||
try t.print('X');
|
||||
t.setCursorPos(t.screens.active.cursor.y, 9);
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
try t.print('A');
|
||||
|
||||
{
|
||||
@@ -4777,7 +4777,7 @@ test "Terminal: horizontal tabs with right margin" {
|
||||
t.scrolling_region.right = 5;
|
||||
t.setCursorPos(t.screens.active.cursor.y, 1);
|
||||
try t.print('X');
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
try t.print('A');
|
||||
|
||||
{
|
||||
@@ -4796,17 +4796,17 @@ test "Terminal: horizontal tabs back" {
|
||||
t.setCursorPos(t.screens.active.cursor.y, 20);
|
||||
|
||||
// HT
|
||||
try t.horizontalTabBack();
|
||||
t.horizontalTabBack();
|
||||
try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x);
|
||||
|
||||
// HT
|
||||
try t.horizontalTabBack();
|
||||
t.horizontalTabBack();
|
||||
try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x);
|
||||
|
||||
// HT
|
||||
try t.horizontalTabBack();
|
||||
t.horizontalTabBack();
|
||||
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
|
||||
try t.horizontalTabBack();
|
||||
t.horizontalTabBack();
|
||||
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
|
||||
}
|
||||
|
||||
@@ -4818,7 +4818,7 @@ test "Terminal: horizontal tabs back starting on tabstop" {
|
||||
t.setCursorPos(t.screens.active.cursor.y, 9);
|
||||
try t.print('X');
|
||||
t.setCursorPos(t.screens.active.cursor.y, 9);
|
||||
try t.horizontalTabBack();
|
||||
t.horizontalTabBack();
|
||||
try t.print('A');
|
||||
|
||||
{
|
||||
@@ -4838,7 +4838,7 @@ test "Terminal: horizontal tabs with left margin in origin mode" {
|
||||
t.scrolling_region.right = 5;
|
||||
t.setCursorPos(1, 2);
|
||||
try t.print('X');
|
||||
try t.horizontalTabBack();
|
||||
t.horizontalTabBack();
|
||||
try t.print('A');
|
||||
|
||||
{
|
||||
@@ -4858,7 +4858,7 @@ test "Terminal: horizontal tab back with cursor before left margin" {
|
||||
t.modes.set(.enable_left_and_right_margin, true);
|
||||
t.setLeftAndRightMargin(5, 0);
|
||||
t.restoreCursor();
|
||||
try t.horizontalTabBack();
|
||||
t.horizontalTabBack();
|
||||
try t.print('X');
|
||||
|
||||
{
|
||||
@@ -10593,11 +10593,11 @@ test "Terminal: tabClear single" {
|
||||
var t = try init(alloc, .{ .cols = 30, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
t.tabClear(.current);
|
||||
try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
|
||||
t.setCursorPos(1, 1);
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x);
|
||||
}
|
||||
|
||||
@@ -10609,7 +10609,7 @@ test "Terminal: tabClear all" {
|
||||
t.tabClear(.all);
|
||||
try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
|
||||
t.setCursorPos(1, 1);
|
||||
try t.horizontalTab();
|
||||
t.horizontalTab();
|
||||
try testing.expectEqual(@as(usize, 29), t.screens.active.cursor.x);
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ pub const Name = enum(u8) {
|
||||
}
|
||||
|
||||
/// Default colors for tagged values.
|
||||
pub fn default(self: Name) !RGB {
|
||||
pub fn default(self: Name) error{NoDefaultValue}!RGB {
|
||||
return switch (self) {
|
||||
.black => RGB{ .r = 0x1D, .g = 0x1F, .b = 0x21 },
|
||||
.red => RGB{ .r = 0xCC, .g = 0x66, .b = 0x66 },
|
||||
@@ -355,7 +355,7 @@ pub const RGB = packed struct(u24) {
|
||||
/// Parse a color from a floating point intensity value.
|
||||
///
|
||||
/// The value should be between 0.0 and 1.0, inclusive.
|
||||
fn fromIntensity(value: []const u8) !u8 {
|
||||
fn fromIntensity(value: []const u8) error{InvalidFormat}!u8 {
|
||||
const i = std.fmt.parseFloat(f64, value) catch {
|
||||
@branchHint(.cold);
|
||||
return error.InvalidFormat;
|
||||
@@ -372,7 +372,7 @@ pub const RGB = packed struct(u24) {
|
||||
///
|
||||
/// The string can contain 1, 2, 3, or 4 characters and represents the color
|
||||
/// value scaled in 4, 8, 12, or 16 bits, respectively.
|
||||
fn fromHex(value: []const u8) !u8 {
|
||||
fn fromHex(value: []const u8) error{InvalidFormat}!u8 {
|
||||
if (value.len == 0 or value.len > 4) {
|
||||
@branchHint(.cold);
|
||||
return error.InvalidFormat;
|
||||
@@ -414,7 +414,7 @@ pub const RGB = packed struct(u24) {
|
||||
/// where `r`, `g`, and `b` are a single hexadecimal digit.
|
||||
/// These specify a color with 4, 8, 12, and 16 bits of precision
|
||||
/// per color channel.
|
||||
pub fn parse(value: []const u8) !RGB {
|
||||
pub fn parse(value: []const u8) error{InvalidFormat}!RGB {
|
||||
if (value.len == 0) {
|
||||
@branchHint(.cold);
|
||||
return error.InvalidFormat;
|
||||
|
||||
@@ -44,16 +44,23 @@ pub const OSC = struct {
|
||||
return {};
|
||||
}
|
||||
|
||||
fn update(self: *OSC, key: u8, value: []const u8) !void {
|
||||
fn update(self: *OSC, key: u8, value: []const u8) error{
|
||||
UnknownKey,
|
||||
InvalidValue,
|
||||
}!void {
|
||||
// All values are numeric, so we can do a small hack here
|
||||
const v = try std.fmt.parseInt(u4, value, 10);
|
||||
const v = std.fmt.parseInt(
|
||||
u4,
|
||||
value,
|
||||
10,
|
||||
) catch return error.InvalidValue;
|
||||
|
||||
switch (key) {
|
||||
's' => {
|
||||
if (v == 0) return error.InvalidValue;
|
||||
self.scale = std.math.cast(u3, v) orelse return error.Overflow;
|
||||
self.scale = std.math.cast(u3, v) orelse return error.InvalidValue;
|
||||
},
|
||||
'w' => self.width = std.math.cast(u3, v) orelse return error.Overflow,
|
||||
'w' => self.width = std.math.cast(u3, v) orelse return error.InvalidValue,
|
||||
'n' => self.numerator = v,
|
||||
'd' => self.denominator = v,
|
||||
'v' => self.valign = std.enums.fromInt(VAlign, v) orelse return error.InvalidValue,
|
||||
@@ -130,7 +137,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
|
||||
cmd.update(k[0], value) catch |err| {
|
||||
switch (err) {
|
||||
error.UnknownKey => log.warn("unknown key: '{c}'", .{k[0]}),
|
||||
else => log.warn("invalid value for key '{c}': {}", .{ k[0], err }),
|
||||
error.InvalidValue => log.warn("invalid value for key '{c}': {}", .{ k[0], err }),
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const std = @import("std");
|
||||
const assert = @import("../../quirks.zig").inlineAssert;
|
||||
const testing = std.testing;
|
||||
const tripwire = @import("../../tripwire.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const point = @import("../point.zig");
|
||||
const highlight = @import("../highlight.zig");
|
||||
@@ -17,6 +18,11 @@ const SlidingWindow = @import("sliding_window.zig").SlidingWindow;
|
||||
|
||||
const log = std.log.scoped(.search_screen);
|
||||
|
||||
const reloadActive_tw = tripwire.module(enum {
|
||||
history_append_new,
|
||||
history_append_existing,
|
||||
}, ScreenSearch.reloadActive);
|
||||
|
||||
/// Searches for a needle within a Screen, handling active area updates,
|
||||
/// pages being pruned from the screen (e.g. scrollback limits), and more.
|
||||
///
|
||||
@@ -386,6 +392,8 @@ pub const ScreenSearch = struct {
|
||||
///
|
||||
/// The caller must hold the necessary locks to access the screen state.
|
||||
pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void {
|
||||
const tw = reloadActive_tw;
|
||||
|
||||
// If our selection pin became garbage it means we scrolled off
|
||||
// the end. Clear our selection and on exit of this function,
|
||||
// try to select the last match.
|
||||
@@ -485,12 +493,16 @@ pub const ScreenSearch = struct {
|
||||
alloc,
|
||||
self.history_results.items.len,
|
||||
);
|
||||
errdefer results.deinit(alloc);
|
||||
errdefer {
|
||||
for (results.items) |*hl| hl.deinit(alloc);
|
||||
results.deinit(alloc);
|
||||
}
|
||||
while (window.next()) |hl| {
|
||||
if (hl.chunks.items(.node)[0] == history_node) continue;
|
||||
|
||||
var hl_cloned = try hl.clone(alloc);
|
||||
errdefer hl_cloned.deinit(alloc);
|
||||
try tw.check(.history_append_new);
|
||||
try results.append(alloc, hl_cloned);
|
||||
}
|
||||
|
||||
@@ -505,6 +517,7 @@ pub const ScreenSearch = struct {
|
||||
// Matches! Reverse our list then append all the remaining
|
||||
// history items that didn't start on our original node.
|
||||
std.mem.reverse(FlattenedHighlight, results.items);
|
||||
try tw.check(.history_append_existing);
|
||||
try results.appendSlice(alloc, self.history_results.items);
|
||||
self.history_results.deinit(alloc);
|
||||
self.history_results = results;
|
||||
@@ -1408,3 +1421,96 @@ test "screen search no scrollback has no history" {
|
||||
defer alloc.free(matches);
|
||||
try testing.expectEqual(0, matches.len);
|
||||
}
|
||||
|
||||
test "reloadActive partial history cleanup on appendSlice error" {
|
||||
// This test verifies that when reloadActive fails at appendSlice (after
|
||||
// the loop), all FlattenedHighlight items are properly cleaned up.
|
||||
const alloc = testing.allocator;
|
||||
var t: Terminal = try .init(alloc, .{
|
||||
.cols = 10,
|
||||
.rows = 2,
|
||||
.max_scrollback = std.math.maxInt(usize),
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
const list: *PageList = &t.screens.active.pages;
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
// Write multiple "Fizz" matches that will end up in history.
|
||||
// We need enough content to push "Fizz" entries into scrollback.
|
||||
try s.nextSlice("Fizz\r\nFizz\r\n");
|
||||
while (list.totalPages() < 3) try s.nextSlice("\r\n");
|
||||
for (0..list.rows) |_| try s.nextSlice("\r\n");
|
||||
try s.nextSlice("Fizz.");
|
||||
|
||||
// Complete initial search
|
||||
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
|
||||
defer search.deinit();
|
||||
try search.searchAll();
|
||||
|
||||
// Now trigger reloadActive by adding more content that changes the
|
||||
// active/history boundary. First add more "Fizz" entries to history.
|
||||
try s.nextSlice("\r\nFizz\r\nFizz\r\n");
|
||||
while (list.totalPages() < 4) try s.nextSlice("\r\n");
|
||||
for (0..list.rows) |_| try s.nextSlice("\r\n");
|
||||
|
||||
// Arm the tripwire to fail at appendSlice (after the loop completes).
|
||||
// At this point, there are FlattenedHighlight items in the results list
|
||||
// that need cleanup.
|
||||
const tw = reloadActive_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
try tw.errorAlways(.history_append_existing, error.OutOfMemory);
|
||||
|
||||
// reloadActive is called by select(), which should trigger the error path.
|
||||
// If the bug exists, testing.allocator will report a memory leak
|
||||
// because FlattenedHighlight items weren't cleaned up.
|
||||
try testing.expectError(error.OutOfMemory, search.select(.next));
|
||||
}
|
||||
|
||||
test "reloadActive partial history cleanup on loop append error" {
|
||||
// This test verifies that when reloadActive fails inside the loop
|
||||
// (after some items have been appended), all FlattenedHighlight items
|
||||
// are properly cleaned up.
|
||||
const alloc = testing.allocator;
|
||||
var t: Terminal = try .init(alloc, .{
|
||||
.cols = 10,
|
||||
.rows = 2,
|
||||
.max_scrollback = std.math.maxInt(usize),
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
const list: *PageList = &t.screens.active.pages;
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
// Write multiple "Fizz" matches that will end up in history.
|
||||
// We need enough content to push "Fizz" entries into scrollback.
|
||||
try s.nextSlice("Fizz\r\nFizz\r\n");
|
||||
while (list.totalPages() < 3) try s.nextSlice("\r\n");
|
||||
for (0..list.rows) |_| try s.nextSlice("\r\n");
|
||||
try s.nextSlice("Fizz.");
|
||||
|
||||
// Complete initial search
|
||||
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
|
||||
defer search.deinit();
|
||||
try search.searchAll();
|
||||
|
||||
// Now trigger reloadActive by adding more content that changes the
|
||||
// active/history boundary. First add more "Fizz" entries to history.
|
||||
try s.nextSlice("\r\nFizz\r\nFizz\r\n");
|
||||
while (list.totalPages() < 4) try s.nextSlice("\r\n");
|
||||
for (0..list.rows) |_| try s.nextSlice("\r\n");
|
||||
|
||||
// Arm the tripwire to fail after the first loop append succeeds.
|
||||
// This leaves at least one FlattenedHighlight in the results list
|
||||
// that needs cleanup.
|
||||
const tw = reloadActive_tw;
|
||||
defer tw.end(.reset) catch unreachable;
|
||||
try tw.errorAfter(.history_append_new, error.OutOfMemory, 1);
|
||||
|
||||
// reloadActive is called by select(), which should trigger the error path.
|
||||
// If the bug exists, testing.allocator will report a memory leak
|
||||
// because FlattenedHighlight items weren't cleaned up.
|
||||
try testing.expectError(error.OutOfMemory, search.select(.next));
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ pub const Handler = struct {
|
||||
.delete_lines => self.terminal.deleteLines(value),
|
||||
.scroll_up => try self.terminal.scrollUp(value),
|
||||
.scroll_down => self.terminal.scrollDown(value),
|
||||
.horizontal_tab => try self.horizontalTab(value),
|
||||
.horizontal_tab_back => try self.horizontalTabBack(value),
|
||||
.horizontal_tab => self.horizontalTab(value),
|
||||
.horizontal_tab_back => self.horizontalTabBack(value),
|
||||
.tab_clear_current => self.terminal.tabClear(.current),
|
||||
.tab_clear_all => self.terminal.tabClear(.all),
|
||||
.tab_set => self.terminal.tabSet(),
|
||||
@@ -200,18 +200,18 @@ pub const Handler = struct {
|
||||
}
|
||||
}
|
||||
|
||||
inline fn horizontalTab(self: *Handler, count: u16) !void {
|
||||
inline fn horizontalTab(self: *Handler, count: u16) void {
|
||||
for (0..count) |_| {
|
||||
const x = self.terminal.screens.active.cursor.x;
|
||||
try self.terminal.horizontalTab();
|
||||
self.terminal.horizontalTab();
|
||||
if (x == self.terminal.screens.active.cursor.x) break;
|
||||
}
|
||||
}
|
||||
|
||||
inline fn horizontalTabBack(self: *Handler, count: u16) !void {
|
||||
inline fn horizontalTabBack(self: *Handler, count: u16) void {
|
||||
for (0..count) |_| {
|
||||
const x = self.terminal.screens.active.cursor.x;
|
||||
try self.terminal.horizontalTabBack();
|
||||
self.terminal.horizontalTabBack();
|
||||
if (x == self.terminal.screens.active.cursor.x) break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ const assert = @import("../quirks.zig").inlineAssert;
|
||||
const RGB = @import("color.zig").RGB;
|
||||
|
||||
/// The map of all available X11 colors.
|
||||
pub const map = colorMap() catch @compileError("failed to parse rgb.txt");
|
||||
pub const map = colorMap();
|
||||
|
||||
pub const ColorMap = std.StaticStringMapWithEql(RGB, std.static_string_map.eqlAsciiIgnoreCase);
|
||||
pub const ColorMap = std.StaticStringMapWithEql(
|
||||
RGB,
|
||||
std.static_string_map.eqlAsciiIgnoreCase,
|
||||
);
|
||||
|
||||
fn colorMap() !ColorMap {
|
||||
fn colorMap() ColorMap {
|
||||
@setEvalBranchQuota(500_000);
|
||||
|
||||
const KV = struct { []const u8, RGB };
|
||||
|
||||
@@ -198,8 +198,8 @@ pub const StreamHandler = struct {
|
||||
.print_repeat => try self.terminal.printRepeat(value),
|
||||
.bell => self.bell(),
|
||||
.backspace => self.terminal.backspace(),
|
||||
.horizontal_tab => try self.horizontalTab(value),
|
||||
.horizontal_tab_back => try self.horizontalTabBack(value),
|
||||
.horizontal_tab => self.horizontalTab(value),
|
||||
.horizontal_tab_back => self.horizontalTabBack(value),
|
||||
.linefeed => {
|
||||
@branchHint(.likely);
|
||||
try self.linefeed();
|
||||
@@ -560,18 +560,18 @@ pub const StreamHandler = struct {
|
||||
self.surfaceMessageWriter(.ring_bell);
|
||||
}
|
||||
|
||||
inline fn horizontalTab(self: *StreamHandler, count: u16) !void {
|
||||
inline fn horizontalTab(self: *StreamHandler, count: u16) void {
|
||||
for (0..count) |_| {
|
||||
const x = self.terminal.screens.active.cursor.x;
|
||||
try self.terminal.horizontalTab();
|
||||
self.terminal.horizontalTab();
|
||||
if (x == self.terminal.screens.active.cursor.x) break;
|
||||
}
|
||||
}
|
||||
|
||||
inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void {
|
||||
inline fn horizontalTabBack(self: *StreamHandler, count: u16) void {
|
||||
for (0..count) |_| {
|
||||
const x = self.terminal.screens.active.cursor.x;
|
||||
try self.terminal.horizontalTabBack();
|
||||
self.terminal.horizontalTabBack();
|
||||
if (x == self.terminal.screens.active.cursor.x) break;
|
||||
}
|
||||
}
|
||||
|
||||
309
src/tripwire.zig
Normal file
309
src/tripwire.zig
Normal file
@@ -0,0 +1,309 @@
|
||||
//! A library for injecting failures into Zig code for the express
|
||||
//! purpose of testing error handling paths.
|
||||
//!
|
||||
//! Improper `errdefer` is one of the highest sources of bugs in Zig code.
|
||||
//! Many `errdefer` points are hard to exercise in unit tests and rare
|
||||
//! to encounter in production, so they often hide bugs. Worse, error
|
||||
//! scenarios are most likely to put your code in an unexpected state
|
||||
//! that can result in future assertion failures or memory safety issues.
|
||||
//!
|
||||
//! This module aims to solve this problem by providing a way to inject
|
||||
//! errors at specific points in your code during unit tests, allowing you
|
||||
//! to test every possible error path.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! To use this package, create a `tripwire.module` for each failable
|
||||
//! function you want to test. The enum must be hand-curated to be the
|
||||
//! set of fail points, and the error set comes directly from the function
|
||||
//! itself.
|
||||
//!
|
||||
//! Pepper your function with `try tw.check` calls wherever you want to
|
||||
//! have a testable failure point. You don't need every "try" to have
|
||||
//! an associated tripwire check, only the ones you care about testing.
|
||||
//! Usually, this is going to be the points where you want to test
|
||||
//! errdefer logic above it.
|
||||
//!
|
||||
//! In unit tests, add `try tw.errorAlways` or related calls to
|
||||
//! configure expected failures. Then, call your function. Finally, always
|
||||
//! call `try tw.end(.reset)` to verify your expectations were met and
|
||||
//! to reset the tripwire module for future tests.
|
||||
//!
|
||||
//! ```
|
||||
//! const tw = tripwire.module(enum { alloc_buf, open_file }, myFunction);
|
||||
//!
|
||||
//! fn myFunction() tw.Error!void {
|
||||
//! try tw.check(.alloc_buf);
|
||||
//! const buf = try allocator.alloc(u8, 1024);
|
||||
//! errdefer allocator.free(buf);
|
||||
//!
|
||||
//! try tw.check(.open_file);
|
||||
//! const file = try std.fs.cwd().openFile("foo.txt", .{});
|
||||
//! // ...
|
||||
//! }
|
||||
//!
|
||||
//! test "myFunction fails on alloc" {
|
||||
//! try tw.errorAlways(.alloc_buf, error.OutOfMemory);
|
||||
//! try std.testing.expectError(error.OutOfMemory, myFunction());
|
||||
//! try tw.end(.reset);
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Transitive Function Calls
|
||||
//!
|
||||
//! To test transitive calls, there are two schools of thought:
|
||||
//!
|
||||
//! 1. Put a failure point above the transitive call in the caller
|
||||
//! and assume the child function error handling works correctly.
|
||||
//!
|
||||
//! 2. Create another tripwire module for the child function and
|
||||
//! trigger failures there. This is recommended if the child function
|
||||
//! can't really be called in isolation (e.g. its an auxiliary function
|
||||
//! to a public API).
|
||||
//!
|
||||
//! Either works, its situationally dependent which is better.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = std.log.scoped(.tripwire);
|
||||
|
||||
// Future ideas:
|
||||
//
|
||||
// - Assert that the errors are actually tripped. e.g. you set a
|
||||
// errorAlways on a point, and want to verify it was tripped.
|
||||
// - Assert that every point is covered by at least one test. We
|
||||
// can probably use global state for this.
|
||||
// - Error only after a certain number of calls to a point.
|
||||
// - Error only on a range of calls (first 5 times, 2-7th time, etc.)
|
||||
//
|
||||
// I don't want to implement these until they're actually needed by
|
||||
// some part of our codebase, but I want to list them here in case they
|
||||
// do become useful.
|
||||
|
||||
/// A tripwire module that can be used to inject failures at specific points.
|
||||
///
|
||||
/// Outside of unit tests, this module is free and completely optimized away.
|
||||
/// It takes up zero binary or runtime space and all function calls are
|
||||
/// optimized out.
|
||||
///
|
||||
/// To use this module, add `check` (or related) calls prior to every
|
||||
/// `try` operation that you want to be able to fail arbitrarily. Then,
|
||||
/// in your unit tests, call the `error` family of functions to configure
|
||||
/// when errors should be injected.
|
||||
///
|
||||
/// P is an enum type representing the failure points in the module.
|
||||
/// E is the error set of possible errors that can be returned from the
|
||||
/// failure points. You can use `anyerror` here but note you may have to
|
||||
/// use `checkConstrained` to narrow down the error type when you call
|
||||
/// it in your function (so your function can compile).
|
||||
///
|
||||
/// E may also be an error union type, in which case the error set of that
|
||||
/// union is used as the error set for the tripwire module.
|
||||
/// If E is a function, then the error set of the return value of that
|
||||
/// function is used as the error set for the tripwire module.
|
||||
pub fn module(
|
||||
comptime P: type,
|
||||
comptime E: anytype,
|
||||
) type {
|
||||
return struct {
|
||||
/// The points this module can fail at.
|
||||
pub const FailPoint = P;
|
||||
|
||||
/// The error set used for failures at the failure points.
|
||||
pub const Error = err: {
|
||||
const T = if (@TypeOf(E) == type) E else @TypeOf(E);
|
||||
break :err switch (@typeInfo(T)) {
|
||||
.error_set => E,
|
||||
.error_union => |info| info.error_set,
|
||||
.@"fn" => |info| @typeInfo(info.return_type.?).error_union.error_set,
|
||||
else => @compileError("E must be an error set or function type"),
|
||||
};
|
||||
};
|
||||
|
||||
/// Whether our module is enabled or not. In the future we may
|
||||
/// want to make this a comptime parameter to the module.
|
||||
pub const enabled = builtin.is_test;
|
||||
|
||||
comptime {
|
||||
assert(@typeInfo(FailPoint) == .@"enum");
|
||||
assert(@typeInfo(Error) == .error_set);
|
||||
}
|
||||
|
||||
/// The configured tripwires for this module.
|
||||
var tripwires: TripwireMap = .empty;
|
||||
const TripwireMap = std.AutoArrayHashMapUnmanaged(FailPoint, Tripwire);
|
||||
const Tripwire = struct {
|
||||
/// Error to return when tripped
|
||||
err: Error,
|
||||
|
||||
/// The amount of times this tripwire has been reached. This
|
||||
/// is NOT the number of times it has tripped, since we may
|
||||
/// have mins for that.
|
||||
reached: usize = 0,
|
||||
|
||||
/// The minimum number of times this must be reached before
|
||||
/// tripping. After this point, it trips every time. This is
|
||||
/// a "before" check so if this is "1" then it'll trip the
|
||||
/// second time it's reached.
|
||||
min: usize = 0,
|
||||
|
||||
/// True if this has been tripped at least once.
|
||||
tripped: bool = false,
|
||||
};
|
||||
|
||||
/// For all allocations we use an allocator that can leak memory
|
||||
/// without reporting it, since this is only used in tests. We don't
|
||||
/// want to use a testing allocator here because that would report
|
||||
/// leaks. Users are welcome to call `deinit` on the module to
|
||||
/// free all memory.
|
||||
const LeakyAllocator = std.heap.DebugAllocator(.{});
|
||||
var alloc_state: LeakyAllocator = .init;
|
||||
|
||||
/// Check for a failure at the given failure point. These should
|
||||
/// be placed directly before the `try` operation that may fail.
|
||||
pub fn check(point: FailPoint) callconv(callingConvention()) Error!void {
|
||||
if (comptime !enabled) return;
|
||||
return checkConstrained(point, Error);
|
||||
}
|
||||
|
||||
/// Same as check but allows specifying a custom error type for the
|
||||
/// return value. This must be a subset of the module's Error type
|
||||
/// and will produce a runtime error if the configured tripwire
|
||||
/// error can't be cast to the ConstrainedError type.
|
||||
pub fn checkConstrained(
|
||||
point: FailPoint,
|
||||
comptime ConstrainedError: type,
|
||||
) callconv(callingConvention()) ConstrainedError!void {
|
||||
if (comptime !enabled) return;
|
||||
const tripwire = tripwires.getPtr(point) orelse return;
|
||||
tripwire.reached += 1;
|
||||
if (tripwire.reached <= tripwire.min) return;
|
||||
tripwire.tripped = true;
|
||||
return tripwire.err;
|
||||
}
|
||||
|
||||
/// Mark a failure point to always trip with the given error.
|
||||
pub fn errorAlways(
|
||||
point: FailPoint,
|
||||
err: Error,
|
||||
) Allocator.Error!void {
|
||||
try errorAfter(point, err, 0);
|
||||
}
|
||||
|
||||
/// Mark a failure point to trip with the given error after
|
||||
/// the failure point is reached at least `min` times. A value of
|
||||
/// zero is equivalent to `errorAlways`.
|
||||
pub fn errorAfter(
|
||||
point: FailPoint,
|
||||
err: Error,
|
||||
min: usize,
|
||||
) Allocator.Error!void {
|
||||
try tripwires.put(
|
||||
alloc_state.allocator(),
|
||||
point,
|
||||
.{ .err = err, .min = min },
|
||||
);
|
||||
}
|
||||
|
||||
/// Ends the tripwire session. This will raise an error if there
|
||||
/// were untripped error expectations. The reset mode specifies
|
||||
/// whether memory is reset too. Memory is always reset, even if
|
||||
/// this returns an error.
|
||||
pub fn end(reset_mode: enum { reset, retain }) error{UntrippedError}!void {
|
||||
var untripped: bool = false;
|
||||
for (tripwires.keys(), tripwires.values()) |key, entry| {
|
||||
if (!entry.tripped) {
|
||||
log.warn("untripped point={t}", .{key});
|
||||
untripped = true;
|
||||
}
|
||||
}
|
||||
|
||||
// We always reset memory before failing
|
||||
switch (reset_mode) {
|
||||
.reset => reset(),
|
||||
.retain => {},
|
||||
}
|
||||
|
||||
if (untripped) return error.UntrippedError;
|
||||
}
|
||||
|
||||
/// Unset all the tripwires and free all allocated memory. You
|
||||
/// should usually call `end` instead.
|
||||
pub fn reset() void {
|
||||
tripwires.clearAndFree(alloc_state.allocator());
|
||||
}
|
||||
|
||||
/// Our calling convention is inline if our tripwire module is
|
||||
/// NOT enabled, so that all calls to `check` are optimized away.
|
||||
fn callingConvention() std.builtin.CallingConvention {
|
||||
return if (!enabled) .@"inline" else .auto;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test {
|
||||
const io = module(enum {
|
||||
read,
|
||||
write,
|
||||
}, anyerror);
|
||||
|
||||
// Reset should work
|
||||
try io.end(.reset);
|
||||
|
||||
// By default, its pass-through
|
||||
try io.check(.read);
|
||||
|
||||
// Always trip
|
||||
try io.errorAlways(.read, error.OutOfMemory);
|
||||
try testing.expectError(
|
||||
error.OutOfMemory,
|
||||
io.check(.read),
|
||||
);
|
||||
// Happens again
|
||||
try testing.expectError(
|
||||
error.OutOfMemory,
|
||||
io.check(.read),
|
||||
);
|
||||
try io.end(.reset);
|
||||
}
|
||||
|
||||
test "module as error set" {
|
||||
const io = module(enum { read, write }, @TypeOf((struct {
|
||||
fn func() error{ Foo, Bar }!void {
|
||||
return error.Foo;
|
||||
}
|
||||
}).func));
|
||||
try io.end(.reset);
|
||||
}
|
||||
|
||||
test "errorAfter" {
|
||||
const io = module(enum { read, write }, anyerror);
|
||||
// Trip after 2 calls (on the 3rd call)
|
||||
try io.errorAfter(.read, error.OutOfMemory, 2);
|
||||
|
||||
// First two calls succeed
|
||||
try io.check(.read);
|
||||
try io.check(.read);
|
||||
|
||||
// Third call and on trips
|
||||
try testing.expectError(error.OutOfMemory, io.check(.read));
|
||||
try testing.expectError(error.OutOfMemory, io.check(.read));
|
||||
|
||||
try io.end(.reset);
|
||||
}
|
||||
|
||||
test "errorAfter untripped error if min not reached" {
|
||||
const io = module(enum { read }, anyerror);
|
||||
try io.errorAfter(.read, error.OutOfMemory, 2);
|
||||
// Only call once, not enough to trip
|
||||
try io.check(.read);
|
||||
// end should fail because tripwire was set but never tripped
|
||||
try testing.expectError(
|
||||
error.UntrippedError,
|
||||
io.end(.reset),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user