From ab21b3f30d2b657ee3691073dc63710a0fe5697f Mon Sep 17 00:00:00 2001 From: Acrux Development Date: Fri, 25 Sep 2026 03:20:35 -0300 Subject: [PATCH] Fix documentation --- CONTRIBUTING.md | 560 +++++++++++++ README.md | 744 +++++------------- source/HotReload/README.md | 40 +- .../HotReload/include/hot_reload/hot_reload.h | 651 ++++++++++----- source/HotReload/src/Architecture.cpp | 84 +- source/HotReload/src/Architecture.hpp | 107 ++- source/HotReload/src/HotReloadContext.cpp | 89 ++- source/HotReload/src/HotReloadContext.hpp | 132 +++- source/HotReload/src/MemoryProtection.cpp | 6 + source/HotReload/src/MemoryProtection.hpp | 83 +- source/HotReload/src/ModuleHandle.cpp | 29 +- source/HotReload/src/ModuleHandle.hpp | 82 +- source/HotReload/src/SymbolTable.cpp | 197 ++++- source/HotReload/src/SymbolTable.hpp | 77 +- source/HotReload/src/c-api/Demangle.cpp | 11 +- source/HotReload/src/c-api/HotReloadApi.cpp | 6 + source/examples/HotReloadDemo/Host/Main.cpp | 39 +- .../HotReloadDemo/PluginV1/src/CppWidget.cpp | 7 +- .../HotReloadDemo/PluginV1/src/Plugin.cpp | 10 +- .../HotReloadDemo/PluginV2/src/CppWidget.cpp | 7 +- .../HotReloadDemo/PluginV2/src/Plugin.cpp | 8 +- .../HotReloadDemo/Shared/CppWidgetApi.h | 65 +- .../examples/HotReloadDemo/Shared/PluginApi.h | 67 +- .../examples/HotReloadLiveDemo/Host/Main.cpp | 21 +- .../Plugin/src/LivePlugin.cpp | 16 +- source/tests/HotReloadTests/Main.cpp | 18 +- .../UndefinedSymbolFixture/src/Fixture.cpp | 24 +- 27 files changed, 2137 insertions(+), 1043 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4c5fc08 --- /dev/null +++ b/CONTRIBUTING.md @@ -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=...` +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//{bin,lib} what `cmake --install` / scripts/install.sh populates + dist/ reserved for future packaging output + docs//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 ])` | A buildless header-only drop under `vendor//include` | `add_vendor_header_only(some_header_lib)` | +| `add_vendor_subdirectory(SUBDIR)` | A vendored dependency with its own `CMakeLists.txt`, under `vendor/` | `add_vendor_subdirectory(some_vendored_lib)` | +| `add_dependency(NAME )` | 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_) +``` +```bat +scripts\docs.bat +scripts\docs.bat hotreload_lib +``` + +Output goes to `out/docs//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/`. diff --git a/README.md b/README.md index a30befc..0398905 100644 --- a/README.md +++ b/README.md @@ -1,575 +1,213 @@ -
- Logo - - # Volkan - - **C++ Hot-Reload library** -
- -
+# Hot Reload A cross-platform, cross-compiler C/C++ library that patches a running process's own code in place, so a new build of a function takes effect -immediately, without restarting -- see -[`source/HotReload/README.md`](source/HotReload/README.md) for what it -does, what it guarantees, and its honest limitations. This file covers -the project around it: the CMake module system, its tooling, and how -the examples and tests fit together. +immediately — no restart, and every existing caller (including a raw C +function pointer cached somewhere) picks it up automatically. + +```cpp +// host.cpp -- loads a plugin, calls into it, then hot-reloads it later +HotReloadContext* ctx = hot_reload_context_create(nullptr); +hot_reload_load(ctx, "plugin", "plugin.so"); + +auto tick = reinterpret_cast(hot_reload_get_symbol(ctx, "plugin", "tick")); +tick(1); // runs V1's code + +hot_reload_reload(ctx, "plugin", "plugin.so"); // plugin.so was rebuilt in between +tick(1); // same pointer, now runs V2's code +``` + +## Features + +- **Works on a real function pointer, not just a lookup table.** Patching + happens at the function's own address, so *every* path that reaches it + — a direct call, a call through the PLT/GOT, a raw function pointer + cached in a struct, a C++ vtable slot — picks up the new code, with + nothing to re-resolve. +- **Only executable code is ever touched — mechanically, not by + convention.** A symbol is a patch candidate only if the loader's own + metadata says it's a function in an executable section (ELF + `STT_FUNC`+`SHF_EXECINSTR`, or a PE export in an executable section). + Data — globals, class instances, vtables, RTTI — is structurally + excluded, never something you have to remember to avoid exporting. +- **Deletion is handled, not just tolerated.** A function missing from a + new build is reported through an event callback, and by default its old + implementation just keeps running — never a crash, never a jump to + garbage. Redirect it to your own stub instead if you want stale calls + to be loud during development. +- **Real C++ works, not just `extern "C"`.** A real, unmodified mangled + name — an ordinary method, a virtual method (redirected wherever it's + reached from, direct call or vtable slot, since both hold the same + address), an overloaded operator — patches exactly like a plain C + function, with no special handling needed. See + ["Hot reloading C++ symbols"](source/HotReload/README.md#hot-reloading-c-symbols-classes-virtual-methods-operators) + for the one real limit this runs into (inlining) and how to work with it. +- **Failures are diagnosable, not a black box.** `hot_reload_last_error()` + returns the platform's own explanation (`dlerror()` / a formatted + `GetLastError()`) for why a load failed — a missing file, a permissions + problem, and a shared library with a genuinely undefined symbol all look + identical from the status code alone, and don't need to. A corrupted or + truncated module is handled with the same care: every offset the ELF + reader trusts is bounds-checked against the file's actual size, confirmed + by feeding it 1500 randomly corrupted variants of a real plugin — see + "What's actually been tested" below. +- **x86, x86-64, and ARM64, across GCC, Clang, MinGW, and MSVC.** See + "What's actually been tested" below for exactly how each combination was + verified — natively, under QEMU, or under Wine, not just reasoned about. + +## Documentation + +- **[`source/HotReload/README.md`](source/HotReload/README.md)** is the + full reference: exactly how patching works, both safety guarantees in + detail, the complete C++ interoperability story, per-platform scope and + how each was verified, and a growing list of real bugs found (and + fixed) by actually running this — on Windows/MSVC, under fuzzing, and + on every other toolchain in the table below — read this before relying + on any specific edge case. +- **Every source file, not just the header, is Doxygen-documented** — + `hot_reload.h`'s public API (every function, status code, and event + kind has a `@brief`/`@param`/`@return`) and every internal class and + helper behind it, down to why each one exists. Build it locally: + ```sh + scripts/setup.sh # one-time: fetches a pinned Doxygen + scripts/docs.sh hotreload_lib + ``` + then open `out/docs/hotreload_lib/html/index.html`. Verified to generate + with zero warnings — for every module this project documents, library, + examples, and tests alike — even reconfigured with + `-DDOXYGEN_WARN_IF_UNDOCUMENTED=YES -DDOXYGEN_EXTRACT_PRIVATE=YES`, the + strictest combination `cmake/Documentation.cmake` exposes. +- **[`CONTRIBUTING.md`](CONTRIBUTING.md)** covers the CMake build system + this project itself uses (module conventions, `scripts/`, output + layout) — only relevant if you're extending *this* repository, not for + using the library in your own. + +## How to use it + +### Build it ```sh -scripts/setup.sh # optional, one-time -- see "Setup" below -scripts/build.sh Release -DBUILD_EXAMPLES=ON -./out/build/bin/examples/hotreload_demo_host -``` - -## What's here - -| | | -|---|---| -| [`source/HotReload/`](source/HotReload/README.md) | The library itself: `hotreload_lib`, a `STATIC` library with a plain C API (`hot_reload.h`) over a C++ implementation | -| [`source/examples/HotReloadDemo/`](source/examples/HotReloadDemo) | A scripted, deterministic demo: a host + two builds of one plugin, exercising every guarantee the library makes | -| [`source/examples/HotReloadLiveDemo/`](source/examples/HotReloadLiveDemo/README.md) | An interactive demo: a host that stays running, and one plugin file you edit and rebuild yourself | -| [`source/tests/HotReloadTests/`](source/tests/HotReloadTests) | An automated `ctest` suite covering the library end to end, including a regression test for a real bug found while building this | - -The rest of this file is about the project *around* that: a per-module -CMake setup adapted from a generic template (each module owns a tiny, -self-contained `CMakeLists.txt`; the root `CMakeLists.txt` discovers -them automatically), `scripts/` for the everyday commands, and the -code-style tooling. None of it is specific to hot-reloading -- if -you're only here for the library, `source/HotReload/README.md` is the -one to read. - -## Layout - -``` -CMakeLists.txt project setup, options, install(), discovery -- edit by hand -Configuration.cmake all output-path configuration -- edit to relocate anything -cmake/BuildHelpers.cmake module macros + discovery mechanism -- edit rarely -cmake/Dependencies.cmake vendoring / installed-library / FetchContent helpers -- edit rarely -cmake/Documentation.cmake per-module Doxygen integration -- only ever LOOKS for a pinned Doxygen -cmake/DoxygenPin.cmake shared version/URL/hash parameters for the pinned Doxygen -cmake/FetchDoxygen.cmake standalone downloader (run via `cmake -P`, not during normal configure) -cmake/NinjaPin.cmake shared version/URL/hash parameters for the pinned Ninja -cmake/FetchNinja.cmake standalone downloader (run via `cmake -P`, not during normal configure) -cmake/HotReload.cmake hot_reload_enable_patchable_functions() / hot_reload_restrict_exports() -source/HotReload/ the library -- library module "hotreload_lib" (STATIC) -source/examples/HotReloadDemo/ worked example: a host + two builds of one hot-reloadable plugin -source/examples/HotReloadLiveDemo/ interactive: a persistent host + one plugin file you edit yourself -source/tests/HotReloadTests/ the automated test suite -source/tests/UndefinedSymbolFixture/ a fixture the test suite loads, deliberately broken -- see its own comment -scripts/setup.sh, setup.bat one-time: fetch pinned Ninja + Doxygen, check tooling, activate git hook -scripts/build.sh, build.bat configure + build -- prefers a pinned Ninja, falls back gracefully -scripts/run.sh, run.bat run a module by its CMake target name -scripts/install.sh, install.bat install this project's own artifacts only -scripts/docs.sh, docs.bat build documentation for every module (or one) -- needs setup.sh first -scripts/refactor.sh, refactor.bat auto-fix formatting/naming across this project's own code -.clang-format, .clang-tidy C++ style guide tooling (see "Code style & tooling" below) -.editorconfig, .gitattributes -- same -.gitignore Visual Studio template + a block auto-synced from Configuration.cmake -c-api/.clang-tidy naming override template for a C API subtree -- applied for real at - source/HotReload/src/c-api/ and source/HotReload/include/hot_reload/ -.githooks/pre-commit formatting/lint pre-commit hook -cpp-style-guide.md, TOOLING.md the style guide itself + tooling docs -``` - -Everything generated -- the build tree, install output, docs, and reserved -packaging output -- lives under one `out/` directory (see "Output paths" -below); nothing under `out/` is source-controlled. - -Each module's `CMakeLists.txt` is (conventionally) one line: - -```cmake -# source/HotReload/CMakeLists.txt -add_lib_module(hotreload_lib TYPE STATIC OUTPUT_NAME "hotreload") -``` - -A module declares its own dependencies in its own `CMakeLists.txt` -- -same "minimal tool intervention" principle as module registration: -nothing to add in the root file. This project's own modules don't -currently need any third-party dependency, so the table in "Three ways -to bring in a dependency" below is showing you machinery this project -isn't using itself right now -- it's still there, exercised, and ready -if you add a module that needs it. - -## Output paths (`Configuration.cmake`) - -Every generated path is a cache variable, overridable with `-D=...` -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//{bin,lib} what `cmake --install` / scripts/install.sh populates - dist/ reserved for future packaging output - docs//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 ])` | A buildless header-only drop under `vendor//include` | `add_vendor_header_only(some_header_lib)` | -| `add_vendor_subdirectory(SUBDIR)` | A vendored dependency with its own `CMakeLists.txt`, under `vendor/` | `add_vendor_subdirectory(some_vendored_lib)` | -| `add_dependency(NAME )` | 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_) -``` -```bat -scripts\docs.bat -scripts\docs.bat hotreload_lib -``` - -Output goes to `out/docs//html/index.html` (a sibling of -`out/build/`, not nested inside it -- see "Output paths" above), plus a -landing page at `out/docs/index.html` linking to every module. -Documentation is **not** built as part of a normal `scripts/build.sh` -- -it's opt-in, the same way tests and examples are. - -**Writing docs for a module** is just normal Doxygen comments in that -module's headers -- e.g. `source/HotReload/include/hot_reload/hot_reload.h`, -which is already written this way throughout. - -Even with zero comments, a module's docs are still generated and useful -- -file lists, function/class signatures, and (if [Graphviz](https://graphviz.org/) -is installed on your system -- this one enhancement, unlike Doxygen -itself, is still detected rather than pinned, since it's optional) call -graphs are produced regardless, since `DOXYGEN_EXTRACT_ALL` is on by -default. Comments just make it richer. Drop a `README.md` in a module's -own directory and it's automatically used as that module's Doxygen main -page -- `source/HotReload/README.md` already does this. - -**Configuring it** -- nothing is required, but everything is overridable: - -```bash -# Disable entirely (docs targets print a message instead of building): -cmake -S . -B out/build -DENABLE_DOXYGEN=OFF - -# Pin a different Doxygen version (also update the two SHA256 hashes in -# cmake/DoxygenPin.cmake to match that release's assets -- a stale hash -# fails scripts/setup.sh loudly, on purpose, rather than silently -# accepting an unexpected file -- then re-run scripts/setup.sh): -cmake -S . -B out/build -DDOXYGEN_PINNED_VERSION=1.17.0 - -# Any Doxyfile tag can be set as a CMake variable, e.g. stricter output: -cmake -S . -B out/build -DDOXYGEN_WARN_IF_UNDOCUMENTED=YES -DDOXYGEN_QUIET=NO -``` - -A single module can opt out with `NO_DOCS`: - -```cmake -# some/internal/module/CMakeLists.txt -add_lib_module(internal_only TYPE STATIC NO_DOCS) -``` - -Vendored and fetched dependencies (`cmake/Dependencies.cmake`) are never -auto-documented -- same reasoning as their `compile_commands.json` -exclusion, it isn't your code. - -## compile_commands.json / clang tooling - -`CMAKE_EXPORT_COMPILE_COMMANDS` is on by default, and every build copies -`compile_commands.json` from the build directory to the project root so -clangd/clang-tidy/IWYU find it without extra configuration (CMake only -ever writes it into the build directory; most editors look for it at the -repo root). Vendored and fetched dependencies are excluded from it, so it -only reflects code this project actually owns. The project also builds -cleanly with clang directly: - -```bash -cmake -G Ninja -S . -B out/build -DCMAKE_CXX_COMPILER=clang++ -DDIST_TARGET=linux-x64-clang -cmake --build out/build -``` - -Verified, not assumed, throughout this project's own development: every -module here (the library, both example demos, and the test suite) was -built and its tests/demos run to completion under both GCC and Clang. - -## Code style & tooling - -This project follows [`cpp-style-guide.md`](cpp-style-guide.md) (tabs, -Allman braces, `PascalCase` file names, `camelCase` functions/variables, -`m_`/`s_`/`g_` member/static/global prefixes, plus a distinct Part II -style -- `snake_case`, library-prefixed names -- for any C API boundary; -see the file itself for the full rules and rationale), enforced by the -tooling described in [`TOOLING.md`](TOOLING.md): - -| File | Enforces | -|---|---| -| `.clang-format` | All formatting: tabs, Allman braces, pointer alignment, include order, line length | -| `.clang-tidy` | Naming conventions and a subset of language-feature rules, for this project's own C++ code | -| `c-api/.clang-tidy` | A naming override (`snake_case`) for a C API subtree -- see below for where this is actually applied | -| `.editorconfig` | Baseline tabs/whitespace for editors that don't run clang-format live | -| `.gitattributes` | Forces LF line endings for source files on every OS | -| `.githooks/pre-commit` | Runs clang-format + clang-tidy on staged files before each commit; warns and skips (never blocks) if a tool isn't installed | - -**Fixing violations automatically**, instead of hunting them down by hand: - -```bash -scripts/refactor.sh # every file under source/ and c-api/ by default -scripts/refactor.sh source/HotReload # just one file or directory -``` -```bat -scripts\refactor.bat -scripts\refactor.bat source\HotReload -``` - -Runs `clang-format -i` (always) and `clang-tidy --fix` (if -`compile_commands.json` exists -- build once first if it doesn't) across -the given scope. The pre-commit hook only *checks*; this is the "just fix -it" counterpart -- review the result with `git diff` afterward, same as -any auto-formatter. - -**Be especially careful reviewing `git diff` on a file with more than one -architecture/OS branch** (`#if defined(__x86_64__) ... #elif ... #endif`, -several of which exist under `source/HotReload/src/` -- see -`Architecture.cpp`, `ModuleHandle.cpp`, `MemoryProtection.cpp`, -`SymbolTable.cpp`). `clang-tidy` only ever analyzes the ONE branch active -for whichever platform you're running it on -- it cannot see, and will -not touch, the source text inside the other, inactive `#elif` branches at -all. Verified directly while building this project: running `--fix` on -`Architecture.cpp` from this x86-64 Linux machine renamed a local -constant inside the x86-64 branch only, then updated a later line shared -by *all three* architecture branches to match the new name -- silently -breaking the build for the other two architectures, which still declared -the old name. Caught only by reviewing the diff and separately -cross-compiling every branch, which is exactly the review step this -paragraph is asking you to actually do on a file shaped like this, not -skip. - -With no argument, only `source/` and `c-api/` are ever scanned -- `out/`, -`.cache/`, and `.git/` are never walked, so this can't accidentally spend -minutes reformatting/analyzing a fetched dependency's entire source tree -(this project doesn't currently have one under `out/build/_deps/`, but the -exclusion is unconditional, not conditional on one existing). Passing an -explicit scope takes it as given: `refactor.sh` still excludes -`out`/`.cache`/`.git` defensively if your scope happens to overlap with -them; `refactor.bat` does not (its Windows batch equivalent of that filter -turned out to be unreliable to verify, so it was removed rather than -shipped uncertain -- pick a scope that doesn't overlap with those -directories, which is the normal case anyway, e.g. `source\HotReload`). - -**One-time setup per clone** to activate the hook -- `scripts/setup.sh` / -`setup.bat` does this for you (see "Setup" below) along with fetching the -pinned Doxygen and checking for clang-format/clang-tidy; the two commands -it runs for the hook specifically are: - -```bash -chmod +x .githooks/pre-commit -git config core.hooksPath .githooks -``` - -**clang-tidy needs `compile_commands.json`** to resolve `#include`s -- -this project already generates and copies it to the repo root on every -build (see above), so as long as you've built at least once, the hook's -clang-tidy check works with no extra setup. - -**This project genuinely has a C API subtree**, unlike the generic -template this was adapted from: `source/HotReload/include/hot_reload/` -(the public header) and `source/HotReload/src/c-api/` (its `extern "C"` -implementation) each carry a copy of `c-api/.clang-tidy`, positioned at -the root of the subtree they apply to -- `clang-tidy` always uses the -closest `.clang-tidy` file up the directory tree, so files under either -directory pick up `snake_case` naming instead of the rest of this -project's `PascalCase`/`camelCase`, while `source/HotReload/src/`'s own -Part I (C++) files, one level up, keep inheriting this repo-root -`.clang-tidy`'s rules exactly as normal. See either copy's own header -comment for the full explanation of how clang-tidy's directory-scoped -config resolution makes that work. - -## How module discovery works - -The root `CMakeLists.txt` calls `_discover_module_subdirectories()` on -three roots, in this order: - -| Root | What it holds | -|---|---| -| `source/*` | library AND app/executable modules, side by side | -| `source/tests/*` | test modules | -| `source/examples/*` | example modules | - -(`source/tests` and `source/examples` aren't picked up by the first call --- they have no `CMakeLists.txt` directly inside them, only their own -children do.) - -Any immediate child directory that contains its own `CMakeLists.txt` gets -`add_subdirectory()`'d automatically (`vendor/` is not one of these roots --- vendored dependencies are opted into explicitly by the modules that -need them, not auto-built by default). This uses `CONFIGURE_DEPENDS`, so -the *next build* reconfigures on its own after you add or remove a module -directory -- no manual `cmake` re-run needed. A module may `DEPENDS` on a -module discovered later in this list, or reference one via -`add_dependencies()`/a generator expression before that other module's -own `CMakeLists.txt` has even run -- order doesn't affect linking or -target existence (CMake resolves target names, and dependency edges to -not-yet-declared targets, across the whole project at generate time -- -verified directly with an isolated, minimal reproduction while building -this project, not assumed). - -A module directory can itself contain more than one CMake target when its -shape doesn't fit the "one target per module" default -- see -`source/examples/HotReloadDemo/PluginV1/CMakeLists.txt`'s own comment for -a real example (a hot-reloadable plugin needs to exist as two separate -builds of the same exported names, which `add_lib_module` doesn't assume) -and `source/examples/HotReloadDemo/CMakeLists.txt` for the small grouping -file that pattern needs, one level up, purely so discovery (which only -looks one level deep into `source/examples/`) finds it at all. - -## Requirements - -CMake >= 3.25, a C++20 compiler (GCC/Clang/MSVC). **No network access is -needed to build this project** -- every module here is self-contained, -with no `add_dependency()`-fetched third-party library anywhere (see -"Three ways to bring in a dependency" above). Ninja and Doxygen are both -pinned and fetched automatically (see "Setup" below) if you want them, -not system dependencies, and `scripts/build.sh` warns rather than fails -if Ninja isn't available at all (see "Build" below). -[Graphviz](https://graphviz.org/) is optional for Doxygen call graphs, and -[clang-format](https://clang.llvm.org/docs/ClangFormat.html)/ -[clang-tidy](https://clang.llvm.org/extra/clang-tidy/) are optional for -the pre-commit hook and `scripts/refactor.sh` (see "Code style & tooling" -above) -- both are detected, not pinned, since they're editor/workflow -tools rather than part of the build itself. `scripts/setup.sh` (below) -checks for both and prints install guidance if either is missing. - -To actually try hot-reloading something (rather than just build the -library), you'll also want a way to rebuild one file while another -process keeps running -- covered in -[`source/examples/HotReloadLiveDemo/README.md`](source/examples/HotReloadLiveDemo/README.md). - -## Setup - -Optional, and separate from building (see "Requirements", "Build", and -"Documentation" above) -- run once per clone, or any time you want to -pick up a newer pinned version after bumping `NINJA_PINNED_VERSION` or -`DOXYGEN_PINNED_VERSION`: - -```bash -scripts/setup.sh -``` -```bat -scripts\setup.bat -``` - -Does four things, each independent of the others: - -1. Fetches the pinned Ninja (`cmake/FetchNinja.cmake`) -- used by - `scripts/build.sh`/`build.bat` if present (see "Build" below); not - required, a system Ninja works fine too. -2. Fetches the pinned Doxygen (`cmake/FetchDoxygen.cmake`) -- needed for - `scripts/docs.sh`, not for building the project. -3. Checks for `clang-format`/`clang-tidy` on `PATH` and prints - OS-appropriate install guidance if either is missing (never - auto-installs -- that would need assumptions about your package - manager and elevated privileges this script shouldn't assume it has). -4. Activates the pre-commit hook for this clone, if it's a git repository - (`git config core.hooksPath .githooks`). - -Safe to re-run any time -- each step is a no-op if there's nothing to do -(e.g. the pinned Ninja/Doxygen are already cached). - -## Build - -```bash -scripts/build.sh # Release (default), library + tests only if BUILD_TESTS was set before -scripts/build.sh Release -DBUILD_TESTS=ON -DBUILD_EXAMPLES=ON # + the automated tests + both demos +scripts/build.sh # library only +scripts/build.sh Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON # + both demos + the test suite ``` ```bat scripts\build.bat -scripts\build.bat Release -DBUILD_TESTS=ON -DBUILD_EXAMPLES=ON +scripts\build.bat Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON ``` -`BUILD_TESTS` and `BUILD_EXAMPLES` both default to `OFF` (see -`CMakeLists.txt`) -- plain `scripts/build.sh` with no extra arguments -builds only `hotreload_lib` itself. Anything after the build type is -passed straight through to `cmake`'s configure step (tested directly: -`scripts/build.sh Debug -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON` builds the -library, both demos, and the test suite together), and CMake caches `-D` -options across reconfigures, so passing them once and calling -`scripts/build.sh` plain afterward for incremental builds keeps them set --- no need to repeat them on every call. +This produces `hotreload_lib`, a `STATIC` library with a plain C API +(`hot_reload.h`) over a C++ implementation. Link your host application +against it (`target_link_libraries(your_app PRIVATE hotreload_lib)` if +you're using CMake; see [`source/HotReload/CMakeLists.txt`](source/HotReload/CMakeLists.txt) +for what it links on each platform if you're not). -Picks a generator in this order, each falling back to the next: +### See it work — a scripted, deterministic demo -1. **A pinned Ninja**, if `scripts/setup.sh`/`setup.bat` has fetched one -- - passed to CMake explicitly via `-DCMAKE_MAKE_PROGRAM=...` so it's used - even if a different Ninja also happens to be on `PATH` (same "always - the pinned copy" preference as Doxygen -- see "Documentation" above). -2. **A system Ninja**, if one is on `PATH` and no pinned copy was found. -3. **CMake's own default generator** for the platform, if neither Ninja is - available anywhere -- `scripts/build.sh`/`build.bat` print a warning - and continue rather than fail. (`CMakeLists.txt` separately warns too, - once CMake reports which generator it actually picked -- this is the - pre-existing "This project targets Ninja" message, unrelated to this - script.) - -Verified concretely, not assumed: with neither a pinned nor a system Ninja -present anywhere, `scripts/build.sh` still successfully configures, builds, -and produces working binaries -- it just uses whatever CMake's default -generator is for the platform (Unix Makefiles on Linux, typically NMake or -a Visual Studio generator on Windows) instead. - -## Run - -```bash -scripts/run.sh hotreload_demo_host -scripts/run.sh hotreload_live_demo_host -scripts/run.sh # no args: lists available targets -``` -```bat -scripts\run.bat hotreload_demo_host -scripts\run.bat hotreload_live_demo_host +```sh +./out/build/bin/examples/hotreload_demo_host ``` -`run.sh`/`run.bat` resolve the CMake **target id** (not the binary's -`OUTPUT_NAME`) to the actual binary via a manifest CMake generates at -configure time, so these scripts never need editing when a module is -added, removed, or renamed. Requires `-DBUILD_EXAMPLES=ON` to have been -set at configure time for either of the two hot-reload targets above, -since they live under `source/examples/`. +Loads one plugin, calls into it, hot-reloads a second build of it, and +calls the *same, never-re-resolved* function pointers again — proving +every guarantee above in one run. Open +[`source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp`](source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp) +and its `PluginV2` counterpart side by side against the output. -## Install +### Try it yourself — a persistent, interactive demo -```bash -scripts/install.sh -``` -```bat -scripts\install.bat +```sh +./out/build/bin/examples/hotreload_live_demo_host # leave this running +cmake --build out/build --target hotreload_live_plugin # in another terminal, or: +source/examples/HotReloadLiveDemo/watch.sh out/build # rebuild automatically on save ``` -Always use the install script (or pass `--component hotreload` yourself if -calling `cmake --install` directly). A vendored/fetched dependency's own -`CMakeLists.txt` usually has `install()` rules of its own with no -component tag; installing without `--component` would also run those, -typically dumping files under `CMAKE_INSTALL_PREFIX` (e.g. `/usr/local`). -Tagging this project's own `install(TARGETS ...)` calls with `COMPONENT -hotreload` and always filtering on it is what keeps `cmake --install` -scoped to just this project's own artifacts -- currently just -`hotreload_lib` itself, since examples and tests are opt-in, dev-time -targets rather than something you'd install. +Edit [`source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp`](source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp) +— its own comment walks through several concrete things worth trying — +and watch the *running* process pick up your change within about a +second, with no restart. See +[`source/examples/HotReloadLiveDemo/README.md`](source/examples/HotReloadLiveDemo/README.md) +for the full walkthrough, including two Windows-specific gotchas +(`Access is denied` on "Rebuild", and why leftover `.hotreload.N` files +next to your plugin are expected, not a leak) that are easy to mistake +for bugs the first time you hit them. -## Adding a module +### Use it in your own module -1. Create a directory under the right root (see the discovery table above). -2. Add a `CMakeLists.txt` inside it with one `add_lib_module` / - `add_exe_module` / `add_test_module` / `add_example_module` call, plus - any `add_vendor_*`/`add_dependency` calls it needs. -3. Add your `.cpp`/`.h` files inside that same directory. -4. Rebuild (`scripts/build.sh`) -- no other file needs touching. +1. `extern "C"` isn't required — a real C++ mangled name works too — but + it's the simplest choice for anything you want to look up by a plain, + human-typed name via `hot_reload_get_symbol()`. +2. Call `hot_reload_enable_patchable_functions()` + (`cmake/HotReload.cmake`) on your plugin's CMake target, so every + exported function has guaranteed room for a jump. +3. Optionally call `hot_reload_restrict_exports()` and mark just + your intended entry points with `HOT_RELOAD_EXPORT`, instead of + exporting everything by default — keeps your own internal helpers out + of the reloadable surface. See `source/HotReload/README.md`'s "Using + it in your own module" for the full version of this list, including + why this specific step doesn't (and can't) quiet `std::string`/ + `std::vector` noise, and why that's fine. +4. Have your host `hot_reload_load()` it once at startup and + `hot_reload_reload()` it after every rebuild, from a point where + nothing else is actively calling into it. A path with no `/` in it is + still resolved against the current working directory (never a system + library path), so a plain `plugin.so` next to your host works exactly + as you'd expect. -```cmake -# source/MyTool/CMakeLists.txt -add_exe_module(my_tool_exe OUTPUT_NAME "my-tool" DEPENDS hotreload_lib) -``` +## Limitations -`scripts/run.sh my_tool_exe` picks it up automatically once it's built, -and `scripts/docs.sh my_tool_exe` documents it automatically too -- both -with zero edits anywhere outside `source/MyTool/`. +Two things this library fundamentally cannot detect, because a symbol +table doesn't carry the information needed to: + +- **A changed function signature.** Patching redirects control flow at a + fixed address; it has no way to know that `void tick(int)` became + `void tick(int, int)` between builds. Give a changed signature a new + exported name instead of reusing the old one. +- **A data layout change reached through code that still gets patched.** + This library guarantees it never *touches* a data symbol directly. It + cannot guarantee that a patched function is safe to run against an + object that already exists in memory with the *old* layout — e.g. a + C++ vtable gaining a new virtual function, or a struct gaining a field, + while an old instance is still alive. + +One thing it doesn't fully solve, by design rather than by omission: +calling `hot_reload_reload()` **concurrently with active execution** of +the code being replaced isn't guaranteed safe on every architecture — the +intended pattern (shared by every tool in this space) is reloading from a +quiescent point, such as your main thread between frames. See +`source/HotReload/README.md`'s "What this cannot detect" for the full +reasoning, including exactly which write-ordering protections *are* in +place and what they do and don't cover. + +## What's actually been tested + +Not just built for, actually run to completion, verified directly rather +than assumed: + +| Toolchain | How it was verified | +|---|---| +| GCC + Clang, native Linux/x86-64 | Both compilers, full demo suite and test suite run to completion, including a CET (`endbr64`) landing pad this toolchain turns out to emit by default, and again explicitly forcing one with `-fcf-protection=full` | +| GCC, native Linux/x86 (32-bit) | Full demo and test suite run to completion under `-m32`. This is also where the suite's own "too small to patch" plugin function turned out not to be small enough at `-O2` on this one target — a real, previously-unverified gap between what the test assumed and what the compiler actually emitted, closed by making that function require no instructions at all | +| ARM64 (GCC cross-compiled) | Run under `qemu-aarch64` — twice, once plain and once with `-mbranch-protection=standard`, to exercise the BTI landing-pad path on real ARM64 semantics | +| MinGW | Cross-compiled and run under Wine, including the PE export-table parser and a real `LoadLibrary` failure (confirmed `hot_reload_last_error()` reports "Bad EXE format" correctly) | +| MSVC | Built and live-tested on real Windows by someone using this library — not by the environment that wrote it. Four real fixes came out of that and are folded in: `windows.h`/`dbghelp.h` include order, `NOMINMAX`, explicit `dbghelp` linking, and a reload-retry widening for Windows' slower post-rebuild file-availability timing | +| AddressSanitizer + UndefinedBehaviorSanitizer, and Valgrind with self-modifying-code checking enabled | Full test suite, clean under all three — the self-modifying-code checking matters specifically here, since patching a function's own bytes at runtime is exactly the pattern a memory checker's JIT otherwise mishandles by default | +| Fuzzing (ELF section-header parsing) | 1500 randomly corrupted variants of a real plugin's section header table — each still a file the OS loader itself accepts via `dlopen()` — all handled cleanly. An earlier version of this same reader crashed on 69 of an initial 300 | + +Three more real, reproducible bugs came out of this same pass, each +confirmed broken before the fix and confirmed fixed after: +a `hot_reload_load()`/`hot_reload_reload()` path with no `/` in it (e.g. +a plain `"plugin.so"`) silently failed to load on POSIX, since `dlopen()` +treats a bare name as a library-search request rather than a file path; +a module name containing a `/` or other unusual character (the header +never promised it had to look like a file name) broke the internal +staging path used to make each reload load from a fresh copy; and the +ELF section-header hardening above. + +**Deliberately not implemented, rather than implemented and untested:** +32-bit ARM (its ARM/Thumb instruction-mode ambiguity fails silently if +gotten wrong, and there was no way to verify it on real or emulated +hardware) and macOS/Mach-O (never requested, and would need a third image +format reader alongside the existing ELF and PE ones). Both are +documented, bounded gaps — see `source/HotReload/README.md`'s "Scope" +section for the reasoning behind each, and the extension point if you +need either. diff --git a/source/HotReload/README.md b/source/HotReload/README.md index 00bb435..234d934 100644 --- a/source/HotReload/README.md +++ b/source/HotReload/README.md @@ -3,12 +3,18 @@ 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 caller (including a raw C function pointer someone cached ten minutes -ago) picks up the change automatically. `hot_reload.h` is the API; -`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. +ago) picks up the change automatically. This is the full technical +reference; see this project's top-level `README.md` for a quicker +overview, feature summary, and build/run instructions if you haven't +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 cmake -S . -B out -DBUILD_EXAMPLES=ON @@ -367,15 +373,21 @@ that file's comment for the full reasoning. ## Known, accepted gaps -- **A `.so` with its section header table deliberately stripped** - reads back as exporting no functions at all (empty, not a crash) -- - `SymbolTable.cpp`'s ELF reader uses section headers to find - `.dynsym`/`.dynstr`, which is simpler and easier to get right than - walking `.dynamic`'s `DT_*` tags the way the dynamic linker itself - does, at the cost of this one edge case. Stripping section headers - from a `.so` you are actively hot-reloading is an unusual, separate - step from an ordinary `strip` and essentially never happens by - accident. +- **A `.so` with its section header table deliberately stripped, + truncated, or otherwise corrupted** reads back as exporting no + functions at all (empty, not a crash) -- `SymbolTable.cpp`'s ELF + reader uses section headers to find `.dynsym`/`.dynstr`, which is + simpler and easier to get right than walking `.dynamic`'s `DT_*` tags + the way the dynamic linker itself does, at the cost of this one edge + case for a deliberately-stripped file. Every offset, size, and index + this reader trusts is bounds-checked against the file's actual size + 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`, which backs a real safety check, the Windows path estimates size from the gap to the next exported function and can only ever prove diff --git a/source/HotReload/include/hot_reload/hot_reload.h b/source/HotReload/include/hot_reload/hot_reload.h index 5ad41de..203e6a9 100644 --- a/source/HotReload/include/hot_reload/hot_reload.h +++ b/source/HotReload/include/hot_reload/hot_reload.h @@ -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 - * Part II (C API Style) for the naming/formatting rules this header - * follows, and section 11 ("Runtime Loading") for why this boundary is - * plain C in the first place: anything crossing a dlopen()/LoadLibrary() - * boundary must have C linkage, so this is exactly the kind of surface - * that section calls out. + * See @c cpp-style-guide.md Part II (C API Style) for the naming/formatting + * rules this header follows, and section 11 ("Runtime Loading") for why + * this boundary is plain C in the first place: anything crossing a + * `dlopen()`/`LoadLibrary()` boundary must have C linkage, so this is + * exactly the kind of surface that section calls out. * - * 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. + * @section hotreload_what_it_does What this library does * - * 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 in - * SymbolTable.hpp 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. + * 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. * - * 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 * 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 - * function's signature changes, give it a new exported name (so the - * old symbol is treated as removed and the new one as added -- see - * HOT_RELOAD_SYMBOL_REMOVED below) rather than reusing the old name. - * See the top-level README for the full list of hazards this library - * cannot detect for you. + * function's signature changes, give it a new exported name (so the old + * symbol is treated as removed and the new one as added -- see + * ::HOT_RELOAD_SYMBOL_REMOVED) rather than reusing the old name. See the + * "Limitations" section of the top-level README (and "What this cannot + * 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 - * name that is stable across recompiles. extern "C" linkage -- see - * cpp-style-guide.md section 11 -- is the simplest way to get one: no + * name that is stable across recompiles. `extern "C"` linkage -- see + * `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 * version, or the function's signature. * * A real, unmodified C++ mangled name works too, and needs no special * handling by this library -- classification only ever looks at a * symbol's type and section, never at what its name looks like, so an - * ordinary out-of-line method, a virtual method (redirected wherever - * it's reached from -- directly or through a vtable slot, since both - * hold the same address), and an operator overload all patch exactly - * like any extern "C" function. What extern "C" actually buys you is - * a name you can type by hand into hot_reload_get_symbol() and a - * guarantee that it won't change if you rebuild with a different - * compiler; a mangled name gives up both of those, only stays stable - * within one ABI family (GCC and Clang share the Itanium C++ ABI; - * MSVC does not), and -- the one hazard that has nothing to do with - * this library and everything to do with what "hot-reloadable" can - * even mean -- only exists to patch at all if the compiler actually - * emitted an out-of-line body for it, which an implicitly-inline - * method defined in a class body, or a template instantiated at the - * call site, generally will not. See the top-level README's "Hot - * reloading C++ symbols" section for the full picture, including how - * to get a readable name for logging with hot_reload_demangle(). + * ordinary out-of-line method, a virtual method (redirected wherever it's + * reached from -- directly or through a vtable slot, since both hold the + * same address), and an operator overload all patch exactly like any + * `extern "C"` function. What `extern "C"` actually buys you is a name + * you can type by hand into hot_reload_get_symbol() and a guarantee that + * it won't change if you rebuild with a different compiler; a mangled + * name gives up both of those, only stays stable within one ABI family + * (GCC and Clang share the Itanium C++ ABI; MSVC does not), and -- the + * one hazard that has nothing to do with this library and everything to + * do with what "hot-reloadable" can even mean -- only exists to patch at + * all if the compiler actually emitted an out-of-line body for it, which + * an implicitly-inline method defined in a class body, or a template + * instantiated at the call site, generally will not. See the "Hot + * reloading C++ symbols" section of `source/HotReload/README.md` for the + * full picture, including how to get a readable name for logging with + * 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 #define HOT_RELOAD_H #include +/** + * @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 HOT_RELOAD_BUILD_SHARED #define HOT_RELOAD_API __declspec(dllexport) @@ -90,23 +137,29 @@ #endif #endif -/* Marks a single declaration for export under - * hot_reload_restrict_exports() (cmake/HotReload.cmake) without - * otherwise changing its linkage: pair with extern "C" yourself for a - * free function you want a stable, human-typable name for (see this - * 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). +/** + * @def HOT_RELOAD_EXPORT + * @brief Marks a single declaration in a hot-reloadable *module* for export + * under `hot_reload_restrict_exports()` (`cmake/HotReload.cmake`). * - * hot_reload_restrict_exports() is a narrower export surface, not a - * quieter one: see the README's "Why do std::string/std::vector - * symbols show up as PATCHED?" section for why it does not, and - * structurally cannot, suppress those specifically. */ + * This is for the plugins you hot-reload, not for this library's own API + * above (::HOT_RELOAD_API). It does not otherwise change a declaration's + * linkage: pair it with `extern "C"` yourself for a free function you want + * 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 #define HOT_RELOAD_EXPORT __declspec(dllexport) #else @@ -118,196 +171,394 @@ extern "C" { #endif -/* Opaque -- 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. */ +/** + * @defgroup hotreload_types Types and callbacks + * @brief The status codes, event/option structs, and callback signatures + * 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; -/* Every fallible function returns one of these -- see section 16 - * ("Do: return an integer error code / status enum from every - * fallible function"). */ +/** + * @brief Result of any fallible call in this API. + * + * 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 { + /** Success. */ 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, - /* dlopen()/LoadLibrary() (or the equivalent parse of the file on - * disk) failed for the given path. On hot_reload_reload(), - * this is non-destructive: the previously loaded version is left - * running untouched, exactly as if reload had not been called -- - * see the file comment above HotReloadContext::reload(). Call - * hot_reload_last_error() for the platform's own description of - * why -- this one status code covers everything from a missing - * file to a genuinely corrupt image to (a real case, found while - * building this feature) a shared library with an unresolved - * symbol reference, and those need very different fixes. */ + /** + * `dlopen()`/`LoadLibrary()` (or the equivalent parse of the file on + * disk) failed for the given path. + * + * On hot_reload_reload(), this is non-destructive: the previously + * loaded version is left running untouched, exactly as if reload had + * not been called. Call hot_reload_last_error() for the + * platform's own description of why -- this one status code covers + * everything from a missing file to a genuinely corrupt image to (a + * 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_reload() was called for a module_name that was never - * hot_reload_load()'ed first, so there is no "old" version to - * diff against. Call hot_reload_load() once, up front. */ + /** + * hot_reload_reload() was called for a @c module_name that was never + * 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, - /* This process's CPU architecture has no patch backend compiled - * in (see Architecture.hpp -- currently x86, x86-64, and ARM64). - * Nothing was patched; hot_reload_load() still succeeded, you - * just cannot hot_reload_reload() on this target. */ + /** + * This process's CPU architecture has no patch backend compiled in + * (see `Architecture.hpp` -- currently x86, x86-64, and ARM64). + * Nothing was patched; hot_reload_load() still succeeded, you just + * cannot hot_reload_reload() on this target. + */ HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE } HotReloadStatus; -/* Reported once per matched/unmatched symbol during hot_reload_reload() - * via HotReloadOptions::on_symbol_event, so the host application can - * log (or assert on, in a test) exactly what happened -- nothing here - * is silent. */ +/** + * @brief What happened to one symbol during a hot_reload_reload() call. + * + * 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 { - /* 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, - /* 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, - /* 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 - * running as-is unless HotReloadOptions::on_deleted_symbol - * supplies a stub to redirect to instead -- see that field. */ + * running as-is unless HotReloadOptions::on_deleted_symbol supplies a + * stub to redirect to instead -- see that field. + */ 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 -- - * e.g. an accidentally-exported global. Never touched, in either - * direction. This is the mechanical half of the "only executable - * code changes" guarantee described at the top of this file. */ + /** + * Present in both builds by name, but the symbol table classifies it + * as data (or something else non-function) in either build -- e.g. + * an accidentally-exported global. + * + * 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, - /* Classified as a function in both builds, but the OLD function's - * compiled size is smaller than the number of bytes this - * platform's jump sequence needs to write -- patching it would - * overwrite whatever comes after it in memory. Skipped rather - * than guessed; see hot_reload_enable_patchable_functions() in - * cmake/HotReload.cmake for how to stop this from happening. */ + /** + * Classified as a function in both builds, but the old function's + * compiled size is smaller than the number of bytes this platform's + * jump sequence needs to write -- patching it would overwrite + * whatever comes after it in memory. + * + * 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, - /* Classified as a function in both builds, correctly sized, but - * the OS refused to make its page temporarily writable (mprotect / - * VirtualProtect failed -- e.g. a hardened environment that denies - * W^X transitions). Rare in a normal developer setup; reported - * rather than silently ignored either way. */ + /** + * Classified as a function in both builds, correctly sized, but the + * OS refused to make its page temporarily writable (`mprotect` / + * `VirtualProtect` failed -- e.g. a hardened environment that denies + * W^X transitions). + * + * Rare in a normal developer setup; reported rather than silently + * ignored either way. + */ HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE } HotReloadSymbolEvent; -/* One event report, passed to HotReloadOptions::on_symbol_event. - * old_address/new_address are NULL when not applicable (e.g. - * old_address is NULL for HOT_RELOAD_SYMBOL_ADDED). */ +/** + * @brief One symbol's outcome, passed to HotReloadOptions::on_symbol_event. + */ typedef struct HotReloadSymbolEventInfo { - const char* name; - HotReloadSymbolEvent kind; - void* old_address; - void* new_address; + const char* name; /**< The symbol's real name (mangled, if it's a C++ symbol -- see hot_reload_demangle()). */ + HotReloadSymbolEvent kind; /**< What happened to it -- see ::HotReloadSymbolEvent. */ + 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; /**< The symbol's address in the new build, or `NULL` if not applicable (e.g. ::HOT_RELOAD_SYMBOL_REMOVED). */ } 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); -/* Called instead of patching, once per HOT_RELOAD_SYMBOL_REMOVED - * event, so the host can catch stale references during development - * instead of them silently continuing to run old code forever. Return - * the address of a replacement to redirect the old function to (its - * 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). - * Return NULL (or pass a NULL callback in HotReloadOptions to begin - * with) to leave the old implementation running untouched -- the - * default, and the safe choice unless you specifically want deleted - * functions to be loud. */ +/** + * @brief Callback invoked instead of patching, once per ::HOT_RELOAD_SYMBOL_REMOVED event. + * + * Lets the host catch stale references during development instead of them + * silently continuing to run old code forever. + * + * Called synchronously, on the thread that called hot_reload_reload(), while the reload is in + * progress; like HotReloadSymbolEventFn, it must not call hot_reload_load() or + * hot_reload_reload() on the same context. + * + * @param symbol_name The removed function's real (possibly mangled) name. + * @param user_data Whatever was passed as HotReloadOptions::user_data, untouched. + * @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); -/* 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 { - HotReloadSymbolEventFn on_symbol_event; /* nullable */ - HotReloadDeletedSymbolFn on_deleted_symbol; /* nullable */ - void* user_data; /* passed back to both callbacks, untouched */ + HotReloadSymbolEventFn on_symbol_event; /**< Nullable. See ::HotReloadSymbolEventFn. */ + HotReloadDeletedSymbolFn on_deleted_symbol; /**< Nullable. See ::HotReloadDeletedSymbolFn. */ + void* user_data; /**< Passed back to both callbacks, untouched. */ } HotReloadOptions; -/* Caller owns the returned pointer and must call - * hot_reload_context_destroy() -- see cpp-style-guide.md section 16 - * ("Document ownership transfer explicitly"). options may be NULL. */ +/** @} */ /* end of hotreload_types */ + +/** + * @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); +/** + * @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); -/* Loads path for the first time under 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. Returns HOT_RELOAD_ERROR_INVALID_ARGUMENT - * if module_name was already loaded -- call hot_reload_reload() - * instead of hot_reload_load() again for the same name. */ +/** @} */ /* end of hotreload_lifecycle */ + +/** + * @defgroup hotreload_loading Loading and reloading + * @brief The core operations: register a module, hot-reload it, and + * 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 + * (`.hotreload..`) 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); -/* Loads a NEW build of the module previously registered under - * module_name, diffs its exported functions against the previous - * version, and patches every one that is safe to patch in place -- - * see HotReloadSymbolEvent above for exactly what happens to each - * kind of change. On any HOT_RELOAD_ERROR_* return, nothing was - * changed: the previous version keeps running exactly as it was. */ +/** + * @brief Hot-reloads a module previously registered with hot_reload_load(). + * + * Loads a new build of the module, diffs its exported functions against + * the previous version, and patches every one that is safe to patch in + * 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); -/* Resolves symbol_name in module_name's CURRENT build (i.e. always - * the freshest one loaded, patched or not) -- for calling a function - * that did not exist yet when the host first started, such as one - * reported via HOT_RELOAD_SYMBOL_ADDED. Returns NULL if module_name - * is unknown or symbol_name is not an exported function in it. */ +/** + * @brief Resolves a symbol in a module's current build. + * + * Always resolves against the freshest build loaded for @c module_name, + * 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); -/* Only meaningful immediately after hot_reload_load() or - * 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 - * Windows -- e.g. "undefined symbol: _ZN6Widget4tickEi" for a shared - * 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, - * right here, once something ODR-uses it and the loader tries to - * resolve it for real). Overwritten by the next - * hot_reload_load()/hot_reload_reload() call on this ctx; copy it out - * immediately if you need to keep it past that point (e.g. for a log - * line). Empty if the platform 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). A static string internally -- - * never free() this. */ +/** @} */ /* end of hotreload_loading */ + +/** + * @defgroup hotreload_diagnostics Diagnostics + * @brief Turning a status code or a mangled name into something readable. + * @{ + */ + +/** + * @brief The platform's own explanation for the most recent failure on this context. + * + * Only meaningful immediately after hot_reload_load() or + * hot_reload_reload() on this *same* @c 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 + * Windows -- e.g. `"undefined symbol: _ZN6Widget4tickEi"` for a shared + * 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, 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); -/* 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); -/* 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 - * library itself was built with) becomes something like - * "Widget::tick(int)"; anything else, including an ordinary extern "C" - * name, comes back unchanged. Safe to call unconditionally on any - * HotReloadSymbolEventInfo::name or hot_reload_get_symbol() key -- - * this never changes what name means for lookup purposes, it is only - * ever applied when you ask for it, purely for display. Returns NULL - * only if name itself is NULL or allocation fails; caller must free - * a non-NULL result with hot_reload_free_string(). */ +/** + * @brief Renders a symbol name for a human to read. + * + * A real C++ mangled name (Itanium on GCC/Clang/MinGW, MSVC's own scheme + * under MSVC -- whichever this library itself was built with) becomes + * something like `"Widget::tick(int)"`; anything else, including an + * ordinary `extern "C"` name, comes back unchanged. Safe to call + * unconditionally on any HotReloadSymbolEventInfo::name or + * hot_reload_get_symbol() key -- this never changes what a name means for + * 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); +/** + * @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); +/** @} */ /* end of hotreload_diagnostics */ + #ifdef __cplusplus } #endif diff --git a/source/HotReload/src/Architecture.cpp b/source/HotReload/src/Architecture.cpp index 1fe2dc7..ea7f7b3 100644 --- a/source/HotReload/src/Architecture.cpp +++ b/source/HotReload/src/Architecture.cpp @@ -1,3 +1,7 @@ +/** + * @file Architecture.cpp + * @brief Per-architecture jump-patch backends for x86, x86-64, and ARM64. + */ #include "Architecture.hpp" #include @@ -51,7 +55,8 @@ * executing through the exact bytes being patched -- doing that in * general on a variable-length ISA needs a signal-handler-based retry * 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 @@ -60,20 +65,30 @@ namespace hot_reload::detail { #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; + + /// Encoding of `endbr64`, the x86-64 CET indirect-branch landing pad. 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 - // new modules were loaded, unlike a 5-byte relative jump - // (E9 rel32) which only reaches +/-2GB. Verified byte-for-byte - // against `as`/objdump output while building this library. + + /// Size in bytes of the jump written over a function's entry: + /// `jmp qword ptr [rip+0]` followed by an 8-byte absolute address. That reaches any + /// 64-bit address regardless of how far apart the old and new modules were loaded, + /// 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; + /// 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) { 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) { std::uint64_t targetValue = 0; @@ -99,20 +114,29 @@ namespace hot_reload::detail #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; + + /// Encoding of `endbr32`, the 32-bit x86 CET indirect-branch landing pad. 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 - // (mod 2^32 wraparound gives the right answer regardless of - // "distance"), so unlike x86-64 there is no need for an - // absolute/indirect form here. + + /// Size in bytes of the jump written over a function's entry: `e9 rel32`, a plain + /// relative near jump. On 32-bit x86 the entire address space fits in the signed 32-bit + /// displacement (mod 2^32 wraparound gives the right answer regardless of "distance"), + /// so unlike x86-64 there is no need for an absolute/indirect form here. 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) { 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) { std::int32_t rel32 = static_cast( @@ -128,35 +152,47 @@ namespace hot_reload::detail #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; - // Matches any BTI variant (plain/C/J/JC): bits [31:6] are fixed - // at 0b010101000000001001001, differing only in bits [6:5] - // (the qualifier) -- equivalently, the low byte is one of - // 0x1F/0x5F/0x9F/0xDF and the next 3 bytes are always - // 24 03 D5. Verified with `as`/objdump for all 4 variants - // while building this library. + /// Whether the instruction word at @p entry is any BTI variant (plain/C/J/JC): bits + /// [31:6] are fixed at 0b010101000000001001001, differing only in bits [6:5] (the + /// qualifier) -- equivalently, the low byte is one of 0x1F/0x5F/0x9F/0xDF and the next 3 + /// bytes are always 24 03 D5. Verified with `as`/objdump for all 4 variants 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) { 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) { return isBti(entry); } - // ldr x17, #8 ; br x17 ; <8-byte absolute address>. x16/x17 - // are AArch64's designated "intra-procedure-call" scratch - // registers (IP0/IP1) -- the AAPCS64 explicitly reserves them - // for exactly this kind of linker/veneer-style long branch, so - // clobbering them at a function's entry point, before any of - // its real prologue has executed, is always safe. Byte values - // verified with `as`/objdump while building this library. + /// Size in bytes of the jump written over a function's entry: + /// `ldr x17, #8 ; br x17 ; <8-byte absolute address>`. x16/x17 are AArch64's designated + /// "intra-procedure-call" scratch registers (IP0/IP1) -- the AAPCS64 explicitly reserves + /// them for exactly this kind of linker/veneer-style long branch, so clobbering them at + /// a function's entry point, before any of its real prologue has executed, is always + /// safe. Byte values verified with `as`/objdump while building this library. 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}; + + /// Encoding of `br x17` (branches to the address `ldr x17, #8` loaded). 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) { std::uint64_t targetValue = 0; diff --git a/source/HotReload/src/Architecture.hpp b/source/HotReload/src/Architecture.hpp index e9ed8b0..428353e 100644 --- a/source/HotReload/src/Architecture.hpp +++ b/source/HotReload/src/Architecture.hpp @@ -2,56 +2,91 @@ #include +/** + * @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 { - /// What planPatch() found at one specific function's entry point -- - /// how many bytes from the entry address must be safe to overwrite, - /// and 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. + /** + * @brief What planPatch() found at one specific function's entry point. + * + * How many bytes from the entry address must be safe to overwrite, and + * 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 { - /// Total bytes from the function's entry address that this - /// patch touches, landing pad included -- compare this against - /// the symbol's compiled size before writing anything. + /** + * Total bytes from the function's entry address that this patch + * touches, landing pad included -- compare this against the + * symbol's compiled size before writing anything. + */ 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 - /// entryAddress + patchOffset. + /** + * How many leading bytes are a landing pad and must be left + * untouched; the jump itself is written starting at + * `entryAddress + patchOffset`. + */ 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 -- - /// see the top of Architecture.cpp). x86, x86-64, and ARM64 are - /// supported; anything else (including 32-bit ARM, whose ARM/Thumb - /// interworking this library does not attempt -- see the README) - /// returns false here, and HotReloadContext surfaces that as - /// HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE. + /** + * @brief Whether this build has a jump-patch backend for the current CPU architecture. + * + * Decided at compile time -- see the top of `Architecture.cpp`. x86, + * x86-64, and ARM64 are supported; anything else (including 32-bit + * ARM, whose ARM/Thumb interworking this library does not attempt -- + * 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(); - /// Inspects the bytes already sitting at entryAddress to determine - /// the real patch layout for THIS function, accounting for a - /// possible leading landing pad. Safe to call even when - /// isArchitectureSupported() is false (returns a zeroed PatchLayout). + /** + * @brief Determines the real patch layout for one specific function. + * + * 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); - /// Overwrites entryAddress so that control transfers unconditionally - /// to targetAddress, following the layout planPatch() would return - /// for this same entryAddress. The caller must already have made - /// [entryAddress, entryAddress + planPatch(entryAddress).totalBytes) - /// writable (see MemoryProtection.hpp) and must flush the - /// instruction cache over that same range afterwards -- this - /// function does neither. - /// - /// Only meaningful precondition: 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. - /// - /// Returns false only if isArchitectureSupported() is false. + /** + * @brief Overwrites a function's entry so it jumps to another address. + * + * Follows the layout planPatch() would return for this same + * @p entryAddress. + * + * @param entryAddress The function entry to overwrite. + * @param targetAddress Where control should transfer to instead. + * @return False only if isArchitectureSupported() is false. + * + * @pre The caller has already made + * `[entryAddress, entryAddress + planPatch(entryAddress).totalBytes)` + * writable (see `MemoryProtection.hpp`) and will flush the + * 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); } diff --git a/source/HotReload/src/HotReloadContext.cpp b/source/HotReload/src/HotReloadContext.cpp index 04d598e..8dc4c7a 100644 --- a/source/HotReload/src/HotReloadContext.cpp +++ b/source/HotReload/src/HotReloadContext.cpp @@ -1,3 +1,7 @@ +/** + * @file HotReloadContext.cpp + * @brief The diff-and-patch orchestration logic: load, reload, and per-symbol policy. + */ #include "HotReloadContext.hpp" #include "Architecture.hpp" @@ -12,6 +16,12 @@ namespace hot_reload { 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) { std::ifstream in(from, std::ios::binary); @@ -28,32 +38,67 @@ namespace hot_reload return static_cast(out); } - // 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 the rest of the CURRENT process's lifetime (see - // HotReloadContext.hpp's LoadedModule comment) -- 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, here, 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. + /// The module name as it appears inside a staging copy's file name. + /// + /// module_name is an arbitrary caller-chosen key (hot_reload.h promises it need not match + /// anything on disk), so it may contain characters that are not legal in a file name -- + /// a '/' in "engine/renderer", say. Everything outside a conservative set is replaced by + /// '_'. stagePathForLoad() and cleanupOrphanedStagingFiles() must agree on the result, so + /// both call this. Names made only of letters, digits, '-' and '_' are unchanged. + /// + /// @param moduleName The logical module name. + /// @return @p moduleName with every character outside `[A-Za-z0-9_-]` replaced by '_'. + std::string stagingTag(const std::string& moduleName) + { + std::string tag = moduleName; + for (char& character : tag) + { + const bool isLower = character >= 'a' && character <= 'z'; + const bool isUpper = character >= 'A' && character <= 'Z'; + const bool isDigit = character >= '0' && character <= '9'; + 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) { namespace fs = std::filesystem; fs::path source(sourcePath); 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; fs::directory_iterator entries(dir, dirError); @@ -120,7 +165,9 @@ namespace hot_reload // name guaranteed never reused sidesteps it regardless of how // the build produced sourcePath. ++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)) { return {}; diff --git a/source/HotReload/src/HotReloadContext.hpp b/source/HotReload/src/HotReloadContext.hpp index 3995d44..d94514b 100644 --- a/source/HotReload/src/HotReloadContext.hpp +++ b/source/HotReload/src/HotReloadContext.hpp @@ -8,62 +8,160 @@ #include #include +/** + * @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 { - /// The real type behind the opaque ::HotReloadContext the public C - /// API hands out -- 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. + /** + * @brief The real type behind the opaque ::HotReloadContext the public C API hands out. + * + * 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 { 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); HotReloadContext(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); - /// 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); - /// 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; - /// 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 { return m_lastError; } private: - /// Everything tracked for one logical module name. Has no - /// meaning outside HotReloadContext, so it lives here rather - /// than in its own header -- see cpp-style-guide.md's carve-out - /// for small types with no use outside their owning type. + /// Everything tracked for one logical module name. Has no meaning + /// outside HotReloadContext, so it lives here rather than in its + /// own header -- see cpp-style-guide.md's carve-out for small + /// types with no use outside their owning type. struct LoadedModule { - // Every version ever loaded for this name, kept alive for - // the rest of the process's lifetime: once a function is - // patched, anything that already holds its old address - // must stay valid memory forever, even though it now holds - // nothing but a jump. versions.back() is the active one. + /// Every version ever loaded for this name, kept alive for as long as the owning + /// context lives (in practice usually the rest of the process): once a function is + /// patched, anything that already holds its old address must stay valid memory, + /// even though it now holds nothing but a jump. versions.back() is the active one. std::vector versions; + + /// The exported symbols of the newest build (versions.back()), as reported by + /// detail::readExportedSymbols(); what the next reload() diffs against. std::vector 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; }; + /** + * @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); + + /** + * @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); + + /** + * @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); + + /** + * @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); + /// Every module loaded so far, by logical name. std::unordered_map m_modules; + + /// HotReloadOptions::on_symbol_event, or null. HotReloadSymbolEventFn m_onSymbolEvent; + + /// HotReloadOptions::on_deleted_symbol, or null. HotReloadDeletedSymbolFn m_onDeletedSymbol; + + /// HotReloadOptions::user_data, handed back to both callbacks untouched. void* m_userData; + + /// The message lastError() returns. std::string m_lastError; }; diff --git a/source/HotReload/src/MemoryProtection.cpp b/source/HotReload/src/MemoryProtection.cpp index 3009867..3b04b7f 100644 --- a/source/HotReload/src/MemoryProtection.cpp +++ b/source/HotReload/src/MemoryProtection.cpp @@ -1,3 +1,7 @@ +/** + * @file MemoryProtection.cpp + * @brief Code-page write-access RAII guard implementation (mprotect/VirtualProtect). + */ #include "MemoryProtection.hpp" #include @@ -20,6 +24,8 @@ namespace hot_reload::detail { 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() { #if defined(_WIN32) diff --git a/source/HotReload/src/MemoryProtection.hpp b/source/HotReload/src/MemoryProtection.hpp index 97efc41..482ec4c 100644 --- a/source/HotReload/src/MemoryProtection.hpp +++ b/source/HotReload/src/MemoryProtection.hpp @@ -5,50 +5,89 @@ namespace hot_reload::detail { - /// RAII guard: makes [address, address + length) writable for its - /// lifetime (the OS only grants permissions in whole pages, so the - /// guard rounds out to the enclosing page range internally) and - /// restores the original protection when it goes out of scope. - /// - /// Assumes the range started out READ+EXECUTE, as every platform's - /// loader maps a .text/code section -- restoring to 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. + /** + * @brief RAII guard that temporarily makes a code range writable. + * + * Makes `[address, address + length)` writable for its lifetime (the + * OS only grants permissions in whole pages, so the guard rounds out + * to the enclosing page range internally) and restores the original + * protection when it goes out of scope. + * + * @note Assumes the range started out READ+EXECUTE, as every + * platform's loader maps a `.text`/code section -- restoring to + * 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 { 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); + + /** + * @brief Restores the range's original protection (does nothing if isValid() is false). + */ ~ScopedCodeWriteAccess(); ScopedCodeWriteAccess(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 - /// written to (the destructor is still safe to run either way). + /** + * @brief Whether the platform call to relax protection succeeded. + * + * 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 { return m_valid; } - /// Call exactly once, after writing the new bytes and before - /// this guard is destroyed: flushes the instruction cache over - /// the original [address, address + length) range so every - /// core sees the new instructions rather than a stale fetch - /// (required on ARM64, where instruction and data caches are - /// not automatically coherent; effectively a formality on - /// x86/x86-64, called anyway for correctness on that - /// architecture too). + /** + * @brief Flushes the instruction cache over the patched range. + * + * Call exactly once, after writing the new bytes and before this + * guard is destroyed, so every core sees the new instructions + * rather than a stale fetch (required on ARM64, where instruction + * and data caches are not automatically coherent; effectively a + * formality on x86/x86-64, called anyway for correctness on that + * architecture too). + */ void finishWrite(); private: + /// Start of the page-aligned region whose protection was changed. void* m_pageAddress; + + /// Length in bytes of that page-aligned region (a whole number of pages). std::size_t m_pageLength; + + /// Start of the range the caller actually asked to write to. void* m_rangeAddress; + + /// Length in bytes of the range the caller actually asked to write to. 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; + + /// Whether the constructor's protection change succeeded. bool m_valid; }; diff --git a/source/HotReload/src/ModuleHandle.cpp b/source/HotReload/src/ModuleHandle.cpp index ee63d91..f465e15 100644 --- a/source/HotReload/src/ModuleHandle.cpp +++ b/source/HotReload/src/ModuleHandle.cpp @@ -1,6 +1,11 @@ +/** + * @file ModuleHandle.cpp + * @brief RAII dlopen()/LoadLibrary() wrapper implementation. + */ #include "ModuleHandle.hpp" #include +#include #if defined(_WIN32) // 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 /// way a person would want to read it, the same information - /// dlerror() gives directly on POSIX. Written against - /// documented Win32 behavior; not exercised on real Windows -- - /// see source/HotReload/README.md's verification notes. + /// dlerror() gives directly on POSIX. Exercised under Wine (MinGW cross-build) -- see + /// "What's actually been tested" in the top-level README; not verified against a real + /// 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() { DWORD errorCode = GetLastError(); @@ -71,6 +79,7 @@ namespace hot_reload::detail ModuleHandle::ModuleHandle(ModuleHandle&& other) noexcept : m_native(other.m_native) , m_loadedBase(other.m_loadedBase) + , m_errorMessage(std::move(other.m_errorMessage)) { other.m_native = nullptr; other.m_loadedBase = nullptr; @@ -90,6 +99,7 @@ namespace hot_reload::detail } m_native = other.m_native; m_loadedBase = other.m_loadedBase; + m_errorMessage = std::move(other.m_errorMessage); other.m_native = nullptr; other.m_loadedBase = nullptr; } @@ -129,7 +139,18 @@ namespace hot_reload::detail // first runs under RTLD_LAZY's deferred binding. For a // long-running host, a clean, catchable load failure is far // 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) { const char* error = dlerror(); diff --git a/source/HotReload/src/ModuleHandle.hpp b/source/HotReload/src/ModuleHandle.hpp index 8ec763a..2ba10e0 100644 --- a/source/HotReload/src/ModuleHandle.hpp +++ b/source/HotReload/src/ModuleHandle.hpp @@ -5,53 +5,103 @@ namespace hot_reload::detail { - /// RAII wrapper around dlopen()/LoadLibrary(). Move-only, closes - /// the underlying module on destruction (or on being replaced by a - /// move-assignment). + /** + * @brief RAII wrapper around `dlopen()`/`LoadLibrary()`. + * + * 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 { public: + /** @brief Creates an empty handle that owns no module (isValid() is false). */ ModuleHandle() = default; + + /** @brief Closes the module, if this handle owns one. */ ~ModuleHandle(); ModuleHandle(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; + + /** + * @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; - /// 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); + /** + * @brief Whether load() succeeded. + * @return True if this handle owns a loaded module. + */ bool isValid() const { 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 - /// bias (struct link_map::l_addr) that turns an ELF symbol's - /// file-relative st_value into a real address. + /** + * @brief The address to pass as SymbolTable's `loadedBase`. + * + * 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 { return m_loadedBase; } - /// Only meaningful when isValid() is false: the underlying - /// platform's own description of why loading failed -- - /// dlerror() on POSIX, the formatted message for - /// GetLastError() on Windows. Captured immediately inside - /// load()'s failing branch, before any other dl*/Win32 call - /// that could otherwise overwrite it first. Empty if load() - /// was never attempted, or if the platform gave no message. + /** + * @brief Why load() failed, if isValid() is false. + * + * The underlying platform's own description of why loading + * failed -- `dlerror()` on POSIX, the formatted message for + * `GetLastError()` on Windows. Captured immediately inside + * 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 { return m_errorMessage; } private: + /// The platform's own handle (`HMODULE` or the `dlopen()` handle); null if none. void* m_native = nullptr; + + /// See loadedBase(). void* m_loadedBase = nullptr; + + /// See errorMessage(). std::string m_errorMessage; }; diff --git a/source/HotReload/src/SymbolTable.cpp b/source/HotReload/src/SymbolTable.cpp index ad57c9f..8b5b5d2 100644 --- a/source/HotReload/src/SymbolTable.cpp +++ b/source/HotReload/src/SymbolTable.cpp @@ -1,3 +1,7 @@ +/** + * @file SymbolTable.cpp + * @brief Reads and classifies a module's exported symbols (ELF and PE). + */ #include "SymbolTable.hpp" #include @@ -5,6 +9,7 @@ #include #include #include +#include #include /* WHY "IS THIS A FUNCTION" IS DECIDED HERE, MECHANICALLY @@ -49,13 +54,18 @@ namespace hot_reload::detail namespace { + /// One PE section, reduced to what the function/data classification needs. struct SectionInfo { - std::uint32_t rvaBegin; - std::uint32_t rvaEnd; - bool executable; + std::uint32_t rvaBegin; /**< First RVA covered by the section. */ + std::uint32_t rvaEnd; /**< One past the last RVA covered by the section. */ + 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) { return reinterpret_cast(base + rva); @@ -170,9 +180,11 @@ namespace hot_reload::detail // large real function) -- it only ever gives a hard "not // enough room" answer when the gap itself is already small. // PE has no per-export size field the way ELF does (see - // below), so /hotpatch (applied automatically by - // cmake/HotReload.cmake) is the primary safety mechanism on - // Windows; this is a secondary backstop, not a substitute. + // below), so /hotpatch (applied by calling + // hot_reload_enable_patchable_functions() from + // cmake/HotReload.cmake on the plugin target) is the primary + // safety mechanism on Windows; this is a secondary backstop, + // not a substitute. std::vector functionRvasSorted; for (const Candidate& candidate : candidates) { @@ -236,6 +248,10 @@ namespace hot_reload::detail 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 readWholeFile(const std::string& path) { std::ifstream file(path, std::ios::binary | std::ios::ate); @@ -257,83 +273,191 @@ namespace hot_reload::detail 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 + bool readStruct(const std::vector& 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(tableSize - offset); + const void* end = std::memchr(begin, '\0', remaining); + if (end == nullptr) + { + return {}; + } + const auto* endPtr = static_cast(end); + return std::string_view(begin, static_cast(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(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(ELF32_ST_BIND(info)); + } + /// Parses one ELF class (32 or 64-bit); Ehdr/Shdr/Sym is /// Elf32_* or Elf64_* depending on the caller, matched against /// e_ident[EI_CLASS] before this is ever instantiated. - template - std::vector parseElf( - const std::vector& file, void* loadedBase, SymType symType, SymBind symBind) + /// + /// Every offset, length and index read from the file is bounds-checked first (see + /// 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 + std::vector parseElf(const std::vector& file, void* loadedBase) { std::vector result; - if (file.size() < sizeof(Ehdr)) + + Ehdr ehdr{}; + if (!readStruct(file, 0, ehdr)) { return result; } - - const auto* ehdr = reinterpret_cast(file.data()); - if (ehdr->e_shoff == 0 || ehdr->e_shnum == 0) + if (ehdr.e_shoff == 0 || ehdr.e_shnum == 0) { return result; // no section header table -- see file comment above } - if (static_cast(ehdr->e_shoff) + static_cast(ehdr->e_shnum) * sizeof(Shdr) > - file.size()) + const auto sectionTableBytes = static_cast(ehdr.e_shnum) * sizeof(Shdr); + const bool sectionsFit = rangeInFile(ehdr.e_shoff, sectionTableBytes, file.size()); + if (ehdr.e_shentsize != sizeof(Shdr) || !sectionsFit) { return result; // truncated/corrupt file } - const auto* sections = reinterpret_cast(file.data() + ehdr->e_shoff); + std::vector 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. - if (ehdr->e_shstrndx >= ehdr->e_shnum) + if (ehdr.e_shstrndx >= ehdr.e_shnum) { 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* 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; - if (std::strcmp(name, ".dynsym") == 0) + const auto nameOffset = section.sh_name; + const std::string_view name = stringAt(shstrtab, shstrSection.sh_size, nameOffset); + if (name == ".dynsym") { - dynsymSection = §ions[i]; + dynsymSection = §ion; } - else if (std::strcmp(name, ".dynstr") == 0) + else if (name == ".dynstr") { - dynstrSection = §ions[i]; + dynstrSection = §ion; } } if (dynsymSection == nullptr || dynstrSection == nullptr) { 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; - std::size_t symbolCount = dynsymSection->sh_size / sizeof(Sym); - const auto* symbols = reinterpret_cast(file.data() + dynsymSection->sh_offset); + const auto symBytes = static_cast(dynsymSection->sh_size); + const std::size_t symbolCount = symBytes / sizeof(Sym); 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) { 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) { 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; - exported.name = dynstr + sym.st_name; + exported.name = std::string(name); exported.address = static_cast(loadedBase) + sym.st_value; exported.size = static_cast(sym.st_size); - bool isFuncType = symType(sym.st_info) == STT_FUNC; + bool isFuncType = elfSymType(sym.st_info) == STT_FUNC; 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; } @@ -354,18 +478,17 @@ namespace hot_reload::detail 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(file[EI_CLASS]); if (elfClass == ELFCLASS64) { - return parseElf( - file, loadedBase, [](unsigned char info) { return ELF64_ST_TYPE(info); }, - [](unsigned char info) { return ELF64_ST_BIND(info); }); + return parseElf(file, loadedBase); } if (elfClass == ELFCLASS32) { - return parseElf( - file, loadedBase, [](unsigned char info) { return ELF32_ST_TYPE(info); }, - [](unsigned char info) { return ELF32_ST_BIND(info); }); + return parseElf(file, loadedBase); } return {}; } diff --git a/source/HotReload/src/SymbolTable.hpp b/source/HotReload/src/SymbolTable.hpp index f585b36..6424aaa 100644 --- a/source/HotReload/src/SymbolTable.hpp +++ b/source/HotReload/src/SymbolTable.hpp @@ -7,45 +7,60 @@ 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 { - std::string name; - void* address = nullptr; + std::string name; /**< The symbol's real (possibly mangled) name. */ + 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 - /// for anything not mechanically identified as code (a data - /// object, a forwarder export, or a symbol kind this reader - /// does not recognize at all). HotReloadContext never patches - /// a symbol where this is false, on either side of a reload. + /** + * The single load-bearing safety gate behind the "only + * executable code changes" guarantee in `hot_reload.h`: false + * for anything not mechanically identified as code (a data + * object, a forwarder export, or a symbol kind this reader does + * not recognize at all). `HotReloadContext` never patches a + * symbol where this is false, on either side of a reload. + */ 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 - /// such field, so this holds a conservative estimate derived - /// from the distance to the next exported function in the - /// same section -- see the file comment in SymbolTable.cpp - /// for exactly what that heuristic can and cannot catch. + /** + * Compiled size in bytes if known, 0 if not. ELF gives this + * exactly (the symbol table's `st_size` field); PE gives no such + * field, so this holds a conservative estimate derived from the + * distance to the next exported function in the same section -- + * see the file comment in `SymbolTable.cpp` for exactly what + * that heuristic can and cannot catch. + */ std::size_t size = 0; }; - /// Reads every exported, defined symbol belonging to the module - /// mapped at loadedBase (the base address dlopen()/LoadLibrary() - /// returned for it), classifying each one as code or data. - /// - /// On POSIX this parses imagePath on disk (its ELF .dynsym / - /// .dynstr / section-header table) and adds loadedBase as the - /// runtime load bias. On Windows this ignores imagePath and reads - /// directly from the already-mapped image at loadedBase (its PE - /// export directory and section table) -- see the per-platform - /// implementation blocks in SymbolTable.cpp for why each platform - /// reads from a different place. - /// - /// Returns an empty vector, 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). + /** + * @brief Reads and classifies every exported, defined symbol in a loaded module. + * + * On POSIX this parses @p imagePath on disk (its ELF `.dynsym` / + * `.dynstr` / section-header table) and adds @p loadedBase as the + * runtime load bias. On Windows this ignores @p imagePath and reads + * directly from the already-mapped image at @p loadedBase (its PE + * export directory and section table) -- see the per-platform + * implementation blocks in `SymbolTable.cpp` for why each platform + * reads from a different place. + * + * A file that is truncated or otherwise corrupt in the parts this reader relies on (the + * section header table and the tables it points at) is treated like any other unparseable + * image: every offset and length read from the file is range-checked against the file's size + * before it is used, so a damaged image yields fewer (or no) symbols rather than an + * 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 readExportedSymbols(const std::string& imagePath, void* loadedBase); } diff --git a/source/HotReload/src/c-api/Demangle.cpp b/source/HotReload/src/c-api/Demangle.cpp index 65e4179..ba2c26c 100644 --- a/source/HotReload/src/c-api/Demangle.cpp +++ b/source/HotReload/src/c-api/Demangle.cpp @@ -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 * own file alongside HotReloadApi.cpp rather than crowding that one's @@ -15,6 +17,7 @@ */ #include "hot_reload/hot_reload.h" +#include #include #include @@ -24,9 +27,9 @@ // COMPILER implements (Itanium here), not the operating system -- // MinGW is still GCC/Clang generating Itanium-mangled names even // though the OS is Windows. MSVC, on the same OS, uses its own, - // unrelated scheme (the #elif below). See README.md's "Hot - // reloading C++ symbols" section for why this distinction matters - // beyond just this function. + // unrelated scheme (the #elif below). See the "Hot reloading C++ + // symbols" section of source/HotReload/README.md for why this + // distinction matters beyond just this function. #include #elif defined(_MSC_VER) // windows.h must come first: dbghelp.h uses types (HANDLE, DWORD, diff --git a/source/HotReload/src/c-api/HotReloadApi.cpp b/source/HotReload/src/c-api/HotReloadApi.cpp index dc1459e..5794dea 100644 --- a/source/HotReload/src/c-api/HotReloadApi.cpp +++ b/source/HotReload/src/c-api/HotReloadApi.cpp @@ -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 "../HotReloadContext.hpp" @@ -9,6 +13,8 @@ namespace /// is a hot_reload::HotReloadContext, following the same /// opaque-pointer PIMPL pattern cpp-style-guide.md's NetManager /// 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) { return reinterpret_cast(ctx); diff --git a/source/examples/HotReloadDemo/Host/Main.cpp b/source/examples/HotReloadDemo/Host/Main.cpp index b617090..419e9b3 100644 --- a/source/examples/HotReloadDemo/Host/Main.cpp +++ b/source/examples/HotReloadDemo/Host/Main.cpp @@ -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 * they now run the new code -- without the Host re-resolving anything. * Along the way this exercises every HotReloadSymbolEvent kind: watch @@ -17,33 +19,40 @@ namespace { + /// The signature every plain-C export in PluginApi.h shares. using PluginFunction = int (*)(int); - // CppWidget::tick's real ABI: an implicit this-pointer followed by - // its one declared parameter, same calling convention the - // compiler itself would generate for widget->tick(x) -- this is - // just spelling that out explicitly so it can be called through a - // manually-resolved address instead. + /// CppWidget::tick's real ABI: an implicit this-pointer followed by + /// its one declared parameter, same calling convention the + /// compiler itself would generate for widget->tick(x) -- this is + /// just spelling that out explicitly so it can be called through a + /// manually-resolved address instead. using CppWidgetTickFunction = int (*)(CppWidget*, int); + /// Stands in for @c plugin_legacy_feature once V2 removes it. + /// @return Always -1. int hostSideDeletedFunctionStub(int) { std::printf(" !! called a deleted plugin function -- this is the Host's stub, not a crash !!\n"); return -1; } - /* Runs once per HOT_RELOAD_SYMBOL_REMOVED, before onSymbolEvent - * below sees the same event -- see hot_reload.h's doc comment on - * HotReloadDeletedSymbolFn. Redirecting a removed function to a - * stub living in the HOST executable (not in either plugin build) - * demonstrates that the target of a patch can be anywhere at all, - * not just inside the module being reloaded. */ + /// Runs once per HOT_RELOAD_SYMBOL_REMOVED, before onSymbolEvent + /// below sees the same event -- see hot_reload.h's doc comment on + /// HotReloadDeletedSymbolFn. Redirecting a removed function to a + /// stub living in the HOST executable (not in either plugin build) + /// demonstrates that the target of a patch can be anywhere at all, + /// 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*/) { std::printf(" [event] REMOVED %-22s -> redirecting old callers to the Host's stub\n", symbolName); return reinterpret_cast(&hostSideDeletedFunctionStub); } + /// @param kind The event kind to name. + /// @return A short, human-readable label for @p kind. const char* eventKindName(HotReloadSymbolEvent kind) { switch (kind) @@ -64,6 +73,8 @@ namespace 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*/) { // 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) { std::string pluginV1Path = argc > 1 ? argv[1] : PLUGIN_V1_PATH; diff --git a/source/examples/HotReloadDemo/PluginV1/src/CppWidget.cpp b/source/examples/HotReloadDemo/PluginV1/src/CppWidget.cpp index 1aef3f6..546f29e 100644 --- a/source/examples/HotReloadDemo/PluginV1/src/CppWidget.cpp +++ b/source/examples/HotReloadDemo/PluginV1/src/CppWidget.cpp @@ -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 - * 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" diff --git a/source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp b/source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp index 1f29ab7..2d1ed10 100644 --- a/source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp +++ b/source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp @@ -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 * (minus plugin_legacy_feature, removed in V2) so HotReloadContext can * 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" @@ -22,9 +25,8 @@ int plugin_legacy_feature(int input) } #if defined(__GNUC__) || defined(__clang__) -int plugin_tiny_function(int input) +void plugin_tiny_function(void) { - return input + 1; } #endif diff --git a/source/examples/HotReloadDemo/PluginV2/src/CppWidget.cpp b/source/examples/HotReloadDemo/PluginV2/src/CppWidget.cpp index 2a95e59..c06fe06 100644 --- a/source/examples/HotReloadDemo/PluginV2/src/CppWidget.cpp +++ b/source/examples/HotReloadDemo/PluginV2/src/CppWidget.cpp @@ -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" diff --git a/source/examples/HotReloadDemo/PluginV2/src/Plugin.cpp b/source/examples/HotReloadDemo/PluginV2/src/Plugin.cpp index 035639d..eef78a3 100644 --- a/source/examples/HotReloadDemo/PluginV2/src/Plugin.cpp +++ b/source/examples/HotReloadDemo/PluginV2/src/Plugin.cpp @@ -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 * deleted, and plugin_new_feature was added -- exactly the three kinds * of change hot_reload.h's HotReloadSymbolEvent enum distinguishes. + * Every function defined here is documented in ../Shared/PluginApi.h. */ #include "PluginApi.h" @@ -24,9 +27,8 @@ int plugin_new_feature(int input) } #if defined(__GNUC__) || defined(__clang__) -int plugin_tiny_function(int input) +void plugin_tiny_function(void) { - return input + 2; } #endif diff --git a/source/examples/HotReloadDemo/Shared/CppWidgetApi.h b/source/examples/HotReloadDemo/Shared/CppWidgetApi.h index b2acdd5..17cb781 100644 --- a/source/examples/HotReloadDemo/Shared/CppWidgetApi.h +++ b/source/examples/HotReloadDemo/Shared/CppWidgetApi.h @@ -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 * function below keeps its real, compiler-mangled name; nothing here * needs special handling by source/HotReload, which classifies * symbols by their type and section, never by what their name looks - * like -- see hot_reload.h's "REQUIREMENTS ON A HOT-RELOADABLE - * MODULE" and README.md's "Hot reloading C++ symbols" for the full - * picture. + * like -- see the "Requirements on a hot-reloadable module" section of + * hot_reload.h and the "Hot reloading C++ symbols" section of + * source/HotReload/README.md for the full picture. * * The two methods below are reached two different ways from * Host/Main.cpp, deliberately: see that file's comments for why. */ #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 { public: + /** + * @brief Virtual, so that destroying a widget through a base pointer works across the + * boundary. + */ 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 * manual symbol lookup at all -- the call compiles down to an * indirect call through the object's vtable slot, not a direct * 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); - /* 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 * symbol, which would need the Host to link against a plugin * build at build time -- exactly what hot-reloading a plugin * means avoiding. So Host/Main.cpp instead resolves it manually * 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); - /* 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() - * above. */ + * above. + * + * @param other Ignored; the operator only prints which build it is. + * @return A new, empty widget. + */ 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 * 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(); diff --git a/source/examples/HotReloadDemo/Shared/PluginApi.h b/source/examples/HotReloadDemo/Shared/PluginApi.h index 84e1cb9..f4af863 100644 --- a/source/examples/HotReloadDemo/Shared/PluginApi.h +++ b/source/examples/HotReloadDemo/Shared/PluginApi.h @@ -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 * cpp-style-guide.md section 11's rule for anything loaded at runtime: * plain C linkage, no classes or exceptions crossing the boundary. @@ -15,31 +17,64 @@ extern "C" { #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); - /* Only in V1, 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. */ + /** + * @brief A feature that exists only in V1. + * + * 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); - /* 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); #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 - * overridden down to zero) -- watch this one get - * HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL rather than corrupt whatever - * comes after it. MSVC has no equivalent per-function override - * this demo could apply, so this one only exists on GCC/Clang. + /** + * @brief An empty function, too small to hold the jump this library writes. + * + * Its patchable-function-entry padding is overridden down to zero, so + * all that is left is the compiled empty body -- a handful of bytes, + * 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 - /* 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 * V1 and V2 just like plugin_tick's behavior does -- watch this * one get HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE. This is the diff --git a/source/examples/HotReloadLiveDemo/Host/Main.cpp b/source/examples/HotReloadLiveDemo/Host/Main.cpp index 9a06cfe..cb3a787 100644 --- a/source/examples/HotReloadLiveDemo/Host/Main.cpp +++ b/source/examples/HotReloadLiveDemo/Host/Main.cpp @@ -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 * loads Plugin/src/LivePlugin.cpp's compiled output once, then polls * that file's last-write time every second: whenever it changes (i.e. @@ -18,13 +20,18 @@ namespace { + /// Cleared by handleSigint(); the main loop polls this to exit cleanly on Ctrl+C. std::atomic 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*/) { g_running = false; } + /// @param kind The event kind to name. + /// @return A short, human-readable label for @p kind. const char* eventKindName(HotReloadSymbolEvent kind) { switch (kind) @@ -45,6 +52,8 @@ namespace 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*/) { if (event->kind == HOT_RELOAD_SYMBOL_REMOVED) @@ -54,6 +63,9 @@ namespace 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) { std::printf( @@ -63,6 +75,8 @@ namespace 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*/) { 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) { // Force line buffering regardless of whether stdout is a real diff --git a/source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp b/source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp index ae4288a..2f88683 100644 --- a/source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp +++ b/source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp @@ -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 * terminal, rebuild just this file from another with: @@ -42,13 +44,19 @@ #include "hot_reload/hot_reload.h" #include -#include +/** + * @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) { int multiplier = 2; // <- try changing this int value = frame * multiplier; - std::printf(" live_tick (%d) -> %d\n", frame, value); - std::cout.flush(); + std::printf(" live_tick(%d) -> %d\n", frame, value); return value; } diff --git a/source/tests/HotReloadTests/Main.cpp b/source/tests/HotReloadTests/Main.cpp index 132b833..0d15c64 100644 --- a/source/tests/HotReloadTests/Main.cpp +++ b/source/tests/HotReloadTests/Main.cpp @@ -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 * ../../examples/HotReloadDemo/Shared/PluginApi.h and CppWidgetApi.h * for what each exported name is there to prove. Deliberately does @@ -22,8 +24,14 @@ namespace { + /// Running count of failed CHECK() calls; main() returns non-zero when this is non-zero. 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) { 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__) + /// The signature every plain-C export in PluginApi.h shares. using PluginFunction = int (*)(int); + /// Every symbol event seen so far in the current reload, keyed by symbol name. std::map 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*/) { 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() { HotReloadOptions options{}; diff --git a/source/tests/UndefinedSymbolFixture/src/Fixture.cpp b/source/tests/UndefinedSymbolFixture/src/Fixture.cpp index 058b98d..c2a5a28 100644 --- a/source/tests/UndefinedSymbolFixture/src/Fixture.cpp +++ b/source/tests/UndefinedSymbolFixture/src/Fixture.cpp @@ -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 * definition. This compiles cleanly and links cleanly into a shared * library -- a shared library is allowed to keep undefined symbols by * default -- and only fails once something actually calls * trigger_undefined_symbol() below, ODR-using counter for the first - * time. See source/HotReload/README.md's "hot_reload_last_error()" - * section for the full story; HotReloadTests/Main.cpp loads this - * fixture specifically to confirm hot_reload_last_error() reports - * something useful for exactly this case, rather than leaving it a - * silent "load failed" black box. + * time. See the "Diagnosing a failed load" section of + * source/HotReload/README.md for the full story; HotReloadTests/Main.cpp + * loads this fixture specifically to confirm hot_reload_last_error() + * reports something useful for exactly this case, rather than leaving it + * a silent "load failed" black box. */ + +/// A class with one static member that is deliberately never defined -- see this file's comment. class HasUndefinedStaticMember { 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() { return counter; } 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() { return HasUndefinedStaticMember::useIt();