Files
2026-11-03 15:22:24 -03:00

275 lines
20 KiB
Markdown

# Volkan
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.
```cpp
// host.cpp -- loads a plugin, calls into it, then hot-reloads it later
hot_reload::Context ctx;
ctx.load("plugin", "plugin.so");
auto tick = reinterpret_cast<int (*)(int)>(ctx.findSymbol("plugin", "tick"));
tick(1); // runs V1's code
ctx.reload("plugin", "plugin.so"); // plugin.so was rebuilt in between
tick(1); // same pointer, now runs V2's code
```
This is `hot_reload::Context`, the primary, modern C++ API
([`Context.hpp`](source/HotReload/include/hot_reload/Context.hpp)) --
RAII, a `Result` instead of a status-code-plus-side-channel, and safe to
call from multiple threads at once. A separate, independently stable C
API ([`hot_reload.h`](source/HotReload/include/hot_reload/hot_reload.h))
wraps it for anything that needs plain C linkage; see ["Pick an
API"](#pick-an-api) below.
## 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"](source/HotReload/README.md#hot-reloading-c-symbols-classes-virtual-methods-operators)
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, ARM64, and ARM32 (both its ARM and Thumb-2 instruction
sets), 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.
- **Safe to call from multiple threads at once, verified under
ThreadSanitizer, not just locked and hoped.** `hot_reload::Context`
uses a reader-writer lock internally: concurrent `findSymbol()` calls
run in parallel with each other, while `load()`/`reload()` briefly
take exclusive access. This covers the library's own bookkeeping —
it does not, and architecturally cannot, make it safe to reload a
module some other thread is at that exact moment executing inside;
see ["Pick an API"](#pick-an-api) for the precise guarantee and
`source/HotReload/README.md`'s "What this cannot detect" for why that
one specific case is out of reach for any tool like this one.
## Documentation
- **[`source/HotReload/README.md`](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, under
ThreadSanitizer, and on every other toolchain in the table below — read
this before relying on any specific edge case.
- **[`Context.hpp`](source/HotReload/include/hot_reload/Context.hpp) is
the primary, modern C++ API**, with the rest of its surface --
[`Result`](source/HotReload/include/hot_reload/Result.hpp),
[`Status`](source/HotReload/include/hot_reload/Status.hpp),
[`Options`](source/HotReload/include/hot_reload/Options.hpp),
[`SymbolEvent`](source/HotReload/include/hot_reload/SymbolEvent.hpp),
[`SymbolEventInfo`](source/HotReload/include/hot_reload/SymbolEventInfo.hpp),
[`demangle()`](source/HotReload/include/hot_reload/Demangle.hpp) --
one type or function per header, each with its own matching `.cpp`.
`hot_reload.h`'s C API is a separate, loosely coupled wrapper built on
top of this one's public methods only (`c-api/HotReloadApi.cpp`), not
the other way around -- see that file's own file comment, and ["Pick
an API"](#pick-an-api) below for which to reach for.
- **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:
```sh
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`](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
```sh
scripts/build.sh # library only
scripts/build.sh Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON # + both demos + the test suite
```
```bat
scripts\build.bat
scripts\build.bat Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON
```
This produces `hotreload_lib`, a `STATIC` library exposing both a
modern C++ API (`Context.hpp` and the headers alongside it) and a
separate, independently stable plain C API (`hot_reload.h`) built on
top of it. Link your host application against it
(`target_link_libraries(your_app PRIVATE hotreload_lib)` if you're
using CMake; see [`source/HotReload/CMakeLists.txt`](source/HotReload/CMakeLists.txt)
for what it links on each platform if you're not).
### Pick an API
Both are first-class, both stay supported, and both end up patching the
exact same way underneath (the C API calls the C++ one's own public
methods, nothing more privileged) -- this is about which fits what
you're writing:
- **`hot_reload::Context` (`Context.hpp`)** — a C++ host. RAII (no
separate create/destroy calls), a `Result` returned directly from
`load()`/`reload()` instead of a status code plus a separate
`last_error()` side-channel to remember to check, `std::function`
callbacks instead of a raw function pointer plus manually-threaded
`void*` user data, and the thread-safety guarantee described above —
there is no equivalent of the C API's stateful `last_error()` to race
on in the first place, since every call's outcome comes back in its
own `Result`. Prefer this for any new C++ code.
- **`hot_reload.h`** — plain C linkage itself is the requirement: a C
host, or a different language's FFI boundary (most FFI story the way
a C header does; few can call a C++ class directly at all). The
opaque `HotReloadContext*` it hands out is a small wrapper struct
(`c-api/HotReloadApi.cpp`) that owns a real `hot_reload::Context` by
composition — never a cast between unrelated types — and
`hot_reload_last_error()` keeps working exactly as documented by
tracking each calling thread's own most recent error internally, so
two threads' errors can never be confused with each other even
though the underlying `Context` itself keeps none of that state.
### See it work — a scripted, deterministic demo
```sh
./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`](source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp)
and its `PluginV2` counterpart side by side against the output.
### Try it yourself — a persistent, interactive demo
```sh
./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`](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`](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 `load()` it once at startup (`hot_reload::Context`,
or `hot_reload_load()` through the C API) and `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, the full demo and both test suites (`hotreload_tests` for the C API, `context_tests` for `hot_reload::Context` directly) 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. Re-run, both test suites included, after `hot_reload::Context` and its reader-writer lock were introduced, to confirm a genuinely multi-threaded C++ host works correctly under real (if emulated) ARM64 hardware too, not just natively |
| ARM32 (GCC cross-compiled, hard-float ABI) | Run under `qemu-arm` — both instruction sets (the default Thumb-2, and ARM state forced with `-marm`), across `-O0` through `-O3`. This architecture needed a genuinely new backend, not just a recompile: a CPU in this family is always in one of two incompatible instruction-decoding states, detected from the same bit the ARM EABI already marks every function symbol with. The one case that depends on runtime layout rather than just compiler flags was built and run deliberately, not just reasoned about: Thumb-2's literal-load jump needs its own address 4-byte aligned, which a Thumb function is only ever guaranteed to be 2-byte aligned for, so a plugin with a function confirmed landing 2 bytes off that boundary was built and reloaded under qemu to prove the one-instruction realignment this backend adds for exactly that case actually works. Also found here: the suite's own "too small to patch" function needed a third implementation (hand-written, not compiler-generated) — even an empty C++ body's unoptimized frame-pointer prologue reaches this architecture's own jump size at `-O0` |
| 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. The same person has since reported it still working after the rewrite to `hot_reload::Context` (reported, not independently re-run here) |
| Clang (GNU-style `clang++`) targeting the MSVC ABI, Windows | **Two real build failures reported against this configuration (Clang 22.1, Windows SDK 10.0.26100), both fixed — but not yet confirmed by a successful end-to-end build on that exact toolchain.** Nothing in the environment this was written in can build for Windows natively, so everything below is a proxy check, not that confirmation. (1) `Demangle.cpp` tested `__clang__` before `_MSC_VER`, but Clang defines both when targeting the MSVC ABI, so it went looking for `<cxxabi.h>`, which that configuration does not have; it now keys on `_MSC_VER` first, since that tracks the mangling scheme in use rather than which compiler frontend produced it. (2) `windows.h` without `WIN32_LEAN_AND_MEAN` pulls in COM headers (`ole2.h` → `combaseapi.h` → …) that failed to compile there; all four files that include it now define it. A third was found by reading the same build log rather than waiting for it to fail: the CMake condition that links `dbghelp` asked whether the compiler takes MSVC *command-line syntax* (the `MSVC` variable, false for GNU-style `clang++`) when it needed to ask whether it produces the MSVC *ABI* — `CMAKE_CXX_SIMULATE_ID`, which CMake sets from the very `_MSC_VER` the C++ code now keys on. What was actually checked: each branch condition under simulated `_MSC_VER` and under plain Clang/GCC; every library source compiled for Windows with mingw-w64 (plus `Demangle.cpp` with its MSVC branch forced on); that `WIN32_LEAN_AND_MEAN` removes exactly the `ole2.h`/`combaseapi.h`/`objidlbase.h`/`servprov.h`/`urlmon.h` chain from the failing log (against mingw-w64's headers, not the real SDK's); the new CMake condition under four compiler configurations, including a `clang++` made to define `_MSC_VER` so CMake reports `MSVC` false and `SIMULATE_ID` `MSVC`, as that log implies; and that Clang accepts `-fpatchable-function-entry=32,0` for the Windows/MSVC target and emits the entry padding this library needs (while `-fms-hotpatch` emits none on x64, so it is not a substitute). The actual `__uuidof` failure itself could not be reproduced here |
| AddressSanitizer + UndefinedBehaviorSanitizer, and Valgrind with self-modifying-code checking enabled | Both test suites, 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 |
| ThreadSanitizer | `context_tests`' own concurrency stress test (several threads calling `findSymbol()` against others calling `reload()`, some on the very same module name as each other, plus several more each `load()`-ing their own distinct module name, all against one shared `Context`) -- five separate runs, to cover more than one possible thread interleaving, zero races reported each time. Exactly what `hot_reload::Context`'s reader-writer lock exists to make true, confirmed rather than assumed, the same standard this project holds every other claim in this table to |
| 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:**
macOS/Mach-O (never requested, and would need a third image format
reader alongside the existing ELF and PE ones) — a documented, bounded
gap; see `source/HotReload/README.md`'s "Scope" section for the
reasoning and the extension point if you need it.