How can I prevent segfault when hot reloading Zig code?

Hi everyone, I’m trying to learn how to hot reload Zig code on the fly.

I followed this article Hot-reloading with Raylib - Zig NEWS and got hot reload to work if I simply change a value of a number variable.

However, as I tried out more complex changes, hot reload results in segfaults.

Let’s say I have this original function when I run zig build run:

fn readRadiusConfig(allocator: std.mem.Allocator) f32 {
    const default_value: f32 = 10.0;
    const config_data = std.fs.cwd().readFileAlloc(allocator, config_filepath, 1024 * 1024) catch {
        std.debug.print("Failed to read {s}\n", .{config_filepath});
        return default_value;
    };
    return std.fmt.parseFloat(f32, config_data[0 .. config_data.len - 1]) catch {
        std.debug.print("Failed to parse {s}\n", .{config_filepath});
        return default_value;
    };
}

The program is running, I go back to the game.zig file, and change that function to:

fn readRadiusConfig(_: std.mem.Allocator) f32 {
    const default_value: f32 = 20.0;
    return default_value;
}

I press F5, this code recompiles and hot reload just fine, no segfault here.
I could revert the changes back to the original readRadiusConfig, hit F5, everything still reload fine.

However, if I change readRadiusConfig to this:

fn readRadiusConfig(allocator: std.mem.Allocator) f32 {
    const default_value: f32 = 10.0;
    const config_data = std.fs.cwd().readFileAlloc(allocator, config_filepath, 1024 * 1024) catch {
        std.debug.print("Failed to read {s}\n", .{config_filepath});
        return default_value;
    };
    std.debug.print("{s}", .{config_data}); // simply adding this line
    return std.fmt.parseFloat(f32, config_data[0 .. config_data.len - 1]) catch {
        std.debug.print("Failed to parse {s}\n", .{config_filepath});
        return default_value;
    };
}

I hit F5, it recompiles, loads in, and segfaults.
This is the stack trace of the segfault:

Segmentation fault at address 0x7ad5ec323500
/usr/lib/zig/std/mem/Allocator.zig:86:29: 0x7ad5e5d69a41 in allocBytesWithAlignment__anon_10701 (game)
    return self.vtable.alloc(self.ptr, len, ptr_align, ret_addr);
                            ^
/usr/lib/zig/std/mem/Allocator.zig:211:40: 0x7ad5e5d5935a in allocWithSizeAndAlignment__anon_9996 (game)
    return self.allocBytesWithAlignment(alignment, byte_count, return_address);
                                       ^
/usr/lib/zig/std/mem/Allocator.zig:205:75: 0x7ad5e5d21358 in alignedAlloc__anon_5551 (game)
    const ptr: [*]align(a) T = @ptrCast(try self.allocWithSizeAndAlignment(@sizeOf(T), a, n, return_address));
                                                                          ^
/usr/lib/zig/std/array_list.zig:457:67: 0x7ad5e5d2079d in ensureTotalCapacityPrecise (game)
                const new_memory = try self.allocator.alignedAlloc(T, alignment, new_capacity);
                                                                  ^
/usr/lib/zig/std/array_list.zig:66:48: 0x7ad5e5d1c699 in initCapacity (game)
            try self.ensureTotalCapacityPrecise(num);
                                               ^
/usr/lib/zig/std/fs/File.zig:1114:74: 0x7ad5e5d1c19b in readToEndAllocOptions__anon_4833 (game)
    var array_list = try std.ArrayListAligned(u8, alignment).initCapacity(allocator, initial_cap);
                                                                         ^
/usr/lib/zig/std/fs/Dir.zig:1938:38: 0x7ad5e5d1d271 in readFileAllocOptions__anon_4713 (game)
    return file.readToEndAllocOptions(allocator, max_bytes, stat_size, alignment, optional_sentinel);
                                     ^
/usr/lib/zig/std/fs/Dir.zig:1910:37: 0x7ad5e5d1d7c2 in readFileAlloc (game)
    return self.readFileAllocOptions(allocator, file_path, max_bytes, null, @alignOf(u8), null);
                                    ^
