Trip[wire]に足を取られるな:Zigにおけるエラー回復のテスト
Tripwire1というライブラリを作りました。Zigプログラムに意図的に失敗を注入し、エラー処理の経路をテストすることだけを目的としたものです。単体テスト以外の場面では完全に最適化されて消滅するため、実行時のコストはメモリも時間もゼロです。
Zigにはerrdeferという言語機能があり、ブロックを抜ける際にエラーが返されるときだけコードを実行できます。発想はシンプルです。エラーが起きたら、それまでに行った中途半端な処理を「元に戻す」ことで、関数がエラーを返したときにも世界の状態がきちんと定義された状態に保たれるようにするのです。
皮肉なことに、このエラー時の後始末こそがZigプログラムの中で最もバグを生みやすい部分の一つであり、リソースリークやメモリ破壊が絶えない原因になっています。
理由は容易に想像がつきます。エラーを通るパスは実行される頻度が極端に低く、テストで意図的に引き起こすのも困難です。その結果、開発中に頭の中で一度か二度見直されるだけで、本番環境でユーザーが実際に踏むまで一度もまともに実行されないままになってしまうのです。
Zigのエラー
この章では、Zigにおけるエラーの仕組みについて背景を簡単に説明します。Zigのエラー処理に詳しい方は、読み飛ばしていただいてかまいません。
Zigではすべての関数がエラー値を返すことができます。エラー値はenumのように振る舞い、たとえばerror.OutOfMemoryのように記述します。エラー値はerror setと呼ばれる名前の集合にまとめられます。そして関数はerror unionと呼ばれる仕組みを使って、エラーか成功値のいずれかを返すことができます。
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はプログラムの正しさを保つための優れた仕組みをいくつも備えています。まず、配列の範囲外アクセスからnullデリファレンスまでをカバーする多数のランタイム安全性チェックがあります。次に、Zigのパラメータ化されたアロケータにより、メモリ不足のシナリオやメモリ制約のある環境などをテストしやすくなっています。そして最後に、Zigには組み込みのテストフレームワークが用意されており、文化的にもテストを書くことが推奨されています。
しかし、errdeferをテストするためにエラーを意図的に引き起こす仕組みはありません。メモリ確保のエラーを試すにはstd.testing.failing_allocatorのような専用のアロケータを使えますが、多数のアロケーションを行い、しかも条件によっては実行されないアロケーションもあるような複雑なコードで、それを正確に設定するのはトリッキーでもろい作業になります。
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を導入しただけで、すぐに多くのバグが見つかりました。最初のプルリクエストでは、errdeferに関するバグを約6件修正しました。いずれも現実世界で発動したことは知られていませんが、それでもバグであることには変わりありません。
そして何より重要なのは、これらのバグは修正されただけでなく、その存在を検証する単体テストとセットになっていることです!もし修正を取り除けば、テストは失敗します!
今後もGhosttyのコードベースのより多くの部分にTripwireを導入し、私自身やメンテナ、コントリビューターが新しく書くコードについても、errdeferのテストを検討するようにしていくつもりです。
もし役に立つと感じたら、ぜひファイルをコピーしてご自身のプロジェクトで使ってみてください!GhosttyはMITライセンスで、Tripwireは単一ファイルで完結しています。どうぞご活用ください!
脚注
記事をランダムに読む