Files
Volkan/README.md
T
2026-09-25 03:20:35 -03:00

12 KiB

Hot Reload

A cross-platform, cross-compiler C/C++ library that patches a running process's own code in place, so a new build of a function takes effect immediately — no restart, and every existing caller (including a raw C function pointer cached somewhere) picks it up automatically.

// host.cpp -- loads a plugin, calls into it, then hot-reloads it later
HotReloadContext* ctx = hot_reload_context_create(nullptr);
hot_reload_load(ctx, "plugin", "plugin.so");

auto tick = reinterpret_cast<int (*)(int)>(hot_reload_get_symbol(ctx, "plugin", "tick"));
tick(1);                                        // runs V1's code

hot_reload_reload(ctx, "plugin", "plugin.so");  // plugin.so was rebuilt in between
tick(1);                                        // same pointer, now runs V2's code

Features

  • Works on a real function pointer, not just a lookup table. Patching happens at the function's own address, so every path that reaches it — a direct call, a call through the PLT/GOT, a raw function pointer cached in a struct, a C++ vtable slot — picks up the new code, with nothing to re-resolve.
  • Only executable code is ever touched — mechanically, not by convention. A symbol is a patch candidate only if the loader's own metadata says it's a function in an executable section (ELF STT_FUNC+SHF_EXECINSTR, or a PE export in an executable section). Data — globals, class instances, vtables, RTTI — is structurally excluded, never something you have to remember to avoid exporting.
  • Deletion is handled, not just tolerated. A function missing from a new build is reported through an event callback, and by default its old implementation just keeps running — never a crash, never a jump to garbage. Redirect it to your own stub instead if you want stale calls to be loud during development.
  • Real C++ works, not just extern "C". A real, unmodified mangled name — an ordinary method, a virtual method (redirected wherever it's reached from, direct call or vtable slot, since both hold the same address), an overloaded operator — patches exactly like a plain C function, with no special handling needed. See "Hot reloading C++ symbols" for the one real limit this runs into (inlining) and how to work with it.
  • Failures are diagnosable, not a black box. hot_reload_last_error() returns the platform's own explanation (dlerror() / a formatted GetLastError()) for why a load failed — a missing file, a permissions problem, and a shared library with a genuinely undefined symbol all look identical from the status code alone, and don't need to. A corrupted or truncated module is handled with the same care: every offset the ELF reader trusts is bounds-checked against the file's actual size, confirmed by feeding it 1500 randomly corrupted variants of a real plugin — see "What's actually been tested" below.
  • x86, x86-64, and ARM64, across GCC, Clang, MinGW, and MSVC. See "What's actually been tested" below for exactly how each combination was verified — natively, under QEMU, or under Wine, not just reasoned about.

Documentation

  • source/HotReload/README.md is the full reference: exactly how patching works, both safety guarantees in detail, the complete C++ interoperability story, per-platform scope and how each was verified, and a growing list of real bugs found (and fixed) by actually running this — on Windows/MSVC, under fuzzing, and on every other toolchain in the table below — read this before relying on any specific edge case.
  • Every source file, not just the header, is Doxygen-documented — hot_reload.h's public API (every function, status code, and event kind has a @brief/@param/@return) and every internal class and helper behind it, down to why each one exists. Build it locally:
    scripts/setup.sh                    # one-time: fetches a pinned Doxygen
    scripts/docs.sh hotreload_lib
    
    then open out/docs/hotreload_lib/html/index.html. Verified to generate with zero warnings — for every module this project documents, library, examples, and tests alike — even reconfigured with -DDOXYGEN_WARN_IF_UNDOCUMENTED=YES -DDOXYGEN_EXTRACT_PRIVATE=YES, the strictest combination cmake/Documentation.cmake exposes.
  • CONTRIBUTING.md covers the CMake build system this project itself uses (module conventions, scripts/, output layout) — only relevant if you're extending this repository, not for using the library in your own.

How to use it

Build it

scripts/build.sh                                              # library only
scripts/build.sh Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON  # + both demos + the test suite
scripts\build.bat
scripts\build.bat Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON

This produces hotreload_lib, a STATIC library with a plain C API (hot_reload.h) over a C++ implementation. Link your host application against it (target_link_libraries(your_app PRIVATE hotreload_lib) if you're using CMake; see source/HotReload/CMakeLists.txt for what it links on each platform if you're not).

See it work — a scripted, deterministic demo

./out/build/bin/examples/hotreload_demo_host

Loads one plugin, calls into it, hot-reloads a second build of it, and calls the same, never-re-resolved function pointers again — proving every guarantee above in one run. Open source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp and its PluginV2 counterpart side by side against the output.

Try it yourself — a persistent, interactive demo

./out/build/bin/examples/hotreload_live_demo_host        # leave this running
cmake --build out/build --target hotreload_live_plugin   # in another terminal, or:
source/examples/HotReloadLiveDemo/watch.sh out/build      # rebuild automatically on save

Edit source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp — its own comment walks through several concrete things worth trying — and watch the running process pick up your change within about a second, with no restart. See source/examples/HotReloadLiveDemo/README.md for the full walkthrough, including two Windows-specific gotchas (Access is denied on "Rebuild", and why leftover .hotreload.N files next to your plugin are expected, not a leak) that are easy to mistake for bugs the first time you hit them.

Use it in your own module

  1. extern "C" isn't required — a real C++ mangled name works too — but it's the simplest choice for anything you want to look up by a plain, human-typed name via hot_reload_get_symbol().
  2. Call hot_reload_enable_patchable_functions(<target>) (cmake/HotReload.cmake) on your plugin's CMake target, so every exported function has guaranteed room for a jump.
  3. Optionally call hot_reload_restrict_exports(<target>) and mark just your intended entry points with HOT_RELOAD_EXPORT, instead of exporting everything by default — keeps your own internal helpers out of the reloadable surface. See source/HotReload/README.md's "Using it in your own module" for the full version of this list, including why this specific step doesn't (and can't) quiet std::string/ std::vector noise, and why that's fine.
  4. Have your host hot_reload_load() it once at startup and hot_reload_reload() it after every rebuild, from a point where nothing else is actively calling into it. A path with no / in it is still resolved against the current working directory (never a system library path), so a plain plugin.so next to your host works exactly as you'd expect.

Limitations

Two things this library fundamentally cannot detect, because a symbol table doesn't carry the information needed to:

  • A changed function signature. Patching redirects control flow at a fixed address; it has no way to know that void tick(int) became void tick(int, int) between builds. Give a changed signature a new exported name instead of reusing the old one.
  • A data layout change reached through code that still gets patched. This library guarantees it never touches a data symbol directly. It cannot guarantee that a patched function is safe to run against an object that already exists in memory with the old layout — e.g. a C++ vtable gaining a new virtual function, or a struct gaining a field, while an old instance is still alive.

One thing it doesn't fully solve, by design rather than by omission: calling hot_reload_reload() concurrently with active execution of the code being replaced isn't guaranteed safe on every architecture — the intended pattern (shared by every tool in this space) is reloading from a quiescent point, such as your main thread between frames. See source/HotReload/README.md's "What this cannot detect" for the full reasoning, including exactly which write-ordering protections are in place and what they do and don't cover.

What's actually been tested

Not just built for, actually run to completion, verified directly rather than assumed:

Toolchain How it was verified
GCC + Clang, native Linux/x86-64 Both compilers, full demo suite and test suite run to completion, including a CET (endbr64) landing pad this toolchain turns out to emit by default, and again explicitly forcing one with -fcf-protection=full
GCC, native Linux/x86 (32-bit) Full demo and test suite run to completion under -m32. This is also where the suite's own "too small to patch" plugin function turned out not to be small enough at -O2 on this one target — a real, previously-unverified gap between what the test assumed and what the compiler actually emitted, closed by making that function require no instructions at all
ARM64 (GCC cross-compiled) Run under qemu-aarch64 — twice, once plain and once with -mbranch-protection=standard, to exercise the BTI landing-pad path on real ARM64 semantics
MinGW Cross-compiled and run under Wine, including the PE export-table parser and a real LoadLibrary failure (confirmed hot_reload_last_error() reports "Bad EXE format" correctly)
MSVC Built and live-tested on real Windows by someone using this library — not by the environment that wrote it. Four real fixes came out of that and are folded in: windows.h/dbghelp.h include order, NOMINMAX, explicit dbghelp linking, and a reload-retry widening for Windows' slower post-rebuild file-availability timing
AddressSanitizer + UndefinedBehaviorSanitizer, and Valgrind with self-modifying-code checking enabled Full test suite, clean under all three — the self-modifying-code checking matters specifically here, since patching a function's own bytes at runtime is exactly the pattern a memory checker's JIT otherwise mishandles by default
Fuzzing (ELF section-header parsing) 1500 randomly corrupted variants of a real plugin's section header table — each still a file the OS loader itself accepts via dlopen() — all handled cleanly. An earlier version of this same reader crashed on 69 of an initial 300

Three more real, reproducible bugs came out of this same pass, each confirmed broken before the fix and confirmed fixed after: a hot_reload_load()/hot_reload_reload() path with no / in it (e.g. a plain "plugin.so") silently failed to load on POSIX, since dlopen() treats a bare name as a library-search request rather than a file path; a module name containing a / or other unusual character (the header never promised it had to look like a file name) broke the internal staging path used to make each reload load from a fresh copy; and the ELF section-header hardening above.

Deliberately not implemented, rather than implemented and untested: 32-bit ARM (its ARM/Thumb instruction-mode ambiguity fails silently if gotten wrong, and there was no way to verify it on real or emulated hardware) and macOS/Mach-O (never requested, and would need a third image format reader alongside the existing ELF and PE ones). Both are documented, bounded gaps — see source/HotReload/README.md's "Scope" section for the reasoning behind each, and the extension point if you need either.