Preserve error return traces across Zig-C-Zig calls

From my Zig code, I need to call somelib_callCallback(), a C library function that takes a pointer to a callback function and calls it (synchronously). My callback might return a Zig error, in which case I want to propagate it all the way back to my code that originally called somelib_callCallback().

The callback takes a userdata pointer, which can be used to propagate the error with some minor casting:

const std = @import("std");

extern fn somelib_callCallback(cb: *const fn (?*anyopaque) callconv(.c) bool, userdata: ?*anyopaque) bool;

pub fn main() !u8 {
    var data: CallbackData = .{};
    const ok = somelib_callCallback(myCallbackC, &data);
    if (data.err) |err| {
        return err;
    }
    return if (ok) 0 else 1;
}

const CallbackData = struct {
    err: ?anyerror = null,
};

fn myCallbackC(userdata: ?*anyopaque) callconv(.c) bool {
    const data: *CallbackData = @alignCast(@ptrCast(userdata.?));
    myCallbackZig() catch |err| {
        data.err = err;
        return false;
    };
    return true;
}

fn myCallbackZig() !void {
    try doWork();
}

fn doWork() !void {
    return error.OutOfMemory;
}

However, when I run this code, the error return trace printed by src/start.zig doesn’t include doWork(), which is where the error was originally returned from, and only reaches as far as main():

error: OutOfMemory
foo.zig:9:9: 0x7ff7a6bbe7b6 in main (foo.exe.obj)
        return err;
        ^

(This behavior makes sense if we consider how Zig implements the error return trace.)

How can I preserve the error return trace across the callconv(.c) function calls?

There might be other possibly better ways to accomplish this, but this is my self-answer that I came up with after some tinkering and looking at how the error return trace is implemented:

  1. Use @errorReturnTrace() to get the error return trace object for the function that calls somelib_callCallback() and pass it forward to the callback.
  2. Inside the callback, get the error return trace object for the callback.
  3. Copy data from the callback’s error return trace object to the original caller’s object (note that the objects will be backed by different buffers with different lifetimes, so use @memcpy() to copy the addresses instead of reassigning the slice).
const std = @import("std");

extern fn somelib_callCallback(cb: *const fn (?*anyopaque) callconv(.c) bool, userdata: ?*anyopaque) bool;

pub fn main() !u8 {
    var data: CallbackData = .{
        .err_trace = @errorReturnTrace(),
    };
    const ok = somelib_callCallback(myCallbackC, &data);
    if (data.err) |err| {
        return err;
    }
    return if (ok) 0 else 1;
}

const CallbackData = struct {
    err: ?anyerror = null,
    err_trace: ?*std.builtin.StackTrace,
};

fn myCallbackC(userdata: ?*anyopaque) callconv(.c) bool {
    const data: *CallbackData = @alignCast(@ptrCast(userdata.?));
    myCallbackZig() catch |err| {
        data.err = err;
        if (@errorReturnTrace()) |src_trace| {
            if (data.err_trace) |dst_trace| {
                dst_trace.index = src_trace.index;
                const len = @min(dst_trace.instruction_addresses.len, src_trace.instruction_addresses.len);
                @memcpy(dst_trace.instruction_addresses[0..len], src_trace.instruction_addresses[0..len]);
            }
        }
        return false;
    };
    return true;
}

fn myCallbackZig() !void {
    try doWork();
}

fn doWork() !void {
    return error.OutOfMemory;
}

With these changes, we get a slightly more complete trace:

error: OutOfMemory
foo.zig:42:5: 0x7ff73af9ecee in doWork (foo.exe.obj)
    return error.OutOfMemory;
    ^
foo.zig:38:5: 0x7ff73af9ed25 in myCallbackZig (foo.exe.obj)
    try doWork();
    ^
foo.zig:11:9: 0x7ff73af9e7b5 in main (foo.exe.obj)
        return err;
        ^

The trace is missing the functions inbetween myCallbackZig() and main(), but this is perfectly fine for my purposes. If I really wanted to, with a bit of dedication and work I guess I could use @returnAddress() or something like std.debug.StackIterator to manually fill in the gaps.

2 Likes