Annexer - tracking external resources and allocations

Hi folks, figured I’d share something I’ve been using for a while to gather some light brainstorming. Long post below!

Often when working with external C libraries (e.g. raylib) you’re required to call functions that allocate resources opaquely. This is pretty normal in other languages, but in Zig it’s considered bad manners. This means these resources aren’t tracked in the same way the lifetimes of pure-zig allocations are tracked, and we lose all the lovely benefits Zig grants us by doing so.

I also like to keep my “platform” code walled off and this presented another challenge when needing to store resouces in the higher up “generic” layer - in Zig it typically meant storing *anyopaque, which isn’t type safe and is somewhat prone to mistakes. It’s especially confusing when the platform type is itself a pointer, making the *anyopaque actually a **T, which is little messy for my liking.

In my personal projects this discrepancy at the “platform” layer bothered me and I came up with a few core requirements for some kind of “fix”:

  • Detect leaks of external resources, especially in tests
  • Track and free resources arena-style when needed
  • Allow type-safe transfer of external resources through code unaware of the external library

I spent some time trying to hack my way into doing this with just a std.mem.Allocator but kept coming up short - how would it know how to free the resource? How would I implement an arena? This was not something that could magically “just work”.


The solution I eventually settled on is Annexer. An annexer is very similar to std.mem.Allocator, but rather than only providing memory it also takes ownership of resources and knows how to free them.

Just like a zig allocator it has alloc and free which simply allocate space for the resource, but it also adds annex and liberate. These allow the caller take ownership of and free a resource. annex must be told how to free the resource, whereas a zig allocator’s alloc/create needs no extra knowledge.

With some safety checks and boilerplate removed the functions look like so:

/// Reserves memory to store type T
/// Calls to `free` will safely free this memory
pub fn alloc(self: Annexer, T: type) error{OutOfMemory}!*T {
    const ptr = self.rawAlloc(@sizeOf(T), .of(T), @returnAddress()) orelse return error.OutOfMemory;
    return @ptrCast(@alignCast(ptr));
}

/// "Annex" given data, storing it for later liberation
/// Note that this copies the resource value so you must use the ptr in the annex to do any operations that might mutate it
/// For this reason, prefer to use `create`
pub fn annex(self: Annexer, T: type, value: anytype, ptr: *T, comptime liberateFn: *const fn (T) void) Annex(T) {
    ...
    self.rawAnnex(std.mem.asBytes(ptr), .of(T), std.mem.asBytes(value_ptr), TypeErased.liberate);
    return .{ .ptr = ptr };
}

/// Liberates the resource using the liberateFn set in `annex`
/// Safe to call if `annex` has not been called
pub fn liberate(self: Annexer, T: type, ptr: *T) void {
    self.rawLiberate(std.mem.asBytes(ptr), .of(T));
}

/// Frees the memory of the annex
/// Unsafe to call if `annex` was used without a call to `liberate`
pub fn free(self: Annexer, T: type, ptr: *T) void {
    self.rawFree(std.mem.asBytes(ptr), .of(T), @returnAddress());
}

Just like std.mem.Allocator an Annexer also provides “helper” functions for better safety and predictability creating concrete types: create and destroy. create especially follows a “reserve first” approach for error safety, as well as ensuring the returned value from createFn is copied into the annex and discarded before accidental use - a common mistake that could occur when using the raw annex function.

/// Reserve and annex the return value from a function in a safe way, correctly using errordefer
pub fn create(self: Annexer, T: type, comptime createFn: anytype, args: anytype, comptime liberateFn: *const fn (T) void) !Annex(T) {
    ...
    // Safely allocate, create the resource and then annex it's value
    // The key here is to ensure we reserve the memory first, prepare the free, and then create the object
    // This follows proper "Reserve First" principles as seen in https://matklad.github.io/2025/08/16/reserve-first.html
    const ptr = try self.alloc(T);
    errdefer self.rawFree(std.mem.asBytes(ptr), .of(T), @returnAddress());
    const val = try @call(.auto, createFn, args);
    return self.annex(T, val, ptr, liberateFn);
}

