別被 Trip[wire] 絆倒:測試 Zig 的錯誤復原
原文由 Mitchell Hashimoto 于 發布,訂閱此部落格
我寫了一個名為 Tripwire1 的函式庫,專門用來對 Zig 程式注入失敗,以測試錯誤處理路徑。在單元測試之外,它會被完全最佳化掉,零執行時成本(不占空間也不耗時)。
Zig 有一個語言特性 errdefer,會在區塊結束且正要回傳錯誤時才執行程式碼。概念很簡單:如果發生錯誤,你需要「復原」已產生的部分效果,讓函式回傳錯誤時,整個世界的狀態仍能處於某個明確定義的狀態。
諷刺的是,錯誤清理正是 Zig 程式中最容易出錯的部分之一,也是資源洩漏與記憶體損毀的常見來源。
這點不難理解:錯誤的程式路徑通常執行頻率低得多,在測試中觸發也很困難。結果就是,開發過程中往往只靠大腦審視個一兩次,直到使用者在正式環境中踩到雷,才真正被執行到。
Zig 中的錯誤
本節會介紹 Zig 中錯誤運作方式的背景知識。如果你已經熟悉 Zig 的錯誤處理,可以直接跳過本節。
在 Zig 中,所有函式都能回傳錯誤值。錯誤值的行為有點像列舉,長得像這樣:error.OutOfMemory。錯誤值會被收攏成稱為錯誤集合(error sets)的具名集合。而函式可以透過一種稱為錯誤聯集(error union)的機制,回傳錯誤或成功的值。
你可以用 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:Buffer 已經配置,所以 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;並利用它讓函式在條件成立時變成空操作(no-op):
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
我只在 Ghostty 的少數幾個地方整合了 Tripwire,就立刻揪出了許多 Bug。在最初的 PR 中,我就修掉了大約 6 個 errdefer 相關的 Bug。這些 Bug 雖然從未在真實世界中被觸發過,但終究還是 Bug。
最重要的是,這些 Bug 現在都已修復,並搭配了能驗證它們確實存在的單元測試!如果我把修正拿掉,測試就會失敗!
我打算繼續將 Tripwire 整合到 Ghostty 程式碼庫的更多地方,並確保無論是我、維護者還是貢獻者撰寫的新程式碼,都會考慮到 errdefer 的測試。
如果你覺得有用,請直接把檔案複製過去、在你自己的專案中使用吧!Ghostty 採用 MIT 授權,而 Tripwire 完全自包含於單一檔案中。儘管拿去用吧!
註腳
隨機一篇部落格
留言
登入後參與討論