RSS

An Intro to Zig's checkAllAllocationFailures

- Programming - Zig

Heap allocation failure is something that is hard or impossible to account for in every case in most programming languages. There are either hidden memory allocations that can't be handled, or it's seen as too inconvenient to handle every possible allocation failure so the possibility is ignored.

For example, when concatenating two strings with the + operator (where there is an implicit allocation that's needed to store the result of the concatenation):

Even in C, where the return of malloc can be checked against NULL to detect allocation failure, it's pretty common to see unchecked malloc calls in C code (and C compilers let you ignore the possibility of allocation failure without complaint).

Zig and allocation failure🔗

One of the unique features of Zig is that it "cares about allocation failure". That is:

Together, these conditions make it so that the code you naturally write in Zig will include handling of OutOfMemory errors. However, because actually running into OutOfMemory organically is rare, it's not easy to be sure that you're handling the error correctly in all cases. Additionally, because there are many functions that have OutOfMemory as their only possible error, the error handling of those function calls are not exercised in a typical test environment.

A strategy for testing OutOfMemory errors🔗

Luckily, though, allocators in Zig also have some unique properties that lend themselves to potential remedies:

The first point means that it's easy/normal to write custom allocators, while the second means that it's easy/normal to swap out allocators during tests. In order to help test OutOfMemory errors, Zig's standard library contains std.testing.FailingAllocator, which will artificially induce an OutOfMemory error once it hits its user-defined number of allocations. Here's a simple example:

test {
	// Create an allocator that will fail on the 0th allocation
	var failing_allocator = std.testing.FailingAllocator.init(std.testing.allocator, 0);
	// Try to allocate 8 bytes
	var allocation = failing_allocator.allocator().alloc(u8, 8);
	// Confirm that the allocation failed and gave OutOfMemory
	try std.testing.expectError(error.OutOfMemory, allocation);
}

This FailingAllocator lays the groundwork for a strategy that allows inducing OutOfMemory for all allocations within a chunk of code. The strategy goes like this:

  1. Run the code once and keep track of the total number of allocations that happen within it.
  2. Then, iterate and run the code X more times, incrementing the failing index each iteration (where X is the total number of allocations determined previously).

As long as the number of memory allocations is deterministic, this strategy works, and is the strategy that the Zig parser tests have employed for years (since 2017) to ensure that the parser handles memory allocation failures without introducing memory leaks (interestingly enough, the implementation of this strategy for the Zig parser tests also happens to be the reason that FailingAllocator was created).

Recently, I went ahead and turned the strategy used by the Zig parser tests into something more re-usable---std.testing.checkAllAllocationFailures---which will be available in the next release of Zig (0.10.0), or can be used now in the latest master version of Zig.

How to use checkAllAllocationFailures🔗

Here's some code that parses a newline-separated list of key=value pairs, e.g.

something=other
equals=equals

and returns it as a std.BufMap:

const std = @import("std");

pub fn parse(allocator: std.mem.Allocator, stream_source: *std.io.StreamSource) !std.BufMap {
    var map = std.BufMap.init(allocator);
    errdefer map.deinit();

    const reader = stream_source.reader();
    const end_pos = try stream_source.getEndPos();
    while ((try stream_source.getPos()) < end_pos) {
        var key = try reader.readUntilDelimiterAlloc(allocator, '=', std.math.maxInt(usize));
        var value = (try reader.readUntilDelimiterOrEofAlloc(allocator, '\n', std.math.maxInt(usize))) orelse return error.EndOfStream;

        try map.putMove(key, value);
    }

    return map;
}

There are some problems lurking in the function that you might be able to spot, but we'll get to them later. Here's a simple test case that passes just fine:

test {
    const data =
        \\foo=bar
        \\baz=qux
    ;
    var stream_source = std.io.StreamSource{ .const_buffer = std.io.fixedBufferStream(data) };
    var parsed = try parse(std.testing.allocator, &stream_source);
    defer parsed.deinit();

    try std.testing.expectEqual(@as(usize, 2), parsed.count());
    try std.testing.expectEqualStrings("bar", parsed.get("foo").?);
    try std.testing.expectEqualStrings("qux", parsed.get("baz").?);
}

In order to be able to use checkAllAllocationFailures for this test, we'll need to make some changes to it. For reference, here's the signature of std.testing.checkAllAllocationFailures along with a small portion of its doc comment:

/// The provided `test_fn` must have a `std.mem.Allocator` as its first argument,
/// and must have a return type of `!void`. Any extra arguments of `test_fn` can
/// be provided via the `extra_args` tuple.
pub fn checkAllAllocationFailures(
    backing_allocator: std.mem.Allocator,
    comptime test_fn: anytype,
    extra_args: anytype,
) !void

So, we'll need to move our test code into an appropriately constructed function that we can provide to checkAllAllocationFailures:

In this case, this ends up looking something like this:

fn parseTest(allocator: std.mem.Allocator, stream_source: *std.io.StreamSource, expected: std.BufMap) !void {
    var parsed = try parse(allocator, stream_source);
    defer parsed.deinit();

    try std.testing.expectEqual(expected.count(), parsed.count());
    var expected_it = expected.iterator();
    while (expected_it.next()) |expected_entry| {
        const actual_value = parsed.get(expected_entry.key_ptr.*).?;
        try std.testing.expectEqualStrings(expected_entry.value_ptr.*, actual_value);
    }
}

with a test block like so:

test {
    const data =
        \\foo=bar
        \\baz=qux
    ;
    var stream_source = std.io.StreamSource{ .const_buffer = std.io.fixedBufferStream(data) };
    var expected = expected: {
        var map = std.BufMap.init(std.testing.allocator);
        errdefer map.deinit();
        try map.put("foo", "bar");
        try map.put("baz", "qux");
        break :expected map;
    };
    defer expected.deinit();

    try parseTest(std.testing.allocator, &stream_source, expected);
}

This still passes just fine. Now let's replace the direct parseTest call with a checkAllAllocationFailures call:

-     try parseTest(std.testing.allocator, &stream_source, expected);
+     try std.testing.checkAllAllocationFailures(
+         std.testing.allocator,
+         parseTest,
+         .{ &stream_source, expected },
+     );

Before running this, though, we'll need to make one last change to the parseTest function. Since checkAllAllocationFailures will now be calling parseTest multiple times (one initial call and then another for each induced allocation failure), we need to make sure that any relevant state is reset at the start of every call. From the checkAllAllocationsFailures doc comment:

/// Any relevant state shared between runs of `test_fn` *must* be reset within `test_fn`.

In this case, the cursor of the StreamSource needs to be reset, as otherwise, after the first run, the cursor will remain at the end of the stream and the next run will immediately fail with EndOfStream (instead of the induced OutOfMemory that we'd expect). To fix this, we need to add this to the beginning of parseTest:

    try stream_source.seekTo(0);

Now when we run the test, it will induce allocation failures and report any problems it finds. Here are the results from the first run (heavily truncated to only include the relevant portions of the stack traces):

fail_index: 1/5
allocated bytes: 8
freed bytes: 5
allocations: 1
deallocations: 0
allocation that was made to fail:
src/main.zig:12:61: 0x20f2b6 in parse (test)
        var value = (try reader.readUntilDelimiterOrEofAlloc(allocator, '\n', std.math.maxInt(usize))) orelse return error.EndOfStream;
                                                            ^
src/main.zig:24:27: 0x20edc9 in parseTest (test)
    var parsed = try parse(allocator, stream_source);
                          ^

Test [1/1] test ""... FAIL (MemoryLeakDetected)
zig/lib/std/testing.zig:713:21: 0x20a5d8 in std.testing.checkAllAllocationFailures (test)
                    return error.MemoryLeakDetected;
                    ^
src/main.zig:50:5: 0x209ac1 in test "" (test)
    try std.testing.checkAllAllocationFailures(
    ^

[gpa] (err): memory address 0x7fda4c422000 leaked:
src/main.zig:10:53: 0x20f139 in parse (test)
        var key = try reader.readUntilDelimiterAlloc(allocator, '=', std.math.maxInt(usize));
                                                    ^
src/main.zig:24:27: 0x20ece9 in parseTest (test)
    var parsed = try parse(allocator, stream_source);
                          ^

From this, we can see a few things (starting from the top):

In particular, this is the problematic code:

var key = try reader.readUntilDelimiterAlloc(allocator, '=', std.math.maxInt(usize));
var value = (try reader.readUntilDelimiterOrEofAlloc(allocator, '\n', std.math.maxInt(usize))) orelse return error.EndOfStream;

try map.putMove(key, value);

That is, we're leaking the allocation for key when the allocation for value fails. This wasn't a problem before the introduction of checkAllAllocationFailures because normally (if all the allocations in the test succeed), the map.putMove would take ownership of the allocated memory of both key and value and then they'd get cleaned up along with the BufMap later on.

The simplest fix here would be to put in an errdefer that will free key (errdefer instead of defer so that it runs only if the value allocation fails or the putMove call fails) like so:

  var key = try reader.readUntilDelimiterAlloc(allocator, '=', std.math.maxInt(usize));
+ errdefer allocator.free(key);

If you happen to be thinking that we'll need the same fix for value, you'd be correct. However, for the sake of completeness let's try running the test again with only the errdefer for key added. Here's the result:

fail_index: 2/5
allocated bytes: 16
freed bytes: 13
allocations: 2
deallocations: 1
allocation that was made to fail:
src/main.zig:15:24: 0x20f3ed in parse (test)
        try map.putMove(key, value);
                       ^

Test [1/1] test ""... FAIL (MemoryLeakDetected)
zig/lib/std/testing.zig:714:21: 0x20a6b3 in std.testing.checkAllAllocationFailures (test)
                    return error.MemoryLeakDetected;
                    ^
src/main.zig:50:5: 0x209b81 in test "" (test)
    try std.testing.checkAllAllocationFailures(
    ^

[gpa] (err): memory address 0x7f6ff57de008 leaked:
src/main.zig:12:61: 0x20f2b6 in parse (test)
        var value = (try reader.readUntilDelimiterOrEofAlloc(allocator, '\n', std.math.maxInt(usize))) orelse return error.EndOfStream;
                                                            ^
src/main.zig:24:27: 0x20edc9 in parseTest (test)
    var parsed = try parse(allocator, stream_source);
                          ^

It is similar to before, but now we can see that value is being leaked when the map.putMove call fails. Now let's put in the errdefer for value:

  var value = (try reader.readUntilDelimiterOrEofAlloc(allocator, '\n', std.math.maxInt(usize))) orelse return error.EndOfStream;
+ errdefer allocator.free(value);

And run the test again:

All 1 tests passed.

With this, we can be reasonably confident that if any of the allocations that occur within the test fail, we handle the OutOfMemory error without introducing more problems.

Making its usage conditional🔗

We might not always want to run our test code N+1 times (where N is the number of allocations that occur within the test). Luckily, once a checkAllAllocationFailures-compatible function is written, it's easy to switch between using it with checkAllAllocationFailures and calling it directly:

const check_allocation_failures = true;

test {
	// (omitted, same as previous example)

    if (check_allocation_failures) {
    	try std.testing.checkAllAllocationFailures(std.testing.allocator, parseTest, .{ &stream_source, expected });
    } else {
        try parseTest(testing.allocator, &stream_source, expected);
    }
}

Integrating with build.zig🔗

To make this nicer, we can make the check_allocation_failures constant an option within build.zig so that we can disable it by doing something like zig build test -Dcheck-allocation-failures=false.

pub fn build(b: *std.build.Builder) void {
    // ...

    // Create the test step
    const main_tests = b.addTest("src/main.zig");

    // Create the command line option (with a default of true)
    const check_allocation_failures = b.option(bool, "check-allocation-failures", "Run tests with checkAllAllocationFailures (default: true)") orelse true;
    
    // Create the option using the value gotten from the command line
    const test_options = b.addOptions();
    test_options.addOption(bool, "check_allocation_failures", check_allocation_failures);

    // Add the options as "test_options" to the main_tests step
    // Our option can then be accessed via `@import("test_options").check_allocation_failures`
    main_tests.addOptions("test_options", test_options);

    // ...
}

which then can be used like so:

test {
    // ...

    if (@import("test_options").check_allocation_failures) {
        try std.testing.checkAllAllocationFailures(std.testing.allocator, parseTest, .{ &stream_source, expected });
    } else {
        try parseTest(std.testing.allocator, &stream_source, expected);
    }
}

Caveat about non-deterministic memory usage🔗

Earlier, I said that:

As long as the number of memory allocations is deterministic, this strategy works

However, it turns out that the code we've been testing can actually have non-deterministic memory usage of a sort (at least with the implementation of std.BufMap as of this article's writing). For example, if we use the following input for our parse function:

foo=bar
baz=dup
a=b
b=c
c=d
d=e
baz=qux

then when running with checkAllAllocationFailures, we hit a scenario in which:

This means that, although an OutOfMemory error was induced, our parseTest call will succeed, which triggers checkAllAllocationFailures to return error.NondeterministicMemoryUsage and fail the test, as it assumes that all calls of the function with an induced allocation failure will have a return of error.OutOfMemory.

This is something of a false positive in terms of non-determinism, though, as the above scenario is still deterministic, but the OutOfMemory in one particular case is handled without bubbling up the error.

Since we know that this is a false-positive, we can ignore error.NondeterministicMemoryUsage by catching it like so:

std.testing.checkAllAllocationFailures(
    std.testing.allocator,
    parseTest,
    .{ &stream_source, expected },
) catch |err| switch (err) {
    error.NondeterministicMemoryUsage => {},
    else => |e| return e,
};

This should generally be avoided, though, as treating error.NondeterministicMemoryUsage as a bug by default makes sense. Unless you know that part of the code you're testing has OutOfMemory recovery in place somewhere (like std.BufMap.putMove), then it's generally a good idea to ensure that the code under test doesn't erroneously/unexpectedly 'swallow' OutOfMemory errors.

If your code's memory allocation is truly non-deterministic in the sense that subsequent runs could have more points of allocation than the initial run, then ignoring the error.NondeterministicMemoryUsage is inadvisable, as the strategy used by checkAllAllocationFailures would no longer be guaranteed to provide full coverage of all possible points of allocation failure.

Integrating with fuzz testing🔗

For projects where fuzz testing makes sense, it's possible to use checkAllAllocationFailures alongside fuzz testing to find bugs related to OutOfMemory error handling that are not (yet) covered by your test cases.

For this, we'll need to create a modified version of our parseTest function from before where:

fn parseTest(allocator: std.mem.Allocator, stream_source: *std.io.StreamSource) !void {
    try stream_source.seekTo(0);

    if (parse(allocator, stream_source)) |*parsed| {
        parsed.deinit();
    } else |err| {
        switch (err) {
            // We only want to return the error if it's OutOfMemory
            error.OutOfMemory => return error.OutOfMemory,
            // Any other error is fine since not all inputs will be valid
            else => {},
        }
    }
}

And then we'll need a fuzzing-compatible main which looks something like this (again, see here for more info):

const std = @import("std");
const parse = @import("main.zig").parse;

pub export fn main() void {
    zigMain() catch unreachable;
}

pub fn zigMain() !void {
    // Set up a GeneralPurposeAllocator so that we can also catch double-frees, etc
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer std.debug.assert(gpa.deinit() == false);
    const allocator = gpa.allocator();

    // Get the fuzzed input form stdin and create a StreamSource with it so we can
    // pass that to parseTest via checkAllAllocationFailures
    const stdin = std.io.getStdIn();
    const data = try stdin.readToEndAlloc(allocator, std.math.maxInt(usize));
    defer allocator.free(data);
    var stream_source = std.io.StreamSource{ .buffer = std.io.fixedBufferStream(data) };

    // Call checkAllAllocationFailures, but ignore error.NondeterministicMemoryUsage
    // (normally you wouldn't ignore NondeterministicMemoryUsage, but it's necessary in our
    // case because we use `std.BufMap.putMove` which has an OutOfMemory recovery strategy)
    std.testing.checkAllAllocationFailures(allocator, parseTest, .{&stream_source}) catch |err| switch (err) {
        error.NondeterministicMemoryUsage => {},
        else => |e| return e,
    };
}

The simple parse function used as an example in this post is not very exciting in terms of fuzzing, unfortunately. Besides the error.NondeterministicMemoryUsage caveat, there's nothing more to be found once we've added in the errdefer's mentioned previously (and the version without the errdefer's would trigger a crash with any reasonable seed input, so afl-fuzz would refuse to fuzz until that is fixed). In more complex projects, though, fuzzing can be very helpful in finding novel OutOfMemory-related bugs.

How it's been used so far🔗

A code search on GitHub for checkAllAllocationFailures comes up with a few projects that have already started using it:

Room for improvement🔗

It seems possible that this strategy could be integrated into the test runner itself, which would remove having to manually add integration on a test-by-test basis. A similar type of integration is already included for leak checking via std.testing.allocator, as the test runner initializes and deinits a new GeneralPurposeAllocator for you for each test and reports the results.

If this is done for checking allocation failures, then that'd allow anyone to run all their tests (presumably only when a command line flag is set) with the checkAllAllocationFailures strategy (given they use std.testing.allocator, although that might depend on the implementation).