Zig NEWS

jack
jack

Posted on

Hot-Reloading in zig

Recently, I've implemented hot-reloading for my little game framework jok.

I didn't consider hot-reloading for a long time, cause my framework is deadly simple, and I don't want to complicate it. However, after watched Billy Basso's wonderul interview, I think maybe it's not that hard at all. Let's do it, in zig of course.

First of all, in order to make some stuff capable of hot-reloading, you have to come up with some kind of standard api for your game objects or plugins, whatever you call it (let's use term plugin for convenience). Here's what I defined:

const InitFn = *const fn (ctx: *const jok.Context) callconv(.C) void;
const DeinitFn = *const fn (ctx: *const jok.Context) callconv(.C) void;
const EventFn = *const fn (ctx: *const jok.Context, e: *const jok.Event) callconv(.C) void;
const UpdateFn = *const fn (ctx: *const jok.Context) callconv(.C) void;
const DrawFn = *const fn (ctx: *const jok.Context) callconv(.C) void;
const GetMemoryFn = *const fn () callconv(.C) ?*const anyopaque;
const ReloadMemoryFn = *const fn (mem: ?*const anyopaque) callconv(.C) void;

pub const Plugin = struct {
    lib: std.DynLib,
    path: []const u8,
    last_modify_time: i128,
    init_fn: InitFn,
    deinit_fn: DeinitFn,
    event_fn: EventFn,
    update_fn: UpdateFn,
    draw_fn: DrawFn,
    get_mem_fn: GetMemoryFn,
    reload_fn: ReloadMemoryFn,
};
Enter fullscreen mode Exit fullscreen mode

There are 7 functions in C ABI. The InitFn/DeinitFn are for initializing and destroying plugins, only called once when plugins are registered and unregistered. The EventFn/UpdateFn/DrawFn are for receiving input events, updating internal state and do renderings in every frame. And there are GetMemoryFn/ReloadMemoryFn, called by framework when reloading plugins and restore plugins' internal states. Plugin struct stores handle to loaded library, path to library and function pointers of all exported api.

The parameter jok.Context is standard application context of my framework, which I normally wouldn't pass around using pointer, however, I kinda have to in this case, cause otherwise API won't be compatible to C ABI.

Let's implement the function for loading shared library.

fn loadLibrary(allocator: std.mem.Allocator, path: []const u8) !struct {
    lib: std.DynLib,
    init_fn: InitFn,
    deinit_fn: DeinitFn,
    event_fn: EventFn,
    update_fn: UpdateFn,
    draw_fn: DrawFn,
    get_mem_fn: GetMemoryFn,
    reload_fn: ReloadMemoryFn,
} {
    const lib_path = try std.fmt.allocPrint(allocator, "./jok.{s}", .{std.fs.path.basename(path)});
    defer allocator.free(lib_path);

    // Create temp library files
    std.fs.cwd().deleteFile(lib_path) catch |e| {
        if (e != error.FileNotFound) return e;
    };
    try std.fs.cwd().copyFile(path, std.fs.cwd(), lib_path, .{});

    // Load library and lookup api
    var lib = try DynLib.open(lib_path);
    const init_fn = lib.lookup(InitFn, "init").?;
    const deinit_fn = lib.lookup(DeinitFn, "deinit").?;
    const event_fn = lib.lookup(EventFn, "event").?;
    const update_fn = lib.lookup(UpdateFn, "update").?;
    const draw_fn = lib.lookup(DrawFn, "draw").?;
    const get_memory_fn = lib.lookup(GetMemoryFn, "get_memory").?;
    const reload_memory_fn = lib.lookup(ReloadMemoryFn, "reload_memory").?;

    return .{
        .lib = lib,
        .init_fn = init_fn,
        .deinit_fn = deinit_fn,
        .event_fn = event_fn,
        .update_fn = update_fn,
        .draw_fn = draw_fn,
        .get_memory_fn = get_memory_fn,
        .reload_memory_fn = reload_memory_fn,
    };

}
Enter fullscreen mode Exit fullscreen mode

Thanks to zig's standard library, we don't need to investigate os documentation to do library loading (it would be great learning experience though). std.DynLib is very easy to use, just open the library with path to it and lookup symbols right away. A little problem that need to be aware of is DLL file can't be written/update when it's being loaded on Windows, so I copied the original library and load the temporary file instead.

OK, time to do hot-reloading. My strategy is very simple: check library file's modify time every frame, reload it if it's different from previously remembered one. On my old Linux notebook, the strategy works fine. However, reloading might fail due to incomplete update of library if you got slow disk IO, and I also noted from documentation of std.fs.statFile that it takes 3 system calls in order to get mtime on Windows, something you guys might need to concern. Here's code:

var plugins = std.StringArrayHashMap(Plugin).init(allocator);

pub fn update(ctx: jok.Context) void {
    var it = self.plugins.iterator();
    while (it.next()) |kv| {
        kv.value_ptr.update_fn(&ctx);

        // Do hot-reload checking
        const stat = std.fs.cwd().statFile(kv.value_ptr.path) catch continue;
        if (stat.mtime != kv.value_ptr.last_modify_time) {
            const mem = kv.value_ptr.get_mem_fn();
            kv.value_ptr.lib.close();

            const loaded = loadLibrary(allocator, kv.value_ptr.path) catch |e| {
                log.err("Load library {s} failed: {}", .{ kv.value_ptr.path, e });
                continue;
            };
            loaded.reload_fn(mem);
            kv.value_ptr.lib = loaded.lib;
            kv.value_ptr.last_modify_time = stat.mtime;
            kv.value_ptr.init_fn = loaded.init_fn;
            kv.value_ptr.deinit_fn = loaded.deinit_fn;
            kv.value_ptr.event_fn = loaded.event_fn;
            kv.value_ptr.update_fn = loaded.update_fn;
            kv.value_ptr.draw_fn = loaded.draw_fn;
            kv.value_ptr.get_mem_fn = loaded.get_mem_fn;
            kv.value_ptr.reload_fn = loaded.reload_fn;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, I'm using std.StringArrayHashMap to maintain all loaded plugins, and update is called by framework in every frame. When modification is discovered, get_memory will be used to withdraw plugin's internal state before closing shared library, and reload_memory will be used to restore plugin's internal state.

This is a fairly simple solution for hot-reloading plugins. I'm sure there are better and more robust ways to implement the idea, but it's a good start!

BTW, here's a little example showcasing the above code if you guys are interested.
https://x.com/jichengde/status/1897996358155743707

Top comments (3)

Collapse
 
falkgaard profile image
Falkgaard • Edited

Interesting, great post!

One thought; instead of having get_memory_fn and reload_memory_fn that get called at unload and reload (which I assume entails full module state memcpying back and forth), wouldn't it be better to have the module do all of its memory allocations on the the runtime side of the dynamic library boundary? E.g., store some runtime-side allocator in the jok.Context; or is there some good reason not to do it this way?

It seems to me like with this approach you could do something like this:

// module.zig
const ModuleState = struct { ... };
var g_module_state: ?ModuleState = undefined;

pub extern fn init( ctx: *const jok.Context ) callconv(.C) void {
   // Allocate the memory on the engine side:
   g_module_state = try ctx.allocator.alloc( ModuleState, 1 );
   try ctx.storeAddr( g_module_state, "MyModuleState" ); // Name it for post-reload access.
   // set fields of g_module_state...
}

pub extern fn reload( ctx: *const jok.Context ) callconv(.C) void {
    g_module_state = try ctx.loadAddr( "MyModuleState" ); // Re-use previous memory.
    // Do any post-reload changes to the state...
}

pub extern fn deinit( ctx: *const jok.Context ) callconv(.C) void {
   ctx.forgetAddr( "MyModuleState" ); // The module is unloaded; clean up.
   ctx.allocator.free( g_module_state );
}
Enter fullscreen mode Exit fullscreen mode

(You'd need to do some casts for the addrs, but this is the gist of it. Merging the (de)allocation+(un)storing into two functions on the API would probably be better too instead.)

Granted, if the layout of ModuleState changes with compilations there will be problems, but hot-reloading doesn't really support such workflows anyways (the primary goal is to tweak values and function logic, after all).

Collapse
 
droneah profile image
drone-ah

This post looks really interesting. What happens if data structures change, and they are currently in use? Does it get detected, or would it just segfault?

Collapse
 
jackji profile image
jack

Yeah, probably segfault.