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
+2136 -1042

No files matched your search

+560
View File
@@ -0,0 +1,560 @@
# Contributing
This covers the CMake module system this project's build is organized
around, `scripts/` for the everyday commands, and how the pieces fit
together -- everything you'd need to add a module, understand the
build output layout, or extend the tooling. None of it is specific to
hot-reloading; if you're here to *use* the library, [`README.md`](README.md)
and [`source/HotReload/README.md`](source/HotReload/README.md) are the
ones to read instead.
## 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 written this way throughout (`@brief`/`@param`/`@return`, and
`@defgroup` sections grouping related declarations -- see that file for
the pattern to follow in a new one).
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
# (this is exactly what was used to verify every Doxygen comment in this
# project while writing them -- zero warnings, checked directly, not assumed):
cmake -S . -B out/build -DDOXYGEN_WARN_IF_UNDOCUMENTED=YES -DDOXYGEN_WARN_NO_PARAMDOC=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 an 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.
## 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
```
```bat
scripts\build.bat
scripts\build.bat Release -DBUILD_TESTS=ON -DBUILD_EXAMPLES=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.
`build.bat` parses its arguments through `for /f "tokens=1,*"` rather
than `%1`/`%2`/etc. directly, deliberately -- `cmd.exe`'s own parameter
parser treats `=` (along with `,` and `;`) as an argument separator, so
`-DBUILD_EXAMPLES=ON` typed as one argument would otherwise arrive
split into two, silently corrupting the value before the script ever
sees it. Verified directly under Wine's `cmd.exe` against exactly that
failure, not just reasoned through -- see the script's own comment.
Picks a generator in this order, each falling back to the next:
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.
**If you're building inside Visual Studio's own CMake integration**
(open-folder, not a generated `.sln`) rather than via these scripts:
"Rebuild" does a full clean first, which will fail with `Access is
denied` on any `.exe` that's currently running -- e.g. the live demo's
host (see `source/examples/HotReloadLiveDemo/README.md`). This is a
Windows constraint (a running process's own image file can't be
deleted), not a bug in this project; a plain "Build", or building one
specific target, never touches an unrelated running executable and
isn't affected.
## 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
```
`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/`.
## Install
```bash
scripts/install.sh
```
```bat
scripts\install.bat
```
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.
## Adding a 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.
```cmake
# source/MyTool/CMakeLists.txt
add_exe_module(my_tool_exe OUTPUT_NAME "my-tool" DEPENDS hotreload_lib)
```
`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/`.
+191 -553
View File
@@ -1,575 +1,213 @@
<div align="center"> # Hot Reload
<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>
A cross-platform, cross-compiler C/C++ library that patches a running 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 process's own code in place, so a new build of a function takes effect
immediately, without restarting -- see immediately — no restart, and every existing caller (including a raw C
[`source/HotReload/README.md`](source/HotReload/README.md) for what it function pointer cached somewhere) picks it up automatically.
does, what it guarantees, and its honest limitations. This file covers
the project around it: the CMake module system, its tooling, and how ```cpp
the examples and tests fit together. // 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 ```sh
scripts/setup.sh # optional, one-time -- see "Setup" below scripts/build.sh # library only
scripts/build.sh Release -DBUILD_EXAMPLES=ON scripts/build.sh Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON # + both demos + the test suite
./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
``` ```
```bat ```bat
scripts\build.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 This produces `hotreload_lib`, a `STATIC` library with a plain C API
`CMakeLists.txt`) -- plain `scripts/build.sh` with no extra arguments (`hot_reload.h`) over a C++ implementation. Link your host application
builds only `hotreload_lib` itself. Anything after the build type is against it (`target_link_libraries(your_app PRIVATE hotreload_lib)` if
passed straight through to `cmake`'s configure step (tested directly: you're using CMake; see [`source/HotReload/CMakeLists.txt`](source/HotReload/CMakeLists.txt)
`scripts/build.sh Debug -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON` builds the for what it links on each platform if you're not).
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.
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 -- ```sh
passed to CMake explicitly via `-DCMAKE_MAKE_PROGRAM=...` so it's used ./out/build/bin/examples/hotreload_demo_host
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
``` ```
`run.sh`/`run.bat` resolve the CMake **target id** (not the binary's Loads one plugin, calls into it, hot-reloads a second build of it, and
`OUTPUT_NAME`) to the actual binary via a manifest CMake generates at calls the *same, never-re-resolved* function pointers again — proving
configure time, so these scripts never need editing when a module is every guarantee above in one run. Open
added, removed, or renamed. Requires `-DBUILD_EXAMPLES=ON` to have been [`source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp`](source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp)
set at configure time for either of the two hot-reload targets above, and its `PluginV2` counterpart side by side against the output.
since they live under `source/examples/`.
## Install ### Try it yourself — a persistent, interactive demo
```bash ```sh
scripts/install.sh ./out/build/bin/examples/hotreload_live_demo_host # leave this running
``` cmake --build out/build --target hotreload_live_plugin # in another terminal, or:
```bat source/examples/HotReloadLiveDemo/watch.sh out/build # rebuild automatically on save
scripts\install.bat
``` ```
Always use the install script (or pass `--component hotreload` yourself if Edit [`source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp`](source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp)
calling `cmake --install` directly). A vendored/fetched dependency's own — its own comment walks through several concrete things worth trying —
`CMakeLists.txt` usually has `install()` rules of its own with no and watch the *running* process pick up your change within about a
component tag; installing without `--component` would also run those, second, with no restart. See
typically dumping files under `CMAKE_INSTALL_PREFIX` (e.g. `/usr/local`). [`source/examples/HotReloadLiveDemo/README.md`](source/examples/HotReloadLiveDemo/README.md)
Tagging this project's own `install(TARGETS ...)` calls with `COMPONENT for the full walkthrough, including two Windows-specific gotchas
hotreload` and always filtering on it is what keeps `cmake --install` (`Access is denied` on "Rebuild", and why leftover `.hotreload.N` files
scoped to just this project's own artifacts -- currently just next to your plugin are expected, not a leak) that are easy to mistake
`hotreload_lib` itself, since examples and tests are opt-in, dev-time for bugs the first time you hit them.
targets rather than something you'd install.
## Adding a module ### Use it in your own module
1. Create a directory under the right root (see the discovery table above). 1. `extern "C"` isn't required — a real C++ mangled name works too — but
2. Add a `CMakeLists.txt` inside it with one `add_lib_module` / it's the simplest choice for anything you want to look up by a plain,
`add_exe_module` / `add_test_module` / `add_example_module` call, plus human-typed name via `hot_reload_get_symbol()`.
any `add_vendor_*`/`add_dependency` calls it needs. 2. Call `hot_reload_enable_patchable_functions(<target>)`
3. Add your `.cpp`/`.h` files inside that same directory. (`cmake/HotReload.cmake`) on your plugin's CMake target, so every
4. Rebuild (`scripts/build.sh`) -- no other file needs touching. 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 ## Limitations
# source/MyTool/CMakeLists.txt
add_exe_module(my_tool_exe OUTPUT_NAME "my-tool" DEPENDS hotreload_lib)
```
`scripts/run.sh my_tool_exe` picks it up automatically once it's built, Two things this library fundamentally cannot detect, because a symbol
and `scripts/docs.sh my_tool_exe` documents it automatically too -- both table doesn't carry the information needed to:
with zero edits anywhere outside `source/MyTool/`.
- **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.
+26 -14
View File
@@ -3,12 +3,18 @@
Patches a running process's own code in place so that a new build of a Patches a running process's own code in place so that a new build of a
function takes effect immediately -- no restart, and every existing function takes effect immediately -- no restart, and every existing
caller (including a raw C function pointer someone cached ten minutes caller (including a raw C function pointer someone cached ten minutes
ago) picks up the change automatically. `hot_reload.h` is the API; ago) picks up the change automatically. This is the full technical
`source/examples/HotReloadDemo` is a full worked example; this file is reference; see this project's top-level `README.md` for a quicker
the overview `hot_reload.h`'s own doc comments don't have room for, overview, feature summary, and build/run instructions if you haven't
especially the parts you should know are *not* guaranteed. already.
If you just want to see it run: `hot_reload.h` is the API (also fully Doxygen-documented -- see the
top-level README's "Documentation" section for how to build that
locally); `source/examples/HotReloadDemo` is a full worked example;
this file is the overview `hot_reload.h`'s own doc comments don't have
room for, especially the parts you should know are *not* guaranteed.
To see it run:
```sh ```sh
cmake -S . -B out -DBUILD_EXAMPLES=ON cmake -S . -B out -DBUILD_EXAMPLES=ON
@@ -367,15 +373,21 @@ that file's comment for the full reasoning.
## Known, accepted gaps ## Known, accepted gaps
- **A `.so` with its section header table deliberately stripped** - **A `.so` with its section header table deliberately stripped,
reads back as exporting no functions at all (empty, not a crash) -- truncated, or otherwise corrupted** reads back as exporting no
`SymbolTable.cpp`'s ELF reader uses section headers to find functions at all (empty, not a crash) -- `SymbolTable.cpp`'s ELF
`.dynsym`/`.dynstr`, which is simpler and easier to get right than reader uses section headers to find `.dynsym`/`.dynstr`, which is
walking `.dynamic`'s `DT_*` tags the way the dynamic linker itself simpler and easier to get right than walking `.dynamic`'s `DT_*` tags
does, at the cost of this one edge case. Stripping section headers the way the dynamic linker itself does, at the cost of this one edge
from a `.so` you are actively hot-reloading is an unusual, separate case for a deliberately-stripped file. Every offset, size, and index
step from an ordinary `strip` and essentially never happens by this reader trusts is bounds-checked against the file's actual size
accident. before use -- confirmed directly by fuzzing the section header table
of a real plugin build: 1500 randomly corrupted variants (each still
a file the OS loader itself accepts via `dlopen()`), zero crashes. An
earlier version of this same reader, without those checks, crashed on
69 of an initial 300. Stripping section headers from a `.so` you are
actively hot-reloading is, separately, an unusual step essentially
never taken by accident.
- **PE exports have no per-symbol size.** Unlike ELF's `st_size`, - **PE exports have no per-symbol size.** Unlike ELF's `st_size`,
which backs a real safety check, the Windows path estimates size which backs a real safety check, the Windows path estimates size
from the gap to the next exported function and can only ever prove from the gap to the next exported function and can only ever prove
+451 -200
View File
@@ -1,79 +1,126 @@
/* hot_reload.h /**
* @file hot_reload.h
* @brief Public C API for the hot-reload library.
* *
* Public C API for the hot-reload library -- see cpp-style-guide.md * See @c cpp-style-guide.md Part II (C API Style) for the naming/formatting
* Part II (C API Style) for the naming/formatting rules this header * rules this header follows, and section 11 ("Runtime Loading") for why
* follows, and section 11 ("Runtime Loading") for why this boundary is * this boundary is plain C in the first place: anything crossing a
* plain C in the first place: anything crossing a dlopen()/LoadLibrary() * `dlopen()`/`LoadLibrary()` boundary must have C linkage, so this is
* boundary must have C linkage, so this is exactly the kind of surface * exactly the kind of surface that section calls out.
* that section calls out.
* *
* WHAT THIS LIBRARY DOES * @section hotreload_what_it_does What this library does
* -----------------------
* hot_reload_load() loads a shared library (.so/.dll) under a logical
* name. hot_reload_reload() loads a NEW build of that same shared
* library and, for every function the two builds have in common,
* rewrites the FIRST FEW BYTES of the old function in memory into a
* jump to the new function -- so every existing call site (anyone who
* already resolved the old function's address, including via a plain
* C function pointer) transparently starts running the new code,
* without restarting the process.
* *
* WHAT THIS LIBRARY DELIBERATELY DOES NOT DO * hot_reload_load() loads a shared library (`.so`/`.dll`) under a logical
* -------------------------------------------- * name. hot_reload_reload() loads a *new* build of that same shared
* It never patches anything that isn't executable code. A symbol is * library and, for every function the two builds have in common, rewrites
* only ever a patch candidate if the loader's own symbol table marks * the first few bytes of the old function in memory into a jump to the
* it as a function (ELF STT_FUNC / a PE export whose address falls * new function -- so every existing call site (anyone who already
* inside an executable section) -- see the design note in * resolved the old function's address, including via a plain C function
* SymbolTable.hpp for exactly how that's determined. Global variables, * pointer) transparently starts running the new code, without restarting
* class/struct instances, vtables, and anything else classified as * the process.
* data are always left alone, on both the old and the new module --
* there is no code path in this library that ever writes to a data
* symbol. This is a mechanical guarantee from how symbols are
* classified, not a naming convention you opt into.
* *
* It also cannot verify that a hot-reloadable function's SIGNATURE * @section hotreload_what_it_wont What this library deliberately does not do
*
* It never patches anything that isn't executable code. A symbol is only
* ever a patch candidate if the loader's own symbol table marks it as a
* function (ELF `STT_FUNC` / a PE export whose address falls inside an
* executable section) -- see the design note at the top of
* `SymbolTable.cpp` (and detail::ExportedSymbol::isFunction) for exactly
* how that's determined. Global variables, class/struct
* instances, vtables, and anything else classified as data are always
* left alone, on both the old and the new module -- there is no code path
* in this library that ever writes to a data symbol. This is a mechanical
* guarantee from how symbols are classified, not a naming convention you
* opt into.
*
* It also cannot verify that a hot-reloadable function's *signature*
* hasn't changed. Patching only ever redirects control flow at a fixed * hasn't changed. Patching only ever redirects control flow at a fixed
* entry point; it has no way to know, from a symbol table alone, that * entry point; it has no way to know, from a symbol table alone, that
* `void tick(int)` became `void tick(int, int)` between builds. If a * `void tick(int)` became `void tick(int, int)` between builds. If a
* function's signature changes, give it a new exported name (so the * function's signature changes, give it a new exported name (so the old
* old symbol is treated as removed and the new one as added -- see * symbol is treated as removed and the new one as added -- see
* HOT_RELOAD_SYMBOL_REMOVED below) rather than reusing the old name. * ::HOT_RELOAD_SYMBOL_REMOVED) rather than reusing the old name. See the
* See the top-level README for the full list of hazards this library * "Limitations" section of the top-level README (and "What this cannot
* cannot detect for you. * detect" in `source/HotReload/README.md`) for the full list of hazards
* this library cannot detect for you.
*
* @section hotreload_requirements Requirements on a hot-reloadable module
* *
* REQUIREMENTS ON A HOT-RELOADABLE MODULE
* ------------------------------------------
* Matching an old build's function up with the new build's requires a * Matching an old build's function up with the new build's requires a
* name that is stable across recompiles. extern "C" linkage -- see * name that is stable across recompiles. `extern "C"` linkage -- see
* cpp-style-guide.md section 11 -- is the simplest way to get one: no * `cpp-style-guide.md` section 11 -- is the simplest way to get one: no
* mangling at all, so the name never depends on the compiler, its * mangling at all, so the name never depends on the compiler, its
* version, or the function's signature. * version, or the function's signature.
* *
* A real, unmodified C++ mangled name works too, and needs no special * A real, unmodified C++ mangled name works too, and needs no special
* handling by this library -- classification only ever looks at a * handling by this library -- classification only ever looks at a
* symbol's type and section, never at what its name looks like, so an * symbol's type and section, never at what its name looks like, so an
* ordinary out-of-line method, a virtual method (redirected wherever * ordinary out-of-line method, a virtual method (redirected wherever it's
* it's reached from -- directly or through a vtable slot, since both * reached from -- directly or through a vtable slot, since both hold the
* hold the same address), and an operator overload all patch exactly * same address), and an operator overload all patch exactly like any
* like any extern "C" function. What extern "C" actually buys you is * `extern "C"` function. What `extern "C"` actually buys you is a name
* a name you can type by hand into hot_reload_get_symbol() and a * you can type by hand into hot_reload_get_symbol() and a guarantee that
* guarantee that it won't change if you rebuild with a different * it won't change if you rebuild with a different compiler; a mangled
* compiler; a mangled name gives up both of those, only stays stable * name gives up both of those, only stays stable within one ABI family
* within one ABI family (GCC and Clang share the Itanium C++ ABI; * (GCC and Clang share the Itanium C++ ABI; MSVC does not), and -- the
* MSVC does not), and -- the one hazard that has nothing to do with * one hazard that has nothing to do with this library and everything to
* this library and everything to do with what "hot-reloadable" can * do with what "hot-reloadable" can even mean -- only exists to patch at
* even mean -- only exists to patch at all if the compiler actually * all if the compiler actually emitted an out-of-line body for it, which
* emitted an out-of-line body for it, which an implicitly-inline * an implicitly-inline method defined in a class body, or a template
* method defined in a class body, or a template instantiated at the * instantiated at the call site, generally will not. See the "Hot
* call site, generally will not. See the top-level README's "Hot * reloading C++ symbols" section of `source/HotReload/README.md` for the
* reloading C++ symbols" section for the full picture, including how * full picture, including how to get a readable name for logging with
* to get a readable name for logging with hot_reload_demangle(). * hot_reload_demangle().
*
* Every function the module exports needs room at its entry point for the
* jump this library writes over it. Compile the module with
* `-fpatchable-function-entry=32,0` (GCC/Clang) or `/hotpatch` (MSVC) to
* guarantee that; without it, a function too small to hold the jump is
* skipped and reported as ::HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL rather than
* patched. Also avoid identical-code-folding linker options (MSVC
* `/OPT:ICF`, `--icf` in gold/lld) for the module: two different exported
* functions folded into one address can only be patched to one target.
*
* @section hotreload_lifetime What carries over a reload -- and what does not
*
* Only *code* is redirected. Every build of the module is a separate
* mapping with its own copy of everything else, so:
* - Global and `static` variables are not carried over. Code that runs
* after a reload uses the *new* build's copies, freshly initialised;
* keep state you need to survive in memory the host owns and pass it in.
* - Static constructors of the new build run when it is loaded (on every
* reload); static destructors of every version run when the context is
* destroyed.
* - Every version stays loaded until hot_reload_context_destroy(). Old
* entry points keep working (they are now jumps), one extra hop per
* reload; destroying the context unloads them all, so no pointer into
* any of the modules may be used afterwards.
*
* @section hotreload_threads Threading
*
* A context is not thread-safe: serialize all calls that use one context.
* Call hot_reload_reload() from a quiescent point -- one where no thread is
* executing inside the module's functions -- since a function that is being
* executed while its entry bytes are rewritten cannot be protected.
*/ */
#ifndef HOT_RELOAD_H #ifndef HOT_RELOAD_H
#define HOT_RELOAD_H #define HOT_RELOAD_H
#include <stddef.h> #include <stddef.h>
/**
* @def HOT_RELOAD_API
* @brief Marks every function of this library's own C API.
*
* Expands to nothing in the default configuration, where the library is linked statically.
* Define `HOT_RELOAD_BUILD_SHARED` while building the library as a shared library, and
* `HOT_RELOAD_USE_SHARED` while compiling code that uses such a build, to get
* `__declspec(dllexport)`/`__declspec(dllimport)` on Windows or default symbol visibility
* elsewhere.
*
* Not to be confused with ::HOT_RELOAD_EXPORT, which marks symbols in a hot-reloadable
* *module* rather than in this library.
*/
#ifdef _WIN32 #ifdef _WIN32
#ifdef HOT_RELOAD_BUILD_SHARED #ifdef HOT_RELOAD_BUILD_SHARED
#define HOT_RELOAD_API __declspec(dllexport) #define HOT_RELOAD_API __declspec(dllexport)
@@ -90,23 +137,29 @@
#endif #endif
#endif #endif
/* Marks a single declaration for export under /**
* hot_reload_restrict_exports() (cmake/HotReload.cmake) without * @def HOT_RELOAD_EXPORT
* otherwise changing its linkage: pair with extern "C" yourself for a * @brief Marks a single declaration in a hot-reloadable *module* for export
* free function you want a stable, human-typable name for (see this * under `hot_reload_restrict_exports()` (`cmake/HotReload.cmake`).
* file's own comment on when you'd want that) -- extern "C" first,
* then this macro, then the return type, e.g.
* `extern "C" HOT_RELOAD_EXPORT int my_function(...)`, which is the
* one ordering GCC/Clang accept without a warning here. Leave the
* extern "C" off to keep a C++ class or method's real mangled name,
* e.g. a virtual method a host only ever reaches through a vtable
* slot (see the top-level README's "Hot reloading C++ symbols"
* section).
* *
* hot_reload_restrict_exports() is a narrower export surface, not a * This is for the plugins you hot-reload, not for this library's own API
* quieter one: see the README's "Why do std::string/std::vector * above (::HOT_RELOAD_API). It does not otherwise change a declaration's
* symbols show up as PATCHED?" section for why it does not, and * linkage: pair it with `extern "C"` yourself for a free function you want
* structurally cannot, suppress those specifically. */ * a stable, human-typable name for (see @ref hotreload_requirements for
* when you'd want that) -- `extern "C"` first, then this macro, then the
* return type, e.g. `extern "C" HOT_RELOAD_EXPORT int my_function(...)`,
* which is the one ordering GCC/Clang accept without a warning here. Leave
* the `extern "C"` off to keep a C++ class or method's real mangled name,
* e.g. a virtual method a host only ever reaches through a vtable slot
* (see the "Hot reloading C++ symbols" section of
* `source/HotReload/README.md`).
*
* @note `hot_reload_restrict_exports()` is a *narrower* export surface,
* not a *quieter* one: see the "Why do std::string/std::vector
* symbols show up as PATCHED?" section of `source/HotReload/README.md`
* for why it does not, and structurally cannot, suppress those
* specifically.
*/
#ifdef _WIN32 #ifdef _WIN32
#define HOT_RELOAD_EXPORT __declspec(dllexport) #define HOT_RELOAD_EXPORT __declspec(dllexport)
#else #else
@@ -118,196 +171,394 @@ extern "C"
{ {
#endif #endif
/* Opaque -- see cpp-style-guide.md section 16 ("Do: use opaque /**
* pointers ... to hide implementation details"). The real definition * @defgroup hotreload_types Types and callbacks
* is the C++ class hot_reload::HotReloadContext (HotReloadContext.hpp), * @brief The status codes, event/option structs, and callback signatures
* never visible from this header. */ * used throughout the rest of this API.
* @{
*/
/**
* @brief Opaque handle to a hot-reload context.
*
* See `cpp-style-guide.md` section 16 ("Do: use opaque pointers ... to
* hide implementation details"). The real definition is the C++ class
* `hot_reload::HotReloadContext` (`HotReloadContext.hpp`), never visible
* from this header.
*/
typedef struct HotReloadContext HotReloadContext; typedef struct HotReloadContext HotReloadContext;
/* Every fallible function returns one of these -- see section 16 /**
* ("Do: return an integer error code / status enum from every * @brief Result of any fallible call in this API.
* fallible function"). */ *
* See `cpp-style-guide.md` section 16 ("Do: return an integer error
* code / status enum from every fallible function"). Render one of these
* as text with hot_reload_status_string(); for a fuller, platform-specific
* explanation of a ::HOT_RELOAD_ERROR_LOAD_FAILED, see
* hot_reload_last_error().
*/
typedef enum HotReloadStatus typedef enum HotReloadStatus
{ {
/** Success. */
HOT_RELOAD_OK = 0, HOT_RELOAD_OK = 0,
/* ctx, module_name, or path/new_path was NULL, or module_name /**
* was not found for hot_reload_reload / hot_reload_get_symbol. */ * A required argument (@c ctx, @c module_name, or @c path / @c new_path)
* was `NULL`, or hot_reload_load() was called for a @c module_name that is
* already loaded -- call hot_reload_reload() for that name instead.
*/
HOT_RELOAD_ERROR_INVALID_ARGUMENT, HOT_RELOAD_ERROR_INVALID_ARGUMENT,
/* dlopen()/LoadLibrary() (or the equivalent parse of the file on /**
* disk) failed for the given path. On hot_reload_reload(), * `dlopen()`/`LoadLibrary()` (or the equivalent parse of the file on
* this is non-destructive: the previously loaded version is left * disk) failed for the given path.
* running untouched, exactly as if reload had not been called -- *
* see the file comment above HotReloadContext::reload(). Call * On hot_reload_reload(), this is non-destructive: the previously
* hot_reload_last_error() for the platform's own description of * loaded version is left running untouched, exactly as if reload had
* why -- this one status code covers everything from a missing * not been called. Call hot_reload_last_error() for the
* file to a genuinely corrupt image to (a real case, found while * platform's own description of why -- this one status code covers
* building this feature) a shared library with an unresolved * everything from a missing file to a genuinely corrupt image to (a
* symbol reference, and those need very different fixes. */ * real case, found while building this feature) a shared library
* with an unresolved symbol reference, and those need very different
* fixes.
*
* It is also reported when the file could not be copied to its staging
* name (see hot_reload_load()): the source is unreadable, or the directory
* it lives in is not writable.
*/
HOT_RELOAD_ERROR_LOAD_FAILED, HOT_RELOAD_ERROR_LOAD_FAILED,
/* hot_reload_reload() was called for a module_name that was never /**
* hot_reload_load()'ed first, so there is no "old" version to * hot_reload_reload() was called for a @c module_name that was never
* diff against. Call hot_reload_load() once, up front. */ * hot_reload_load()'ed first, so there is no "old" version to diff
* against. Call hot_reload_load() once, up front.
*/
HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION, HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION,
/* This process's CPU architecture has no patch backend compiled /**
* in (see Architecture.hpp -- currently x86, x86-64, and ARM64). * This process's CPU architecture has no patch backend compiled in
* Nothing was patched; hot_reload_load() still succeeded, you * (see `Architecture.hpp` -- currently x86, x86-64, and ARM64).
* just cannot hot_reload_reload() on this target. */ * Nothing was patched; hot_reload_load() still succeeded, you just
* cannot hot_reload_reload() on this target.
*/
HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE
} HotReloadStatus; } HotReloadStatus;
/* Reported once per matched/unmatched symbol during hot_reload_reload() /**
* via HotReloadOptions::on_symbol_event, so the host application can * @brief What happened to one symbol during a hot_reload_reload() call.
* log (or assert on, in a test) exactly what happened -- nothing here *
* is silent. */ * Reported once per matched/unmatched symbol via
* HotReloadOptions::on_symbol_event, so the host application can log (or
* assert on, in a test) exactly what happened -- nothing here is silent.
*/
typedef enum HotReloadSymbolEvent typedef enum HotReloadSymbolEvent
{ {
/* Existed in both builds and was classified as code in both -- /**
* the old function's entry now jumps to the new one. */ * Existed in both builds and was classified as code in both -- the
* old function's entry now jumps to the new one.
*/
HOT_RELOAD_SYMBOL_PATCHED, HOT_RELOAD_SYMBOL_PATCHED,
/* Exists in the new build only. Nothing to patch -- it becomes /**
* reachable the normal way, e.g. via hot_reload_get_symbol(). */ * Exists in the new build only. Nothing to patch -- it becomes
* reachable the normal way, e.g. via hot_reload_get_symbol().
*/
HOT_RELOAD_SYMBOL_ADDED, HOT_RELOAD_SYMBOL_ADDED,
/* Existed in the old build, missing from the new one. Per /**
* cpp-style-guide.md's spirit of never leaving a caller with an * Existed in the old build, missing from the new one.
*
* Per `cpp-style-guide.md`'s spirit of never leaving a caller with an
* ambiguous, half-updated state: the old implementation is left * ambiguous, half-updated state: the old implementation is left
* running as-is unless HotReloadOptions::on_deleted_symbol * running as-is unless HotReloadOptions::on_deleted_symbol supplies a
* supplies a stub to redirect to instead -- see that field. */ * stub to redirect to instead -- see that field.
*/
HOT_RELOAD_SYMBOL_REMOVED, HOT_RELOAD_SYMBOL_REMOVED,
/* Present in both builds by name, but the symbol table classifies /**
* it as data (or something else non-function) in either build -- * Present in both builds by name, but the symbol table classifies it
* e.g. an accidentally-exported global. Never touched, in either * as data (or something else non-function) in either build -- e.g.
* direction. This is the mechanical half of the "only executable * an accidentally-exported global.
* code changes" guarantee described at the top of this file. */ *
* Never touched, in either direction. This is the mechanical half of
* the "only executable code changes" guarantee described in
* @ref hotreload_what_it_wont.
*/
HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE, HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE,
/* Classified as a function in both builds, but the OLD function's /**
* compiled size is smaller than the number of bytes this * Classified as a function in both builds, but the old function's
* platform's jump sequence needs to write -- patching it would * compiled size is smaller than the number of bytes this platform's
* overwrite whatever comes after it in memory. Skipped rather * jump sequence needs to write -- patching it would overwrite
* than guessed; see hot_reload_enable_patchable_functions() in * whatever comes after it in memory.
* cmake/HotReload.cmake for how to stop this from happening. */ *
* Skipped rather than guessed. Compile the module with
* `-fpatchable-function-entry=32,0` (GCC/Clang) or `/hotpatch` (MSVC) to
* stop this from happening; `hot_reload_enable_patchable_functions()` in
* `cmake/HotReload.cmake` applies whichever one your compiler needs.
*/
HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL, HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL,
/* Classified as a function in both builds, correctly sized, but /**
* the OS refused to make its page temporarily writable (mprotect / * Classified as a function in both builds, correctly sized, but the
* VirtualProtect failed -- e.g. a hardened environment that denies * OS refused to make its page temporarily writable (`mprotect` /
* W^X transitions). Rare in a normal developer setup; reported * `VirtualProtect` failed -- e.g. a hardened environment that denies
* rather than silently ignored either way. */ * W^X transitions).
*
* Rare in a normal developer setup; reported rather than silently
* ignored either way.
*/
HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE
} HotReloadSymbolEvent; } HotReloadSymbolEvent;
/* One event report, passed to HotReloadOptions::on_symbol_event. /**
* old_address/new_address are NULL when not applicable (e.g. * @brief One symbol's outcome, passed to HotReloadOptions::on_symbol_event.
* old_address is NULL for HOT_RELOAD_SYMBOL_ADDED). */ */
typedef struct HotReloadSymbolEventInfo typedef struct HotReloadSymbolEventInfo
{ {
const char* name; const char* name; /**< The symbol's real name (mangled, if it's a C++ symbol -- see hot_reload_demangle()). */
HotReloadSymbolEvent kind; HotReloadSymbolEvent kind; /**< What happened to it -- see ::HotReloadSymbolEvent. */
void* old_address; void* old_address; /**< The symbol's address in the previous build, or `NULL` if not applicable (e.g. ::HOT_RELOAD_SYMBOL_ADDED). */
void* new_address; void* new_address; /**< The symbol's address in the new build, or `NULL` if not applicable (e.g. ::HOT_RELOAD_SYMBOL_REMOVED). */
} HotReloadSymbolEventInfo; } HotReloadSymbolEventInfo;
/**
* @brief Callback invoked once per symbol on every hot_reload_reload() call.
*
* Called synchronously, on the thread that called hot_reload_reload(), while the reload is in
* progress.
*
* @param event Describes one symbol's outcome; valid only for the duration of the call.
* @param user_data Whatever was passed as HotReloadOptions::user_data, untouched.
*
* @warning Do not call hot_reload_load() or hot_reload_reload() on the same context from inside
* this callback.
*/
typedef void (*HotReloadSymbolEventFn)(const HotReloadSymbolEventInfo* event, void* user_data); typedef void (*HotReloadSymbolEventFn)(const HotReloadSymbolEventInfo* event, void* user_data);
/* Called instead of patching, once per HOT_RELOAD_SYMBOL_REMOVED /**
* event, so the host can catch stale references during development * @brief Callback invoked instead of patching, once per ::HOT_RELOAD_SYMBOL_REMOVED event.
* instead of them silently continuing to run old code forever. Return *
* the address of a replacement to redirect the old function to (its * Lets the host catch stale references during development instead of them
* only safe use is a fixed-signature stub -- e.g. `void stub(void)`-- * silently continuing to run old code forever.
* that logs/asserts; this library has no way to know the deleted *
* function's real signature, so redirecting to anything that reads * Called synchronously, on the thread that called hot_reload_reload(), while the reload is in
* arguments the caller didn't pass is undefined behavior, exactly * progress; like HotReloadSymbolEventFn, it must not call hot_reload_load() or
* like calling any function through the wrong function-pointer type). * hot_reload_reload() on the same context.
* Return NULL (or pass a NULL callback in HotReloadOptions to begin *
* with) to leave the old implementation running untouched -- the * @param symbol_name The removed function's real (possibly mangled) name.
* default, and the safe choice unless you specifically want deleted * @param user_data Whatever was passed as HotReloadOptions::user_data, untouched.
* functions to be loud. */ * @return The address of a replacement to redirect the old function to, or
* `NULL` to leave the old implementation running untouched (the
* default, and the safe choice unless you specifically want
* deleted functions to be loud).
*
* @warning A non-`NULL` return's only safe use is a fixed-signature stub
* (e.g. `void stub(void)`) that logs/asserts; this library has
* no way to know the deleted function's real signature, so
* redirecting to anything that reads arguments the caller didn't
* pass is undefined behavior, exactly like calling any function
* through the wrong function-pointer type.
*/
typedef void* (*HotReloadDeletedSymbolFn)(const char* symbol_name, void* user_data); typedef void* (*HotReloadDeletedSymbolFn)(const char* symbol_name, void* user_data);
/* Passed to hot_reload_context_create(); NULL is a valid options /**
* pointer meaning "no callbacks, default policy for everything". */ * @brief Options passed to hot_reload_context_create().
*
* A `NULL` options pointer is valid and means "no callbacks, default
* policy for everything."
*/
typedef struct HotReloadOptions typedef struct HotReloadOptions
{ {
HotReloadSymbolEventFn on_symbol_event; /* nullable */ HotReloadSymbolEventFn on_symbol_event; /**< Nullable. See ::HotReloadSymbolEventFn. */
HotReloadDeletedSymbolFn on_deleted_symbol; /* nullable */ HotReloadDeletedSymbolFn on_deleted_symbol; /**< Nullable. See ::HotReloadDeletedSymbolFn. */
void* user_data; /* passed back to both callbacks, untouched */ void* user_data; /**< Passed back to both callbacks, untouched. */
} HotReloadOptions; } HotReloadOptions;
/* Caller owns the returned pointer and must call /** @} */ /* end of hotreload_types */
* hot_reload_context_destroy() -- see cpp-style-guide.md section 16
* ("Document ownership transfer explicitly"). options may be NULL. */ /**
* @defgroup hotreload_lifecycle Context lifecycle
* @brief Creating and destroying a hot-reload context.
* @{
*/
/**
* @brief Creates a new hot-reload context.
* @param options Nullable; see ::HotReloadOptions. Its contents are copied, so it need not
* outlive this call.
* @return A new context, or `NULL` if allocation failed. The caller owns it and must call
* hot_reload_context_destroy() (see `cpp-style-guide.md` section 16,
* "Document ownership transfer explicitly").
*/
HOT_RELOAD_API HotReloadContext* hot_reload_context_create(const HotReloadOptions* options); HOT_RELOAD_API HotReloadContext* hot_reload_context_create(const HotReloadOptions* options);
/**
* @brief Destroys a context created by hot_reload_context_create().
*
* Unloads every version of every module the context loaded (running their static destructors).
* From then on, every pointer obtained through this context -- functions, data, and the old
* entry points that had been patched to jump into newer versions -- is dangling and must not be
* used. Destroy a context only when nothing can still call into its modules.
*
* @param ctx The context to destroy. Safe to pass `NULL`.
*/
HOT_RELOAD_API void hot_reload_context_destroy(HotReloadContext* ctx); HOT_RELOAD_API void hot_reload_context_destroy(HotReloadContext* ctx);
/* Loads path for the first time under module_name -- an arbitrary /** @} */ /* end of hotreload_lifecycle */
* caller-chosen key used to look the module back up in later
* hot_reload_reload()/hot_reload_get_symbol() calls; it does not need /**
* to match anything on disk. Returns HOT_RELOAD_ERROR_INVALID_ARGUMENT * @defgroup hotreload_loading Loading and reloading
* if module_name was already loaded -- call hot_reload_reload() * @brief The core operations: register a module, hot-reload it, and
* instead of hot_reload_load() again for the same name. */ * resolve its symbols.
* @{
*/
/**
* @brief Loads a module for the first time under a logical name.
*
* The file is not loaded in place: it is first copied to a uniquely named sibling
* (`<path>.hotreload.<module_name>.<n>`) and that copy is loaded, which keeps a rebuild that
* overwrites @c path in place from being mistaken for the already-loaded version. The copy is
* deleted again right after loading on POSIX; on Windows it stays until the module is unloaded
* (see the top-level README). This means the directory containing @c path must be writable.
*
* The module's static constructors run as part of loading it.
*
* @note If the module's symbol table cannot be read (for instance a `.so` whose section headers
* were stripped), loading still succeeds but the module exports nothing as far as this
* library is concerned: hot_reload_get_symbol() finds nothing and a later
* hot_reload_reload() has nothing to patch.
*
* @param ctx The context to load into.
* @param module_name An arbitrary caller-chosen key used to look the
* module back up in later hot_reload_reload()/
* hot_reload_get_symbol() calls; it does not need to match
* anything on disk.
* @param path Path to the shared library (`.so`/`.dll`) to load. Always treated as a file path:
* a bare file name such as `plugin.so` means the file of that name in the current
* working directory (it is never looked up through the library search path).
* @return ::HOT_RELOAD_OK, or ::HOT_RELOAD_ERROR_INVALID_ARGUMENT if
* an argument was `NULL` or @c module_name was already loaded (call
* hot_reload_reload() instead of hot_reload_load() again for the same
* name), or ::HOT_RELOAD_ERROR_LOAD_FAILED (see hot_reload_last_error()
* for why).
*/
HOT_RELOAD_API HotReloadStatus hot_reload_load(HotReloadContext* ctx, const char* module_name, const char* path); HOT_RELOAD_API HotReloadStatus hot_reload_load(HotReloadContext* ctx, const char* module_name, const char* path);
/* Loads a NEW build of the module previously registered under /**
* module_name, diffs its exported functions against the previous * @brief Hot-reloads a module previously registered with hot_reload_load().
* version, and patches every one that is safe to patch in place -- *
* see HotReloadSymbolEvent above for exactly what happens to each * Loads a new build of the module, diffs its exported functions against
* kind of change. On any HOT_RELOAD_ERROR_* return, nothing was * the previous version, and patches every one that is safe to patch in
* changed: the previous version keeps running exactly as it was. */ * place -- see ::HotReloadSymbolEvent for exactly what happens to each
* kind of change. The previous version stays loaded (its entry points now
* jump into the new one), and the new build's static constructors run.
* If the new build exports no functions at all -- including when its symbol
* table could not be read -- every previously exported function is reported
* as ::HOT_RELOAD_SYMBOL_REMOVED.
*
* Call this from a quiescent point (see @ref hotreload_threads), and see
* @ref hotreload_lifetime for what does not carry over. @c new_path follows
* the same rules as @c path in hot_reload_load().
*
* @param ctx The context @c module_name was hot_reload_load()'ed into.
* @param module_name The same logical name passed to hot_reload_load().
* @param new_path Path to the new build of the shared library.
* @return ::HOT_RELOAD_OK, or an `HOT_RELOAD_ERROR_*` code. On any error,
* nothing was changed: the previous version keeps running exactly
* as it was.
*/
HOT_RELOAD_API HotReloadStatus hot_reload_reload(HotReloadContext* ctx, const char* module_name, const char* new_path); HOT_RELOAD_API HotReloadStatus hot_reload_reload(HotReloadContext* ctx, const char* module_name, const char* new_path);
/* Resolves symbol_name in module_name's CURRENT build (i.e. always /**
* the freshest one loaded, patched or not) -- for calling a function * @brief Resolves a symbol in a module's current build.
* that did not exist yet when the host first started, such as one *
* reported via HOT_RELOAD_SYMBOL_ADDED. Returns NULL if module_name * Always resolves against the freshest build loaded for @c module_name,
* is unknown or symbol_name is not an exported function in it. */ * patched or not -- useful for calling a function that did not exist yet
* when the host first started, such as one reported via
* ::HOT_RELOAD_SYMBOL_ADDED. Any exported symbol resolves, data included
* (a global variable, say); only functions are ever patched.
*
* @param ctx The context @c module_name was hot_reload_load()'ed into.
* @param module_name The module to resolve @c symbol_name in.
* @param symbol_name The symbol's real (possibly mangled) name.
* @return The symbol's current address, or `NULL` if an argument is `NULL`,
* @c module_name is unknown, or @c symbol_name is not exported by
* the module's newest build.
*/
HOT_RELOAD_API void* hot_reload_get_symbol(HotReloadContext* ctx, const char* module_name, const char* symbol_name); HOT_RELOAD_API void* hot_reload_get_symbol(HotReloadContext* ctx, const char* module_name, const char* symbol_name);
/* Only meaningful immediately after hot_reload_load() or /** @} */ /* end of hotreload_loading */
* hot_reload_reload() on this SAME ctx returned something other than
* HOT_RELOAD_OK: the underlying platform's own description of why -- /**
* dlerror() on POSIX, the formatted message for GetLastError() on * @defgroup hotreload_diagnostics Diagnostics
* Windows -- e.g. "undefined symbol: _ZN6Widget4tickEi" for a shared * @brief Turning a status code or a mangled name into something readable.
* library with an unresolved reference (a real, confusing-until-you- * @{
* see-this-message case: a non-inline static class member that is */
* declared but never given an out-of-class definition compiles fine
* and links fine into a shared library -- the linker allows a shared /**
* library to keep undefined symbols by default -- and only fails, * @brief The platform's own explanation for the most recent failure on this context.
* right here, once something ODR-uses it and the loader tries to *
* resolve it for real). Overwritten by the next * Only meaningful immediately after hot_reload_load() or
* hot_reload_load()/hot_reload_reload() call on this ctx; copy it out * hot_reload_reload() on this *same* @c ctx returned something other than
* immediately if you need to keep it past that point (e.g. for a log * ::HOT_RELOAD_OK: the underlying platform's own description of why --
* line). Empty if the platform gave no further detail, or if the * `dlerror()` on POSIX, the formatted message for `GetLastError()` on
* failure never reached the platform loader at all (e.g. a NULL * Windows -- e.g. `"undefined symbol: _ZN6Widget4tickEi"` for a shared
* argument, which is HOT_RELOAD_ERROR_INVALID_ARGUMENT before * library with an unresolved reference (a real, confusing-until-you-see-
* anything platform-specific happens). A static string internally -- * this-message case: a non-inline static class member that is declared
* never free() this. */ * but never given an out-of-class definition compiles fine and links fine
* into a shared library -- the linker allows a shared library to keep
* undefined symbols by default -- and only fails, right here, once
* something ODR-uses it and the loader tries to resolve it for real).
*
* @param ctx The context to query.
* @return The platform's error message. Empty if it gave no further
* detail, or if the failure never reached the platform loader at
* all (e.g. a `NULL` argument, which is
* ::HOT_RELOAD_ERROR_INVALID_ARGUMENT before anything
* platform-specific happens). Owned by @c ctx -- never `free()`
* this. It stays valid only until the next hot_reload_load()/
* hot_reload_reload() call on this @c ctx, or until the context is
* destroyed; copy it out immediately if you need to keep it past
* that point (e.g. for a log line).
*/
HOT_RELOAD_API const char* hot_reload_last_error(HotReloadContext* ctx); HOT_RELOAD_API const char* hot_reload_last_error(HotReloadContext* ctx);
/* A static string literal -- never free() this. */ /**
* @brief Renders a ::HotReloadStatus as text.
* @param status The status to render.
* @return A static string literal -- never `free()` this.
*/
HOT_RELOAD_API const char* hot_reload_status_string(HotReloadStatus status); HOT_RELOAD_API const char* hot_reload_status_string(HotReloadStatus status);
/* Renders name for a human to read: a real C++ mangled name (Itanium /**
* on GCC/Clang/MinGW, MSVC's own scheme under MSVC -- whichever this * @brief Renders a symbol name for a human to read.
* library itself was built with) becomes something like *
* "Widget::tick(int)"; anything else, including an ordinary extern "C" * A real C++ mangled name (Itanium on GCC/Clang/MinGW, MSVC's own scheme
* name, comes back unchanged. Safe to call unconditionally on any * under MSVC -- whichever this library itself was built with) becomes
* HotReloadSymbolEventInfo::name or hot_reload_get_symbol() key -- * something like `"Widget::tick(int)"`; anything else, including an
* this never changes what name means for lookup purposes, it is only * ordinary `extern "C"` name, comes back unchanged. Safe to call
* ever applied when you ask for it, purely for display. Returns NULL * unconditionally on any HotReloadSymbolEventInfo::name or
* only if name itself is NULL or allocation fails; caller must free * hot_reload_get_symbol() key -- this never changes what a name means for
* a non-NULL result with hot_reload_free_string(). */ * lookup purposes, it is only ever applied when you ask for it, purely
* for display.
*
* @param name The name to render.
* @return The rendered name, or `NULL` if @c name itself is `NULL` or
* allocation fails. Caller must free a non-`NULL` result with
* hot_reload_free_string().
*/
HOT_RELOAD_API char* hot_reload_demangle(const char* name); HOT_RELOAD_API char* hot_reload_demangle(const char* name);
/**
* @brief Frees a string returned by hot_reload_demangle().
* @param str The string to free. Safe to pass `NULL`.
*/
HOT_RELOAD_API void hot_reload_free_string(char* str); HOT_RELOAD_API void hot_reload_free_string(char* str);
/** @} */ /* end of hotreload_diagnostics */
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
+60 -24
View File
@@ -1,3 +1,7 @@
/**
* @file Architecture.cpp
* @brief Per-architecture jump-patch backends for x86, x86-64, and ARM64.
*/
#include "Architecture.hpp" #include "Architecture.hpp"
#include <atomic> #include <atomic>
@@ -51,7 +55,8 @@
* executing through the exact bytes being patched -- doing that in * executing through the exact bytes being patched -- doing that in
* general on a variable-length ISA needs a signal-handler-based retry * general on a variable-length ISA needs a signal-handler-based retry
* protocol (a much bigger mechanism, well beyond a dev-loop tool's * protocol (a much bigger mechanism, well beyond a dev-loop tool's
* requirements). See the README for this precondition stated plainly. * requirements). See the "Limitations" section of the top-level README
* for this precondition stated plainly.
*/ */
namespace hot_reload::detail namespace hot_reload::detail
@@ -60,20 +65,30 @@ namespace hot_reload::detail
{ {
#if defined(__x86_64__) || defined(_M_X64) #if defined(__x86_64__) || defined(_M_X64)
/// Size in bytes of the CET landing pad (`endbr64`) a function may start with.
constexpr std::size_t kLandingPadBytes = 4; constexpr std::size_t kLandingPadBytes = 4;
/// Encoding of `endbr64`, the x86-64 CET indirect-branch landing pad.
constexpr unsigned char kEndbr64[kLandingPadBytes] = {0xF3, 0x0F, 0x1E, 0xFA}; constexpr unsigned char kEndbr64[kLandingPadBytes] = {0xF3, 0x0F, 0x1E, 0xFA};
// jmp qword ptr [rip+0]; <8-byte absolute address> -- reaches
// any 64-bit address regardless of how far apart the old and /// Size in bytes of the jump written over a function's entry:
// new modules were loaded, unlike a 5-byte relative jump /// `jmp qword ptr [rip+0]` followed by an 8-byte absolute address. That reaches any
// (E9 rel32) which only reaches +/-2GB. Verified byte-for-byte /// 64-bit address regardless of how far apart the old and new modules were loaded,
// against `as`/objdump output while building this library. /// unlike a 5-byte relative jump (`E9 rel32`) which only reaches +/-2GB. Verified
/// byte-for-byte against `as`/objdump output while building this library.
constexpr std::size_t kJumpBytes = 14; constexpr std::size_t kJumpBytes = 14;
/// Whether the function at @p entry starts with an `endbr64` landing pad.
/// @param entry The function's entry address (at least kLandingPadBytes readable bytes).
/// @return True if the first bytes are `endbr64`.
bool hasLandingPad(const unsigned char* entry) bool hasLandingPad(const unsigned char* entry)
{ {
return std::memcmp(entry, kEndbr64, kLandingPadBytes) == 0; return std::memcmp(entry, kEndbr64, kLandingPadBytes) == 0;
} }
/// Writes the x86-64 absolute jump at @p site, payload first and the opcode last.
/// @param site Where the jump goes (just past the landing pad, if there is one).
/// @param target Address the jump transfers control to.
void writeJumpAt(unsigned char* site, const void* target) void writeJumpAt(unsigned char* site, const void* target)
{ {
std::uint64_t targetValue = 0; std::uint64_t targetValue = 0;
@@ -99,20 +114,29 @@ namespace hot_reload::detail
#elif defined(__i386__) || defined(_M_IX86) #elif defined(__i386__) || defined(_M_IX86)
/// Size in bytes of the CET landing pad (`endbr32`) a function may start with.
constexpr std::size_t kLandingPadBytes = 4; constexpr std::size_t kLandingPadBytes = 4;
/// Encoding of `endbr32`, the 32-bit x86 CET indirect-branch landing pad.
constexpr unsigned char kEndbr32[kLandingPadBytes] = {0xF3, 0x0F, 0x1E, 0xFB}; constexpr unsigned char kEndbr32[kLandingPadBytes] = {0xF3, 0x0F, 0x1E, 0xFB};
// e9 rel32 -- a plain relative near jump. On 32-bit x86 the
// entire address space fits in the signed 32-bit displacement /// Size in bytes of the jump written over a function's entry: `e9 rel32`, a plain
// (mod 2^32 wraparound gives the right answer regardless of /// relative near jump. On 32-bit x86 the entire address space fits in the signed 32-bit
// "distance"), so unlike x86-64 there is no need for an /// displacement (mod 2^32 wraparound gives the right answer regardless of "distance"),
// absolute/indirect form here. /// so unlike x86-64 there is no need for an absolute/indirect form here.
constexpr std::size_t kJumpBytes = 5; constexpr std::size_t kJumpBytes = 5;
/// Whether the function at @p entry starts with an `endbr32` landing pad.
/// @param entry The function's entry address (at least kLandingPadBytes readable bytes).
/// @return True if the first bytes are `endbr32`.
bool hasLandingPad(const unsigned char* entry) bool hasLandingPad(const unsigned char* entry)
{ {
return std::memcmp(entry, kEndbr32, kLandingPadBytes) == 0; return std::memcmp(entry, kEndbr32, kLandingPadBytes) == 0;
} }
/// Writes the x86 relative jump at @p site, displacement first and the opcode last.
/// @param site Where the jump goes (just past the landing pad, if there is one).
/// @param target Address the jump transfers control to.
void writeJumpAt(unsigned char* site, const void* target) void writeJumpAt(unsigned char* site, const void* target)
{ {
std::int32_t rel32 = static_cast<std::int32_t>( std::int32_t rel32 = static_cast<std::int32_t>(
@@ -128,35 +152,47 @@ namespace hot_reload::detail
#elif defined(__aarch64__) || defined(_M_ARM64) #elif defined(__aarch64__) || defined(_M_ARM64)
/// Size in bytes of the BTI landing pad a function may start with (one instruction word).
constexpr std::size_t kLandingPadBytes = 4; constexpr std::size_t kLandingPadBytes = 4;
// Matches any BTI variant (plain/C/J/JC): bits [31:6] are fixed /// Whether the instruction word at @p entry is any BTI variant (plain/C/J/JC): bits
// at 0b010101000000001001001, differing only in bits [6:5] /// [31:6] are fixed at 0b010101000000001001001, differing only in bits [6:5] (the
// (the qualifier) -- equivalently, the low byte is one of /// qualifier) -- equivalently, the low byte is one of 0x1F/0x5F/0x9F/0xDF and the next 3
// 0x1F/0x5F/0x9F/0xDF and the next 3 bytes are always /// bytes are always 24 03 D5. Verified with `as`/objdump for all 4 variants while
// 24 03 D5. Verified with `as`/objdump for all 4 variants /// building this library.
// while building this library. /// @param entry The function's entry address (at least 4 readable bytes).
/// @return True if the first instruction is a BTI landing pad.
bool isBti(const unsigned char* entry) bool isBti(const unsigned char* entry)
{ {
return (entry[0] & 0x3Fu) == 0x1Fu && entry[1] == 0x24 && entry[2] == 0x03 && entry[3] == 0xD5; return (entry[0] & 0x3Fu) == 0x1Fu && entry[1] == 0x24 && entry[2] == 0x03 && entry[3] == 0xD5;
} }
/// Whether the function at @p entry starts with a BTI landing pad.
/// @param entry The function's entry address (at least kLandingPadBytes readable bytes).
/// @return True if the first instruction is a BTI landing pad.
bool hasLandingPad(const unsigned char* entry) bool hasLandingPad(const unsigned char* entry)
{ {
return isBti(entry); return isBti(entry);
} }
// ldr x17, #8 ; br x17 ; <8-byte absolute address>. x16/x17 /// Size in bytes of the jump written over a function's entry:
// are AArch64's designated "intra-procedure-call" scratch /// `ldr x17, #8 ; br x17 ; <8-byte absolute address>`. x16/x17 are AArch64's designated
// registers (IP0/IP1) -- the AAPCS64 explicitly reserves them /// "intra-procedure-call" scratch registers (IP0/IP1) -- the AAPCS64 explicitly reserves
// for exactly this kind of linker/veneer-style long branch, so /// them for exactly this kind of linker/veneer-style long branch, so clobbering them at
// clobbering them at a function's entry point, before any of /// a function's entry point, before any of its real prologue has executed, is always
// its real prologue has executed, is always safe. Byte values /// safe. Byte values verified with `as`/objdump while building this library.
// verified with `as`/objdump while building this library.
constexpr std::size_t kJumpBytes = 16; constexpr std::size_t kJumpBytes = 16;
/// Encoding of `ldr x17, #8` (loads the literal address stored 8 bytes further on).
constexpr unsigned char kLdrX17Pc8[4] = {0x51, 0x00, 0x00, 0x58}; constexpr unsigned char kLdrX17Pc8[4] = {0x51, 0x00, 0x00, 0x58};
/// Encoding of `br x17` (branches to the address `ldr x17, #8` loaded).
constexpr unsigned char kBrX17[4] = {0x20, 0x02, 0x1F, 0xD6}; constexpr unsigned char kBrX17[4] = {0x20, 0x02, 0x1F, 0xD6};
/// Writes the ARM64 `ldr`/`br` pair plus literal address at @p site, payload first and
/// the leading `ldr` last.
/// @param site Where the jump goes (just past the landing pad, if there is one).
/// @param target Address the jump transfers control to.
void writeJumpAt(unsigned char* site, const void* target) void writeJumpAt(unsigned char* site, const void* target)
{ {
std::uint64_t targetValue = 0; std::uint64_t targetValue = 0;
+71 -36
View File
@@ -2,56 +2,91 @@
#include <cstddef> #include <cstddef>
/**
* @namespace hot_reload::detail
* @brief Internal building blocks of the hot-reload runtime -- not part of the public API.
*
* The jump-patch backends (`Architecture.hpp`), the page-protection helper
* (`MemoryProtection.hpp`), the loader wrapper (`ModuleHandle.hpp`) and the symbol-table readers
* (`SymbolTable.hpp`) all live here. They are implementation details of
* hot_reload::HotReloadContext and may change freely; the supported surface is the C API in
* `hot_reload.h`.
*/
namespace hot_reload::detail namespace hot_reload::detail
{ {
/// What planPatch() found at one specific function's entry point -- /**
/// how many bytes from the entry address must be safe to overwrite, * @brief What planPatch() found at one specific function's entry point.
/// and how many of those (if any) are a landing pad that must be *
/// preserved rather than overwritten. See Architecture.cpp's file * How many bytes from the entry address must be safe to overwrite, and
/// comment for why a landing pad can be present at all. * how many of those (if any) are a landing pad that must be preserved
* rather than overwritten. See `Architecture.cpp`'s file comment for
* why a landing pad can be present at all.
*/
struct PatchLayout struct PatchLayout
{ {
/// Total bytes from the function's entry address that this /**
/// patch touches, landing pad included -- compare this against * Total bytes from the function's entry address that this patch
/// the symbol's compiled size before writing anything. * touches, landing pad included -- compare this against the
* symbol's compiled size before writing anything.
*/
std::size_t totalBytes = 0; std::size_t totalBytes = 0;
/// How many leading bytes are a landing pad and must be left /**
/// untouched; the jump itself is written starting at * How many leading bytes are a landing pad and must be left
/// entryAddress + patchOffset. * untouched; the jump itself is written starting at
* `entryAddress + patchOffset`.
*/
std::size_t patchOffset = 0; std::size_t patchOffset = 0;
}; };
/// True if this build has a jump-patch backend for the CPU /**
/// architecture it was compiled for (decided at compile time -- * @brief Whether this build has a jump-patch backend for the current CPU architecture.
/// see the top of Architecture.cpp). x86, x86-64, and ARM64 are *
/// supported; anything else (including 32-bit ARM, whose ARM/Thumb * Decided at compile time -- see the top of `Architecture.cpp`. x86,
/// interworking this library does not attempt -- see the README) * x86-64, and ARM64 are supported; anything else (including 32-bit
/// returns false here, and HotReloadContext surfaces that as * ARM, whose ARM/Thumb interworking this library does not attempt --
/// HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE. * see the "Scope" section of `source/HotReload/README.md`) returns false here, and
* `HotReloadContext` surfaces that as
* ::HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE.
*
* @return True if writeJump() can succeed on this architecture.
*/
bool isArchitectureSupported(); bool isArchitectureSupported();
/// Inspects the bytes already sitting at entryAddress to determine /**
/// the real patch layout for THIS function, accounting for a * @brief Determines the real patch layout for one specific function.
/// possible leading landing pad. Safe to call even when *
/// isArchitectureSupported() is false (returns a zeroed PatchLayout). * Inspects the bytes already sitting at @p entryAddress, accounting
* for a possible leading landing pad. Safe to call even when
* isArchitectureSupported() is false (returns a zeroed PatchLayout).
*
* @param entryAddress The function's entry address.
* @return The layout to check against the symbol's compiled size
* before writing anything, and to pass to writeJump().
*/
PatchLayout planPatch(const void* entryAddress); PatchLayout planPatch(const void* entryAddress);
/// Overwrites entryAddress so that control transfers unconditionally /**
/// to targetAddress, following the layout planPatch() would return * @brief Overwrites a function's entry so it jumps to another address.
/// for this same entryAddress. The caller must already have made *
/// [entryAddress, entryAddress + planPatch(entryAddress).totalBytes) * Follows the layout planPatch() would return for this same
/// writable (see MemoryProtection.hpp) and must flush the * @p entryAddress.
/// instruction cache over that same range afterwards -- this *
/// function does neither. * @param entryAddress The function entry to overwrite.
/// * @param targetAddress Where control should transfer to instead.
/// Only meaningful precondition: the caller has already checked the * @return False only if isArchitectureSupported() is false.
/// target symbol's compiled size against planPatch(entryAddress) *
/// .totalBytes; this function does not re-check it and will happily * @pre The caller has already made
/// overwrite whatever comes next in memory if asked to. * `[entryAddress, entryAddress + planPatch(entryAddress).totalBytes)`
/// * writable (see `MemoryProtection.hpp`) and will flush the
/// Returns false only if isArchitectureSupported() is false. * instruction cache over that same range afterwards -- this
* function does neither.
* @pre The caller has already checked the target symbol's compiled
* size against `planPatch(entryAddress).totalBytes`; this
* function does not re-check it and will happily overwrite
* whatever comes next in memory if asked to.
*/
bool writeJump(void* entryAddress, const void* targetAddress); bool writeJump(void* entryAddress, const void* targetAddress);
} }
+68 -21
View File
@@ -1,3 +1,7 @@
/**
* @file HotReloadContext.cpp
* @brief The diff-and-patch orchestration logic: load, reload, and per-symbol policy.
*/
#include "HotReloadContext.hpp" #include "HotReloadContext.hpp"
#include "Architecture.hpp" #include "Architecture.hpp"
@@ -12,6 +16,12 @@ namespace hot_reload
{ {
namespace namespace
{ {
/// Copies a file byte for byte, overwriting the destination.
/// @param from The file to read.
/// @param to The file to create (or truncate).
/// @return True if the whole copy succeeded. An empty source counts as a failure (inserting
/// zero characters into an output stream sets its failbit) -- harmless, since an
/// empty file is never a loadable module anyway.
bool copyFile(const std::string& from, const std::string& to) bool copyFile(const std::string& from, const std::string& to)
{ {
std::ifstream in(from, std::ios::binary); std::ifstream in(from, std::ios::binary);
@@ -28,32 +38,67 @@ namespace hot_reload
return static_cast<bool>(out); return static_cast<bool>(out);
} }
// Best-effort: deletes any staging copy left over from a /// The module name as it appears inside a staging copy's file name.
// PREVIOUS run of this same host/module -- see ///
// stagePathForLoad()'s own comment for why those copies exist /// module_name is an arbitrary caller-chosen key (hot_reload.h promises it need not match
// at all. On POSIX this normally finds nothing (an active /// anything on disk), so it may contain characters that are not legal in a file name --
// run's own copies are already gone by the time it makes its /// a '/' in "engine/renderer", say. Everything outside a conservative set is replaced by
// next one -- see the std::remove() calls after each /// '_'. stagePathForLoad() and cleanupOrphanedStagingFiles() must agree on the result, so
// ModuleHandle::load() below). On Windows, a copy backing a /// both call this. Names made only of letters, digits, '-' and '_' are unchanged.
// still-loaded module cannot be deleted for as long as that ///
// module stays mapped -- which, by this library's own design, /// @param moduleName The logical module name.
// is for the rest of the CURRENT process's lifetime (see /// @return @p moduleName with every character outside `[A-Za-z0-9_-]` replaced by '_'.
// HotReloadContext.hpp's LoadedModule comment) -- so within a std::string stagingTag(const std::string& moduleName)
// single long-running session some accumulation is expected {
// and unavoidable; this specifically cleans up the PREVIOUS std::string tag = moduleName;
// session's leftovers once that process has exited and for (char& character : tag)
// released whatever lock Windows held. Called once, here, at {
// the first hot_reload_load() for a given name -- not on every const bool isLower = character >= 'a' && character <= 'z';
// reload() -- since a copy from earlier in the SAME session const bool isUpper = character >= 'A' && character <= 'Z';
// would still be locked for the same reason and retrying const bool isDigit = character >= '0' && character <= '9';
// wouldn't change that. const bool isDashOrUnderscore = character == '-' || character == '_';
const bool safe = isLower || isUpper || isDigit || isDashOrUnderscore;
if (!safe)
{
character = '_';
}
}
return tag;
}
/// Best-effort: deletes any staging copy left over from a
/// PREVIOUS run of this same host/module -- see
/// stagePathForLoad()'s own comment for why those copies exist
/// at all.
///
/// On POSIX this normally finds nothing (an active
/// run's own copies are already gone by the time it makes its
/// next one -- see the std::remove() calls after each
/// ModuleHandle::load() below). On Windows, a copy backing a
/// still-loaded module cannot be deleted for as long as that
/// module stays mapped -- which, by this library's own design,
/// is for as long as the owning context lives, usually the rest
/// of the CURRENT process's lifetime (see HotReloadContext.hpp's
/// LoadedModule::versions) -- so within a
/// single long-running session some accumulation is expected
/// and unavoidable; this specifically cleans up the PREVIOUS
/// session's leftovers once that process has exited and
/// released whatever lock Windows held. Called once, at
/// the first hot_reload_load() for a given name -- not on every
/// reload() -- since a copy from earlier in the SAME session
/// would still be locked for the same reason and retrying
/// wouldn't change that.
///
/// @param sourcePath The path the module is being loaded from; the copies live next to it.
/// @param moduleName The module's logical name.
void cleanupOrphanedStagingFiles(const std::string& sourcePath, const std::string& moduleName) void cleanupOrphanedStagingFiles(const std::string& sourcePath, const std::string& moduleName)
{ {
namespace fs = std::filesystem; namespace fs = std::filesystem;
fs::path source(sourcePath); fs::path source(sourcePath);
fs::path dir = source.has_parent_path() ? source.parent_path() : fs::path("."); fs::path dir = source.has_parent_path() ? source.parent_path() : fs::path(".");
std::string prefix = source.filename().string() + ".hotreload." + moduleName + "."; const std::string tag = stagingTag(moduleName);
std::string prefix = source.filename().string() + ".hotreload." + tag + ".";
std::error_code dirError; std::error_code dirError;
fs::directory_iterator entries(dir, dirError); fs::directory_iterator entries(dir, dirError);
@@ -120,7 +165,9 @@ namespace hot_reload
// name guaranteed never reused sidesteps it regardless of how // name guaranteed never reused sidesteps it regardless of how
// the build produced sourcePath. // the build produced sourcePath.
++module.reloadCounter; ++module.reloadCounter;
std::string staged = sourcePath + ".hotreload." + moduleName + "." + std::to_string(module.reloadCounter); const std::string tag = stagingTag(moduleName);
const std::string counter = std::to_string(module.reloadCounter);
std::string staged = sourcePath + ".hotreload." + tag + "." + counter;
if (!copyFile(sourcePath, staged)) if (!copyFile(sourcePath, staged))
{ {
return {}; return {};
+115 -17
View File
@@ -8,62 +8,160 @@
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
/**
* @namespace hot_reload
* @brief The C++ implementation behind the C API declared in `hot_reload.h`.
*
* The one type meant to be used from outside is HotReloadContext, and even that only through the
* opaque ::HotReloadContext pointer the C API hands out. The building blocks it is made of live in
* hot_reload::detail.
*/
namespace hot_reload namespace hot_reload
{ {
/// The real type behind the opaque ::HotReloadContext the public C /**
/// API hands out -- see HotReloadApi.cpp for the extern "C" shim * @brief The real type behind the opaque ::HotReloadContext the public C API hands out.
/// that casts between the two, following the same opaque-pointer *
/// PIMPL pattern as cpp-style-guide.md's own NetManager example. * See `HotReloadApi.cpp` for the `extern "C"` shim that casts between
* the two, following the same opaque-pointer PIMPL pattern as
* `cpp-style-guide.md`'s own `NetManager` example.
*
* A context owns every version of every module loaded through it. Destroying the context
* closes all of them, after which any function or data pointer obtained from any of those
* modules -- including old, patched entry points that now only jump into a newer version --
* is dangling.
*
* @note Not thread-safe: the class holds no locks, so all calls on one context (and any
* reload it performs) must be serialized by the caller.
*/
class HotReloadContext class HotReloadContext
{ {
public: public:
/**
* @brief Creates an empty context.
* @param options Callbacks and user data to use, copied out immediately; may be null,
* meaning "no callbacks".
*/
explicit HotReloadContext(const HotReloadOptions* options); explicit HotReloadContext(const HotReloadOptions* options);
HotReloadContext(const HotReloadContext&) = delete; HotReloadContext(const HotReloadContext&) = delete;
HotReloadContext& operator=(const HotReloadContext&) = delete; HotReloadContext& operator=(const HotReloadContext&) = delete;
/// See hot_reload_load()'s doc comment in hot_reload.h. /**
* @brief Implements hot_reload_load().
* @param moduleName The logical name to register the module under.
* @param path Path to the shared library to load.
* @return The status hot_reload_load() reports; see its doc comment in `hot_reload.h`.
*/
HotReloadStatus load(const std::string& moduleName, const std::string& path); HotReloadStatus load(const std::string& moduleName, const std::string& path);
/// See hot_reload_reload()'s doc comment in hot_reload.h. /**
* @brief Implements hot_reload_reload().
* @param moduleName The logical name the module was loaded under.
* @param newPath Path to the new build of the shared library.
* @return The status hot_reload_reload() reports; see its doc comment in `hot_reload.h`.
*/
HotReloadStatus reload(const std::string& moduleName, const std::string& newPath); HotReloadStatus reload(const std::string& moduleName, const std::string& newPath);
/// See hot_reload_get_symbol()'s doc comment in hot_reload.h. /**
* @brief Implements hot_reload_get_symbol().
* @param moduleName The logical name the module was loaded under.
* @param symbolName The symbol's real (possibly mangled) name.
* @return The symbol's address in the module's newest build, or null if the module or
* symbol is unknown; see hot_reload_get_symbol()'s doc comment in `hot_reload.h`.
*/
void* getSymbol(const std::string& moduleName, const std::string& symbolName) const; void* getSymbol(const std::string& moduleName, const std::string& symbolName) const;
/// See hot_reload_last_error()'s doc comment in hot_reload.h. /**
* @brief Implements hot_reload_last_error().
* @return The platform's message for the most recent load()/reload() failure on this
* context; empty if there was none. Valid until the next load()/reload().
*/
const std::string& lastError() const const std::string& lastError() const
{ {
return m_lastError; return m_lastError;
} }
private: private:
/// Everything tracked for one logical module name. Has no /// Everything tracked for one logical module name. Has no meaning
/// meaning outside HotReloadContext, so it lives here rather /// outside HotReloadContext, so it lives here rather than in its
/// than in its own header -- see cpp-style-guide.md's carve-out /// own header -- see cpp-style-guide.md's carve-out for small
/// for small types with no use outside their owning type. /// types with no use outside their owning type.
struct LoadedModule struct LoadedModule
{ {
// Every version ever loaded for this name, kept alive for /// Every version ever loaded for this name, kept alive for as long as the owning
// the rest of the process's lifetime: once a function is /// context lives (in practice usually the rest of the process): once a function is
// patched, anything that already holds its old address /// patched, anything that already holds its old address must stay valid memory,
// must stay valid memory forever, even though it now holds /// even though it now holds nothing but a jump. versions.back() is the active one.
// nothing but a jump. versions.back() is the active one.
std::vector<detail::ModuleHandle> versions; std::vector<detail::ModuleHandle> versions;
/// The exported symbols of the newest build (versions.back()), as reported by
/// detail::readExportedSymbols(); what the next reload() diffs against.
std::vector<detail::ExportedSymbol> currentSymbols; std::vector<detail::ExportedSymbol> currentSymbols;
/// How many staging copies have been made for this name so far; makes each copy's
/// file name unique (see stagePathForLoad()).
int reloadCounter = 0; int reloadCounter = 0;
}; };
/**
* @brief Stages a source file through a fresh, uniquely-named copy.
*
* Called before a path is ever passed to detail::ModuleHandle::load() -- see the .cpp
* definition's comment for why (a dlopen caching gotcha on POSIX; a Windows file-locking
* side effect that turns out to be handled by the same fix).
*
* @param module The module the copy is for; its reloadCounter is advanced.
* @param moduleName The module's logical name (used, sanitized, in the copy's file name).
* @param sourcePath The file to copy.
* @return The copy's path, or an empty string if copying failed.
*/
std::string stagePathForLoad(LoadedModule& module, const std::string& moduleName, const std::string& sourcePath); std::string stagePathForLoad(LoadedModule& module, const std::string& moduleName, const std::string& sourcePath);
/**
* @brief Redirects one matched function pair, or emits a SKIPPED_* event instead if that
* isn't safe.
*
* Patches @p oldSymbol's entry to jump to @p newSymbol's address; emits exactly one event
* describing the outcome either way.
*
* @param oldSymbol The function in the previous build.
* @param newSymbol The same-named function in the new build.
*/
void patchOne(const detail::ExportedSymbol& oldSymbol, const detail::ExportedSymbol& newSymbol); void patchOne(const detail::ExportedSymbol& oldSymbol, const detail::ExportedSymbol& newSymbol);
/**
* @brief Handles a symbol present in the old build but missing from the new one.
*
* Emits REMOVED, and first redirects to a caller-supplied stub if
* HotReloadOptions::on_deleted_symbol provides one and it's safe to do so.
*
* @param oldSymbol The symbol the new build no longer exports.
*/
void handleRemoved(const detail::ExportedSymbol& oldSymbol); void handleRemoved(const detail::ExportedSymbol& oldSymbol);
/**
* @brief Invokes HotReloadOptions::on_symbol_event, if one was supplied.
* @param kind What happened to the symbol.
* @param name The symbol's real (possibly mangled) name.
* @param oldAddress The symbol's address in the previous build, or null.
* @param newAddress The symbol's address in the new build, or null.
*/
void emitEvent(HotReloadSymbolEvent kind, const std::string& name, void* oldAddress, void* newAddress); void emitEvent(HotReloadSymbolEvent kind, const std::string& name, void* oldAddress, void* newAddress);
/// Every module loaded so far, by logical name.
std::unordered_map<std::string, LoadedModule> m_modules; std::unordered_map<std::string, LoadedModule> m_modules;
/// HotReloadOptions::on_symbol_event, or null.
HotReloadSymbolEventFn m_onSymbolEvent; HotReloadSymbolEventFn m_onSymbolEvent;
/// HotReloadOptions::on_deleted_symbol, or null.
HotReloadDeletedSymbolFn m_onDeletedSymbol; HotReloadDeletedSymbolFn m_onDeletedSymbol;
/// HotReloadOptions::user_data, handed back to both callbacks untouched.
void* m_userData; void* m_userData;
/// The message lastError() returns.
std::string m_lastError; std::string m_lastError;
}; };
@@ -1,3 +1,7 @@
/**
* @file MemoryProtection.cpp
* @brief Code-page write-access RAII guard implementation (mprotect/VirtualProtect).
*/
#include "MemoryProtection.hpp" #include "MemoryProtection.hpp"
#include <cstdint> #include <cstdint>
@@ -20,6 +24,8 @@ namespace hot_reload::detail
{ {
namespace namespace
{ {
/// The operating system's virtual-memory page size.
/// @return The page size in bytes (4096 if the platform could not report one).
std::size_t systemPageSize() std::size_t systemPageSize()
{ {
#if defined(_WIN32) #if defined(_WIN32)
+61 -22
View File
@@ -5,50 +5,89 @@
namespace hot_reload::detail namespace hot_reload::detail
{ {
/// RAII guard: makes [address, address + length) writable for its /**
/// lifetime (the OS only grants permissions in whole pages, so the * @brief RAII guard that temporarily makes a code range writable.
/// guard rounds out to the enclosing page range internally) and *
/// restores the original protection when it goes out of scope. * Makes `[address, address + length)` writable for its lifetime (the
/// * OS only grants permissions in whole pages, so the guard rounds out
/// Assumes the range started out READ+EXECUTE, as every platform's * to the enclosing page range internally) and restores the original
/// loader maps a .text/code section -- restoring to exactly that, * protection when it goes out of scope.
/// rather than querying and restoring whatever the previous *
/// protection actually was, since POSIX mprotect() has no portable * @note Assumes the range started out READ+EXECUTE, as every
/// way to ask for the current protection of a range and code pages * platform's loader maps a `.text`/code section -- restoring to
/// are never anything else in practice. * exactly that, rather than querying and restoring whatever the
* previous protection actually was, since POSIX `mprotect()`
* has no portable way to ask for the current protection of a
* range and code pages are never anything else in practice.
*
* @note The change applies to whole pages and is process-wide: every thread sees the
* enclosing pages as writable (and still executable) while the guard is alive.
*/
class ScopedCodeWriteAccess class ScopedCodeWriteAccess
{ {
public: public:
/**
* @brief Attempts to make a range writable immediately.
*
* Check isValid() afterwards: the platform call can be refused (for example by a
* hardened environment that denies writable+executable transitions).
*
* @param address Start of the range.
* @param length Length of the range in bytes.
*/
ScopedCodeWriteAccess(void* address, std::size_t length); ScopedCodeWriteAccess(void* address, std::size_t length);
/**
* @brief Restores the range's original protection (does nothing if isValid() is false).
*/
~ScopedCodeWriteAccess(); ~ScopedCodeWriteAccess();
ScopedCodeWriteAccess(const ScopedCodeWriteAccess&) = delete; ScopedCodeWriteAccess(const ScopedCodeWriteAccess&) = delete;
ScopedCodeWriteAccess& operator=(const ScopedCodeWriteAccess&) = delete; ScopedCodeWriteAccess& operator=(const ScopedCodeWriteAccess&) = delete;
/// False if the platform call to relax protection failed -- /**
/// when false, nothing was changed and the range must not be * @brief Whether the platform call to relax protection succeeded.
/// written to (the destructor is still safe to run either way). *
* When false, nothing was changed and the range must not be
* written to (the destructor is still safe to run either way).
*
* @return True if the range is currently writable.
*/
bool isValid() const bool isValid() const
{ {
return m_valid; return m_valid;
} }
/// Call exactly once, after writing the new bytes and before /**
/// this guard is destroyed: flushes the instruction cache over * @brief Flushes the instruction cache over the patched range.
/// the original [address, address + length) range so every *
/// core sees the new instructions rather than a stale fetch * Call exactly once, after writing the new bytes and before this
/// (required on ARM64, where instruction and data caches are * guard is destroyed, so every core sees the new instructions
/// not automatically coherent; effectively a formality on * rather than a stale fetch (required on ARM64, where instruction
/// x86/x86-64, called anyway for correctness on that * and data caches are not automatically coherent; effectively a
/// architecture too). * formality on x86/x86-64, called anyway for correctness on that
* architecture too).
*/
void finishWrite(); void finishWrite();
private: private:
/// Start of the page-aligned region whose protection was changed.
void* m_pageAddress; void* m_pageAddress;
/// Length in bytes of that page-aligned region (a whole number of pages).
std::size_t m_pageLength; std::size_t m_pageLength;
/// Start of the range the caller actually asked to write to.
void* m_rangeAddress; void* m_rangeAddress;
/// Length in bytes of the range the caller actually asked to write to.
std::size_t m_rangeLength; std::size_t m_rangeLength;
/// The protection to restore on destruction, in the platform's own encoding
/// (`PROT_*` flags on POSIX, a `PAGE_*` constant on Windows).
unsigned long m_previousProtection; unsigned long m_previousProtection;
/// Whether the constructor's protection change succeeded.
bool m_valid; bool m_valid;
}; };
+25 -4
View File
@@ -1,6 +1,11 @@
/**
* @file ModuleHandle.cpp
* @brief RAII dlopen()/LoadLibrary() wrapper implementation.
*/
#include "ModuleHandle.hpp" #include "ModuleHandle.hpp"
#include <string> #include <string>
#include <utility>
#if defined(_WIN32) #if defined(_WIN32)
// NOMINMAX: see MemoryProtection.cpp's identical comment -- same // NOMINMAX: see MemoryProtection.cpp's identical comment -- same
@@ -23,9 +28,12 @@ namespace hot_reload::detail
{ {
/// GetLastError() alone is just a number; this renders it the /// GetLastError() alone is just a number; this renders it the
/// way a person would want to read it, the same information /// way a person would want to read it, the same information
/// dlerror() gives directly on POSIX. Written against /// dlerror() gives directly on POSIX. Exercised under Wine (MinGW cross-build) -- see
/// documented Win32 behavior; not exercised on real Windows -- /// "What's actually been tested" in the top-level README; not verified against a real
/// see source/HotReload/README.md's verification notes. /// Windows/MSVC build.
/// @return The system's message for the calling thread's last Win32 error, without
/// trailing line breaks, or a generic message naming the error code if the
/// system has none.
std::string formatLastWindowsError() std::string formatLastWindowsError()
{ {
DWORD errorCode = GetLastError(); DWORD errorCode = GetLastError();
@@ -71,6 +79,7 @@ namespace hot_reload::detail
ModuleHandle::ModuleHandle(ModuleHandle&& other) noexcept ModuleHandle::ModuleHandle(ModuleHandle&& other) noexcept
: m_native(other.m_native) : m_native(other.m_native)
, m_loadedBase(other.m_loadedBase) , m_loadedBase(other.m_loadedBase)
, m_errorMessage(std::move(other.m_errorMessage))
{ {
other.m_native = nullptr; other.m_native = nullptr;
other.m_loadedBase = nullptr; other.m_loadedBase = nullptr;
@@ -90,6 +99,7 @@ namespace hot_reload::detail
} }
m_native = other.m_native; m_native = other.m_native;
m_loadedBase = other.m_loadedBase; m_loadedBase = other.m_loadedBase;
m_errorMessage = std::move(other.m_errorMessage);
other.m_native = nullptr; other.m_native = nullptr;
other.m_loadedBase = nullptr; other.m_loadedBase = nullptr;
} }
@@ -129,7 +139,18 @@ namespace hot_reload::detail
// first runs under RTLD_LAZY's deferred binding. For a // first runs under RTLD_LAZY's deferred binding. For a
// long-running host, a clean, catchable load failure is far // long-running host, a clean, catchable load failure is far
// preferable to a delayed, uncatchable one. // preferable to a delayed, uncatchable one.
void* module = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); //
// The "./" prefix: dlopen() only treats its argument as a file
// path if it contains a '/'. A bare name (e.g. "plugin.so") is
// instead searched for like a library name -- LD_LIBRARY_PATH,
// ld.so.cache, the default directories -- and is never looked
// for in the current directory, so a plain relative file name
// would fail with a baffling "cannot open shared object file"
// for a file that is sitting right there. Everything this
// library loads is a real file on disk (the staged copy
// HotReloadContext makes), so always force path semantics.
const std::string loadPath = path.find('/') == std::string::npos ? "./" + path : path;
void* module = dlopen(loadPath.c_str(), RTLD_NOW | RTLD_LOCAL);
if (module == nullptr) if (module == nullptr)
{ {
const char* error = dlerror(); const char* error = dlerror();
+66 -16
View File
@@ -5,53 +5,103 @@
namespace hot_reload::detail namespace hot_reload::detail
{ {
/// RAII wrapper around dlopen()/LoadLibrary(). Move-only, closes /**
/// the underlying module on destruction (or on being replaced by a * @brief RAII wrapper around `dlopen()`/`LoadLibrary()`.
/// move-assignment). *
* Move-only, closes the underlying module on destruction (or on
* being replaced by a move-assignment).
*
* @note Closing a module unmaps its code (unless the platform keeps it resident, e.g. a
* library that defines C++ unique symbols). Any function pointer into a closed module
* -- including old, patched entry points that jump into it -- dangles from then on.
*/
class ModuleHandle class ModuleHandle
{ {
public: public:
/** @brief Creates an empty handle that owns no module (isValid() is false). */
ModuleHandle() = default; ModuleHandle() = default;
/** @brief Closes the module, if this handle owns one. */
~ModuleHandle(); ~ModuleHandle();
ModuleHandle(const ModuleHandle&) = delete; ModuleHandle(const ModuleHandle&) = delete;
ModuleHandle& operator=(const ModuleHandle&) = delete; ModuleHandle& operator=(const ModuleHandle&) = delete;
/**
* @brief Takes ownership of @p other's module (and its error message), leaving @p other
* empty.
* @param other The handle to move from.
*/
ModuleHandle(ModuleHandle&& other) noexcept; ModuleHandle(ModuleHandle&& other) noexcept;
/**
* @brief Closes this handle's current module, then takes ownership of @p other's module
* (and its error message), leaving @p other empty.
* @param other The handle to move from.
* @return This handle.
*/
ModuleHandle& operator=(ModuleHandle&& other) noexcept; ModuleHandle& operator=(ModuleHandle&& other) noexcept;
/// Loads path. On failure the returned handle's isValid() is /**
/// false and loadedBase() is null. * @brief Loads a shared library.
*
* @param path Path to the shared library to load. Always treated as a filesystem path: a
* bare file name such as `plugin.so` means the file of that name in the current
* working directory (on POSIX it is loaded as `./plugin.so`, because `dlopen()`
* would otherwise search the library path for it instead).
* @return The loaded handle. On failure, isValid() is false and
* loadedBase() is null -- check errorMessage() for why.
*/
static ModuleHandle load(const std::string& path); static ModuleHandle load(const std::string& path);
/**
* @brief Whether load() succeeded.
* @return True if this handle owns a loaded module.
*/
bool isValid() const bool isValid() const
{ {
return m_native != nullptr; return m_native != nullptr;
} }
/// The address to pass as SymbolTable's loadedBase: on Windows /**
/// this is simply the HMODULE; on POSIX it is the runtime load * @brief The address to pass as SymbolTable's `loadedBase`.
/// bias (struct link_map::l_addr) that turns an ELF symbol's *
/// file-relative st_value into a real address. * On Windows this is simply the `HMODULE`; on POSIX it is the
* runtime load bias (`struct link_map::l_addr`) that turns an
* ELF symbol's file-relative `st_value` into a real address.
*
* @return The value described above, or null if isValid() is false.
*/
void* loadedBase() const void* loadedBase() const
{ {
return m_loadedBase; return m_loadedBase;
} }
/// Only meaningful when isValid() is false: the underlying /**
/// platform's own description of why loading failed -- * @brief Why load() failed, if isValid() is false.
/// dlerror() on POSIX, the formatted message for *
/// GetLastError() on Windows. Captured immediately inside * The underlying platform's own description of why loading
/// load()'s failing branch, before any other dl*/Win32 call * failed -- `dlerror()` on POSIX, the formatted message for
/// that could otherwise overwrite it first. Empty if load() * `GetLastError()` on Windows. Captured immediately inside
/// was never attempted, or if the platform gave no message. * load()'s failing branch, before any other POSIX dl-family or
* Win32 call that could otherwise overwrite it first.
*
* @return The platform's message, or empty if load() was never
* attempted or the platform gave no message.
*/
const std::string& errorMessage() const const std::string& errorMessage() const
{ {
return m_errorMessage; return m_errorMessage;
} }
private: private:
/// The platform's own handle (`HMODULE` or the `dlopen()` handle); null if none.
void* m_native = nullptr; void* m_native = nullptr;
/// See loadedBase().
void* m_loadedBase = nullptr; void* m_loadedBase = nullptr;
/// See errorMessage().
std::string m_errorMessage; std::string m_errorMessage;
}; };
+160 -37
View File
@@ -1,3 +1,7 @@
/**
* @file SymbolTable.cpp
* @brief Reads and classifies a module's exported symbols (ELF and PE).
*/
#include "SymbolTable.hpp" #include "SymbolTable.hpp"
#include <algorithm> #include <algorithm>
@@ -5,6 +9,7 @@
#include <cstring> #include <cstring>
#include <fstream> #include <fstream>
#include <map> #include <map>
#include <string_view>
#include <vector> #include <vector>
/* WHY "IS THIS A FUNCTION" IS DECIDED HERE, MECHANICALLY /* WHY "IS THIS A FUNCTION" IS DECIDED HERE, MECHANICALLY
@@ -49,13 +54,18 @@ namespace hot_reload::detail
namespace namespace
{ {
/// One PE section, reduced to what the function/data classification needs.
struct SectionInfo struct SectionInfo
{ {
std::uint32_t rvaBegin; std::uint32_t rvaBegin; /**< First RVA covered by the section. */
std::uint32_t rvaEnd; std::uint32_t rvaEnd; /**< One past the last RVA covered by the section. */
bool executable; bool executable; /**< Whether the section is marked as containing code. */
}; };
/// Turns a relative virtual address inside the mapped image into a string pointer.
/// @param base The image's base address.
/// @param rva A relative virtual address inside the image.
/// @return `base + rva`, viewed as a C string.
const char* rvaToPointer(std::uint8_t* base, std::uint32_t rva) const char* rvaToPointer(std::uint8_t* base, std::uint32_t rva)
{ {
return reinterpret_cast<const char*>(base + rva); return reinterpret_cast<const char*>(base + rva);
@@ -170,9 +180,11 @@ namespace hot_reload::detail
// large real function) -- it only ever gives a hard "not // large real function) -- it only ever gives a hard "not
// enough room" answer when the gap itself is already small. // enough room" answer when the gap itself is already small.
// PE has no per-export size field the way ELF does (see // PE has no per-export size field the way ELF does (see
// below), so /hotpatch (applied automatically by // below), so /hotpatch (applied by calling
// cmake/HotReload.cmake) is the primary safety mechanism on // hot_reload_enable_patchable_functions() from
// Windows; this is a secondary backstop, not a substitute. // cmake/HotReload.cmake on the plugin target) is the primary
// safety mechanism on Windows; this is a secondary backstop,
// not a substitute.
std::vector<std::uint32_t> functionRvasSorted; std::vector<std::uint32_t> functionRvasSorted;
for (const Candidate& candidate : candidates) for (const Candidate& candidate : candidates)
{ {
@@ -236,6 +248,10 @@ namespace hot_reload::detail
namespace namespace
{ {
/// Reads a whole file into memory.
/// @param path The file to read.
/// @return The file's bytes, or an empty vector if it could not be opened, is empty, or
/// could not be read completely.
std::vector<char> readWholeFile(const std::string& path) std::vector<char> readWholeFile(const std::string& path)
{ {
std::ifstream file(path, std::ios::binary | std::ios::ate); std::ifstream file(path, std::ios::binary | std::ios::ate);
@@ -257,83 +273,191 @@ namespace hot_reload::detail
return buffer; return buffer;
} }
/// Whether a range read out of an ELF file lies entirely inside that file.
///
/// Every offset and length in an ELF header is untrusted input until proven otherwise:
/// the dynamic loader validates the program headers it maps, but nothing validates the
/// section header table this reader walks. Written so the sum can never overflow.
///
/// @param offset Start of the range, as read from the file.
/// @param length Length of the range, as read from the file.
/// @param fileSize Size of the whole file in bytes.
/// @return True if `[offset, offset + length)` is inside `[0, fileSize)`.
bool rangeInFile(std::uint64_t offset, std::uint64_t length, std::size_t fileSize)
{
return offset <= fileSize && length <= fileSize - offset;
}
/// Copies one `T` out of the file, if it lies entirely inside it.
///
/// A `memcpy` rather than a `reinterpret_cast`, so an offset read from a corrupt file
/// can never produce a misaligned access.
///
/// @tparam T A trivially copyable ELF structure (`Elf64_Ehdr`, `Elf32_Sym`, ...).
/// @param file The whole file.
/// @param offset Where the structure starts.
/// @param[out] out Receives the structure.
/// @return False (leaving @p out untouched) if the structure would extend past the end.
template <typename T>
bool readStruct(const std::vector<char>& file, std::uint64_t offset, T& out)
{
if (!rangeInFile(offset, sizeof(T), file.size()))
{
return false;
}
std::memcpy(&out, file.data() + offset, sizeof(T));
return true;
}
/// Looks up a NUL-terminated string in an ELF string table, without ever reading past it.
/// @param table Start of the string table.
/// @param tableSize Size of the string table in bytes.
/// @param offset The string's offset within the table, as read from the file.
/// @return The string, or an empty view if @p offset is outside the table or the string
/// is not NUL-terminated inside it.
std::string_view stringAt(const char* table, std::uint64_t tableSize, std::uint64_t offset)
{
if (offset >= tableSize)
{
return {};
}
const char* begin = table + offset;
const auto remaining = static_cast<std::size_t>(tableSize - offset);
const void* end = std::memchr(begin, '\0', remaining);
if (end == nullptr)
{
return {};
}
const auto* endPtr = static_cast<const char*>(end);
return std::string_view(begin, static_cast<std::size_t>(endPtr - begin));
}
/// @param info An ELF32 or ELF64 `st_info` byte (the encoding is identical for both).
/// @return The symbol's type (`STT_*`).
unsigned char elfSymType(unsigned char info)
{
return static_cast<unsigned char>(ELF32_ST_TYPE(info));
}
/// @param info An ELF32 or ELF64 `st_info` byte (the encoding is identical for both).
/// @return The symbol's binding (`STB_*`).
unsigned char elfSymBind(unsigned char info)
{
return static_cast<unsigned char>(ELF32_ST_BIND(info));
}
/// Parses one ELF class (32 or 64-bit); Ehdr/Shdr/Sym is /// Parses one ELF class (32 or 64-bit); Ehdr/Shdr/Sym is
/// Elf32_* or Elf64_* depending on the caller, matched against /// Elf32_* or Elf64_* depending on the caller, matched against
/// e_ident[EI_CLASS] before this is ever instantiated. /// e_ident[EI_CLASS] before this is ever instantiated.
template <typename Ehdr, typename Shdr, typename Sym, typename SymType, typename SymBind> ///
std::vector<ExportedSymbol> parseElf( /// Every offset, length and index read from the file is bounds-checked first (see
const std::vector<char>& file, void* loadedBase, SymType symType, SymBind symBind) /// rangeInFile()), so a damaged image produces a shorter result rather than an
/// out-of-bounds read.
///
/// @tparam Ehdr The ELF file header type (`Elf32_Ehdr` or `Elf64_Ehdr`).
/// @tparam Shdr The ELF section header type (`Elf32_Shdr` or `Elf64_Shdr`).
/// @tparam Sym The ELF symbol type (`Elf32_Sym` or `Elf64_Sym`).
/// @param file The whole shared object, as read from disk.
/// @param loadedBase The runtime load bias to add to every symbol's `st_value`.
/// @return Every defined, global or weak symbol in `.dynsym`, classified; empty if the
/// file's section headers or symbol tables are missing or inconsistent.
template <typename Ehdr, typename Shdr, typename Sym>
std::vector<ExportedSymbol> parseElf(const std::vector<char>& file, void* loadedBase)
{ {
std::vector<ExportedSymbol> result; std::vector<ExportedSymbol> result;
if (file.size() < sizeof(Ehdr))
Ehdr ehdr{};
if (!readStruct(file, 0, ehdr))
{ {
return result; return result;
} }
if (ehdr.e_shoff == 0 || ehdr.e_shnum == 0)
const auto* ehdr = reinterpret_cast<const Ehdr*>(file.data());
if (ehdr->e_shoff == 0 || ehdr->e_shnum == 0)
{ {
return result; // no section header table -- see file comment above return result; // no section header table -- see file comment above
} }
if (static_cast<std::uint64_t>(ehdr->e_shoff) + static_cast<std::uint64_t>(ehdr->e_shnum) * sizeof(Shdr) > const auto sectionTableBytes = static_cast<std::uint64_t>(ehdr.e_shnum) * sizeof(Shdr);
file.size()) const bool sectionsFit = rangeInFile(ehdr.e_shoff, sectionTableBytes, file.size());
if (ehdr.e_shentsize != sizeof(Shdr) || !sectionsFit)
{ {
return result; // truncated/corrupt file return result; // truncated/corrupt file
} }
const auto* sections = reinterpret_cast<const Shdr*>(file.data() + ehdr->e_shoff); std::vector<Shdr> sections(ehdr.e_shnum);
const auto sectionBytes = sections.size() * sizeof(Shdr);
std::memcpy(sections.data(), file.data() + ehdr.e_shoff, sectionBytes);
// Section header string table, to find sections by name. // Section header string table, to find sections by name.
if (ehdr->e_shstrndx >= ehdr->e_shnum) if (ehdr.e_shstrndx >= ehdr.e_shnum)
{ {
return result; return result;
} }
const char* shstrtab = file.data() + sections[ehdr->e_shstrndx].sh_offset; const Shdr& shstrSection = sections[ehdr.e_shstrndx];
if (!rangeInFile(shstrSection.sh_offset, shstrSection.sh_size, file.size()))
{
return result;
}
const char* shstrtab = file.data() + shstrSection.sh_offset;
const Shdr* dynsymSection = nullptr; const Shdr* dynsymSection = nullptr;
const Shdr* dynstrSection = nullptr; const Shdr* dynstrSection = nullptr;
for (std::size_t i = 0; i < ehdr->e_shnum; ++i) for (const Shdr& section : sections)
{ {
const char* name = shstrtab + sections[i].sh_name; const auto nameOffset = section.sh_name;
if (std::strcmp(name, ".dynsym") == 0) const std::string_view name = stringAt(shstrtab, shstrSection.sh_size, nameOffset);
if (name == ".dynsym")
{ {
dynsymSection = &sections[i]; dynsymSection = &section;
} }
else if (std::strcmp(name, ".dynstr") == 0) else if (name == ".dynstr")
{ {
dynstrSection = &sections[i]; dynstrSection = &section;
} }
} }
if (dynsymSection == nullptr || dynstrSection == nullptr) if (dynsymSection == nullptr || dynstrSection == nullptr)
{ {
return result; return result;
} }
if (!rangeInFile(dynsymSection->sh_offset, dynsymSection->sh_size, file.size()) ||
!rangeInFile(dynstrSection->sh_offset, dynstrSection->sh_size, file.size()))
{
return result;
}
const char* dynstr = file.data() + dynstrSection->sh_offset; const char* dynstr = file.data() + dynstrSection->sh_offset;
std::size_t symbolCount = dynsymSection->sh_size / sizeof(Sym); const auto symBytes = static_cast<std::size_t>(dynsymSection->sh_size);
const auto* symbols = reinterpret_cast<const Sym*>(file.data() + dynsymSection->sh_offset); const std::size_t symbolCount = symBytes / sizeof(Sym);
for (std::size_t i = 0; i < symbolCount; ++i) for (std::size_t i = 0; i < symbolCount; ++i)
{ {
const Sym& sym = symbols[i]; Sym sym{};
const char* symPtr = file.data() + dynsymSection->sh_offset + i * sizeof(Sym);
std::memcpy(&sym, symPtr, sizeof(Sym));
if (sym.st_shndx == SHN_UNDEF || sym.st_name == 0) if (sym.st_shndx == SHN_UNDEF || sym.st_name == 0)
{ {
continue; // imported/unresolved, not defined here continue; // imported/unresolved, not defined here
} }
unsigned char bind = symBind(sym.st_info); unsigned char bind = elfSymBind(sym.st_info);
if (bind != STB_GLOBAL && bind != STB_WEAK) if (bind != STB_GLOBAL && bind != STB_WEAK)
{ {
continue; // not part of this module's visible surface continue; // not part of this module's visible surface
} }
const auto symNameOffset = sym.st_name;
const auto dynstrSize = dynstrSection->sh_size;
const std::string_view name = stringAt(dynstr, dynstrSize, symNameOffset);
if (name.empty())
{
continue; // name offset outside .dynstr (corrupt file)
}
ExportedSymbol exported; ExportedSymbol exported;
exported.name = dynstr + sym.st_name; exported.name = std::string(name);
exported.address = static_cast<char*>(loadedBase) + sym.st_value; exported.address = static_cast<char*>(loadedBase) + sym.st_value;
exported.size = static_cast<std::size_t>(sym.st_size); exported.size = static_cast<std::size_t>(sym.st_size);
bool isFuncType = symType(sym.st_info) == STT_FUNC; bool isFuncType = elfSymType(sym.st_info) == STT_FUNC;
bool sectionExecutable = false; bool sectionExecutable = false;
if (sym.st_shndx != SHN_ABS && sym.st_shndx != SHN_COMMON && sym.st_shndx < ehdr->e_shnum) const bool realSection = sym.st_shndx != SHN_ABS && sym.st_shndx != SHN_COMMON;
if (realSection && sym.st_shndx < ehdr.e_shnum)
{ {
sectionExecutable = (sections[sym.st_shndx].sh_flags & SHF_EXECINSTR) != 0; sectionExecutable = (sections[sym.st_shndx].sh_flags & SHF_EXECINSTR) != 0;
} }
@@ -354,18 +478,17 @@ namespace hot_reload::detail
return {}; return {};
} }
// ELF32_ST_TYPE/BIND and ELF64_ST_TYPE/BIND decode the same bit layout (the System V ABI
// defines st_info identically for both classes) -- elfSymType()/elfSymBind() above use the
// ELF32 macros for both, so one pair of extractors serves both instantiations below.
unsigned char elfClass = static_cast<unsigned char>(file[EI_CLASS]); unsigned char elfClass = static_cast<unsigned char>(file[EI_CLASS]);
if (elfClass == ELFCLASS64) if (elfClass == ELFCLASS64)
{ {
return parseElf<Elf64_Ehdr, Elf64_Shdr, Elf64_Sym>( return parseElf<Elf64_Ehdr, Elf64_Shdr, Elf64_Sym>(file, loadedBase);
file, loadedBase, [](unsigned char info) { return ELF64_ST_TYPE(info); },
[](unsigned char info) { return ELF64_ST_BIND(info); });
} }
if (elfClass == ELFCLASS32) if (elfClass == ELFCLASS32)
{ {
return parseElf<Elf32_Ehdr, Elf32_Shdr, Elf32_Sym>( return parseElf<Elf32_Ehdr, Elf32_Shdr, Elf32_Sym>(file, loadedBase);
file, loadedBase, [](unsigned char info) { return ELF32_ST_TYPE(info); },
[](unsigned char info) { return ELF32_ST_BIND(info); });
} }
return {}; return {};
} }
+46 -31
View File
@@ -7,45 +7,60 @@
namespace hot_reload::detail namespace hot_reload::detail
{ {
/// One exported symbol found in a loaded module, already classified. /**
* @brief One exported symbol found in a loaded module, already classified.
*/
struct ExportedSymbol struct ExportedSymbol
{ {
std::string name; std::string name; /**< The symbol's real (possibly mangled) name. */
void* address = nullptr; void* address = nullptr; /**< The symbol's runtime address. */
/// The single load-bearing safety gate behind the "only /**
/// executable code changes" guarantee in hot_reload.h: false * The single load-bearing safety gate behind the "only
/// for anything not mechanically identified as code (a data * executable code changes" guarantee in `hot_reload.h`: false
/// object, a forwarder export, or a symbol kind this reader * for anything not mechanically identified as code (a data
/// does not recognize at all). HotReloadContext never patches * object, a forwarder export, or a symbol kind this reader does
/// a symbol where this is false, on either side of a reload. * not recognize at all). `HotReloadContext` never patches a
* symbol where this is false, on either side of a reload.
*/
bool isFunction = false; bool isFunction = false;
/// Compiled size in bytes if known, 0 if not. ELF gives this /**
/// exactly (the symbol table's st_size field); PE gives no * Compiled size in bytes if known, 0 if not. ELF gives this
/// such field, so this holds a conservative estimate derived * exactly (the symbol table's `st_size` field); PE gives no such
/// from the distance to the next exported function in the * field, so this holds a conservative estimate derived from the
/// same section -- see the file comment in SymbolTable.cpp * distance to the next exported function in the same section --
/// for exactly what that heuristic can and cannot catch. * see the file comment in `SymbolTable.cpp` for exactly what
* that heuristic can and cannot catch.
*/
std::size_t size = 0; std::size_t size = 0;
}; };
/// Reads every exported, defined symbol belonging to the module /**
/// mapped at loadedBase (the base address dlopen()/LoadLibrary() * @brief Reads and classifies every exported, defined symbol in a loaded module.
/// returned for it), classifying each one as code or data. *
/// * On POSIX this parses @p imagePath on disk (its ELF `.dynsym` /
/// On POSIX this parses imagePath on disk (its ELF .dynsym / * `.dynstr` / section-header table) and adds @p loadedBase as the
/// .dynstr / section-header table) and adds loadedBase as the * runtime load bias. On Windows this ignores @p imagePath and reads
/// runtime load bias. On Windows this ignores imagePath and reads * directly from the already-mapped image at @p loadedBase (its PE
/// directly from the already-mapped image at loadedBase (its PE * export directory and section table) -- see the per-platform
/// export directory and section table) -- see the per-platform * implementation blocks in `SymbolTable.cpp` for why each platform
/// implementation blocks in SymbolTable.cpp for why each platform * reads from a different place.
/// reads from a different place. *
/// * A file that is truncated or otherwise corrupt in the parts this reader relies on (the
/// Returns an empty vector, never throws, if the image could not * section header table and the tables it points at) is treated like any other unparseable
/// be parsed as this platform's native format (including the rare * image: every offset and length read from the file is range-checked against the file's size
/// case of a .so with its section header table deliberately * before it is used, so a damaged image yields fewer (or no) symbols rather than an
/// stripped -- see the file comment in SymbolTable.cpp). * out-of-bounds read.
*
* @param imagePath Path to the module on disk (POSIX only -- see above).
* @param loadedBase The base address `dlopen()`/`LoadLibrary()` returned for it.
* @return Every exported symbol, classified via ExportedSymbol::isFunction.
* Empty (never throws) if the image could not be parsed as
* this platform's native format (including the rare case of
* a `.so` with its section header table deliberately
* stripped -- see the file comment in `SymbolTable.cpp`).
*/
std::vector<ExportedSymbol> readExportedSymbols(const std::string& imagePath, void* loadedBase); std::vector<ExportedSymbol> readExportedSymbols(const std::string& imagePath, void* loadedBase);
} }
+7 -4
View File
@@ -1,4 +1,6 @@
/* Demangle.cpp -- hot_reload_demangle() / hot_reload_free_string(). /**
* @file Demangle.cpp
* @brief hot_reload_demangle() / hot_reload_free_string() implementation.
* *
* Pure string utility, no HotReloadContext involved, so it gets its * Pure string utility, no HotReloadContext involved, so it gets its
* own file alongside HotReloadApi.cpp rather than crowding that one's * own file alongside HotReloadApi.cpp rather than crowding that one's
@@ -15,6 +17,7 @@
*/ */
#include "hot_reload/hot_reload.h" #include "hot_reload/hot_reload.h"
#include <cstdlib>
#include <cstring> #include <cstring>
#include <string> #include <string>
@@ -24,9 +27,9 @@
// COMPILER implements (Itanium here), not the operating system -- // COMPILER implements (Itanium here), not the operating system --
// MinGW is still GCC/Clang generating Itanium-mangled names even // MinGW is still GCC/Clang generating Itanium-mangled names even
// though the OS is Windows. MSVC, on the same OS, uses its own, // though the OS is Windows. MSVC, on the same OS, uses its own,
// unrelated scheme (the #elif below). See README.md's "Hot // unrelated scheme (the #elif below). See the "Hot reloading C++
// reloading C++ symbols" section for why this distinction matters // symbols" section of source/HotReload/README.md for why this
// beyond just this function. // distinction matters beyond just this function.
#include <cxxabi.h> #include <cxxabi.h>
#elif defined(_MSC_VER) #elif defined(_MSC_VER)
// windows.h must come first: dbghelp.h uses types (HANDLE, DWORD, // windows.h must come first: dbghelp.h uses types (HANDLE, DWORD,
@@ -1,3 +1,7 @@
/**
* @file HotReloadApi.cpp
* @brief The extern "C" shim implementing hot_reload.h by delegating to hot_reload::HotReloadContext.
*/
#include "hot_reload/hot_reload.h" #include "hot_reload/hot_reload.h"
#include "../HotReloadContext.hpp" #include "../HotReloadContext.hpp"
@@ -9,6 +13,8 @@ namespace
/// is a hot_reload::HotReloadContext, following the same /// is a hot_reload::HotReloadContext, following the same
/// opaque-pointer PIMPL pattern cpp-style-guide.md's NetManager /// opaque-pointer PIMPL pattern cpp-style-guide.md's NetManager
/// example uses. This is the one place that cast happens. /// example uses. This is the one place that cast happens.
/// @param ctx A context previously returned by hot_reload_context_create(), or null.
/// @return The same object viewed as the C++ type it really is (null if @p ctx is null).
hot_reload::HotReloadContext* impl(::HotReloadContext* ctx) hot_reload::HotReloadContext* impl(::HotReloadContext* ctx)
{ {
return reinterpret_cast<hot_reload::HotReloadContext*>(ctx); return reinterpret_cast<hot_reload::HotReloadContext*>(ctx);
+27 -12
View File
@@ -1,4 +1,6 @@
/* Main.cpp -- loads PluginV1, calls into it, reloads to PluginV2, and /**
* @file Main.cpp
* @brief Scripted, deterministic demo: loads PluginV1, calls into it, reloads to PluginV2, and
* calls the SAME already-resolved function pointers again to show * calls the SAME already-resolved function pointers again to show
* they now run the new code -- without the Host re-resolving anything. * they now run the new code -- without the Host re-resolving anything.
* Along the way this exercises every HotReloadSymbolEvent kind: watch * Along the way this exercises every HotReloadSymbolEvent kind: watch
@@ -17,33 +19,40 @@
namespace namespace
{ {
/// The signature every plain-C export in PluginApi.h shares.
using PluginFunction = int (*)(int); using PluginFunction = int (*)(int);
// CppWidget::tick's real ABI: an implicit this-pointer followed by /// CppWidget::tick's real ABI: an implicit this-pointer followed by
// its one declared parameter, same calling convention the /// its one declared parameter, same calling convention the
// compiler itself would generate for widget->tick(x) -- this is /// compiler itself would generate for widget->tick(x) -- this is
// just spelling that out explicitly so it can be called through a /// just spelling that out explicitly so it can be called through a
// manually-resolved address instead. /// manually-resolved address instead.
using CppWidgetTickFunction = int (*)(CppWidget*, int); using CppWidgetTickFunction = int (*)(CppWidget*, int);
/// Stands in for @c plugin_legacy_feature once V2 removes it.
/// @return Always -1.
int hostSideDeletedFunctionStub(int) int hostSideDeletedFunctionStub(int)
{ {
std::printf(" !! called a deleted plugin function -- this is the Host's stub, not a crash !!\n"); std::printf(" !! called a deleted plugin function -- this is the Host's stub, not a crash !!\n");
return -1; return -1;
} }
/* Runs once per HOT_RELOAD_SYMBOL_REMOVED, before onSymbolEvent /// Runs once per HOT_RELOAD_SYMBOL_REMOVED, before onSymbolEvent
* below sees the same event -- see hot_reload.h's doc comment on /// below sees the same event -- see hot_reload.h's doc comment on
* HotReloadDeletedSymbolFn. Redirecting a removed function to a /// HotReloadDeletedSymbolFn. Redirecting a removed function to a
* stub living in the HOST executable (not in either plugin build) /// stub living in the HOST executable (not in either plugin build)
* demonstrates that the target of a patch can be anywhere at all, /// demonstrates that the target of a patch can be anywhere at all,
* not just inside the module being reloaded. */ /// not just inside the module being reloaded.
/// @param symbolName The removed function's real (possibly mangled) name.
/// @return The host stub to redirect old callers to.
void* onDeletedSymbol(const char* symbolName, void* /*userData*/) void* onDeletedSymbol(const char* symbolName, void* /*userData*/)
{ {
std::printf(" [event] REMOVED %-22s -> redirecting old callers to the Host's stub\n", symbolName); std::printf(" [event] REMOVED %-22s -> redirecting old callers to the Host's stub\n", symbolName);
return reinterpret_cast<void*>(&hostSideDeletedFunctionStub); return reinterpret_cast<void*>(&hostSideDeletedFunctionStub);
} }
/// @param kind The event kind to name.
/// @return A short, human-readable label for @p kind.
const char* eventKindName(HotReloadSymbolEvent kind) const char* eventKindName(HotReloadSymbolEvent kind)
{ {
switch (kind) switch (kind)
@@ -64,6 +73,8 @@ namespace
return "?"; return "?";
} }
/// Logs every symbol event except REMOVED, which onDeletedSymbol() already logs.
/// @param event Describes one symbol's outcome.
void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/) void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/)
{ {
// REMOVED is already reported, with more context, by // REMOVED is already reported, with more context, by
@@ -85,6 +96,10 @@ namespace
} }
/// Runs the scripted demo described in this file's @ref Main.cpp "file comment".
/// @param argc Argument count.
/// @param argv `argv[1]`/`argv[2]`, if present, override the compiled-in plugin paths.
/// @return 0 on success, 1 if the context or either load/reload step failed.
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
std::string pluginV1Path = argc > 1 ? argv[1] : PLUGIN_V1_PATH; std::string pluginV1Path = argc > 1 ? argv[1] : PLUGIN_V1_PATH;
@@ -1,6 +1,9 @@
/* CppWidget V1 -- "before your edit". See PluginV2/src/CppWidget.cpp /**
* @file CppWidget.cpp
* @brief CppWidget V1 -- "before your edit". See PluginV2/src/CppWidget.cpp
* for "after": same three exports change behavior together, exactly * for "after": same three exports change behavior together, exactly
* like PluginV1/src/Plugin.cpp's plain-C functions do. * like PluginV1/src/Plugin.cpp's plain-C functions do. Every member
* defined here is documented in ../Shared/CppWidgetApi.h.
*/ */
#include "CppWidgetApi.h" #include "CppWidgetApi.h"
@@ -1,8 +1,11 @@
/* PluginV1 -- "before your edit". See PluginV2/src/Plugin.cpp for /**
* @file Plugin.cpp
* @brief PluginV1 -- "before your edit". See PluginV2/src/Plugin.cpp for
* "after"; the two deliberately export the identical set of names * "after"; the two deliberately export the identical set of names
* (minus plugin_legacy_feature, removed in V2) so HotReloadContext can * (minus plugin_legacy_feature, removed in V2) so HotReloadContext can
* line them up. See ../Shared/PluginApi.h for what each export is * line them up. See ../Shared/PluginApi.h for what each export is
* there to demonstrate. * there to demonstrate (and for the documentation of every function
* defined here).
*/ */
#include "PluginApi.h" #include "PluginApi.h"
@@ -22,9 +25,8 @@ int plugin_legacy_feature(int input)
} }
#if defined(__GNUC__) || defined(__clang__) #if defined(__GNUC__) || defined(__clang__)
int plugin_tiny_function(int input) void plugin_tiny_function(void)
{ {
return input + 1;
} }
#endif #endif
@@ -1,5 +1,8 @@
/* CppWidget V2 -- "after your edit". See PluginV1/src/CppWidget.cpp /**
* for "before". * @file CppWidget.cpp
* @brief CppWidget V2 -- "after your edit". See PluginV1/src/CppWidget.cpp
* for "before". Every member defined here is documented in
* ../Shared/CppWidgetApi.h.
*/ */
#include "CppWidgetApi.h" #include "CppWidgetApi.h"
@@ -1,7 +1,10 @@
/* PluginV2 -- "after your edit". See PluginV1/src/Plugin.cpp for /**
* @file Plugin.cpp
* @brief PluginV2 -- "after your edit". See PluginV1/src/Plugin.cpp for
* "before". plugin_tick's behavior changed, plugin_legacy_feature was * "before". plugin_tick's behavior changed, plugin_legacy_feature was
* deleted, and plugin_new_feature was added -- exactly the three kinds * deleted, and plugin_new_feature was added -- exactly the three kinds
* of change hot_reload.h's HotReloadSymbolEvent enum distinguishes. * of change hot_reload.h's HotReloadSymbolEvent enum distinguishes.
* Every function defined here is documented in ../Shared/PluginApi.h.
*/ */
#include "PluginApi.h" #include "PluginApi.h"
@@ -24,9 +27,8 @@ int plugin_new_feature(int input)
} }
#if defined(__GNUC__) || defined(__clang__) #if defined(__GNUC__) || defined(__clang__)
int plugin_tiny_function(int input) void plugin_tiny_function(void)
{ {
return input + 2;
} }
#endif #endif
@@ -1,47 +1,88 @@
/* CppWidgetApi.h -- a C++ (not extern "C") surface living alongside /**
* @file CppWidgetApi.h
* @brief A C++ (not extern "C") surface living alongside
* PluginApi.h's plain C one, in the SAME plugin build. Every member * PluginApi.h's plain C one, in the SAME plugin build. Every member
* function below keeps its real, compiler-mangled name; nothing here * function below keeps its real, compiler-mangled name; nothing here
* needs special handling by source/HotReload, which classifies * needs special handling by source/HotReload, which classifies
* symbols by their type and section, never by what their name looks * symbols by their type and section, never by what their name looks
* like -- see hot_reload.h's "REQUIREMENTS ON A HOT-RELOADABLE * like -- see the "Requirements on a hot-reloadable module" section of
* MODULE" and README.md's "Hot reloading C++ symbols" for the full * hot_reload.h and the "Hot reloading C++ symbols" section of
* picture. * source/HotReload/README.md for the full picture.
* *
* The two methods below are reached two different ways from * The two methods below are reached two different ways from
* Host/Main.cpp, deliberately: see that file's comments for why. * Host/Main.cpp, deliberately: see that file's comments for why.
*/ */
#pragma once #pragma once
/**
* @brief A small C++ class whose members are defined in the plugin, not the host.
*
* Each member below keeps its real, compiler-mangled name and is reached a different way from
* Host/Main.cpp -- through the vtable, through a manually resolved mangled name, and (for the
* operator) not at all -- to show that all of them patch like any plain C function.
*/
class CppWidget class CppWidget
{ {
public: public:
/**
* @brief Virtual, so that destroying a widget through a base pointer works across the
* boundary.
*/
virtual ~CppWidget() = default; virtual ~CppWidget() = default;
/* Virtual: Host/Main.cpp calls this through ordinary C++ /**
* @brief Scales its input, through a virtual call.
*
* Virtual: Host/Main.cpp calls this through ordinary C++
* polymorphic dispatch (widget->virtualTick(x)) and needs no * polymorphic dispatch (widget->virtualTick(x)) and needs no
* manual symbol lookup at all -- the call compiles down to an * manual symbol lookup at all -- the call compiles down to an
* indirect call through the object's vtable slot, not a direct * indirect call through the object's vtable slot, not a direct
* reference to this specific symbol, so it needs no link-time * reference to this specific symbol, so it needs no link-time
* dependency on whichever plugin build actually defines it. */ * dependency on whichever plugin build actually defines it.
*
* @param input The value to scale.
* @return V1: `input * 2`. V2: `input * 3`.
*/
virtual int virtualTick(int input); virtual int virtualTick(int input);
/* An ordinary out-of-line method. Calling this as widget->tick(x) /**
* @brief Scales its input, through an ordinary out-of-line method.
*
* Calling this as widget->tick(x)
* would compile to a direct call to CppWidget::tick's own mangled * would compile to a direct call to CppWidget::tick's own mangled
* symbol, which would need the Host to link against a plugin * symbol, which would need the Host to link against a plugin
* build at build time -- exactly what hot-reloading a plugin * build at build time -- exactly what hot-reloading a plugin
* means avoiding. So Host/Main.cpp instead resolves it manually * means avoiding. So Host/Main.cpp instead resolves it manually
* by its mangled name via hot_reload_get_symbol(), the same way * by its mangled name via hot_reload_get_symbol(), the same way
* it resolves PluginApi.h's plain C functions. */ * it resolves PluginApi.h's plain C functions.
*
* @param input The value to scale.
* @return V1: `input * 2`. V2: `input * 3`.
*/
int tick(int input); int tick(int input);
/* An overloaded operator: mechanically just a function with an /**
* @brief An overloaded operator.
*
* Mechanically just a function with an
* unusual mangled name, reached the same manual way as tick() * unusual mangled name, reached the same manual way as tick()
* above. */ * above.
*
* @param other Ignored; the operator only prints which build it is.
* @return A new, empty widget.
*/
CppWidget operator+(const CppWidget& other) const; CppWidget operator+(const CppWidget& other) const;
}; };
/* The one entry point Host/Main.cpp looks up by a plain, human-typed /**
* @brief Creates a widget.
*
* The one entry point Host/Main.cpp looks up by a plain, human-typed
* name -- extern "C" here for exactly the reason hot_reload.h * name -- extern "C" here for exactly the reason hot_reload.h
* recommends it: something to hand a human-readable name to, without * recommends it: something to hand a human-readable name to, without
* needing to know or hardcode a mangled string. */ * needing to know or hardcode a mangled string.
*
* @return A new widget. The caller owns it and must `delete` it (safe across the plugin
* boundary here because host and plugin share one C++ runtime and allocator).
*/
extern "C" CppWidget* make_cpp_widget(); extern "C" CppWidget* make_cpp_widget();
@@ -1,4 +1,6 @@
/* PluginApi.h -- the contract PluginV1 and PluginV2 both implement, /**
* @file PluginApi.h
* @brief The contract PluginV1 and PluginV2 both implement,
* and the Host loads by name. Every function here follows * and the Host loads by name. Every function here follows
* cpp-style-guide.md section 11's rule for anything loaded at runtime: * cpp-style-guide.md section 11's rule for anything loaded at runtime:
* plain C linkage, no classes or exceptions crossing the boundary. * plain C linkage, no classes or exceptions crossing the boundary.
@@ -15,31 +17,64 @@ extern "C"
{ {
#endif #endif
/* Changes behavior between V1 and V2 -- watch this one get /**
* HOT_RELOAD_SYMBOL_PATCHED. */ * @brief Scales its input, and prints which build it is.
*
* Changes behavior between V1 and V2 -- watch this one get
* HOT_RELOAD_SYMBOL_PATCHED.
*
* @param input The value to scale.
* @return V1: `input * 2`. V2: `input * 3`.
*/
int plugin_tick(int input); int plugin_tick(int input);
/* Only in V1, gone in V2 -- watch this one get /**
* HOT_RELOAD_SYMBOL_REMOVED, and the Host's stub take over for any * @brief A feature that exists only in V1.
* call still aimed at it afterwards. */ *
* Gone in V2 -- watch this one get HOT_RELOAD_SYMBOL_REMOVED, and the
* Host's stub take over for any call still aimed at it afterwards.
*
* @param input The value to echo.
* @return @p input, unchanged.
*/
int plugin_legacy_feature(int input); int plugin_legacy_feature(int input);
/* Only in V2 -- watch this one get HOT_RELOAD_SYMBOL_ADDED, then /**
* get called for the first time right after. */ * @brief A feature that exists only in V2.
*
* Watch this one get HOT_RELOAD_SYMBOL_ADDED, then get called for the
* first time right after.
*
* @param input The value to scale.
* @return `input * 10`.
*/
int plugin_new_feature(int input); int plugin_new_feature(int input);
#if defined(__GNUC__) || defined(__clang__) #if defined(__GNUC__) || defined(__clang__)
/* Deliberately too small to safely hold a jump on any of this /**
* library's backends (its patchable-function-entry padding is * @brief An empty function, too small to hold the jump this library writes.
* overridden down to zero) -- watch this one get *
* HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL rather than corrupt whatever * Its patchable-function-entry padding is overridden down to zero, so
* comes after it. MSVC has no equivalent per-function override * all that is left is the compiled empty body -- a handful of bytes,
* this demo could apply, so this one only exists on GCC/Clang. * fewer than the jump any of this library's backends writes on x86-64
* and ARM64 at any optimization level. Watch this one get
* HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL rather than corrupt whatever comes
* after it.
*
* Two configurations cannot promise "too small": unoptimized 32-bit x86
* (its jump is only 5 bytes, which even an empty unoptimized function
* reaches), and Windows, where a PE image carries no per-function size
* and the library can only estimate one from the distance to the next
* export. MSVC has no equivalent per-function override this demo could
* apply, so this one only exists on GCC/Clang.
*/ */
__attribute__((patchable_function_entry(0))) int plugin_tiny_function(int input); __attribute__((patchable_function_entry(0))) void plugin_tiny_function(void);
#endif #endif
/* An exported VARIABLE, not a function, sitting right next to /**
* @brief The build's version number: 1 in V1, 2 in V2.
*
* An exported VARIABLE, not a function, sitting right next to
* real functions in the same library and changing value between * real functions in the same library and changing value between
* V1 and V2 just like plugin_tick's behavior does -- watch this * V1 and V2 just like plugin_tick's behavior does -- watch this
* one get HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE. This is the * one get HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE. This is the
@@ -1,4 +1,6 @@
/* Main.cpp -- a persistent process, unlike source/examples/ /**
* @file Main.cpp
* @brief Interactive demo: a persistent process, unlike source/examples/
* HotReloadDemo/Host (which runs a fixed script and exits). This one * HotReloadDemo/Host (which runs a fixed script and exits). This one
* loads Plugin/src/LivePlugin.cpp's compiled output once, then polls * loads Plugin/src/LivePlugin.cpp's compiled output once, then polls
* that file's last-write time every second: whenever it changes (i.e. * that file's last-write time every second: whenever it changes (i.e.
@@ -18,13 +20,18 @@
namespace namespace
{ {
/// Cleared by handleSigint(); the main loop polls this to exit cleanly on Ctrl+C.
std::atomic<bool> g_running{true}; std::atomic<bool> g_running{true};
/// SIGINT handler: requests a clean shutdown instead of the default terminate.
/// The signal number itself is unused (this is only ever registered for SIGINT).
void handleSigint(int /*signal*/) void handleSigint(int /*signal*/)
{ {
g_running = false; g_running = false;
} }
/// @param kind The event kind to name.
/// @return A short, human-readable label for @p kind.
const char* eventKindName(HotReloadSymbolEvent kind) const char* eventKindName(HotReloadSymbolEvent kind)
{ {
switch (kind) switch (kind)
@@ -45,6 +52,8 @@ namespace
return "?"; return "?";
} }
/// Logs every symbol event except REMOVED, which onDeletedSymbol() already logs.
/// @param event Describes one symbol's outcome.
void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/) void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/)
{ {
if (event->kind == HOT_RELOAD_SYMBOL_REMOVED) if (event->kind == HOT_RELOAD_SYMBOL_REMOVED)
@@ -54,6 +63,9 @@ namespace
std::printf(" [reload] %-24s %s\n", eventKindName(event->kind), event->name); std::printf(" [reload] %-24s %s\n", eventKindName(event->kind), event->name);
} }
/// Stands in for @c live_tick once a rebuild removes it.
/// @param frame The current frame number.
/// @return Always -1.
int fallbackForDeletedTick(int frame) int fallbackForDeletedTick(int frame)
{ {
std::printf( std::printf(
@@ -63,6 +75,8 @@ namespace
return -1; return -1;
} }
/// @param symbolName The removed function's real (possibly mangled) name.
/// @return The host fallback to redirect old callers to.
void* onDeletedSymbol(const char* symbolName, void* /*userData*/) void* onDeletedSymbol(const char* symbolName, void* /*userData*/)
{ {
std::printf(" [reload] REMOVED %s -> falling back to the Host's stub\n", symbolName); std::printf(" [reload] REMOVED %s -> falling back to the Host's stub\n", symbolName);
@@ -71,6 +85,11 @@ namespace
} }
/// Runs the interactive demo described in this file's @ref Main.cpp "file comment".
/// @param argc Argument count.
/// @param argv `argv[1]`, if present, overrides the compiled-in plugin path.
/// @return 0 on clean shutdown (Ctrl+C), 1 if the context could not be created or the initial
/// load failed.
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
// Force line buffering regardless of whether stdout is a real // Force line buffering regardless of whether stdout is a real
@@ -1,4 +1,6 @@
/* LivePlugin.cpp -- THE FILE TO EDIT. /**
* @file LivePlugin.cpp
* @brief THE FILE TO EDIT.
* *
* With source/examples/HotReloadLiveDemo/Host running in one * With source/examples/HotReloadLiveDemo/Host running in one
* terminal, rebuild just this file from another with: * terminal, rebuild just this file from another with:
@@ -42,13 +44,19 @@
#include "hot_reload/hot_reload.h" #include "hot_reload/hot_reload.h"
#include <cstdio> #include <cstdio>
#include <iostream>
/**
* @brief The one function this demo starts with; edit this file and rebuild to see it change live.
*
* See this file's own comment for what to try.
*
* @param frame The Host's running frame counter, incremented once per second.
* @return @p frame times the multiplier below.
*/
extern "C" HOT_RELOAD_EXPORT int live_tick(int frame) extern "C" HOT_RELOAD_EXPORT int live_tick(int frame)
{ {
int multiplier = 2; // <- try changing this int multiplier = 2; // <- try changing this
int value = frame * multiplier; int value = frame * multiplier;
std::printf(" live_tick(%d) -> %d\n", frame, value); std::printf(" live_tick(%d) -> %d\n", frame, value);
std::cout.flush();
return value; return value;
} }
+17 -1
View File
@@ -1,4 +1,6 @@
/* Exercises source/HotReload end to end against the real PluginV1/ /**
* @file Main.cpp
* @brief Automated test suite: exercises source/HotReload end to end against the real PluginV1/
* PluginV2 builds from source/examples/HotReloadDemo -- see * PluginV2 builds from source/examples/HotReloadDemo -- see
* ../../examples/HotReloadDemo/Shared/PluginApi.h and CppWidgetApi.h * ../../examples/HotReloadDemo/Shared/PluginApi.h and CppWidgetApi.h
* for what each exported name is there to prove. Deliberately does * for what each exported name is there to prove. Deliberately does
@@ -22,8 +24,14 @@
namespace namespace
{ {
/// Running count of failed CHECK() calls; main() returns non-zero when this is non-zero.
int g_failures = 0; int g_failures = 0;
/// Records a failed check to stderr and increments g_failures. Called only through CHECK().
/// @param condition The check's result.
/// @param expression The checked expression, stringified.
/// @param file The source file the check appears in.
/// @param line The line the check appears on.
void checkImpl(bool condition, const char* expression, const char* file, int line) void checkImpl(bool condition, const char* expression, const char* file, int line)
{ {
if (!condition) if (!condition)
@@ -33,12 +41,18 @@ namespace
} }
} }
/// Checks @p expr and records a failure (with its source location) if it is false, without
/// relying on assert(), which NDEBUG (this project's default Release build) compiles out.
#define CHECK(expr) checkImpl((expr), #expr, __FILE__, __LINE__) #define CHECK(expr) checkImpl((expr), #expr, __FILE__, __LINE__)
/// The signature every plain-C export in PluginApi.h shares.
using PluginFunction = int (*)(int); using PluginFunction = int (*)(int);
/// Every symbol event seen so far in the current reload, keyed by symbol name.
std::map<std::string, HotReloadSymbolEvent> g_events; std::map<std::string, HotReloadSymbolEvent> g_events;
/// Records each event into g_events for later CHECK()s to inspect.
/// @param event Describes one symbol's outcome.
void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/) void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/)
{ {
g_events[event->name] = event->kind; g_events[event->name] = event->kind;
@@ -46,6 +60,8 @@ namespace
} }
/// Runs every check described in this file's @ref Main.cpp "file comment".
/// @return 0 if every CHECK() passed, 1 otherwise.
int main() int main()
{ {
HotReloadOptions options{}; HotReloadOptions options{};
@@ -1,27 +1,37 @@
/* A genuine ODR violation, kept deliberately: a non-inline static /**
* @file Fixture.cpp
* @brief A genuine ODR violation, kept deliberately: a non-inline static
* class member that is declared but never given an out-of-class * class member that is declared but never given an out-of-class
* definition. This compiles cleanly and links cleanly into a shared * definition. This compiles cleanly and links cleanly into a shared
* library -- a shared library is allowed to keep undefined symbols by * library -- a shared library is allowed to keep undefined symbols by
* default -- and only fails once something actually calls * default -- and only fails once something actually calls
* trigger_undefined_symbol() below, ODR-using counter for the first * trigger_undefined_symbol() below, ODR-using counter for the first
* time. See source/HotReload/README.md's "hot_reload_last_error()" * time. See the "Diagnosing a failed load" section of
* section for the full story; HotReloadTests/Main.cpp loads this * source/HotReload/README.md for the full story; HotReloadTests/Main.cpp
* fixture specifically to confirm hot_reload_last_error() reports * loads this fixture specifically to confirm hot_reload_last_error()
* something useful for exactly this case, rather than leaving it a * reports something useful for exactly this case, rather than leaving it
* silent "load failed" black box. * a silent "load failed" black box.
*/ */
/// A class with one static member that is deliberately never defined -- see this file's comment.
class HasUndefinedStaticMember class HasUndefinedStaticMember
{ {
public: public:
/// ODR-uses counter, which is what actually triggers the undefined-symbol link error -- not
/// merely declaring or taking its address, and not merely loading this file's compiled output.
/// @return Never returns: linking the reference this creates is what fails.
static int useIt() static int useIt()
{ {
return counter; return counter;
} }
private: private:
static int counter; // declared, deliberately never defined anywhere /// Declared, deliberately never defined anywhere -- see this file's comment.
static int counter;
}; };
/// The one exported entry point: calls into the class above to force the undefined-symbol error.
/// @return Never returns a value in practice; see this file's comment.
extern "C" int trigger_undefined_symbol() extern "C" int trigger_undefined_symbol()
{ {
return HasUndefinedStaticMember::useIt(); return HasUndefinedStaticMember::useIt();