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

Mitchell Hashimoto

別被自己絆倒:Zig 的錯誤復原測試

我寫了一個名為 Tripwire1 的函式庫,用於向 Zig 程式注入失敗,以專門測試錯誤處理路徑。在單元測試之外,它會被完全最佳化掉,達到零執行期成本(不占空間也不耗時)。

Zig 有一個語言功能 errdefer,用於僅在回傳錯誤時,於區塊結束時執行程式碼。概念很簡單:如果發生錯誤,你需要「復原」已產生的部分效果,讓函式回傳錯誤時,世界狀態仍能處於某個明確定義的狀態。

諷刺的是,錯誤清理是 Zig 程式中最容易出錯的部分之一,也是資源洩漏與記憶體毀損的一貫來源。

這很容易理解:錯誤程式碼路徑通常執行頻率低得多,且在測試中觸發它們可能很困難。因此,它們在開發過程中通常只會被從概念上審視一兩次,直到使用者在正式環境中觸發前,從未真正被執行過。


Zig 中的錯誤

本節將介紹 Zig 中錯誤運作方式的背景知識。如果你已熟悉 Zig 的錯誤處理,歡迎跳過本節。

Zig 中的所有函式都能回傳 error values(錯誤值)。錯誤值的行為有點像 enum,看起來像這樣:error.OutOfMemory。錯誤值會被收集到稱為 error sets(錯誤集合) 的名稱集合中。而函式可以使用一種稱為 error union(錯誤聯集) 的東西來回傳錯誤或成功值。

你可以用 try 來包裝 error union(通常是函式呼叫的結果),以解開其值或回傳錯誤。

最後,也是本文的重點,你可以在任何地方放入 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 為程式正確性提供了一些很棒的工具。首先,它有許多 runtime safety checks(執行期安全檢查),涵蓋從索引越界到空值解參考等檢查。再來,Zig 的 parameterized allocators(參數化配置器) 讓測試記憶體不足情境、受限記憶體環境等變得更容易。最後,Zig 內建了 test framework(測試框架),並在文化上鼓勵撰寫測試。

但是,它沒有用來觸發錯誤以測試 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 停用時,沒有任何程式碼會參照這些狀態,因此我們的全域狀態也不會被放到二進位檔中。


錯誤,錯誤

我僅在 Ghostty 的少數幾個地方整合了 Tripwire,就立刻發現了許多錯誤。在最初的 PR 中,我修復了約 6 個 errdefer 錯誤。它們在現實世界中從未被發現曾觸發過,但無論如何仍是錯誤。

而且最重要的是,這些錯誤現在都已修復,並搭配了能驗證其存在的單元測試!如果我移除修正,測試就會失敗!

我計畫持續將 Tripwire 整合到 Ghostty 程式碼庫的更多部分,並確保我與維護者或貢獻者撰寫的任何新程式碼,都有考慮到 errdefer 測試。

如果你覺得它有用,請直接複製該檔案並在你自己的專案中使用!Ghostty 採用 MIT 授權,而 Tripwire 完全自含於單一檔案中。儘管使用吧!

註腳

  1. 它是單一檔案。如果你想使用,只要複製並貼到你的專案中即可。 ↩2

  2. 我原本在本文中放了許多範例,但我覺得那會讓文章過長,看一下 PR 8249 和 PR 10401 中的一些提交會更容易理解。

原文由 Mitchell Hashimoto 發布

本文章由 muse-spark-1.2-contributor 進行翻譯