/mnt/136gb_SSD/learn-zig/learn-hot-reload-zig/src/game.zig:57:51: 0x7ad5e5d1a9da in readRadiusConfig (game)
    const config_data = std.fs.cwd().readFileAlloc(allocator, config_filepath, 1024 * 1024) catch {
                                                  ^
/mnt/136gb_SSD/learn-zig/learn-hot-reload-zig/src/game.zig:29:41: 0x7ad5e5d1ac2d in gameReload (game)
    game_state.radius = readRadiusConfig(game_state.allocator);
                                        ^
/mnt/136gb_SSD/learn-zig/learn-hot-reload-zig/src/main.zig:33:23: 0x1036db5 in main (hotreload)
            gameReload(game_state);
                      ^
/usr/lib/zig/std/start.zig:524:37: 0x10373f0 in main (hotreload)
            const result = root.main() catch |err| {
                                    ^
???:?:?: 0x7ad5ec439c87 in ??? (libc.so.6)
Unwind information for `libc.so.6:0x7ad5ec439c87` was not available, trace may be incomplete

???:?:?: 0x7ad5ec439d4b in ??? (libc.so.6)
???:?:?: 0x1035584 in ??? (???)
run
└─ run hotreload failure

Is there anything that I can do differently to prevent segfaults from happening? Thank you very much!

These are the Zig files in my project:

main.zig

// main.zig (hot-reloading)
const std = @import("std");
const c = @cImport({
    @cInclude("raylib.h");
});

const screen_w = 400;
const screen_h = 200;

// The main exe doesn't know anything about the GameState structure
// because that information exists inside the DLL, but it doesn't
// need to care. All main cares about is where it exists in memory
// so *anyopaque is just a pointer to a place in memory.
const GameStatePtr = *anyopaque;

var gameInit: *const fn () callconv(.C) GameStatePtr = undefined;
var gameReload: *const fn (GameStatePtr) callconv(.C) void = undefined;
var gameTick: *const fn (GameStatePtr) callconv(.C) void = undefined;
var gameDraw: *const fn (GameStatePtr) callconv(.C) void = undefined;

pub fn main() !void {
    loadGameDll() catch @panic("Failed to load game.dll");
    const game_state = gameInit();
    const allocator = std.heap.c_allocator;
    c.SetConfigFlags(c.FLAG_WINDOW_TRANSPARENT);
    c.InitWindow(screen_w, screen_h, "Zig Hot-Reload");
    c.SetTargetFPS(60);
    while (!c.WindowShouldClose()) {
        if (c.IsKeyPressed(c.KEY_F5)) {
            unloadGameDll() catch unreachable;
            recompileGameDll(allocator) catch std.debug.print("Failed to recompile game.dll", .{});
            loadGameDll() catch @panic("Failed to load game.dll");
            gameReload(game_state);
        }
        gameTick(game_state);
        c.BeginDrawing();
        gameDraw(game_state);
        c.EndDrawing();
    }
    c.CloseWindow();
}

var game_dyn_lib: ?std.DynLib = null;
fn loadGameDll() !void {
    if (game_dyn_lib != null) return error.AlreadyLoaded;
    var dyn_lib = std.DynLib.open("zig-out/lib/libgame.so") catch {
        return error.OpenFail;
    };
    game_dyn_lib = dyn_lib;
    gameInit = dyn_lib.lookup(@TypeOf(gameInit), "gameInit") orelse return error.LookupFail;
    gameReload = dyn_lib.lookup(@TypeOf(gameReload), "gameReload") orelse return error.LookupFail;
    gameTick = dyn_lib.lookup(@TypeOf(gameTick), "gameTick") orelse return error.LookupFail;
    gameDraw = dyn_lib.lookup(@TypeOf(gameDraw), "gameDraw") orelse return error.LookupFail;
    std.debug.print("Loaded game.dll\n", .{});
}

fn unloadGameDll() !void {
    if (game_dyn_lib) |*dyn_lib| {
        dyn_lib.close();
        game_dyn_lib = null;
    } else {
        return error.AlreadyUnloaded;
    }
}

fn recompileGameDll(allocator: std.mem.Allocator) !void {
    const process_args = [_][]const u8{
        "zig",
        "build",
        "-Dgame_only=true",
    };

    var build_process = std.process.Child.init(&process_args, allocator);
    try build_process.spawn();

    const term = try build_process.wait();
    switch (term) {
        .Exited => |exited| {
            if (exited == 2) return error.RecompileFail;
        },
        else => return,
    }
}

game.zig

// game.zig
const std = @import("std");
const c = @cImport({
    @cInclude("raylib.h");
});

const GameState = struct {
    allocator: std.mem.Allocator,
    time: f32 = 0,
    radius: f32 = 0,
};

const screen_w = 400;
const screen_h = 200;
const config_filepath = "config/radius.txt";

export fn gameInit() *anyopaque {
    var allocator = std.heap.c_allocator;
    const game_state = allocator.create(GameState) catch @panic("Out of memory.");
    game_state.* = GameState{
        .allocator = allocator,
        .radius = readRadiusConfig(allocator),
    };
    return game_state;
}

export fn gameReload(game_state_ptr: *anyopaque) void {
    var game_state: *GameState = @ptrCast(@alignCast(game_state_ptr));
    game_state.radius = readRadiusConfig(game_state.allocator);
}

export fn gameTick(game_state_ptr: *anyopaque) void {
    var game_state: *GameState = @ptrCast(@alignCast(game_state_ptr));
    game_state.time += c.GetFrameTime();
}

export fn gameDraw(game_state_ptr: *anyopaque) void {
    const game_state: *GameState = @ptrCast(@alignCast(game_state_ptr));
    c.ClearBackground(c.BLANK);

    // Create zero terminated string with the time and radius.
    var buf: [256]u8 = undefined;
    const slice = std.fmt.bufPrintZ(
        &buf,
        "radius: {d:.02}, time: {d:.02}",
        .{ game_state.radius, game_state.time },
    ) catch "error";
    c.DrawText(slice, 10, 10, 30, c.RAYWHITE);

    // Draw a circle moving across the screen with the config radius.
    const circle_x: f32 = @mod(game_state.time * 240.0, screen_w + game_state.radius * 2) - game_state.radius;
    c.DrawCircleV(.{ .x = circle_x, .y = screen_h / 2 }, game_state.radius, c.BLUE);
}

fn readRadiusConfig(allocator: std.mem.Allocator) f32 {
    const default_value: f32 = 10.0;
    const config_data = std.fs.cwd().readFileAlloc(allocator, config_filepath, 1024 * 1024) catch {
        std.debug.print("Failed to read {s}\n", .{config_filepath});
        return default_value;
    };
    return std.fmt.parseFloat(f32, config_data[0 .. config_data.len - 1]) catch {
        std.debug.print("Failed to parse {s}\n", .{config_filepath});
        return default_value;
    };
}

build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const game_only = b.option(bool, "game_only", "only build the game shared library") orelse false;
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const game_lib = b.addSharedLibrary(.{
        .name = "game",
        .root_source_file = b.path("src/game.zig"),
        .target = target,
        .optimize = optimize,
    });

    game_lib.linkSystemLibrary("raylib");
    game_lib.linkLibC();
    b.installArtifact(game_lib);

    if (!game_only) {
        const exe = b.addExecutable(.{
            .name = "hotreload",
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        });

        exe.linkSystemLibrary("raylib");
        exe.linkLibC();
        b.installArtifact(exe);
        const run_cmd = b.addRunArtifact(exe);
        run_cmd.step.dependOn(b.getInstallStep());
        if (b.args) |args| {
            run_cmd.addArgs(args);
        }
        const run_step = b.step("run", "Run the app");
        run_step.dependOn(&run_cmd.step);
    }
}

I tried to change gameReload function in game.zig to:

export fn gameReload(game_state_ptr: *anyopaque) void {
    var game_state: *GameState = @ptrCast(@alignCast(game_state_ptr));
    game_state.radius = readRadiusConfig(std.heap.c_allocator);
    // instead of: readRadiusConfig(game_state.allocator)
}

and this seems like it’s no longer segfaulting…
I’ll try to edit other things to make sure nothing else segfaults.

I’m not exactly sure but I have a feeling std.heap.c_allocator is the culprit. Since it is a global variable and you call gameInit() only once while initializing the main executable and not in the subsequent library loads C allocator’s VTable address is not a valid address anymore because you unload the previously loaded game library. You can make sure by printing the address of the std.heap.c_allocator.vtable in main executable and in game library, I’m pretty sure they are going to be different, actually it will be different for each build of the game library. Since you keep c_allocator of the first game library build in the GameState, subsequent builds try to use the old obsolete c_allocator.

I would create an allocator in the main executable and pass it to the gameInit as an argument and keep that passed allocator in the GemState struct.

3 Likes

Hi, I tried to follow your advice but I ran into this error:

1. parameter of type 'mem.Allocator' not allowed in function with calling convention 'C' [zig_build]

Is there a way to bypass this? Thank you very much!

After some unga bunga tries, I managed to do this and not segfault so far:

// game.zig
export fn gameInit(allocator_ptr: *anyopaque) *anyopaque {
    const allocator: *std.mem.Allocator = @ptrCast(@alignCast(allocator_ptr));
    const game_state = allocator.create(GameState) catch @panic("Out of memory.");
    game_state.* = GameState{
        .allocator = allocator.*,
        .radius = readRadiusConfig(allocator.*),
    };
    return game_state;
}
// main.zig
var gameInit: *const fn (*anyopaque) callconv(.C) GameStatePtr = undefined;
var gameReload: *const fn (GameStatePtr) callconv(.C) void = undefined;
var gameTick: *const fn (GameStatePtr) callconv(.C) void = undefined;
var gameDraw: *const fn (GameStatePtr) callconv(.C) void = undefined;

pub fn main() !void {
    var checker = rest.HotReloadChecker{};
    const thread = try std.Thread.spawn(.{}, rest.spawnServer, .{&checker});
    thread.detach();

    loadGameDll() catch @panic("Failed to load game.dll");

    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const game_state = gameInit(allocator.ptr);

    c.SetConfigFlags(c.FLAG_WINDOW_TRANSPARENT);
    c.InitWindow(screen_w, screen_h, "Zig Hot-Reload");
    c.SetTargetFPS(60);

    while (!c.WindowShouldClose()) {
        if (checker.should_reload()) {
            unloadGameDll() catch unreachable;
            loadGameDll() catch @panic("Failed to load game.dll");
            gameReload(game_state);
            checker.set_should_reload_on_next_loop(false);
        }

        gameTick(game_state);
        c.BeginDrawing();
        gameDraw(game_state);
        c.EndDrawing();
    }

    c.CloseWindow();
}

Thanks @umurgdk for the suggestion.

You may pass sort of a Environment struct to the gameInit, for your needs such as an allocator and more.

Although I allocate a big (say 256mb) block of memory from the main executable during initialization and pass it to the game library, and use allocators like arena on that block in the game library, and don’t ask to the OS for more memory.

2 Likes

I solved this problem in my project (which reloads a dll containing all the game logic), by re-running the initialization, which has the effect of fixing up any function pointers that point to code in the previous dll

    /// Called after initial initialization and after reloading.
    ///
    /// Systems that use function pointers to code in the game dll
    /// need to do their initialization here. When the game dll is
    /// reloaded, any function pointers are invalidated since they
    /// point to code in the now-unloaded dll.
    pub fn loaded(self: *GameState, game_memory: *ion.GameMemory, reloaded: bool) !void {
        gui.initNoContext(game_memory.allocator.*);
        errdefer gui.deinitNoContext();

        zmesh.init(game_memory.allocator.*);
        errdefer zmesh.deinit();

        if (reloaded) {
            self.name_table.allocator = self.fixed_allocator.allocator();
            self.world.static_allocator = self.fixed_allocator.allocator();
            for (self.world.component_set.toValueSlice()) |*column| {
                column.index.allocator = self.world.static_allocator;
            }

            self.editor.reload_count += 1;
        }

        try self.asset_cache.registerAssetType(asset.ShdcShaderAsset);
        try self.asset_cache.registerLoader(asset.ShdcShaderAsset.loader);

        try self.initRenderState(game_memory, reloaded);
        errdefer self.deinitRenderState(false);

        if (reloaded) {
            if (self.active_mode) |*mode| try mode.reloaded(self);
        } else {
            self.active_mode = try Mode.init(.render_test, self);
            errdefer self.active_mode.?.deinit(self);
            try self.active_mode.?.enter(self);
        }

The allocator vtables need to be recreated, and the asset loaders need to be re-registered (they also use vtables). The active game mode gets reloaded (and the logic in the game mode needs to do the same thing - update any function pointers. I tried to keep it simple, where writing the init code just works for both reload and init, but in some cases it has to do special logic when reloaded.

This solution isn’t perfect because when adding something new it’s easy to forget to replace some nested allocator.