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

Mitchell Hashimoto

트립[와이어]에 걸리지 마세요: Zig에서 오류 복구 테스트하기

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

Tripwire1라는 라이브러리를 만들었습니다. Zig 프로그램에 실패를 주입해 오류 처리 경로를 테스트하는 것이 목적입니다. 단위 테스트 밖에서는 완전히 최적화되어 사라지므로 런타임 비용이 전혀 없습니다(공간이든 시간이든).

Zig에는 블록을 빠져나갈 때 오류가 반환되는 경우에만 코드를 실행하는 errdefer라는 언어 기능이 있습니다. 아이디어는 단순합니다. 오류가 발생하면 부분적으로 수행한 효과를 “되돌려야” 하며, 그래야 함수가 오류를 반환할 때 외부 상태가 잘 정의된 상태로 유지될 수 있습니다.

아이러니하게도 오류 정리 코드는 Zig 프로그램에서 가장 오류가 발생하기 쉬운 부분 중 하나이며, 리소스 누수와 메모리 손상의 고질적인 원인이 됩니다.

이유는 쉽게 이해할 수 있습니다. 오류 코드 경로는 대체로 실행 빈도가 훨씬 낮고 테스트에서 이를 발생시키기가 어렵습니다. 그 결과 개발 과정에서 한두 번 머릿속으로 검토하는 데 그치는 경우가 많고, 실제 사용자가 프로덕션에서 마주치기 전까지는 제대로 실행조차 되지 않습니다.


Zig에서의 오류

이 섹션에서는 Zig에서 오류가 일반적으로 어떻게 동작하는지에 대한 배경을 다룹니다. Zig의 오류 처리에 익숙하다면 이 섹션은 건너뛰어도 됩니다.

Zig의 모든 함수는 오류 값을 반환할 수 있습니다. 오류 값은 일종의 enum처럼 동작하며 error.OutOfMemory와 같이 생겼습니다. 오류 값들은 에러 집합(error set)이라 불리는 이름 집합으로 모아집니다. 그리고 함수는 에러 유니온(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;
}

이 예제에서 기본 흐름을 볼 수 있습니다:

  1. Widget을 위한 공간을 할당한 뒤 오류가 발생하면 할당을 해제해야 합니다.
  2. Widget을 초기화한 뒤 오류가 발생하면 초기화를 해제해야 합니다.
  3. 기타 등등.

이는 지극히 전형적인 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 종료 후에 외부 상태가 정상인지 검증하기 위해 약간의 추가 expectation이 필요할 수 있습니다. 하지만 기본적인 수준에서도 이를 통해 런타임 안전 검사나 누수가 감지되지 않음을 쉽게 보장할 수 있습니다.

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가 비활성화된 상태에서는 어떤 코드도 이 상태를 참조하지 않으므로, 전역 상태 중 어느 것도 바이너리에 포함되지 않습니다.


버그, 버그

Ghostty의 몇 군데에만 Tripwire를 통합했는데도 즉시 많은 버그가 드러났습니다. 초기 PR에서 errdefer 버그를 약 6개 정도 고쳤습니다. 모두 실제 환경에서 발생한 것으로 알려진 적은 없지만, 그래도 버그인 것은 분명합니다.

그리고 가장 중요한 점은, 이제 버그들이 존재했음을 검증하는 단위 테스트와 함께 수정되었다는 것입니다! 수정을 되돌리면 테스트가 실패합니다!

앞으로도 Ghostty 코드베이스의 더 많은 부분에 Tripwire를 통합하고, 저 자신이나 메인테이너, 기여자가 새로 작성하는 모든 코드에 대해 errdefer 테스트를 고려하도록 할 계획입니다.

유용하다고 생각되시면 파일을 복사해 여러분의 프로젝트에서 사용해 보세요! Ghostty는 MIT 라이선스이며 Tripwire는 단일 파일로 완전히 자립적입니다. 마음껏 사용하세요!

각주

  1. 단일 파일입니다. 사용하고 싶다면 프로젝트에 복사해 붙여 넣으면 됩니다. ↩2

  2. 원래 이 글에 예제를 많이 넣었지만 글이 너무 길어지는 것 같아, 그냥 PR 8249와 PR 10401의 커밋들을 살펴보는 편이 더 쉽다고 생각했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글