/// Liberate the resource and free the annex memory
/// Providing the type here at the call site ensures mistakes are not made, e.g. when the resource is itself a pointer
pub fn destroy(self: Annexer, T: type, ptr: *T) void {
    self.rawLiberate(std.mem.asBytes(ptr), .of(T));
    self.rawFree(std.mem.asBytes(ptr), .of(T), @returnAddress());
}

One thing that makes it clear how an annexer is still very close to an allocator is it’s vtable, which only adds two extra functions on top of std.mem.Allocator’s.

ptr: *anyopaque,
vtable: *const struct {
    /// Copy the memory provided into the reserved buffer, in doing so set the
    /// function to free the resource, used when free is called.
    /// If `annex` is not used, liberate must still be safe to call
    annex: *const fn (self: *anyopaque, memory: []u8, alignment: std.mem.Alignment, data: []const u8, liberateFn: LiberateFn) void,

    /// Liberate the resource held in the annex using the chosen liberateFn
    liberate: *const fn (self: *anyopaque, memory: []u8, alignment: std.mem.Alignment) void,

    /// Store the vtable for a zig allocator using the same backing memory
    /// The alloc/free provided here are used to allocate and free the annex
    allocator: std.mem.Allocator.VTable,
},

For convenience Annexer also provides access to a std.mem.Allocator. This largely came about about because it’s common to be making “real” allocations alongside creating resources, and having fn(allocator: std.mem.Allocator, annexer: Annexer) everywhere was getting quite tedious. That this allocator will have identical lifecycle semantics to the annexer - if the annexer was created as an arena, then this allocator will behave the same too.

/// Return a zig allocator to use for regular allocations
pub fn allocator(self: *const Annexer) std.mem.Allocator {
    return .{ .ptr = self.ptr, .vtable = &self.vtable.allocator };
}

One type seen above is an Annex(T). This is a small wrapper providing type safe access and a init/deinit lifecycle that allows for simplified usage. The “annexed” resource (i.e. T) may itself be a pointer, so an Annex(T) removes the **anyopaque type confusion for the user.

/// An Annex represents a references to an external object, ID, handle or pointer
pub fn Annex(T: type) type {
    return struct {
        const Self = @This();
        ptr: *T,

        fn noLiberate(_: T) void {}

        pub fn init(a: Annexer, comptime annexFn: anytype, args: anytype, comptime liberateFn: ?*const fn (val: T) void) !Self {
            return a.create(T, annexFn, args, if (liberateFn) |f| f else noLiberate);
        }

        pub fn deinit(self: Self, a: Annexer) void {
            a.destroy(T, self.ptr);
        }

        pub fn value(self: Self) T {
            return self.ptr.*;
        }

        ...
    }
}

That final bullet point requirement was to pass the type through type-unaware code safely, this is achieved by borrowing some runtime type safety other’s have come up with. Annex(T) has one extra function that returns an OpaqueAnnex which can be passed around as needed, although so far this need has been rare.

/// An OpaqueAnnex represents a type-erased reference to a external object/id/handle/ptr
/// This type can be stored above the platform layer and used generically between different implementations
/// When the type is known, prefer Annex(T)
pub const OpaqueAnnex = struct {
    memory: []u8,
    typeid: if (std.debug.runtime_safety) TypeId else void,

    /// Zig native "type ID" in userspace
    /// See https://github.com/ziglang/zig/issues/19858#issuecomment-2369861301
    const TypeId = *const struct {
        _: u8,
    };

    /// Generate a typeID at comptime
    fn typeId(comptime T: type) TypeId {
        return &struct {
            comptime {
                _ = T;
            }
            var id: @typeInfo(TypeId).pointer.child = undefined;
        }.id;
    }

    pub fn typed(self: OpaqueAnnex, comptime T: type) Annex(T) {
        // Protect against misuse, ensure we only resolve to the correct type
        if (std.debug.runtime_safety)
            std.debug.assert(typeId(T) == self.typeid);
        return .{ .ptr = @ptrCast(@alignCast(self.memory.ptr)) };
    }
};


