⟵ All articles

Testing memory allocation failures with Zig

November 23, 2020

Image credit: Zig project

Zig is a relatively new language that describes itself as "a general-purpose programming language and toolchain for maintaining robust, optimal, and reusable software."  One interesting feature of Zig is that all memory management is manual. There are no implicit heap allocations anywhere, even in the standard library (compare to, for example, C's strcpy/strncpy). Any function that wishes to allocate memory must take an allocator parameter. This means that the same code can run on machines with dramatically different memory availability, even a freestanding (embedded) target, simply by choosing an appropriate allocator

Another important feature of Zig is that errors cannot be implicitly ignored. Errors can easily be passed up the stack with `try`, or explicitly handled or ignored, but gone are the days of forgetting (or being too lazy) to check the return code of a function. Since the default behavior is for errors to be checked or propagated, the language naturally guides developers into properly handling errors. It takes special effort to ignore an error!

With these two features, memory allocation failures become "just another failure" mode that must be checked - compare to the common but regrettable C practice of ignoring malloc errors - and it is also very easy to plug in different allocators that fail in different or interesting ways, to see how your code will behave. In this post we'll demonstrate an allocator that fails after N allocations, one that fails on allocations of a specific size, and one that fails randomly with a certain probability (using a PRNG from the Zig standard library).

For these examples we'll use a very simple function that allocates and immediately frees `size` byte blocks of memory `times` times:

const std = @import("std");
pub fn functionThatUsesAnAllocator(allocator: *std.mem.Allocator, size: usize, times: usize) !usize {
    var i : usize = 0;
    while (i < times) {
        const memory = try allocator.alloc(u8, size);
        allocator.free(memory);
        i += 1;
    }
    return size;
}

Now, suppose we want to test what happens to our code if allocations start failing after the Nth request for memory. Luckily we can use the built-in FailingAllocator in the std.testing package:

const std = @import("std");

test "fail after N allocations" {
    var failing_allocator = std.testing.FailingAllocator.init(std.testing.allocator, 5);
    const result = functionThatUsesAnAllocator(&failing_allocator.allocator, 32, 10);
    std.testing.expectError(error.OutOfMemory, result);
}

However, what if we want to check other kinds of failures? For those cases not covered by the built-in FailingAllocator, we'll have to provide our own. Luckily this is fairly simple, and we can even use the standard failing allocator as the basis for our code. The relevant portion is the alloc function, shown below with a comment in place of the code that implements failing on the Nth request:

fn alloc(
    allocator: *std.mem.Allocator,
    len: usize,
    ptr_align: u29,
    len_align: u29,
    return_address: usize,
) error{OutOfMemory}![]u8 {
    const self = @fieldParentPtr(MyAllocator, "allocator", allocator);
    // implement custom logic here
    return self.internal_allocator.allocFn(self.internal_allocator, len, ptr_align, len_align, return_address);
}

With this in hand, we can start implementing custom failure modes. For example, maybe we want allocations of a specific size to fail. We'll replace the internal fail_index with fail_size, and return a memory failure if the requested size matches:

fn alloc(
    allocator: *std.mem.Allocator,
    len: usize,
    ptr_align: u29,
    len_align: u29,
    return_address: usize,
) error{OutOfMemory}![]u8 {
    const self = @fieldParentPtr(FailingAllocator, "allocator", allocator);
    if (self.fail_size == len) {
        return error.OutOfMemory;
    }
    return self.internal_allocator.allocFn(self.internal_allocator, len, ptr_align, len_align, return_address);
}

Alternatively, maybe we want an allocator that fails randomly with a certain probability (perhaps as part of fuzz testing to explore a large set of paths through the code before memory failure). In this case we're using a pseudorandom number generator from the std.rand package, but in real code we would make the seed a parameter instead of hardcoding it to zero.

fn alloc(
    allocator: *std.mem.Allocator,
    len: usize,
    ptr_align: u29,
    len_align: u29,
    return_address: usize,
) error{OutOfMemory}![]u8 {
    const self = @fieldParentPtr(FailingAllocator, "allocator", allocator);
    const val = self.prng.float(f64);
    if (val <= self.fail_chance) {
        return error.OutOfMemory;
    }
    return self.internal_allocator.allocFn(self.internal_allocator, len, ptr_align, len_align, return_address);
}

Notice that no special work was needed to adapt the code for testing it with different allocators. Furthermore, any standard Zig code will follow this pattern of taking an allocator as a parameter if it needs to allocate memory. This means that code from the standard library or a third party will be amenable to this testing strategy, without requiring any more work than providing an appropriate custom allocator. To see the full example code, see our repo