别给自己埋绊线:测试 Zig 中的错误恢复
原文由 Mitchell Hashimoto 于 发布,订阅该博客
我写了一个名为 Tripwire1 的库,专门用于向 Zig 程序注入故障,以测试错误处理路径。在单元测试之外,它会被完全优化掉,没有任何运行时开销(既不占空间也不耗时)。
Zig 有一个语言特性 errdefer,用于在返回错误时才在块退出时执行代码。它的想法很简单:如果发生了错误,你需要“撤销”已经产生的部分副作用,这样当函数返回错误时,外部世界的状态仍然处于某种明确定义的状态。
颇具讽刺意味的是,错误清理恰恰是 Zig 程序中最容易出错的部分之一,也是资源泄漏和内存损坏的常见源头。
这很容易理解:错误路径通常很少被执行,在测试中触发它们也很困难。因此,在开发过程中,它们往往只会在脑中过一两遍,直到用户在生产环境中真正触发时,才会得到真正的检验。
Zig 中的错误
本节介绍 Zig 中错误机制的一些背景知识。如果你已经熟悉 Zig 的错误处理,可以直接跳过本节。
在 Zig 中,所有函数都可以返回错误值。错误值的行为有点像枚举,看起来像这样:error.OutOfMemory。错误值会被归入名为 错误集的命名集合中。而函数可以通过一种名为 错误联合的机制来返回错误或成功值。
你可以用 try 来包装一个错误联合(通常是函数调用的结果),以解包成功值或直接返回错误。
最后,也是本文的关键点,你可以在任何地方放置 errdefer,当有错误被返回时(无论是直接返回还是通过 try 返回),当前作用域中在此之前的所有 errdefer 语句都会按逆序执行。
总的来说,用一个简单的示例来看,大概是这样:
fn create(alloc: Allocator) !*Widget {
const self = try alloc.create(Widget);
errdefer alloc.destroy(self);
self.* = try .init(alloc);
errdefer self.deinit();
const file = try std.fs.cwd().openFile("config.txt", .{});
// etc...
return self;
}在这个例子中,你可以看到基本的流程:
- 为
Widget分配空间后,如果出现错误,我们应该释放它。 - 初始化
Widget后,如果出现错误,我们应该反初始化它。 - 等等。
这是 Zig 中非常典型的模式,在 Zig 标准库和大多数 Zig 程序中随处可见。这是一个简单的场景,即使没有测试,也能明显看出 errdefer 的用法是正确的。
但现实世界很快就会变得一团糟。2
为什么如此脆弱?
了解一下为什么在 Zig 中测试错误处理路径如此困难,也有助于理解背景。
Zig 为保证程序正确性提供了一些很棒的工具。首先,它有许多运行时安全检查,涵盖从越界访问到空指针解引用等各种情况。其次,Zig 的参数化分配器让测试内存不足场景、受限内存环境等变得更容易。最后,Zig 内置了测试框架,并在文化上鼓励编写测试。
但是,它没有提供触发错误来测试 errdefer 的机制。对于内存分配错误,你可以使用像 std.testing.failing_allocator 这样的专用分配器,但在会进行大量分配、且分配可能是有条件的复杂代码中,要把它精确配置正确既棘手又脆弱。
如果你为代码编写了单元测试,那么 defer 的测试是免费附赠的,因为 defer 在块退出时总会无条件执行。但 errdefer 只有在发生错误时才会执行,所以如果你无法触发错误,就无法测试对应的 errdefer!
除了工具层面的原因,错误处理代码本身就很少被执行,而且发生错误时的世界状态往往比成功路径更复杂(因为它还可能取决于错误发生的位置)。
Tripwire
我最终厌倦了靠肉眼检查错误处理代码并祈祷它是正确的,或者花上几个小时编写测试去构造一个完美却脆弱的场景来触发某条精确的错误路径。于是,我写了 Tripwire。
Tripwire 是一个小巧的单文件库1,它让你可以在代码中放置带命名的故障点,以便在测试期间触发错误。在测试之外,它的写法保证了它会被完全优化掉(不占用内存,也不会产生任何机器码)。
从概念上讲,它的工作原理是这样的:
Tripwire 如何注入故障
点击触发:.alloc_buffer .open_file
fn init(alloc: Allocator) !*Self {
try tw.check(.alloc_buffer);
const buf = try alloc.alloc(u8, 1024);
errdefer alloc.free(buf);
try tw.check(.open_file); ← error injected!
const file = try openFile("config");
errdefer file.close();
return self;
}触发 .open_file 时:缓冲区已经分配,因此 errdefer alloc.free(buf) 必须执行。如果它缺失或写错,测试就会因内存泄漏而失败!
用代码来表示,是这样的:
const tripwire = @import("tripwire.zig");
// Define a tripwire module with named failure points. The second
// argument is the function itself to get its error set.
const init_tw = tripwire.module(enum {
alloc_buffer,
open_file,
}, init);
fn init(alloc: Allocator) !*Self {
// Check the tripwire before the fallible operation.
// In tests, this can be configured to return an error.
// In release builds, this compiles to nothing.
try init_tw.check(.alloc_buffer);
const buf = try alloc.alloc(u8, 1024);
errdefer alloc.free(buf);
try init_tw.check(.open_file);
const file = try std.fs.cwd().openFile("config.txt", .{});
errdefer file.close();
// ...
}
test "init error on open_file" {
// Configure the tripwire to fail at the open_file point.
try init_tw.errorAlways(.open_file, error.OutOfMemory);
// Call the function and expect the error.
try std.testing.expectError(error.OutOfMemory, init(std.testing.allocator));
// End the tripwire session and reset state.
// This also verifies the tripwire was actually hit.
try init_tw.end(.reset);
}关键在于,如果有任何内存泄漏,std.testing.allocator 会让测试失败。因此,通过在 .open_file 处触发错误,我们迫使 errdefer alloc.free(buf) 执行。如果这个 errdefer 缺失或写错,测试就会因内存泄漏而失败。在更复杂的场景中,你还会在触发错误后,在单元测试中加入额外的状态检查。
我常用的另一种模式是遍历所有可能的故障点,以获得完整的覆盖率:
test "init handles all error points" {
for (std.meta.tags(init_tw.FailPoint)) |point| {
try init_tw.errorAlways(point, error.OutOfMemory);
try std.testing.expectError(error.OutOfMemory, init(std.testing.allocator));
try init_tw.end(.reset);
}
}如前所述,在实践中,你可能需要在 tripwire 结束之后加入更多断言,来验证外部世界的状态是否合理。但即便是在最基本的层面上,这也能让你轻松确保不会触发任何运行时安全检查或泄漏。
除了 errorAlways,你还可以使用 errorAfter,让某个故障点在被命中一定次数后才触发错误。而 end 会验证它确实被触发了。这对于捕捉因循环清理不当导致的资源泄漏或状态损坏非常有用。
在测试之外零开销
在测试之外,Tripwire 不会产生任何机器码,也不占用内存;它会被完全优化掉。
为此,我们利用 Zig 的 comptime 来检测测试框架:
/// Whether our module is enabled or not.
pub const enabled = builtin.is_test;并以此为条件,让我们的函数在不需要时变成空操作:
pub fn check(point: FailPoint) callconv(callingConvention()) Error!void {
if (comptime !enabled) return;
// actually do stuff
}我们还使用 comptime 来设置调用约定,以便在未启用时让函数被内联:
/// 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;
}有人告诉我这没有必要,但我们确实见过真实的编译产出了带有完整函数调用开销的机器码——调用的只是一个只有 ret 指令的空函数体。内联修复了这个问题,直到我们弄清楚是什么导致了这种现象。
最后,Zig 编译器只会分析和生成被实际引用(或标记为 volatile)的声明的代码。由于在禁用 Tripwire 时没有任何代码引用这些状态,我们的全局状态也不会被发射到二进制文件中。
Bug,Bug,还是 Bug
我仅在 Ghostty 的少数几个地方集成了 Tripwire,就立刻发现了许多 Bug。在最初的 PR 中,我修复了大约 6 个 errdefer 相关的 Bug。它们在现实世界中从未被触发过,但终究还是 Bug。
而且最重要的是,这些 Bug 现在都已修复,并配有验证其存在的单元测试!如果我移除修复,测试就会失败!
我计划继续将 Tripwire 集成到 Ghostty 代码库的更多部分,并确保对于我自己、维护者或贡献者编写的任何新代码,都会考虑对 errdefer 进行测试。
如果你觉得它有用,请直接复制这个文件,在你自己的项目中使用吧!Ghostty 采用 MIT 许可证,Tripwire 完全自包含于单个文件中。尽管拿去用吧!
脚注
随机一篇博客
评论
登录后参与讨论