自分でTrip[wire]するな:Zigにおけるエラー回復のテスト
原文は Mitchell Hashimoto により に公開されました。 このブログを購読する
Zigプログラムに失敗を注入し、エラー処理パスをテストすることだけを目的としたTripwire1というライブラリを作りました。ユニットテスト以外では、これは完全に最適化されて消えるため、ランタイムコストはゼロです(時間もメモリも)。
Zigには、エラーが返されるときにのみブロックを抜ける際にコードを実行するための言語機能errdeferがあります。考え方はシンプルです。エラーが発生した場合、部分的になされた作用を「元に戻す」必要がある、そうすれば関数がエラーを返したときに世界の状態を何らかのwell-definedな状態に保てる、というものです。
皮肉なことに、エラーのクリーンアップはZigプログラムの中でも最も間違いが起こりやすい部分の一つであり、リソースリークやメモリ破壊の恒常的な原因となっています。
理由は簡単に理解できます。エラーのコードパスは実行される頻度がはるかに低く、テストでそれを引き起こすのが難しいからです。その結果、開発中に一度か二度、頭の中でレビューされるだけで、本番環境でユーザーが実際に踏むまで本当に実行されることはありません。
Zigにおけるエラー
このセクションでは、Zigでエラーが一般的にどのように機能するかという背景を説明します。Zigのエラー処理に詳しい方は、このセクションを読み飛ばしていただいて構いません。
Zigではすべての関数がエラー値を返すことができます。エラー値はenumのように振る舞い、error.OutOfMemoryのように記述します。エラー値はエラーセットと呼ばれる名前の集合にまとめられます。そして関数はエラーunionと呼ばれるものを使って、エラーか成功値のいずれかを返すことができます。
エラー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はプログラムの正しさを保つための優れたユーティリティをいくつか提供しています。まず、インデックスの範囲外からnull参照までにわたる多くのランタイム安全性チェックがあります。次に、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が無効なときはどのコードもこの状態を参照しないため、グローバルな状態もバイナリに一切含まれません。
バグ、バグ、バグ
TripwireをGhosttyのほんの数カ所に組み込んだだけで、すぐに多くのバグが見つかりました。最初のPRでは約6件のerrdeferのバグを修正しました。それらは現実世界で発動したことが知られているわけではありませんが、それでもバグであることに変わりはありません。
そして最も重要なのは、バグは現在修正され、その存在を検証するユニットテストと対になっていることです!修正を取り除けば、テストは失敗します!
今後もGhosttyのコードベースのより多くの部分にTripwireを組み込み、自分自身やメンテナ、コントリビューターによる新規コードについてもerrdeferのテストを検討するようにしていく予定です。
役に立つと思ったら、ぜひファイルをコピーしてご自身のプロジェクトで使ってみてください!GhosttyはMITライセンスであり、Tripwireは単一ファイルで完全に自己完結しています。ぜひ使ってみてください!
脚注
記事をランダムに読む
コメント
ログインしてコメントする