别给自己设障:用Tripwire测试Zig中的错误恢复
摘要
Mitchell Hashimoto 介绍了 Tripwire,这是一个Zig库,它通过注入失败来测试错误恢复路径,并且在禁用时零运行时开销。
暂无内容
查看缓存全文
缓存时间: 2026/05/16 03:38
# 别被[地雷]绊倒:在 Zig 中测试错误恢复
来源:https://mitchellh.com/writing/tripwire
我写了一个名为 `Tripwire`(https://github.com/ghostty-org/ghostty/blob/main/src/tripwire.zig)¹ 的库,用于向 Zig 程序中注入故障,专门测试错误处理路径。在单元测试之外,它会被**完全优化掉**,运行时成本为零(空间或时间)。
Zig 有一个语言特性 `errdefer`(https://ziglang.org/documentation/0.15.2/#errdefer),仅在返回错误时在块退出时执行代码。思路很简单:如果发生错误,你需要“撤销”部分效果,这样当函数返回错误时,世界状态可以处于某个明确定义的状态。
讽刺的是,错误清理是 Zig 程序中最容易出错的部分之一,也是资源泄漏和内存损坏的常见来源。
这很容易理解:错误代码路径通常很少被执行,在测试中触发它们可能很困难。因此,它们通常只在开发过程中被审查一两次,直到用户在生产环境中遇到它们时才真正得到锻炼。
---
## Zig 中的错误
本节介绍一些关于 Zig 中错误如何工作的背景知识。如果你熟悉 Zig 的错误处理,可以跳过这一节。
Zig 中的所有函数都可以返回错误值(https://ziglang.org/documentation/0.15.2/#Errors)。错误值有点像枚举,看起来像这样:`error.OutOfMemory`。错误值被收集到名为错误集(https://ziglang.org/documentation/0.15.2/#Error-Set-Type)的名称集合中。函数可以使用称为错误联合(https://ziglang.org/documentation/0.15.2/#Error-Union-Type)的东西返回错误或成功值。
你可以用 `try`(https://ziglang.org/documentation/0.15.2/#try) 包装错误联合(通常是函数调用结果),以解包值或返回错误。
最后,也是本文的关键点,你可以在任何地方放置 `errdefer`(https://ziglang.org/documentation/0.15.2/#errdefer),当返回错误时(无论是直接返回还是通过 `try`),当前作用域中所有先前的 `errdefer` 语句将以相反顺序执行。
总的来说,在一个玩具示例中看起来像这样:
``zig
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", .{});
// 等等...
return self;
}
``
在这个例子中,你可以看到基本路径:
1. 分配 `Widget` 的空间后,如果遇到错误,我们应该释放分配。
2. 初始化 `Widget` 后,如果遇到错误,我们应该反初始化。
3. 等等。
这是一个典型的 Zig 模式,你会在 Zig 标准库和大多数 Zig 程序中随处可见。这是一个即使没有测试也*明显正确*的 `errdefer` 简单案例。
但现实世界很快就会变得混乱。²
---
## 为什么如此脆弱?
了解为什么在 Zig 中测试错误处理代码路径如此困难也很有用。
Zig 提供了一些很好的工具来确保程序正确性。首先,它有许多运行时安全检查(https://ziglang.org/documentation/0.15.2/#Illegal-Behavior),涵盖从索引越界到空指针解引用等。其次,Zig 的参数化分配器(https://ziglang.org/documentation/0.15.2/#Memory)使得测试内存不足场景、受限内存环境等更容易。最后,Zig 内置了测试框架(https://ziglang.org/documentation/0.15.2/#Zig-Test),并在文化上鼓励编写测试。
但是,它没有触发错误来测试 `errdefer` 的机制。对于内存分配错误,你可以使用专门的分配器,如 `std.testing.failing_allocator`,但在复杂代码中正确配置它可能很棘手且脆弱,因为代码可能进行许多分配,并且其分配可能是有条件的。
如果你对代码进行单元测试,你总是可以免费获得 `defer` 测试,因为 defer 在块退出时无条件运行。但是 `errdefer` 仅在发生错误时运行,所以如果你无法触发错误,就无法测试相应的 `errdefer`!
除了工具之外,错误处理代码本质上执行频率较低,并且发生错误时的世界状态通常比成功路径更复杂(因为它也可能取决于错误发生的*位置*)。
---
## Tripwire
最终,我厌倦了仅凭肉眼检查错误处理代码并希望它是正确的,或者花几个小时编写测试来创建一个完美但脆弱的场景来触发特定的错误路径。所以,我写了 Tripwire(https://github.com/ghostty-org/ghostty/blob/main/src/tripwire.zig)。
Tripwire 是一个小型、单一文件的库¹,它允许你在代码中放置命名点,以便在测试期间可以触发错误。在测试之外,它的编写方式使其被完全优化掉(不占用内存,不产生机器码)。
概念上它像这样工作:
tripwire 如何注入故障
点击触发:
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); ← 错误注入!
const file = try openFile("config");
errdefer file.close();
return self;
}
**触发 .open_file:** 缓冲区已分配,因此 `errdefer alloc.free(buf)` 必须运行。如果缺少或错误,测试将因内存泄漏而失败!
用代码表示如下:
``zig
const tripwire = @import("tripwire.zig");
// 定义一个带有命名故障点的 tripwire 模块。第二个参数是函数本身,以获取其错误集。
const init_tw = tripwire.module(enum {
alloc_buffer,
open_file,
}, init);
fn init(alloc: Allocator) !*Self {
// 在可失败操作之前检查 tripwire。
// 在测试中,可以配置为返回错误。
// 在发布构建中,编译为空操作。
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" {
// 配置 tripwire 在 open_file 点失败。
try init_tw.errorAlways(.open_file, error.OutOfMemory);
// 调用函数并期望错误。
try std.testing.expectError(error.OutOfMemory, init(std.testing.allocator));
// 结束 tripwire 会话并重置状态。
// 这也验证了 tripwire 确实被触发了。
try init_tw.end(.reset);
}
``
关键洞察在于 `std.testing.allocator` 会在任何内存泄漏时使测试失败。因此,通过在 `.open_file` 触发错误,我们强制 `errdefer alloc.free(buf)` 运行。如果那个 `errdefer` 缺失或错误,测试将因内存泄漏而失败。在更复杂的场景中,你会在错误触发后在单元测试中添加额外的状态测试。
我使用的另一个常见模式是迭代所有可能的故障点以获得完全覆盖:
``zig
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 来检测测试框架:
``zig
/// 我们的模块是否启用。
pub const enabled = builtin.is_test;
``
并利用它来有条件地使我们的函数变成空操作:
``zig
pub fn check(point: FailPoint) callconv(callingConvention()) Error!void {
if (comptime !enabled) return;
// 实际执行操作
}
``
我们还使用 comptime 来设置调用约定,这样如果我们未启用,函数就会被内联:
``zig
/// 如果我们的 tripwire 模块**未**启用,则调用约定为内联,
/// 以便所有对 `check` 的调用都被优化掉。
fn callingConvention() std.builtin.CallingConvention {
return if (!enabled) .@"inline" else .auto;
}
``
有人告诉我这不是必需的,但我们看到过实际编译产生机器码,为一个只有空体和 `ret` 的函数增加了完整的函数调用开销。内联解决了这个问题,直到我们找到原因。
最后,Zig 编译器只分析并发出实际被引用(或易变)的声明的代码。由于在 Tripwire 禁用时没有代码引用这些状态,我们的任何全局状态也不会被输出到二进制文件中。
---
## 错误,错误
我只在 Ghostty 中的少数几个地方集成了 Tripwire,并立即发现了许多错误(https://github.com/ghostty-org/ghostty/pull/10401)。在最初的 PR 中,我修复了大约 6 个 `errdefer` 错误。它们从未在现实世界中已知被触发,但它们仍然是错误。
最重要的是,这些错误现在已经被修复,并与验证它们存在的单元测试配对!如果我移除修复,测试就会失败!
我计划继续将 Tripwire 集成到 Ghostty 代码库的更多部分中,并确保我、维护者或贡献者在编写任何新代码时都考虑 `errdefer` 测试。
如果你觉得它有用,请复制文件并在你自己的项目中使用它!Ghostty 是 MIT 许可的,Tripwire 完全包含在一个文件中。使用它吧!
1. 这是一个单一文件。如果你想使用它,可以复制并粘贴到你的项目中。↩(https://mitchellh.com/writing/tripwire#user-content-fnref-1)↩²(https://mitchellh.com/writing/tripwire#user-content-fnref-1-2)
2. 我原本在此帖中有很多示例,但我认为它们使帖子变得太长,更容易的是查看 PR8249(https://github.com/ghostty-org/ghostty/pull/8249) 和 PR10401(https://github.com/ghostty-org/ghostty/pull/10401) 中的一些提交。↩(https://mitchellh.com/writing/tripwire#user-content-fnref-2)
相似文章
最小可行的Zig错误上下文
一篇博文,详细介绍了使用errdefer日志在Zig中添加错误上下文的最小模式,并将其与完整的诊断接收器(diagnostics sinks)和catch块进行比较,讨论了权衡取舍。
重返Zig
作者描述了从Zig到Rust再回到Zig的历程,探讨了编程语言中稳定性与表达力之间的权衡。
在Zig中使用Comptime条件禁用代码
Mitchell Hashimoto解释了如何利用Zig的comptime特性在编译时条件禁用代码,并与C和Go中的实现方式进行了比较。
256行或更少:测试用例最小化
一篇技术博客文章,描述了作者用约256行Zig代码实现的极简属性测试库,该库具有用于可复现测试用例生成和算法验证的有限随机数生成器。
Zig 示例
Zig by Example 是一本 Zig 编程语言的实践入门教程,包含针对版本 0.16 的带注释的示例程序。