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:
- Use
@errorReturnTrace()
to get the error return trace object for the function that calls somelib_callCallback()
and pass it forward to the callback.
- Inside the callback, get the error return trace object for the callback.
- 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