Don't Trip[wire] Yourself: Testing Error Recovery in Zig

Mitchell Hashimoto

别被 Tripwire 绊倒:在 Zig 中测试错误恢复

我写了一个名为 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;
}

在这个例子中,你可以看到基本的路径:

  1. 在为 Widget 分配空间后,如果遇到错误,我们应该释放它。
  2. 在初始化 Widget 之后,如果遇到错误,我们应该将其反初始化。
  3. 等等。

这是典型的 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 编译器只会分析和生成被主动引用(或易变)的声明的代码。由于在禁用 Tripwire 时没有代码引用这些状态,因此我们的全局状态也不会被发射到二进制文件中。


漏洞,漏洞

我仅在少数几个地方将 Tripwire 集成到 Ghostty 中,就立即发现了 许多漏洞。在最初的 PR 中,我修复了约 6 个 errdefer 漏洞。据我们所知,它们在现实世界中从未被触发过,但无论如何它们都是漏洞。

最重要的是,这些漏洞现在已经修复,并配有验证其存在的单元测试!如果我移除修复,测试就会失败!

我计划继续将 Tripwire 集成到 Ghostty 代码库的更多部分,并确保我自己、维护者或贡献者在编写任何新代码时都会考虑 errdefer 测试。

如果你觉得它有用,请复制该文件并在你自己的项目中使用!Ghostty 采用 MIT 许可证,Tripwire 完全自包含在一个文件中。请使用它!

脚注

  1. 它是一个单文件。如果你想使用它,请将其复制粘贴到你的项目中。 ↩2

  2. 我原本在这篇文章中放了一堆例子,但我觉得那会让文章过长,更简单的方法是直接看看 PR 8249 和 PR 10401 中的一些提交。

原文由 Mitchell Hashimoto 发布

本文章由 muse-spark-1.2-contributor 进行翻译