- Zig 100%
| examples/print | Link mode | |
| src | Link mode | |
| .gitignore | Initial commit | |
| build.zig | Stop using dladdr(), instead let the user provide their own backend. | |
| build.zig.zon | Oops | |
| LICENSE.md | Initial commit | |
| README.md | Tag 0.2 | |
Simple, batteries included hot-reloading for Zig
zr strives to be the best possible hot reloader for Zig, while still relatively simple to use. Inspired by fungos' cr library.
In the future, hopefully, this library will become irrelevant once Zig supports built-in hot reloading by directly patching the executable code. In the meantime, zr strives to be the next best thing.
How it works
zr works like most other hot reloading solutions: your program is compiled as a shared library (called a zr.Plugin), which a "host" executable loads dynamically and checks for changes. It keeps track of exported functions and calls them as normal. When it detects the shared library has changed, it reloads them and changes the function pointers to the updated version.
This solution comes with a lot of caveats (most will be listed below) and requires you to avoid things like function pointers and global variables, or to manually track them and patch them in. zr solves the most painful ones for you, and the rest have simple solutions you can just keep in mind.
zr works out of the box on POSIX and Windows operating systems and can be used without libc on both Windows and Linux thanks to std.DynLib. Implementing zr for other targets is very easy though: You just need to provide it with three simple functions in a custom backend. (Example of this coming soon)
What does zr do that manual hot reloading doesn't?
- Very simple integration and configuration - it should be working in a few minutes
Pluginsymbols are type checked, the plugin module is the "single source of truth"- Allows for totally disappearing (and just statically linking the
Plugin) with a simple boolean flip - Allows for nearly-seamless (one line) reloading of static/global variables and function pointers with the
Registry - Fully documented code that you would have had to write anyway
Quick example
Minimum required Zig version is 0.15.1.
zig fetch --save git+https://codeberg.org/zwynd/zr
Minimal build.zig:
// zr dependencyconstzr=b.dependency("zr",.{.target=target,.optimize=optimize});// Host module, what holds the Plugin and reloads itconsthost_mod=b.addModule("host",.{.root_source_file=b.path("<path to host main.zig>"),.target=target,.optimize=optimize,.link_libc=true,// Necessary for zr// ... etc....});// Plugin module, contains the code that will be live reloadedconstplugin_mod=b.addModule("plugin",.{.root_source_file=b.path("<path to plugin root.zig>"),.target=target,.optimize=optimize,.link_libc=true,// Necessary for zr// ... etc....});host_mod.addImport("zr",zr.module("zr"));plugin_mod.addImport("zr",zr.module("zr"));// Host executableconsthost_exe=b.addExecutable(.{.name="host",.root_module=host_mod,// ... etc....});b.installArtifact(host_exe);// Plugin dynamic library (can choose to compile only on debug mode)if(optimize==.Debug){constplugin_dl=b.addLibrary(.{.linkage=.dynamic,.name="plugin",.root_module=plugin_mod,// ... etc....});b.installArtifact(plugin_dl);}Check out a working code example in examples/print.
Edge cases and caveats
Please read this section fully as hot reloading bugs can be a big pain.
Type safety
dlsym or GetProcAddress or the like return *anyopaque typeless pointers that then need to be casted to function pointers with the appropriate signature. Thankfully, with some meta-programming shenanigans, zr makes sure they are always casted to the same exact signature as the exported function in the plugin module. You only need to type the function signature once: in the actual function declaration. Everywhere else takes that into account, allowing for full type safety.
Distribution
On a release/publish build it may be desired to disable hot-reloading or even just link statically alltogether. This can be tedious to setup manually, but zr can do it for you with a switch. With cfg.link_mode you can control how zr uses your plugin's symbols:
LinkMode.dynamicfetches them from a dynamic library and performs hot-reload logic.LinkMode.dynamic_no_reloadfetches them from a dynamic library, but performs no hot-reload logic. This is useful in case you still want to use dynamic libraries even on a release/publish build.LinkMode.staticfetches them from the plugin's imported Zig module, and performs no hot-reload logic or even any dynamic library loading. This is effectively full static linking that makeszrdisappear!
Memory and leaks
Memory is managed per process, and as such when unloading a shared library its allocations are not automatically freed like it is on process exit. This means that memory allocated by the OS (with mmap, VirtualAlloc, etc) in plugin code will leak. However, with global state being reset, it also means you cannot free memory from a previous DLL version without weird, indecipherable corruption bugs.
The two main solutions to this are:
- Use an allocator that comes from another dynamic library, such as
std.heap.c_allocator - Only allocate host-side. Provide the plugin with allocators and use only those.
Another option is to just not care and leak memory - you are in debug mode after all. But that's sloppy!
ABI boundary
When calling a function from a dynamic library it needs to go through an ABI. Zig has no stable ABI, so it can change and reorder structs as much as it wants to. This is a problem for calling a function from a shared library, which is why all zr plugin functions must be callconv(.c) and they must only take extern struct or C compatible values...
That being said, since zr is meant to be used from Zig only, we actually don't need the struct to be extern as long as we pass a pointer to it, and assume the shared libraries and the host are compiled with the same exact compiler, which they most probably always are.
Reloading globals
Global and static data gets reset when unloading the plugin. However zr is able to keep track of it and patch it in after a reload if, on initialization, you manually tell it to.
Simply provide a plugin function with a reg: *zr.Registry, and call reg.track_variable(&variable). Additionally, after every hot reload, reg.restore_variables() needs to be called plugin-side. This is demonstrated in the examples/print example.
zr can't do anything for third party library globals, such as those in ImGui, SDL, Sokol, Raylib, Vulkan, etc. In that case, you'll either have to link the libraries statically to the host and only use them in the plugin through provided wrapper functions, or, more easily, link them dynamically.
Additionally, even with tracked globals, pointer values will not work upon reload unless pointing to host-allocated data.
A note on string literals
String literals (generated with "s) point to static memory, so just like all other static data, it gets reset when the plugin reloads. Note that this is only a problem if you wanna keep them long lived.
If you do, while you can track string literals like any other global, it's way easier to simply allocate them with dupe into a memory arena living in the host.
Reloading function pointers
The linker never guarantees it'll place a function in the same address when recompiling. This means on reload it's 99.99% likely a pointer to a function inside of a plugin will become invalid.
zr can reload function pointers for you if you track it with the Registry. Simply call reg.track_fnptr(&fnptr, "export_name") and the function pointer will work after a hot-reload, with some caveats:
- The function that the pointer is, er, pointing to, must be
@exported. The export name must be used intrack_fnptr. - And since it must be
@exported, it also must becallconv(.c). Theoretically, if you only call it plugin-side, it never crosses the dll boundary, and as such acallconv(.auto)function would be safe to call, however, Zig simply doesn't let you@exportthose functions, so we are stuck with this.
Regardless, it's best adviced to not use function pointers if you gain no benefit in return. They make code harder to read and to optimize. Switching on enums for behaviour is usually the better idea.
Threading
Thread local storage should be reloadable the same way globals are, with reg.track_variable. zr.Registry should be thread-safe.
That being said, hot reloading may cause weird thread bugs. To avoid them, all threads running inside a plugin should be paused for a short period of time while the reload is taking place. zr has no mechanism of doing this for you yet.
For threads that you don't control, such as those by third party libraries, much like with global state the only solution is to link them dynamically or link them statically and use them in the host exclusively. :/
Changing size or layout of structures
When you change the size or layout of your plugin's state, the code changes to expect the new structures, but the memory keeps representing the old ones. Weird unholy eldritch bugs ensue.
Most of the times you want to hot reload is to change behaviour, so this isn't much of an issue. Simply restart the application completely.
If you program in a way that your plugin's entire state is "plain old data" (by not storing pointers, mainly), you actually can get around this limitation by serializing the state on unload and deserializing on reload, using something like MessagePack. You don't even need to do it to a file, just to host memory.
Thanks to Zig's reflection, this is not as difficult to write as in C/C++, however, this solution is very opinionated and demands the entire plugin to be written in a very specific way. As such, it's not provided by zr.
Potential improvement points
I would like zr to be the best solution for hot reloading in Zig before the compiler unlocks its superpowers. I think it's already that, but it can be better. I would appreciate some help though:
- A thread tracker that pauses all threads for a moment while the hot-reload is being performed is very possible, however I would like to make it work with any thread structure, be it
std.Threador an SDL thread or native win32 / POSIX threads. - If we make the assumption that every
Pluginhas its own globalStatestructure, we could perhaps track when its size or layout changes. Notifying of this could help with crashing early to avoid corruption bugs or implementing some custom serialization logic to allow for it.