pub fn Annex(T: type) type {
    return struct {
        ...
        pub fn toOpaque(self: Self) OpaqueAnnex {
            return .{
                .memory = std.mem.asBytes(self.ptr),
                .typeid = if (std.debug.runtime_safety) OpaqueAnnex.typeId(T) else {},
            };
        }
    };
}

Put together this allows me to write the code below when interacting with external libraries. This example uses the excellent raylib-zig that already wraps the external library with error handling and zig lifecycles.

const raylib = @import("raylib");

pub const Shader = struct {
    annex: Annex(raylib.Shader),

    pub fn init(vs: [:0]const u8, fs: [:0]const u8) !Shader {
        return .{
            .annex = try .init(annexer, raylib.loadShader, .{ vs, fs }, raylib.unloadShader),
        };
    }

    pub fn deinit(self: *const Shader, annexer: Annexer) void {
        self.annex.deinit(annexer);
    }

    ...
}

The key thing to note here is that for the caller this Shader class largely behaves exactly like any other zig class, only needing an Annexer rather than an Allocator. In tests, a failure to call deinit will result in triggered leak detection, and in normal usage an arena-style Annexer could be passed in and deinit can be completely skipped.

Put all this together and it allows me to write concise and zig-like code, freed from (most) of the shackles of using untracked external resources.

// Set up a general annexer, similar to the Zig GeneralPurposeAllocator
var gpannexer: GeneralPurposeAnnexer = .init(std.testing.allocator);
const annexer = gpannexer.annexer();
const allocator = gpannexer.allocator();

// Perform regular work
const shader: Shader = try .init("foo.vs", "foo.fs", annexer);
defer shader.deint(annexer);
const mem = allocator.create(u128);
defer allocator.destroy(mem);

// Set up an arena annexer, similar to the zig ArenaAllocator
var arenannexer: ArenaAnnexer = .init(annexer);
defer arenannexer.deinit();
const arena_annexer = arenannexer.annexer();
const arena = arenannexer.allocator();

// No need to clean up
const shader2: Shader = try .init("foo.vs", "foo.fs", annexer);
const mem2 = allocator.create(u128);

The full implementation of Annexer, GeneralPurposeAnnexer and ArenaAnnexer along with much more thorough example/testing code can be found here. They could all use a little more refinement but for my needs they’re in a good place already - the memory and performance overhead is small enough that the benefits of found leaks and cleaner code has far outweighed any potential losses.

What do you think? Is this overkill? Are there other known solutions that meet my needs? Personally I hugely enjoyed working on this, especially discovering how elegant std.mem.Allocator already is and how possible it was to build on it’s foundation. If someone points out a far, far simpler solution then if nothing else I learned a lot about allocations and memory lifecycles!

5 Likes

Just to be sure, are you talking about single threaded environment? (arena etc)

Yup, I have a big TODO to work out how to handle multithread :slight_smile:

1 Like

Funny, while walking the dog this morning I thought about something quite similar, but not in Zig itself.
As a hobby project, I’m tinkering with implementing an object-oriented toy language to try out some of my ideas about what annoys me in existing languages and how to fix it.
And “resource” as a concept directly in the language was something I miss, including resource leak detection.
So, I like your approach and maybe use some of your ideas.

1 Like

i know only one solution - to add comment:

/// Only for single-threaded mode 

i’m serious

Haha. To be fair plenty of external libs don’t let you free stuff on any thread either, or at least don’t support any kind of concurrency.

For now I could simply put in runtime checks to ensure an annexer is only being used on a single thread.

1 Like