Fix documentation

This commit is contained in:
alejo committed 2026-09-25 03:20:35 -03:00
1 parent a317c07124
commit ab21b3f30d
27 files changed
+2137 -1043

No files matched your search

+191 -553
View File
@@ -1,575 +1,213 @@
<div align="center">
<img src="https://github.com/AcruxDevelopment/Volkan/blob/main/res/icon.png" alt="Logo" width="auto" height="80">
# Volkan
**C++ Hot-Reload library**
</div>
<br>
# 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, without restarting -- see
[`source/HotReload/README.md`](source/HotReload/README.md) for what it
does, what it guarantees, and its honest limitations. This file covers
the project around it: the CMake module system, its tooling, and how
the examples and tests fit together.
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
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"](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, 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`](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:
```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/setup.sh # optional, one-time -- see "Setup" below
scripts/build.sh Release -DBUILD_EXAMPLES=ON
./out/build/bin/examples/hotreload_demo_host
```
## What's here
| | |
|---|---|
| [`source/HotReload/`](source/HotReload/README.md) | The library itself: `hotreload_lib`, a `STATIC` library with a plain C API (`hot_reload.h`) over a C++ implementation |
| [`source/examples/HotReloadDemo/`](source/examples/HotReloadDemo) | A scripted, deterministic demo: a host + two builds of one plugin, exercising every guarantee the library makes |
| [`source/examples/HotReloadLiveDemo/`](source/examples/HotReloadLiveDemo/README.md) | An interactive demo: a host that stays running, and one plugin file you edit and rebuild yourself |
| [`source/tests/HotReloadTests/`](source/tests/HotReloadTests) | An automated `ctest` suite covering the library end to end, including a regression test for a real bug found while building this |
The rest of this file is about the project *around* that: a per-module
CMake setup adapted from a generic template (each module owns a tiny,
self-contained `CMakeLists.txt`; the root `CMakeLists.txt` discovers
them automatically), `scripts/` for the everyday commands, and the
code-style tooling. None of it is specific to hot-reloading -- if
you're only here for the library, `source/HotReload/README.md` is the
one to read.
## Layout
```
CMakeLists.txt project setup, options, install(), discovery -- edit by hand
Configuration.cmake all output-path configuration -- edit to relocate anything
cmake/BuildHelpers.cmake module macros + discovery mechanism -- edit rarely
cmake/Dependencies.cmake vendoring / installed-library / FetchContent helpers -- edit rarely
cmake/Documentation.cmake per-module Doxygen integration -- only ever LOOKS for a pinned Doxygen
cmake/DoxygenPin.cmake shared version/URL/hash parameters for the pinned Doxygen
cmake/FetchDoxygen.cmake standalone downloader (run via `cmake -P`, not during normal configure)
cmake/NinjaPin.cmake shared version/URL/hash parameters for the pinned Ninja
cmake/FetchNinja.cmake standalone downloader (run via `cmake -P`, not during normal configure)
cmake/HotReload.cmake hot_reload_enable_patchable_functions() / hot_reload_restrict_exports()
source/HotReload/ the library -- library module "hotreload_lib" (STATIC)
source/examples/HotReloadDemo/ worked example: a host + two builds of one hot-reloadable plugin
source/examples/HotReloadLiveDemo/ interactive: a persistent host + one plugin file you edit yourself
source/tests/HotReloadTests/ the automated test suite
source/tests/UndefinedSymbolFixture/ a fixture the test suite loads, deliberately broken -- see its own comment
scripts/setup.sh, setup.bat one-time: fetch pinned Ninja + Doxygen, check tooling, activate git hook
scripts/build.sh, build.bat configure + build -- prefers a pinned Ninja, falls back gracefully
scripts/run.sh, run.bat run a module by its CMake target name
scripts/install.sh, install.bat install this project's own artifacts only
scripts/docs.sh, docs.bat build documentation for every module (or one) -- needs setup.sh first
scripts/refactor.sh, refactor.bat auto-fix formatting/naming across this project's own code
.clang-format, .clang-tidy C++ style guide tooling (see "Code style & tooling" below)
.editorconfig, .gitattributes -- same
.gitignore Visual Studio template + a block auto-synced from Configuration.cmake
c-api/.clang-tidy naming override template for a C API subtree -- applied for real at
source/HotReload/src/c-api/ and source/HotReload/include/hot_reload/
.githooks/pre-commit formatting/lint pre-commit hook
cpp-style-guide.md, TOOLING.md the style guide itself + tooling docs
```
Everything generated -- the build tree, install output, docs, and reserved
packaging output -- lives under one `out/` directory (see "Output paths"
below); nothing under `out/` is source-controlled.
Each module's `CMakeLists.txt` is (conventionally) one line:
```cmake
# source/HotReload/CMakeLists.txt
add_lib_module(hotreload_lib TYPE STATIC OUTPUT_NAME "hotreload")
```
A module declares its own dependencies in its own `CMakeLists.txt` --
same "minimal tool intervention" principle as module registration:
nothing to add in the root file. This project's own modules don't
currently need any third-party dependency, so the table in "Three ways
to bring in a dependency" below is showing you machinery this project
isn't using itself right now -- it's still there, exercised, and ready
if you add a module that needs it.
## Output paths (`Configuration.cmake`)
Every generated path is a cache variable, overridable with `-D<NAME>=...`
at configure time instead of editing any file:
| Variable | Default | What it controls |
|---|---|---|
| `OUT_DIR` | `out/` | Root of everything generated; override this to relocate all of the below at once |
| `INSTALL_OUTPUT_DIR` | `out/install` | Root of the `cmake --install` tree, one subdirectory per `DIST_TARGET` |
| `DIST_OUTPUT_DIR` | `out/dist` | Reserved for packaged/archived output (e.g. a future CPack integration) -- not populated by this project yet, but created and ready |
| `DOCS_OUTPUT_DIR` | `out/docs` | Root of generated Doxygen documentation, one subdirectory per module |
| `TOOLCACHE_DIR` | `.cache/tools` | Where downloaded, pinned build tools (currently Ninja and Doxygen) are cached -- kept outside `OUT_DIR` so `rm -rf out/` doesn't force a re-download |
| `COMPILE_COMMANDS_DESTINATION` | `compile_commands.json` | Where `compile_commands.json` is copied to after every build (see "compile_commands.json / clang tooling" below) |
| `RUNTIME_OUTPUT_SUBDIR` | `bin` | Subdirectory (inside whatever the build dir is) for executables/`.dll`/`.so` |
| `LIBRARY_OUTPUT_SUBDIR` | `bin` | Subdirectory for shared-library artifacts (kept alongside executables so `$ORIGIN` RPATH resolves them) |
| `ARCHIVE_OUTPUT_SUBDIR` | `lib` | Subdirectory for static/import-library artifacts |
The build directory itself (`CMAKE_BINARY_DIR`, where `CMakeCache.txt`
lives) isn't a `Configuration.cmake` variable -- it's fixed by whatever you
pass to `-B` when configuring. `scripts/build.sh`/`build.bat` default to
`out/build`, so the resulting layout is:
```
out/
build/ CMAKE_BINARY_DIR -- out/build/bin, out/build/lib during normal builds
install/<DIST_TARGET>/{bin,lib} what `cmake --install` / scripts/install.sh populates
dist/ reserved for future packaging output
docs/<module>/html/, docs/index.html generated documentation
```
Example -- build into a different location entirely:
```bash
cmake -G Ninja -S . -B /tmp/my-build -DOUT_DIR=/tmp/my-out
cmake --build /tmp/my-build
```
### `.gitignore` stays in sync automatically
`Configuration.cmake` is the single source of truth for every path above
-- `.gitignore` doesn't hardcode its own copy of them. Every configure,
a marked block near the bottom of `.gitignore` is regenerated from the
*current* value of each path variable:
```
# >>> Configuration.cmake generated paths -- BEGIN (do not edit by hand; edit Configuration.cmake and reconfigure) >>>
.cache/tools/
compile_commands.json
out/
out/dist/
out/docs/
out/install/
# <<< Configuration.cmake generated paths -- END <<<
```
Change `OUT_DIR` (or any of the others, permanently in `Configuration.cmake`
or per-configure with `-D...=...`) and this block updates to match on the
next configure -- nothing to keep in sync by hand. Everything outside the
block is untouched: `.gitignore` ships based on GitHub's
[`VisualStudio.gitignore`](https://github.com/github/gitignore/blob/main/VisualStudio.gitignore)
template, and that content is never rewritten. A path is only added if it
resolves *inside* the repo -- pointing `TOOLCACHE_DIR` somewhere else
entirely just omits it, since `.gitignore` has nothing useful to say about
a location outside the repository it lives in. The file is only rewritten
when the computed block actually differs from what's already there, so a
normal reconfigure doesn't spuriously touch its contents or mtime.
## Three ways to bring in a dependency (`cmake/Dependencies.cmake`)
Not currently used by any module in this project (`hotreload_lib` and
everything under `source/examples/`/`source/tests/` are entirely
self-contained -- no network access is needed to build any of it), but
fully exercised machinery, ready for a module that does need one:
| Helper | For | Usage |
|---|---|---|
| `add_vendor_header_only(NAME [INCLUDE_DIR <dir>])` | A buildless header-only drop under `vendor/<NAME>/include` | `add_vendor_header_only(some_header_lib)` |
| `add_vendor_subdirectory(SUBDIR)` | A vendored dependency with its own `CMakeLists.txt`, under `vendor/<SUBDIR>` | `add_vendor_subdirectory(some_vendored_lib)` |
| `add_dependency(NAME <FetchContent_Declare args>)` | An installed package (`FIND_PACKAGE_ARGS ...`) with a fetch-from-source fallback | `add_dependency(fmt GIT_REPOSITORY ... FIND_PACKAGE_ARGS NAMES fmt)` |
All three are safe to call more than once for the same name (e.g. two
modules that need the same dependency) and mark the dependency's headers
`SYSTEM`, so `-Wall -Wextra -Wpedantic`/`/W4` -- which we only ever apply
per-target inside our own module macros, never globally -- doesn't fire on
code you don't control. Their targets are also dropped from
`compile_commands.json` (see below).
`add_dependency` is CMake's native find-then-fetch integration: it tries
`find_package()` first (an already-installed apt/vcpkg/conan copy) via
`FIND_PACKAGE_ARGS`, and only downloads + builds from source if nothing is
found. Omit `FIND_PACKAGE_ARGS` to always fetch. Requires network access
the first time a dependency isn't already installed.
A module `DEPENDS` on whatever target the chosen path defines exactly
like depending on another module -- `add_lib_module`/`add_exe_module`
don't need to know or care which of the three supplied it.
## Documentation (automatic per module, pinned Doxygen, entirely opt-in)
Every module registered via `add_lib_module`/`add_exe_module`/
`add_test_module`/`add_example_module` gets its own
[Doxygen](https://www.doxygen.nl/) documentation target for free -- nothing
to add to a module's own `CMakeLists.txt`. This project uses a pinned
Doxygen (currently **1.16.1**, see `DOXYGEN_PINNED_VERSION` in
`cmake/DoxygenPin.cmake`) -- it deliberately never looks for or uses a
system-installed `doxygen`, so every machine building this project
generates identical output regardless of what (if anything) is on `PATH`.
**Building the project never needs Doxygen.** `cmake/Documentation.cmake`
only ever *looks* for an already-fetched pinned Doxygen under
`.cache/tools/` (`TOOLCACHE_DIR`) -- it never downloads anything itself
during a normal configure, so `scripts/build.sh` has no Doxygen/network
dependency at all. Fetching it is a separate, explicit, one-time step:
```bash
scripts/setup.sh # see "Setup" below -- fetches the pinned Doxygen once
```
Until you've run that, `scripts/docs.sh` prints a clear message instead of
building anything -- everything else (the actual C++ project) builds
completely normally either way:
```
Documentation isn't set up yet (or ENABLE_DOXYGEN=OFF). Run scripts/setup.sh (or setup.bat) once to fetch the pinned Doxygen, then reconfigure.
```
Once set up:
```bash
scripts/docs.sh # build docs for every module, prints where to open it
scripts/docs.sh hotreload_lib # build docs for just one module (docs_<target>)
```
```bat
scripts\docs.bat
scripts\docs.bat hotreload_lib
```
Output goes to `out/docs/<module>/html/index.html` (a sibling of
`out/build/`, not nested inside it -- see "Output paths" above), plus a
landing page at `out/docs/index.html` linking to every module.
Documentation is **not** built as part of a normal `scripts/build.sh` --
it's opt-in, the same way tests and examples are.
**Writing docs for a module** is just normal Doxygen comments in that
module's headers -- e.g. `source/HotReload/include/hot_reload/hot_reload.h`,
which is already written this way throughout.
Even with zero comments, a module's docs are still generated and useful --
file lists, function/class signatures, and (if [Graphviz](https://graphviz.org/)
is installed on your system -- this one enhancement, unlike Doxygen
itself, is still detected rather than pinned, since it's optional) call
graphs are produced regardless, since `DOXYGEN_EXTRACT_ALL` is on by
default. Comments just make it richer. Drop a `README.md` in a module's
own directory and it's automatically used as that module's Doxygen main
page -- `source/HotReload/README.md` already does this.
**Configuring it** -- nothing is required, but everything is overridable:
```bash
# Disable entirely (docs targets print a message instead of building):
cmake -S . -B out/build -DENABLE_DOXYGEN=OFF
# Pin a different Doxygen version (also update the two SHA256 hashes in
# cmake/DoxygenPin.cmake to match that release's assets -- a stale hash
# fails scripts/setup.sh loudly, on purpose, rather than silently
# accepting an unexpected file -- then re-run scripts/setup.sh):
cmake -S . -B out/build -DDOXYGEN_PINNED_VERSION=1.17.0
# Any Doxyfile tag can be set as a CMake variable, e.g. stricter output:
cmake -S . -B out/build -DDOXYGEN_WARN_IF_UNDOCUMENTED=YES -DDOXYGEN_QUIET=NO
```
A single module can opt out with `NO_DOCS`:
```cmake
# some/internal/module/CMakeLists.txt
add_lib_module(internal_only TYPE STATIC NO_DOCS)
```
Vendored and fetched dependencies (`cmake/Dependencies.cmake`) are never
auto-documented -- same reasoning as their `compile_commands.json`
exclusion, it isn't your code.
## compile_commands.json / clang tooling
`CMAKE_EXPORT_COMPILE_COMMANDS` is on by default, and every build copies
`compile_commands.json` from the build directory to the project root so
clangd/clang-tidy/IWYU find it without extra configuration (CMake only
ever writes it into the build directory; most editors look for it at the
repo root). Vendored and fetched dependencies are excluded from it, so it
only reflects code this project actually owns. The project also builds
cleanly with clang directly:
```bash
cmake -G Ninja -S . -B out/build -DCMAKE_CXX_COMPILER=clang++ -DDIST_TARGET=linux-x64-clang
cmake --build out/build
```
Verified, not assumed, throughout this project's own development: every
module here (the library, both example demos, and the test suite) was
built and its tests/demos run to completion under both GCC and Clang.
## Code style & tooling
This project follows [`cpp-style-guide.md`](cpp-style-guide.md) (tabs,
Allman braces, `PascalCase` file names, `camelCase` functions/variables,
`m_`/`s_`/`g_` member/static/global prefixes, plus a distinct Part II
style -- `snake_case`, library-prefixed names -- for any C API boundary;
see the file itself for the full rules and rationale), enforced by the
tooling described in [`TOOLING.md`](TOOLING.md):
| File | Enforces |
|---|---|
| `.clang-format` | All formatting: tabs, Allman braces, pointer alignment, include order, line length |
| `.clang-tidy` | Naming conventions and a subset of language-feature rules, for this project's own C++ code |
| `c-api/.clang-tidy` | A naming override (`snake_case`) for a C API subtree -- see below for where this is actually applied |
| `.editorconfig` | Baseline tabs/whitespace for editors that don't run clang-format live |
| `.gitattributes` | Forces LF line endings for source files on every OS |
| `.githooks/pre-commit` | Runs clang-format + clang-tidy on staged files before each commit; warns and skips (never blocks) if a tool isn't installed |
**Fixing violations automatically**, instead of hunting them down by hand:
```bash
scripts/refactor.sh # every file under source/ and c-api/ by default
scripts/refactor.sh source/HotReload # just one file or directory
```
```bat
scripts\refactor.bat
scripts\refactor.bat source\HotReload
```
Runs `clang-format -i` (always) and `clang-tidy --fix` (if
`compile_commands.json` exists -- build once first if it doesn't) across
the given scope. The pre-commit hook only *checks*; this is the "just fix
it" counterpart -- review the result with `git diff` afterward, same as
any auto-formatter.
**Be especially careful reviewing `git diff` on a file with more than one
architecture/OS branch** (`#if defined(__x86_64__) ... #elif ... #endif`,
several of which exist under `source/HotReload/src/` -- see
`Architecture.cpp`, `ModuleHandle.cpp`, `MemoryProtection.cpp`,
`SymbolTable.cpp`). `clang-tidy` only ever analyzes the ONE branch active
for whichever platform you're running it on -- it cannot see, and will
not touch, the source text inside the other, inactive `#elif` branches at
all. Verified directly while building this project: running `--fix` on
`Architecture.cpp` from this x86-64 Linux machine renamed a local
constant inside the x86-64 branch only, then updated a later line shared
by *all three* architecture branches to match the new name -- silently
breaking the build for the other two architectures, which still declared
the old name. Caught only by reviewing the diff and separately
cross-compiling every branch, which is exactly the review step this
paragraph is asking you to actually do on a file shaped like this, not
skip.
With no argument, only `source/` and `c-api/` are ever scanned -- `out/`,
`.cache/`, and `.git/` are never walked, so this can't accidentally spend
minutes reformatting/analyzing a fetched dependency's entire source tree
(this project doesn't currently have one under `out/build/_deps/`, but the
exclusion is unconditional, not conditional on one existing). Passing an
explicit scope takes it as given: `refactor.sh` still excludes
`out`/`.cache`/`.git` defensively if your scope happens to overlap with
them; `refactor.bat` does not (its Windows batch equivalent of that filter
turned out to be unreliable to verify, so it was removed rather than
shipped uncertain -- pick a scope that doesn't overlap with those
directories, which is the normal case anyway, e.g. `source\HotReload`).
**One-time setup per clone** to activate the hook -- `scripts/setup.sh` /
`setup.bat` does this for you (see "Setup" below) along with fetching the
pinned Doxygen and checking for clang-format/clang-tidy; the two commands
it runs for the hook specifically are:
```bash
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
```
**clang-tidy needs `compile_commands.json`** to resolve `#include`s --
this project already generates and copies it to the repo root on every
build (see above), so as long as you've built at least once, the hook's
clang-tidy check works with no extra setup.
**This project genuinely has a C API subtree**, unlike the generic
template this was adapted from: `source/HotReload/include/hot_reload/`
(the public header) and `source/HotReload/src/c-api/` (its `extern "C"`
implementation) each carry a copy of `c-api/.clang-tidy`, positioned at
the root of the subtree they apply to -- `clang-tidy` always uses the
closest `.clang-tidy` file up the directory tree, so files under either
directory pick up `snake_case` naming instead of the rest of this
project's `PascalCase`/`camelCase`, while `source/HotReload/src/`'s own
Part I (C++) files, one level up, keep inheriting this repo-root
`.clang-tidy`'s rules exactly as normal. See either copy's own header
comment for the full explanation of how clang-tidy's directory-scoped
config resolution makes that work.
## How module discovery works
The root `CMakeLists.txt` calls `_discover_module_subdirectories()` on
three roots, in this order:
| Root | What it holds |
|---|---|
| `source/*` | library AND app/executable modules, side by side |
| `source/tests/*` | test modules |
| `source/examples/*` | example modules |
(`source/tests` and `source/examples` aren't picked up by the first call
-- they have no `CMakeLists.txt` directly inside them, only their own
children do.)
Any immediate child directory that contains its own `CMakeLists.txt` gets
`add_subdirectory()`'d automatically (`vendor/` is not one of these roots
-- vendored dependencies are opted into explicitly by the modules that
need them, not auto-built by default). This uses `CONFIGURE_DEPENDS`, so
the *next build* reconfigures on its own after you add or remove a module
directory -- no manual `cmake` re-run needed. A module may `DEPENDS` on a
module discovered later in this list, or reference one via
`add_dependencies()`/a generator expression before that other module's
own `CMakeLists.txt` has even run -- order doesn't affect linking or
target existence (CMake resolves target names, and dependency edges to
not-yet-declared targets, across the whole project at generate time --
verified directly with an isolated, minimal reproduction while building
this project, not assumed).
A module directory can itself contain more than one CMake target when its
shape doesn't fit the "one target per module" default -- see
`source/examples/HotReloadDemo/PluginV1/CMakeLists.txt`'s own comment for
a real example (a hot-reloadable plugin needs to exist as two separate
builds of the same exported names, which `add_lib_module` doesn't assume)
and `source/examples/HotReloadDemo/CMakeLists.txt` for the small grouping
file that pattern needs, one level up, purely so discovery (which only
looks one level deep into `source/examples/`) finds it at all.
## Requirements
CMake >= 3.25, a C++20 compiler (GCC/Clang/MSVC). **No network access is
needed to build this project** -- every module here is self-contained,
with no `add_dependency()`-fetched third-party library anywhere (see
"Three ways to bring in a dependency" above). Ninja and Doxygen are both
pinned and fetched automatically (see "Setup" below) if you want them,
not system dependencies, and `scripts/build.sh` warns rather than fails
if Ninja isn't available at all (see "Build" below).
[Graphviz](https://graphviz.org/) is optional for Doxygen call graphs, and
[clang-format](https://clang.llvm.org/docs/ClangFormat.html)/
[clang-tidy](https://clang.llvm.org/extra/clang-tidy/) are optional for
the pre-commit hook and `scripts/refactor.sh` (see "Code style & tooling"
above) -- both are detected, not pinned, since they're editor/workflow
tools rather than part of the build itself. `scripts/setup.sh` (below)
checks for both and prints install guidance if either is missing.
To actually try hot-reloading something (rather than just build the
library), you'll also want a way to rebuild one file while another
process keeps running -- covered in
[`source/examples/HotReloadLiveDemo/README.md`](source/examples/HotReloadLiveDemo/README.md).
## Setup
Optional, and separate from building (see "Requirements", "Build", and
"Documentation" above) -- run once per clone, or any time you want to
pick up a newer pinned version after bumping `NINJA_PINNED_VERSION` or
`DOXYGEN_PINNED_VERSION`:
```bash
scripts/setup.sh
```
```bat
scripts\setup.bat
```
Does four things, each independent of the others:
1. Fetches the pinned Ninja (`cmake/FetchNinja.cmake`) -- used by
`scripts/build.sh`/`build.bat` if present (see "Build" below); not
required, a system Ninja works fine too.
2. Fetches the pinned Doxygen (`cmake/FetchDoxygen.cmake`) -- needed for
`scripts/docs.sh`, not for building the project.
3. Checks for `clang-format`/`clang-tidy` on `PATH` and prints
OS-appropriate install guidance if either is missing (never
auto-installs -- that would need assumptions about your package
manager and elevated privileges this script shouldn't assume it has).
4. Activates the pre-commit hook for this clone, if it's a git repository
(`git config core.hooksPath .githooks`).
Safe to re-run any time -- each step is a no-op if there's nothing to do
(e.g. the pinned Ninja/Doxygen are already cached).
## Build
```bash
scripts/build.sh # Release (default), library + tests only if BUILD_TESTS was set before
scripts/build.sh Release -DBUILD_TESTS=ON -DBUILD_EXAMPLES=ON # + the automated tests + both demos
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_TESTS=ON -DBUILD_EXAMPLES=ON
scripts\build.bat Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON
```
`BUILD_TESTS` and `BUILD_EXAMPLES` both default to `OFF` (see
`CMakeLists.txt`) -- plain `scripts/build.sh` with no extra arguments
builds only `hotreload_lib` itself. Anything after the build type is
passed straight through to `cmake`'s configure step (tested directly:
`scripts/build.sh Debug -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON` builds the
library, both demos, and the test suite together), and CMake caches `-D`
options across reconfigures, so passing them once and calling
`scripts/build.sh` plain afterward for incremental builds keeps them set
-- no need to repeat them on every call.
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`](source/HotReload/CMakeLists.txt)
for what it links on each platform if you're not).
Picks a generator in this order, each falling back to the next:
### See it work — a scripted, deterministic demo
1. **A pinned Ninja**, if `scripts/setup.sh`/`setup.bat` has fetched one --
passed to CMake explicitly via `-DCMAKE_MAKE_PROGRAM=...` so it's used
even if a different Ninja also happens to be on `PATH` (same "always
the pinned copy" preference as Doxygen -- see "Documentation" above).
2. **A system Ninja**, if one is on `PATH` and no pinned copy was found.
3. **CMake's own default generator** for the platform, if neither Ninja is
available anywhere -- `scripts/build.sh`/`build.bat` print a warning
and continue rather than fail. (`CMakeLists.txt` separately warns too,
once CMake reports which generator it actually picked -- this is the
pre-existing "This project targets Ninja" message, unrelated to this
script.)
Verified concretely, not assumed: with neither a pinned nor a system Ninja
present anywhere, `scripts/build.sh` still successfully configures, builds,
and produces working binaries -- it just uses whatever CMake's default
generator is for the platform (Unix Makefiles on Linux, typically NMake or
a Visual Studio generator on Windows) instead.
## Run
```bash
scripts/run.sh hotreload_demo_host
scripts/run.sh hotreload_live_demo_host
scripts/run.sh # no args: lists available targets
```
```bat
scripts\run.bat hotreload_demo_host
scripts\run.bat hotreload_live_demo_host
```sh
./out/build/bin/examples/hotreload_demo_host
```
`run.sh`/`run.bat` resolve the CMake **target id** (not the binary's
`OUTPUT_NAME`) to the actual binary via a manifest CMake generates at
configure time, so these scripts never need editing when a module is
added, removed, or renamed. Requires `-DBUILD_EXAMPLES=ON` to have been
set at configure time for either of the two hot-reload targets above,
since they live under `source/examples/`.
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.
## Install
### Try it yourself — a persistent, interactive demo
```bash
scripts/install.sh
```
```bat
scripts\install.bat
```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
```
Always use the install script (or pass `--component hotreload` yourself if
calling `cmake --install` directly). A vendored/fetched dependency's own
`CMakeLists.txt` usually has `install()` rules of its own with no
component tag; installing without `--component` would also run those,
typically dumping files under `CMAKE_INSTALL_PREFIX` (e.g. `/usr/local`).
Tagging this project's own `install(TARGETS ...)` calls with `COMPONENT
hotreload` and always filtering on it is what keeps `cmake --install`
scoped to just this project's own artifacts -- currently just
`hotreload_lib` itself, since examples and tests are opt-in, dev-time
targets rather than something you'd install.
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.
## Adding a module
### Use it in your own module
1. Create a directory under the right root (see the discovery table above).
2. Add a `CMakeLists.txt` inside it with one `add_lib_module` /
`add_exe_module` / `add_test_module` / `add_example_module` call, plus
any `add_vendor_*`/`add_dependency` calls it needs.
3. Add your `.cpp`/`.h` files inside that same directory.
4. Rebuild (`scripts/build.sh`) -- no other file needs touching.
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.
```cmake
# source/MyTool/CMakeLists.txt
add_exe_module(my_tool_exe OUTPUT_NAME "my-tool" DEPENDS hotreload_lib)
```
## Limitations
`scripts/run.sh my_tool_exe` picks it up automatically once it's built,
and `scripts/docs.sh my_tool_exe` documents it automatically too -- both
with zero edits anywhere outside `source/MyTool/`.
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.