Add project files.

This commit is contained in:
alejo committed 2026-09-12 00:08:03 -03:00
commit 3087fcea8a
71 files changed
+8325

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# The hot-reload runtime is infrastructure the host application links
# against directly. Unlike the plugins it loads at runtime (see
# source/examples/HotReloadDemo), this library is never itself meant
# to be hot-reloaded, so TYPE STATIC is a deliberate choice here,
# independent of the project-wide BUILD_SHARED_LIBS default.
add_lib_module(hotreload_lib TYPE STATIC OUTPUT_NAME "hotreload")
if(NOT WIN32)
# dlopen/dlclose/dlinfo (ModuleHandle.cpp). PUBLIC, not PRIVATE:
# for a STATIC library this needs to reach whatever executable
# ultimately links hotreload_lib, not just hotreload_lib's own
# (nonexistent, for a static lib) link step.
target_link_libraries(hotreload_lib PUBLIC ${CMAKE_DL_LIBS})
endif()
+396
View File
@@ -0,0 +1,396 @@
# Hot Reload
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.
If you just want to see it run:
```sh
cmake -S . -B out -DBUILD_EXAMPLES=ON
cmake --build out
./out/bin/examples/hotreload_demo_host
```
Then open `source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp` and
`PluginV2/src/Plugin.cpp` side by side against the output -- they're
written to stand in for "the file before your edit" and "after". The
same two directories' `CppWidget.cpp` do the same for a real C++
class with no `extern "C"` anywhere -- see "Hot reloading C++ symbols"
below.
Both of those exist to prove things deterministically, though -- for
actually playing with this yourself, see
`source/examples/HotReloadLiveDemo/README.md` instead: a host that
stays running, and one plugin file you edit and rebuild as many times
as you want while it's live.
## How it works, in one paragraph
`hot_reload_load()` loads a shared library and reads its exported
symbol table. `hot_reload_reload()` loads a *new* build of that same
library, reads its symbol table too, and for every name both builds
export as a function, overwrites the first few bytes of the *old*
function with an unconditional jump to the *new* one's address. Every
existing call site -- direct calls, calls through the PLT/GOT, raw
function pointers already sitting in some struct -- reaches the old
address and immediately redirects, because that's now all that's
there. Full byte-level detail, verified against a real assembler
rather than transcribed from memory, is in
`src/Architecture.cpp`'s file comment.
## The two guarantees this library asked to make
**"Only executable code changes, never data."** This isn't a naming
convention you have to follow -- it's mechanical. `src/SymbolTable.cpp`
classifies every exported symbol using the loader's own metadata (an
ELF symbol's type + its section's executable flag; a PE export's
containing section's characteristics), and `HotReloadContext` refuses
to patch anything not classified as a function, on *either* side of a
reload. `source/examples/HotReloadDemo` proves this concretely:
`plugin_build_marker` is an exported `int` sitting right next to real
functions, changes value between V1 and V2 exactly like a function's
behavior would, and the demo reads its original address again *after*
reloading to show it's untouched.
**"Handle deletion gracefully."** A function missing from the new
build is reported through `HotReloadOptions::on_symbol_event`
(`HOT_RELOAD_SYMBOL_REMOVED`) and, by default, its old implementation
is simply left running -- nothing is ever patched to a null or
garbage address. If you want stale calls to be loud instead,
`on_deleted_symbol` lets you redirect it to a stub of your own. Both
policies are exercised in this repo:
`source/examples/HotReloadDemo/Host` supplies a stub;
`source/tests/HotReloadTests` covers the default.
Neither guarantee depends on anything the hot-reloadable module's
author has to remember to do correctly -- see "What this cannot
detect" below for the one important thing that's true in reverse.
## Hot reloading C++ symbols (classes, virtual methods, operators)
Everything above works for a real, unmodified C++ mangled name with
**no changes to this library at all** -- classification only ever
looks at a symbol's type and section (see `src/SymbolTable.cpp`),
never at what its name looks like, so a class's ordinary method, a
virtual method, and an overloaded operator are, mechanically, no
different from an `extern "C"` function. This isn't a theoretical
claim: `source/examples/HotReloadDemo/Shared/CppWidgetApi.h` declares
a class with all three, exported with no `extern "C"` anywhere, living
in the *same* plugin build as the plain-C demo, and
`source/examples/HotReloadDemo/Host/Main.cpp` hot-reloads it -- run
the demo (see the top of this file) and watch the `CppWidget::` lines
in the output.
**Virtual methods work automatically, including through a real
vtable.** A virtual call is dispatched by loading a function pointer
out of the object's vtable and calling through it -- and that pointer
holds exactly the same address a direct call would, since the plugin's
own code populated the vtable with it. Patch that address, and *every*
path that reaches it redirects: a raw function pointer, a direct call,
and a vtable slot are all just different ways of holding the same
address. The demo proves this directly: it calls `widget->virtualTick(x)`
through ordinary polymorphic dispatch, once before `hot_reload_reload()`
and once after, on the *same* pre-existing object, and the second call
runs the new build's code.
That said, a vtable is data, and changing its *layout* -- adding,
removing, or reordering virtual functions, or changing base classes --
is exactly the data-layout hazard "What this cannot detect" describes
below, just in a C++-specific shape: a pre-existing object's vptr
still points at the *old* build's vtable, with the old slot count and
order, so new code that assumes the new layout while running against
an old object's vtable will read the wrong slot. A newly-constructed
object (created after the reload) gets the new vtable and is fine.
This library does not check for this -- it isn't something a symbol
table carries any information about -- so keep a hot-reloadable
class's virtual function set unchanged across a reload, the same
discipline as keeping a free function's signature unchanged.
**Operators are just functions with unusual mangled names** and need
nothing special either -- `CppWidgetApi.h`'s `operator+` patches
exactly like `tick()` below.
**The one real requirement, restated for C++:** a function only exists
to be patched at all if the compiler emitted an actual out-of-line
body for it somewhere addressable. This was already true for
`extern "C"` functions (see "Using it in your own module" below), but
C++ makes it much easier to violate by accident:
- A method defined *inside* a class body (`struct S { int f() { ...
} };`) is implicitly `inline`, and a caller that includes the header
will very often get the body inlined directly into its own compiled
code, with no separate symbol left anywhere to patch. This is not
hypothetical -- built and inspected while writing this feature:
```cpp
struct InlineWidget { int tick(int x) { return x * 2; } };
int caller(int x) { InlineWidget w; return w.tick(x); }
```
compiled with optimization on, `nm -D` on the resulting library shows
**no `InlineWidget::tick` symbol at all** -- the compiler deleted the
separate function entirely, so there is nothing any binary-patching
tool, this one included, could ever redirect. `caller`'s own compiled
code has to be replaced by reloading `caller` itself.
- Templates are instantiated per translation unit and, unless
explicitly instantiated once elsewhere, typically get inlined at
each call site for the same reason.
- The fix, where it applies, is the same one C already needed: declare
in a header, define exactly once out-of-line in a `.cpp` compiled
into the reloadable module, and don't let a caller outside that
module see the definition (so the compiler never has the option to
inline it away). `CppWidgetApi.h`'s methods are declared in the
header and defined in `PluginV1/src/CppWidget.cpp` /
`PluginV2/src/CppWidget.cpp` for exactly this reason.
**Mangled names are only stable within one ABI family.** GCC and
Clang implement the same (Itanium) C++ ABI and mangle the demo's
`CppWidget` identically -- confirmed directly: the demo was built and
run with both compilers, and separately cross-compiled with MinGW and
run under Wine, all using the exact same hardcoded mangled-name string
in `Host/Main.cpp`. MSVC uses its own, unrelated scheme. This isn't a
new limitation this library introduces -- you were always going to
reload with the same toolchain you started a session with -- but it's
worth stating plainly for mangled names specifically, since unlike an
`extern "C"` name, a mangled one simply won't match at all across the
two families.
**Looking up a mangled name is genuinely more annoying than an
`extern "C"` one**, which is the practical reason to prefer the
latter for anything the *host* needs to call by a human-typed string.
There's no portable way to ask the compiler for another declaration's
mangled name at compile time; the demo gets it the same way you would
in practice, with `nm -D --defined-only <library> | c++filt`. Two ways
around this, both shown in the demo:
1. Keep one `extern "C"` factory/entry function (`make_cpp_widget()`
in the demo) and reach everything else through it -- a returned
object's *virtual* methods then need no lookup at all (see above).
2. For a method that isn't virtual, call `hot_reload_demangle()` on
whatever name you *do* have (e.g. for logging -- see
`HotReloadOptions::on_symbol_event`'s output in the demo, where
`_ZN9CppWidget4tickEi` prints as `CppWidget::tick(int)`), or accept
the manual `nm | c++filt` lookup as a one-time cost, same as the
demo does for `CppWidget::tick`.
`hot_reload_demangle()` renders a real mangled name for display (via
`__cxa_demangle` on GCC/Clang/MinGW, `UnDecorateSymbolName` on MSVC)
and returns anything else -- including a plain `extern "C"` name --
completely unchanged, so it's safe to call on every
`HotReloadSymbolEventInfo::name` unconditionally, as the demo does.
It never changes what a name means for lookup purposes; it exists
purely so a log is readable.
**One general C++-plugin caveat, not specific to hot-reloading:**
exceptions and RTTI (`dynamic_cast`, `typeid`) generally should not
cross the boundary into or out of a dynamically-loaded module at
all -- this is already true of any `dlopen()`/`LoadLibrary()`-based
plugin architecture, for the same reasons cpp-style-guide.md section
11 gives for the plain C boundary, and hot-reloading a module doesn't
change it either way. The demo's `delete widget` relies on `new` and
`delete` resolving to the same allocator across the boundary, which
holds on Linux/macOS as tested here; it's worth double-checking on
Windows if you link the CRT in an unusual way (e.g. mismatched
static/dynamic CRT between the host and a plugin).
## Scope: what's actually implemented and checked
| | x86 | x86-64 | ARM64 | ARM32 |
|---|---|---|---|---|
| Jump-patch backend | yes | yes | yes | not implemented |
| Landing pad handled | ENDBR32 | ENDBR64 | BTI | -- |
*"Any architecture"* was the ask; ARM32 is the one mainstream target
deliberately left out, because it's the one case this project's
testing couldn't back up with confidence -- see below.
**Compilers.** GCC and Clang share one backend (including MinGW,
which is one or the other targeting Windows); MSVC has its own
`/hotpatch`-based path in `cmake/HotReload.cmake` and its own PE
export-table reader in `SymbolTable.cpp`.
**What was actually run, not just compiled**, while building this:
- Native GCC and Clang on Linux/x86-64: the full demo, both compilers,
including a real ENDBR64 landing pad this toolchain turned out to
emit by default -- see `src/Architecture.cpp`'s file comment.
- Cross-compiled to ARM64 and run under `qemu-aarch64`: the full demo,
twice -- once plain, once with `-mbranch-protection=bti` -- to
exercise the BTI-landing-pad path on real (emulated) ARM64
semantics, not just have it compile.
- Cross-compiled with MinGW and run under Wine: the full demo,
exercising the real Win32 `LoadLibrary`/`VirtualProtect`/
`FlushInstructionCache` calls and the PE export-table parser. This
run is also *why* the demo plugins now set
`WINDOWS_EXPORT_ALL_SYMBOLS ON` -- the first attempt silently
exported nothing under an MSVC-style build until that was added.
Re-run after adding `CppWidgetApi.h`'s C++ class: confirmed the PE
reader also correctly classifies a vtable and typeinfo as data (not
code) on Windows, not just on ELF, and that GCC and Clang produce
identical mangled names for the same class (the demo's hardcoded
`_ZN9CppWidget4tickEi` lookup string was derived from a native GCC
build but also worked unmodified against this MinGW/Wine build and
the separate native Clang build).
- **MSVC itself was not run anywhere in this process** -- there is no
Windows machine or MSVC install available in the environment this
was built in. The MSVC-specific code (`/hotpatch`,
`IMAGE_EXPORT_DIRECTORY` parsing, `VirtualProtect`,
`FlushInstructionCache`) is written directly against Microsoft's
documented structures and behavior, and the equivalent PE-reading
code was compiled clean against real `<windows.h>` headers via
MinGW, but treat the MSVC path as unverified until you've built it
on an actual Windows box. If it doesn't work, `SymbolTable.cpp`'s
PE branch and `cmake/HotReload.cmake`'s `/hotpatch` branch are the
two places to look first.
- **32-bit ARM was not implemented at all**, deliberately: it's the
one architecture in this list where getting it wrong is easy and
getting it verified is hard. Its instructions are either 4 bytes
(ARM mode) or a 2/4-byte mix (Thumb mode), and a function pointer's
low bit is the only thing that says which -- get the interworking
wrong and you don't get a clean crash, you get a CPU that's decoding
the wrong instruction stream. Nothing in this environment could
exercise that on real or emulated ARM32 hardware, so rather than
ship an ARM32 backend on the strength of a manual reading of the
ISA reference alone, it's left out. Adding it is a matter of
implementing one more branch of `src/Architecture.cpp`'s interface;
test it for real before trusting it.
- **macOS (Mach-O) was not implemented.** Nothing in the request named
it, and it would need a third format reader alongside
`SymbolTable.cpp`'s ELF and PE branches. `readExportedSymbols()` is
the one function that would need it.
## What this cannot detect
**A changed function signature.** Patching redirects control flow at a
fixed address; it has no way to know from a symbol table alone that
`void tick(int)` became `void tick(int, int)`. If a function's
signature changes, give it a new exported name instead of reusing the
old one -- the old name is then correctly reported as
`HOT_RELOAD_SYMBOL_REMOVED` and the new one as `HOT_RELOAD_SYMBOL_ADDED`,
rather than being patched into a mismatched calling convention.
**A data layout change reached only through code that still gets
patched.** The library guarantees it never *touches* a data symbol
directly. It cannot guarantee that a patched function is safe to run
against a `struct`/class instance that already exists in memory with
the *old* layout -- e.g. a game object allocated before the reload,
if the new code adds a field to its type. This is a correctness
property of your own code across the reload boundary, not something a
symbol table (which carries no field-offset information) can check.
If you need this checked, the extension point is the same
place: `HotReloadContext::reload()` -- diffing the two builds' debug
info (DWARF or PDB) for layout changes in types touched by patched
functions is real work, genuinely useful, and out of scope for what's
here now.
**Being called concurrently with the code it's patching.** The
intended use is the same as every other tool in this space (Unreal's
Live Coding, Handmade Hero's hot reload, etc.): call
`hot_reload_reload()` from a quiescent point -- your main thread
between frames or ticks -- where nothing else is actively calling into
the module you're reloading. The write order in
`src/Architecture.cpp` (payload bytes before the opcode that arms
them, with a release fence between) protects the realistic case where
a call arrives right at that boundary. It does not protect a thread
that is genuinely, actively executing through the exact bytes being
patched at that moment -- doing that in general on a variable-length
ISA needs a signal-handler-based retry protocol, a substantially
bigger mechanism than a dev-loop tool like this one calls for. See
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.
- **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
"not enough room," never prove "enough" -- see `SymbolTable.cpp`'s
file comment. `/hotpatch` (applied automatically by
`hot_reload_enable_patchable_functions()`) is the real safety
mechanism on Windows; treat the size estimate as a bonus, not a
guarantee.
- **A forwarder PE export** (one that re-exports another DLL's
function) is classified as non-function and left alone, not
followed.
## Diagnosing a failed load
`HOT_RELOAD_ERROR_LOAD_FAILED` alone doesn't say *why* -- a missing
file, a permissions problem, a corrupt or wrong-architecture image,
and a genuinely undefined symbol all look the same from the status
code alone. Call `hot_reload_last_error(ctx)` right after a failing
`hot_reload_load()`/`hot_reload_reload()` for the platform's own
description (`dlerror()` on POSIX, a formatted `GetLastError()` on
Windows).
The specific case that motivated adding this, reproduced directly
while writing it: a **non-inline `static` class member that is
declared but never given an out-of-class definition**.
```cpp
class Randomizer
{
static std::mt19937& gen() { return m_gen; } // ODR-uses m_gen
static std::mt19937 m_gen; // declared here -- never defined anywhere
};
```
This compiles cleanly and links cleanly into a shared library --
unlike an executable, a shared library is allowed to keep undefined
symbols by default, on the assumption that whatever eventually loads
it will supply them. It only fails once `dlopen(..., RTLD_NOW)` (this
library always uses `RTLD_NOW`, deliberately -- see the comment in
`ModuleHandle.cpp` on why a clean, catchable failure right here beats
a deferred, uncatchable one under `RTLD_LAZY`) tries to eagerly
resolve every symbol and can't find this one anywhere in the process.
Confirmed directly: building the same class with the code path that
uses it left uncalled produces a `.so` with no trace of the missing
symbol at all (the compiler drops the unused, never-instantiated
inline method entirely); the moment something calls it,
`hot_reload_last_error()` reports exactly
`undefined symbol: _ZN10Randomizer4m_genE`. The fix is the one C++17
already offers: mark it `inline static` (with its initializer, if it
has one) instead of a bare `static` declaration, the same way you'd
fix this in any shared library, hot-reloaded or not.
## Using it in your own module
1. Every function you want reloadable needs C linkage --
`extern "C"` -- for the same reason any `dlopen()`'d interface
needs it (see `hot_reload.h`'s file comment and style guide §11).
2. Build it as its own `SHARED` library target and call
`hot_reload_enable_patchable_functions(<target>)`
(`cmake/HotReload.cmake`) on it, so every exported function has
guaranteed room for a jump. Without this, reload still works for
any function whose compiled body happens to be large enough on its
own -- functions that aren't get skipped
(`HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL`), not corrupted.
3. On Windows, also set `WINDOWS_EXPORT_ALL_SYMBOLS ON` on that
target, or add explicit `__declspec(dllexport)` to each function
yourself -- MSVC exports nothing by default. (Prefer explicit
per-symbol export in a real project, even though the demo uses the
blanket property to match this repo's existing `add_lib_module()`
convention: exporting everything means anything your build happens
to pull in -- including, as this project's own Windows testing
turned up, incidental runtime-library internals -- becomes part of
your reloadable surface too, redirected right along with the
functions you actually meant.)
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 calling into it (see "What this cannot detect"
above).
5. Rebuilding to the *same output path* every time is fine --
`HotReloadContext` stages every load through a fresh, uniquely-named
copy internally specifically so your build system's exact
write pattern can't cause a stale reload (a known gotcha for this
style of tool; see `HotReloadContext.cpp`'s
`stagePathForLoad()`).
@@ -0,0 +1,45 @@
---
# source/HotReload/include/hot_reload/.clang-tidy
#
# hot_reload.h itself is Part II (C API) style -- snake_case, per style
# guide §13. Copied from the repo-root c-api/.clang-tidy template (see
# that file's own header comment for how clang-tidy's directory-scoped
# config resolution makes this work); see also
# source/HotReload/src/c-api/.clang-tidy for this same override applied
# to the C API's .cpp implementation, one directory tree over.
InheritParentConfig: true
Checks: >
readability-identifier-naming,
bugprone-exception-escape
CheckOptions:
- key: readability-identifier-naming.FunctionCase
value: lower_case
- key: readability-identifier-naming.VariableCase
value: lower_case
- key: readability-identifier-naming.ParameterCase
value: lower_case
- key: readability-identifier-naming.LocalVariableCase
value: lower_case
- key: readability-identifier-naming.StructCase
value: CamelCase
- key: readability-identifier-naming.EnumCase
value: CamelCase
- key: readability-identifier-naming.EnumConstantCase
value: UPPER_CASE
- key: readability-identifier-naming.GlobalConstantCase
value: UPPER_CASE
- key: readability-identifier-naming.MacroDefinitionCase
value: UPPER_CASE
- key: readability-identifier-naming.GlobalVariableCase
value: lower_case
- key: readability-identifier-naming.GlobalVariablePrefix
value: g_
- key: readability-identifier-naming.StaticVariableCase
value: lower_case
- key: readability-identifier-naming.StaticVariablePrefix
value: s_
@@ -0,0 +1,292 @@
/* hot_reload.h
*
* 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.
*
* WHAT THIS LIBRARY DOES
* -----------------------
* hot_reload_load() loads a shared library (.so/.dll) under a logical
* name. hot_reload_reload() loads a NEW build of that same shared
* library and, for every function the two builds have in common,
* rewrites the FIRST FEW BYTES of the old function in memory into a
* jump to the new function -- so every existing call site (anyone who
* already resolved the old function's address, including via a plain
* C function pointer) transparently starts running the new code,
* without restarting the process.
*
* WHAT THIS LIBRARY DELIBERATELY DOES NOT DO
* --------------------------------------------
* 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.
*
* 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.
*
* 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
* 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().
*/
#ifndef HOT_RELOAD_H
#define HOT_RELOAD_H
#include <stddef.h>
#ifdef _WIN32
#ifdef HOT_RELOAD_BUILD_SHARED
#define HOT_RELOAD_API __declspec(dllexport)
#elif defined(HOT_RELOAD_USE_SHARED)
#define HOT_RELOAD_API __declspec(dllimport)
#else
#define HOT_RELOAD_API
#endif
#else
#if defined(HOT_RELOAD_BUILD_SHARED) || defined(HOT_RELOAD_USE_SHARED)
#define HOT_RELOAD_API __attribute__((visibility("default")))
#else
#define HOT_RELOAD_API
#endif
#endif
#ifdef __cplusplus
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. */
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"). */
typedef enum HotReloadStatus
{
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. */
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. */
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_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. */
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. */
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. */
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(). */
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
* 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. */
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. */
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. */
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. */
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). */
typedef struct HotReloadSymbolEventInfo
{
const char* name;
HotReloadSymbolEvent kind;
void* old_address;
void* new_address;
} HotReloadSymbolEventInfo;
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. */
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". */
typedef struct HotReloadOptions
{
HotReloadSymbolEventFn on_symbol_event; /* nullable */
HotReloadDeletedSymbolFn on_deleted_symbol; /* nullable */
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. */
HOT_RELOAD_API HotReloadContext* hot_reload_context_create(const HotReloadOptions* options);
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. */
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. */
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. */
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. */
HOT_RELOAD_API const char* hot_reload_last_error(HotReloadContext* ctx);
/* 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(). */
HOT_RELOAD_API char* hot_reload_demangle(const char* name);
HOT_RELOAD_API void hot_reload_free_string(char* str);
#ifdef __cplusplus
}
#endif
#endif /* HOT_RELOAD_H */
+228
View File
@@ -0,0 +1,228 @@
#include "Architecture.hpp"
#include <atomic>
#include <cstdint>
#include <cstring>
/* WHY THERE CAN BE A "LANDING PAD" IN FRONT OF A FUNCTION
* -----------------------------------------------------------
* Modern toolchains can emit a marker instruction as the very first
* instruction of a function, required by a CPU-level indirect-branch
* protection feature: x86 CET ("Control-flow Enforcement Technology",
* ENDBR64/ENDBR32) and ARM64 BTI ("Branch Target Identification").
* When the CPU enforces either, an indirect call/jump (which is
* exactly how a call through a function pointer, or a call across a
* shared-library boundary via the PLT/GOT, actually reaches a
* function) is only allowed to land on one of these marker
* instructions -- landing anywhere else raises a fault.
*
* This matters here because it is genuinely on by default in
* mainstream environments, not a rare opt-in: this exact toolchain
* (Ubuntu 24.04's system GCC 13, x86-64) emits ENDBR64 at every
* function's entry with no extra flags beyond -fpatchable-function-entry,
* confirmed empirically while building this library --
* `gcc -O2 -fpatchable-function-entry=16,0` alone was enough to produce
* an `endbr64` as the very first instruction, ahead of the requested
* NOP padding. Overwriting it unconditionally would silently work on
* a CPU/kernel combination that does not enforce IBT and silently
* crash on one that does -- exactly the kind of environment-dependent
* failure this library exists to avoid.
*
* So every backend below first checks the live bytes at the function's
* entry for its architecture's landing-pad encoding and, if present,
* treats it as a fixed, immovable prefix: the jump is written
* immediately after it rather than over it. Since a landing pad is
* defined to otherwise behave as a NOP, falling through it into our
* jump is always correct whether or not the CPU actually enforces the
* protection it marks.
*
* WRITE ORDERING (AND WHAT IT DOES / DOES NOT PROTECT AGAINST)
* -----------------------------------------------------------
* Every backend below writes the target-address bytes first and the
* leading opcode/instruction word that "arms" the jump last, with a
* release fence in between. This protects the realistic case this
* library is designed for: hot_reload_reload() runs at a quiescent
* point (e.g. your main thread between frames) where nothing is
* actively calling INTO the function while it is being patched, and
* this ordering guarantees that any call arriving right at that
* boundary sees either the fully-old or the fully-new jump, never a
* target address that is half-old-half-new. It does NOT make the
* write safe against a thread that is concurrently, actively
* 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.
*/
namespace hot_reload::detail
{
namespace
{
#if defined(__x86_64__) || defined(_M_X64)
constexpr std::size_t kLandingPadBytes = 4;
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.
constexpr std::size_t kJumpBytes = 14;
bool hasLandingPad(const unsigned char* entry)
{
return std::memcmp(entry, kEndbr64, kLandingPadBytes) == 0;
}
void writeJumpAt(unsigned char* site, const void* target)
{
std::uint64_t targetValue = 0;
std::memcpy(&targetValue, &target, sizeof(target));
// Payload first: the absolute address (site+6) and the
// constant zero rip-displacement (site+2) are only ever
// consumed as DATA once the opcode below is in place, so
// writing them early cannot be misinterpreted as a
// different, fresh instruction stream by anything that
// might fall through this region in its old, all-NOP state.
std::memcpy(site + 6, &targetValue, sizeof(targetValue));
std::memcpy(site + 2, "\x00\x00\x00\x00", 4);
std::atomic_thread_fence(std::memory_order_release);
// Arm last: until these 2 bytes land, site+0 still reads as
// whatever it was (a NOP, on first patch; a previous FF 25
// on a later reload -- either way harmless/inert).
unsigned char opcode[2] = {0xFF, 0x25};
std::memcpy(site, opcode, sizeof(opcode));
std::atomic_thread_fence(std::memory_order_release);
}
#elif defined(__i386__) || defined(_M_IX86)
constexpr std::size_t kLandingPadBytes = 4;
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.
constexpr std::size_t kJumpBytes = 5;
bool hasLandingPad(const unsigned char* entry)
{
return std::memcmp(entry, kEndbr32, kLandingPadBytes) == 0;
}
void writeJumpAt(unsigned char* site, const void* target)
{
std::int32_t rel32 = static_cast<std::int32_t>(
reinterpret_cast<std::intptr_t>(target) - reinterpret_cast<std::intptr_t>(site) - 5);
// Payload (the displacement) first, opcode byte last --
// same reasoning as the x86-64 backend above.
std::memcpy(site + 1, &rel32, sizeof(rel32));
std::atomic_thread_fence(std::memory_order_release);
site[0] = 0xE9;
std::atomic_thread_fence(std::memory_order_release);
}
#elif defined(__aarch64__) || defined(_M_ARM64)
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.
bool isBti(const unsigned char* entry)
{
return (entry[0] & 0x3Fu) == 0x1Fu && entry[1] == 0x24 && entry[2] == 0x03 && entry[3] == 0xD5;
}
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.
constexpr std::size_t kJumpBytes = 16;
constexpr unsigned char kLdrX17Pc8[4] = {0x51, 0x00, 0x00, 0x58};
constexpr unsigned char kBrX17[4] = {0x20, 0x02, 0x1F, 0xD6};
void writeJumpAt(unsigned char* site, const void* target)
{
std::uint64_t targetValue = 0;
std::memcpy(&targetValue, &target, sizeof(target));
// Payload (the literal address) first. ARM64 instructions
// are fixed-width and naturally aligned, so -- unlike the
// variable-length x86 case -- each 4-byte slot here is
// unambiguous: it is either the old NOP, the old
// instruction, or the new one, never a byte-misaligned
// mix. Writing high-to-low keeps the two real instructions
// (which is what any in-flight fall-through NOP execution
// could actually reach) inert for as long as possible.
std::memcpy(site + 8, &targetValue, sizeof(targetValue));
std::atomic_thread_fence(std::memory_order_release);
std::memcpy(site + 4, kBrX17, sizeof(kBrX17));
std::atomic_thread_fence(std::memory_order_release);
// Arm last: until this word lands, site+0 still reads as a
// NOP (or a previous ldr, on a later reload) and execution
// never reaches the br at site+4 via fall-through.
std::memcpy(site, kLdrX17Pc8, sizeof(kLdrX17Pc8));
std::atomic_thread_fence(std::memory_order_release);
}
#endif
}
bool isArchitectureSupported()
{
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
defined(_M_ARM64)
return true;
#else
return false;
#endif
}
PatchLayout planPatch(const void* entryAddress)
{
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
defined(_M_ARM64)
const unsigned char* entry = static_cast<const unsigned char*>(entryAddress);
PatchLayout layout;
layout.patchOffset = hasLandingPad(entry) ? kLandingPadBytes : 0;
layout.totalBytes = layout.patchOffset + kJumpBytes;
return layout;
#else
(void) entryAddress;
return PatchLayout{};
#endif
}
bool writeJump(void* entryAddress, const void* targetAddress)
{
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
defined(_M_ARM64)
PatchLayout layout = planPatch(entryAddress);
unsigned char* site = static_cast<unsigned char*>(entryAddress) + layout.patchOffset;
writeJumpAt(site, targetAddress);
return true;
#else
(void) entryAddress;
(void) targetAddress;
return false;
#endif
}
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <cstddef>
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.
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.
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.
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.
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).
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.
bool writeJump(void* entryAddress, const void* targetAddress);
}
+274
View File
@@ -0,0 +1,274 @@
#include "HotReloadContext.hpp"
#include "Architecture.hpp"
#include "MemoryProtection.hpp"
#include <cstdio>
#include <fstream>
#include <unordered_set>
namespace hot_reload
{
namespace
{
bool copyFile(const std::string& from, const std::string& to)
{
std::ifstream in(from, std::ios::binary);
if (!in)
{
return false;
}
std::ofstream out(to, std::ios::binary | std::ios::trunc);
if (!out)
{
return false;
}
out << in.rdbuf();
return static_cast<bool>(out);
}
}
HotReloadContext::HotReloadContext(const HotReloadOptions* options)
: m_onSymbolEvent(nullptr)
, m_onDeletedSymbol(nullptr)
, m_userData(nullptr)
{
if (options != nullptr)
{
m_onSymbolEvent = options->on_symbol_event;
m_onDeletedSymbol = options->on_deleted_symbol;
m_userData = options->user_data;
}
}
void HotReloadContext::emitEvent(HotReloadSymbolEvent kind, const std::string& name, void* oldAddress, void* newAddress)
{
if (m_onSymbolEvent == nullptr)
{
return;
}
HotReloadSymbolEventInfo info;
info.name = name.c_str();
info.kind = kind;
info.old_address = oldAddress;
info.new_address = newAddress;
m_onSymbolEvent(&info, m_userData);
}
std::string HotReloadContext::stagePathForLoad(
LoadedModule& module, const std::string& moduleName, const std::string& sourcePath)
{
// Every load (first and subsequent) goes through a freshly,
// uniquely-named copy rather than dlopen()/LoadLibrary()'ing
// sourcePath directly. Without this, a build that overwrites
// the SAME path in place for every rebuild (rather than the
// more common write-temp-then-rename pattern) can hit
// dlopen's device+inode-based caching and hand back the
// already-mapped OLD content instead of genuinely reloading --
// a well-known gotcha for this style of tool. Copying to a
// 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);
if (!copyFile(sourcePath, staged))
{
return {};
}
return staged;
}
HotReloadStatus HotReloadContext::load(const std::string& moduleName, const std::string& path)
{
m_lastError.clear();
if (m_modules.find(moduleName) != m_modules.end())
{
// Already loaded -- call reload() to load a new build of
// an existing name, rather than silently discarding
// whatever was already patched into the first one.
return HOT_RELOAD_ERROR_INVALID_ARGUMENT;
}
LoadedModule module;
std::string staged = stagePathForLoad(module, moduleName, path);
if (staged.empty())
{
m_lastError = "could not stage '" + path + "' for loading (source unreadable, or destination path unwritable)";
return HOT_RELOAD_ERROR_LOAD_FAILED;
}
detail::ModuleHandle handle = detail::ModuleHandle::load(staged);
if (!handle.isValid())
{
m_lastError = handle.errorMessage();
std::remove(staged.c_str());
return HOT_RELOAD_ERROR_LOAD_FAILED;
}
module.currentSymbols = detail::readExportedSymbols(staged, handle.loadedBase());
std::remove(staged.c_str()); // best-effort tidy-up; the mapping survives regardless (see stagePathForLoad)
module.versions.push_back(std::move(handle));
m_modules.emplace(moduleName, std::move(module));
return HOT_RELOAD_OK;
}
HotReloadStatus HotReloadContext::reload(const std::string& moduleName, const std::string& newPath)
{
m_lastError.clear();
auto it = m_modules.find(moduleName);
if (it == m_modules.end())
{
return HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION;
}
if (!detail::isArchitectureSupported())
{
return HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE;
}
LoadedModule& module = it->second;
std::string staged = stagePathForLoad(module, moduleName, newPath);
if (staged.empty())
{
m_lastError =
"could not stage '" + newPath + "' for loading (source unreadable, or destination path unwritable)";
return HOT_RELOAD_ERROR_LOAD_FAILED;
}
detail::ModuleHandle newHandle = detail::ModuleHandle::load(staged);
if (!newHandle.isValid())
{
m_lastError = newHandle.errorMessage();
std::remove(staged.c_str());
return HOT_RELOAD_ERROR_LOAD_FAILED; // nothing touched -- the previous version keeps running
}
std::vector<detail::ExportedSymbol> newSymbols = detail::readExportedSymbols(staged, newHandle.loadedBase());
std::remove(staged.c_str());
std::unordered_map<std::string, const detail::ExportedSymbol*> newByName;
newByName.reserve(newSymbols.size());
for (const detail::ExportedSymbol& symbol : newSymbols)
{
newByName[symbol.name] = &symbol;
}
std::unordered_set<std::string> matchedNewNames;
for (const detail::ExportedSymbol& oldSymbol : module.currentSymbols)
{
auto found = newByName.find(oldSymbol.name);
if (found == newByName.end())
{
handleRemoved(oldSymbol);
continue;
}
const detail::ExportedSymbol& newSymbol = *found->second;
matchedNewNames.insert(oldSymbol.name);
if (!oldSymbol.isFunction || !newSymbol.isFunction)
{
// Present on both sides, but classified as data (or
// something this library does not recognize as code)
// on at least one side -- the mechanical half of the
// "only executable code changes" guarantee. Never
// touched, in either direction.
emitEvent(HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE, oldSymbol.name, oldSymbol.address, newSymbol.address);
continue;
}
patchOne(oldSymbol, newSymbol);
}
for (const detail::ExportedSymbol& newSymbol : newSymbols)
{
if (matchedNewNames.find(newSymbol.name) == matchedNewNames.end())
{
emitEvent(HOT_RELOAD_SYMBOL_ADDED, newSymbol.name, nullptr, newSymbol.address);
}
}
module.currentSymbols = std::move(newSymbols);
module.versions.push_back(std::move(newHandle));
return HOT_RELOAD_OK;
}
void* HotReloadContext::getSymbol(const std::string& moduleName, const std::string& symbolName) const
{
auto it = m_modules.find(moduleName);
if (it == m_modules.end())
{
return nullptr;
}
for (const detail::ExportedSymbol& symbol : it->second.currentSymbols)
{
if (symbol.name == symbolName)
{
return symbol.address;
}
}
return nullptr;
}
void HotReloadContext::patchOne(const detail::ExportedSymbol& oldSymbol, const detail::ExportedSymbol& newSymbol)
{
detail::PatchLayout layout = detail::planPatch(oldSymbol.address);
if (oldSymbol.size != 0 && oldSymbol.size < layout.totalBytes)
{
emitEvent(HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL, oldSymbol.name, oldSymbol.address, newSymbol.address);
return;
}
detail::ScopedCodeWriteAccess writable(oldSymbol.address, layout.totalBytes);
if (!writable.isValid())
{
emitEvent(HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE, oldSymbol.name, oldSymbol.address, newSymbol.address);
return;
}
detail::writeJump(oldSymbol.address, newSymbol.address);
writable.finishWrite();
emitEvent(HOT_RELOAD_SYMBOL_PATCHED, oldSymbol.name, oldSymbol.address, newSymbol.address);
}
void HotReloadContext::handleRemoved(const detail::ExportedSymbol& oldSymbol)
{
// Ask for a stub at most once, and only for an old symbol that
// was itself real code -- there is no sensible "redirect" for
// a removed data symbol. A returned NULL (including because no
// callback was supplied at all) means the default: leave the
// old implementation running untouched.
void* stub = nullptr;
if (oldSymbol.isFunction && m_onDeletedSymbol != nullptr)
{
stub = m_onDeletedSymbol(oldSymbol.name.c_str(), m_userData);
}
if (stub != nullptr)
{
detail::PatchLayout layout = detail::planPatch(oldSymbol.address);
bool sizeOk = oldSymbol.size == 0 || oldSymbol.size >= layout.totalBytes;
if (sizeOk)
{
detail::ScopedCodeWriteAccess writable(oldSymbol.address, layout.totalBytes);
if (writable.isValid())
{
detail::writeJump(oldSymbol.address, stub);
writable.finishWrite();
}
// If the OS refused write access, or the function was
// too small to safely hold the stub redirect, this
// silently falls back to leaving the old implementation
// running -- the same safe default as not supplying a
// stub at all, rather than adding a second kind of
// event on top of HOT_RELOAD_SYMBOL_REMOVED below for
// what is already a rare, best-effort extra.
}
}
emitEvent(HOT_RELOAD_SYMBOL_REMOVED, oldSymbol.name, oldSymbol.address, nullptr);
}
}
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include "ModuleHandle.hpp"
#include "SymbolTable.hpp"
#include "hot_reload/hot_reload.h"
#include <string>
#include <unordered_map>
#include <vector>
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.
class HotReloadContext
{
public:
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.
HotReloadStatus load(const std::string& moduleName, const std::string& path);
/// See hot_reload_reload()'s 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.
void* getSymbol(const std::string& moduleName, const std::string& symbolName) const;
/// See hot_reload_last_error()'s doc comment in hot_reload.h.
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.
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.
std::vector<detail::ModuleHandle> versions;
std::vector<detail::ExportedSymbol> currentSymbols;
int reloadCounter = 0;
};
std::string stagePathForLoad(LoadedModule& module, const std::string& moduleName, const std::string& sourcePath);
void patchOne(const detail::ExportedSymbol& oldSymbol, const detail::ExportedSymbol& newSymbol);
void handleRemoved(const detail::ExportedSymbol& oldSymbol);
void emitEvent(HotReloadSymbolEvent kind, const std::string& name, void* oldAddress, void* newAddress);
std::unordered_map<std::string, LoadedModule> m_modules;
HotReloadSymbolEventFn m_onSymbolEvent;
HotReloadDeletedSymbolFn m_onDeletedSymbol;
void* m_userData;
std::string m_lastError;
};
}
+92
View File
@@ -0,0 +1,92 @@
#include "MemoryProtection.hpp"
#include <cstdint>
#if defined(_WIN32)
#include <windows.h>
#else
#include <sys/mman.h>
#include <unistd.h>
#endif
namespace hot_reload::detail
{
namespace
{
std::size_t systemPageSize()
{
#if defined(_WIN32)
SYSTEM_INFO info;
GetSystemInfo(&info);
return static_cast<std::size_t>(info.dwPageSize);
#else
long size = sysconf(_SC_PAGESIZE);
return size > 0 ? static_cast<std::size_t>(size) : 4096;
#endif
}
}
ScopedCodeWriteAccess::ScopedCodeWriteAccess(void* address, std::size_t length)
: m_pageAddress(nullptr)
, m_pageLength(0)
, m_rangeAddress(address)
, m_rangeLength(length)
, m_previousProtection(0)
, m_valid(false)
{
const std::size_t page = systemPageSize();
const std::uintptr_t start = reinterpret_cast<std::uintptr_t>(address);
const std::uintptr_t alignedStart = start - (start % page);
const std::size_t frontSlack = static_cast<std::size_t>(start - alignedStart);
const std::size_t alignedLength = ((frontSlack + length + page - 1) / page) * page;
m_pageAddress = reinterpret_cast<void*>(alignedStart);
m_pageLength = alignedLength;
#if defined(_WIN32)
DWORD oldProtect = 0;
if (VirtualProtect(m_pageAddress, m_pageLength, PAGE_EXECUTE_READWRITE, &oldProtect))
{
m_previousProtection = oldProtect;
m_valid = true;
}
#else
if (mprotect(m_pageAddress, m_pageLength, PROT_READ | PROT_WRITE | PROT_EXEC) == 0)
{
m_previousProtection = PROT_READ | PROT_EXEC;
m_valid = true;
}
#endif
}
ScopedCodeWriteAccess::~ScopedCodeWriteAccess()
{
if (!m_valid)
{
return;
}
#if defined(_WIN32)
DWORD ignored = 0;
VirtualProtect(m_pageAddress, m_pageLength, static_cast<DWORD>(m_previousProtection), &ignored);
#else
mprotect(m_pageAddress, m_pageLength, static_cast<int>(m_previousProtection));
#endif
}
void ScopedCodeWriteAccess::finishWrite()
{
if (!m_valid)
{
return;
}
#if defined(_WIN32)
FlushInstructionCache(GetCurrentProcess(), m_rangeAddress, m_rangeLength);
#else
char* begin = static_cast<char*>(m_rangeAddress);
__builtin___clear_cache(begin, begin + m_rangeLength);
#endif
}
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstddef>
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.
class ScopedCodeWriteAccess
{
public:
ScopedCodeWriteAccess(void* address, std::size_t length);
~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).
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).
void finishWrite();
private:
void* m_pageAddress;
std::size_t m_pageLength;
void* m_rangeAddress;
std::size_t m_rangeLength;
unsigned long m_previousProtection;
bool m_valid;
};
}
+155
View File
@@ -0,0 +1,155 @@
#include "ModuleHandle.hpp"
#include <string>
#if defined(_WIN32)
#include <windows.h>
#else
#include <dlfcn.h>
#include <link.h>
#endif
namespace hot_reload::detail
{
#if defined(_WIN32)
namespace
{
/// 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.
std::string formatLastWindowsError()
{
DWORD errorCode = GetLastError();
LPSTR buffer = nullptr;
DWORD length = FormatMessageA(
FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr,
errorCode, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), reinterpret_cast<LPSTR>(&buffer), 0, nullptr);
std::string message;
if (length > 0 && buffer != nullptr)
{
message.assign(buffer, length);
while (!message.empty() && (message.back() == '\n' || message.back() == '\r'))
{
message.pop_back();
}
}
if (buffer != nullptr)
{
LocalFree(buffer);
}
if (message.empty())
{
message = "LoadLibrary failed (error code " + std::to_string(errorCode) + ")";
}
return message;
}
}
#endif
ModuleHandle::~ModuleHandle()
{
if (m_native != nullptr)
{
#if defined(_WIN32)
FreeLibrary(static_cast<HMODULE>(m_native));
#else
dlclose(m_native);
#endif
}
}
ModuleHandle::ModuleHandle(ModuleHandle&& other) noexcept
: m_native(other.m_native)
, m_loadedBase(other.m_loadedBase)
{
other.m_native = nullptr;
other.m_loadedBase = nullptr;
}
ModuleHandle& ModuleHandle::operator=(ModuleHandle&& other) noexcept
{
if (this != &other)
{
if (m_native != nullptr)
{
#if defined(_WIN32)
FreeLibrary(static_cast<HMODULE>(m_native));
#else
dlclose(m_native);
#endif
}
m_native = other.m_native;
m_loadedBase = other.m_loadedBase;
other.m_native = nullptr;
other.m_loadedBase = nullptr;
}
return *this;
}
ModuleHandle ModuleHandle::load(const std::string& path)
{
ModuleHandle handle;
#if defined(_WIN32)
HMODULE module = LoadLibraryA(path.c_str());
if (module != nullptr)
{
handle.m_native = module;
handle.m_loadedBase = module; // the HMODULE already IS the mapped base on Windows
}
else
{
handle.m_errorMessage = formatLastWindowsError();
}
#else
// RTLD_LOCAL: keep each version's symbols out of the global
// symbol namespace. Hot reload deliberately resolves symbols
// itself (see SymbolTable.cpp) rather than through the dynamic
// linker's own global name lookup, and RTLD_GLOBAL would let
// an old and a new build of the same plugin -- which export
// the very same names on purpose -- collide there.
//
// RTLD_NOW (eager symbol resolution), not RTLD_LAZY: this
// means a plugin with a genuinely undefined symbol (e.g. a
// non-inline static class member declared but never defined,
// only ODR-used once some previously-dead code path becomes
// reachable -- a real case found while building this feature)
// fails right here, cleanly and reportably, INSTEAD of
// crashing the whole host process the moment that code path
// 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);
if (module == nullptr)
{
const char* error = dlerror();
handle.m_errorMessage = error != nullptr ? error : "dlopen failed with no further detail";
}
else
{
struct link_map* map = nullptr;
if (dlinfo(module, RTLD_DI_LINKMAP, &map) == 0 && map != nullptr)
{
handle.m_native = module;
handle.m_loadedBase = reinterpret_cast<void*>(map->l_addr);
}
else
{
// Captured before dlclose() below, which could
// otherwise overwrite dlerror()'s state first.
const char* error = dlerror();
handle.m_errorMessage =
error != nullptr ? error : "dlinfo(RTLD_DI_LINKMAP) failed with no further detail";
dlclose(module);
}
}
#endif
return handle;
}
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <string>
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).
class ModuleHandle
{
public:
ModuleHandle() = default;
~ModuleHandle();
ModuleHandle(const ModuleHandle&) = delete;
ModuleHandle& operator=(const ModuleHandle&) = delete;
ModuleHandle(ModuleHandle&& other) noexcept;
ModuleHandle& operator=(ModuleHandle&& other) noexcept;
/// Loads path. On failure the returned handle's isValid() is
/// false and loadedBase() is null.
static ModuleHandle load(const std::string& path);
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.
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.
const std::string& errorMessage() const
{
return m_errorMessage;
}
private:
void* m_native = nullptr;
void* m_loadedBase = nullptr;
std::string m_errorMessage;
};
}
+365
View File
@@ -0,0 +1,365 @@
#include "SymbolTable.hpp"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <map>
#include <vector>
/* WHY "IS THIS A FUNCTION" IS DECIDED HERE, MECHANICALLY
* ---------------------------------------------------------
* hot_reload.h promises that data -- globals, class instances, vtables,
* anything that isn't executable code -- is never patched. That
* guarantee is enforced in exactly one place: here. Every symbol this
* file reports carries a real classification pulled from the loader's
* own metadata (ELF's symbol type + section flags, or a PE export's
* containing section's characteristics), never inferred from the
* symbol's name or any convention the hot-reloadable module is
* expected to follow. HotReloadContext (the only caller) simply
* refuses to patch anything this file marks isFunction = false, on
* either side of a reload -- so nothing downstream has to be trusted
* to double-check that part.
*/
namespace hot_reload::detail
{
#if defined(_WIN32)
// ---------------------------------------------------------------
// PE / Windows: read straight out of the image already mapped at
// loadedBase. Unlike ELF (below), the OS loader has already fully
// relocated this image and handed us its exact base address, so
// there is no separate "load bias" to compute -- every RVA in the
// headers just gets added directly to loadedBase.
// ---------------------------------------------------------------
#include <windows.h>
namespace
{
struct SectionInfo
{
std::uint32_t rvaBegin;
std::uint32_t rvaEnd;
bool executable;
};
const char* rvaToPointer(std::uint8_t* base, std::uint32_t rva)
{
return reinterpret_cast<const char*>(base + rva);
}
}
std::vector<ExportedSymbol> readExportedSymbols(const std::string& /*imagePath*/, void* loadedBase)
{
std::vector<ExportedSymbol> result;
if (loadedBase == nullptr)
{
return result;
}
std::uint8_t* base = static_cast<std::uint8_t*>(loadedBase);
auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
if (dos->e_magic != IMAGE_DOS_SIGNATURE)
{
return result;
}
auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(base + dos->e_lfanew);
if (nt->Signature != IMAGE_NT_SIGNATURE)
{
return result;
}
// Section table for the executable-section check, following
// the same "code lives in a section marked executable" rule
// as the ELF path's SHF_EXECINSTR check below.
std::vector<SectionInfo> sections;
const auto* sectionHeaders = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i)
{
const IMAGE_SECTION_HEADER& section = sectionHeaders[i];
SectionInfo info;
info.rvaBegin = section.VirtualAddress;
info.rvaEnd = section.VirtualAddress + std::max(section.Misc.VirtualSize, section.SizeOfRawData);
info.executable = (section.Characteristics & IMAGE_SCN_MEM_EXECUTE) != 0;
sections.push_back(info);
}
auto sectionFor = [&sections](std::uint32_t rva) -> const SectionInfo*
{
for (const SectionInfo& section : sections)
{
if (rva >= section.rvaBegin && rva < section.rvaEnd)
{
return &section;
}
}
return nullptr;
};
if (nt->OptionalHeader.NumberOfRvaAndSizes <= IMAGE_DIRECTORY_ENTRY_EXPORT)
{
return result;
}
const IMAGE_DATA_DIRECTORY& exportDirEntry = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
if (exportDirEntry.VirtualAddress == 0 || exportDirEntry.Size == 0)
{
return result; // no exports at all
}
const auto* exportDir = reinterpret_cast<const IMAGE_EXPORT_DIRECTORY*>(base + exportDirEntry.VirtualAddress);
const auto* nameRvas = reinterpret_cast<const DWORD*>(base + exportDir->AddressOfNames);
const auto* nameOrdinals = reinterpret_cast<const WORD*>(base + exportDir->AddressOfNameOrdinals);
const auto* functionRvas = reinterpret_cast<const DWORD*>(base + exportDir->AddressOfFunctions);
const std::uint32_t exportDirBegin = exportDirEntry.VirtualAddress;
const std::uint32_t exportDirEnd = exportDirEntry.VirtualAddress + exportDirEntry.Size;
struct Candidate
{
std::string name;
std::uint32_t rva;
bool isFunction;
};
std::vector<Candidate> candidates;
for (DWORD i = 0; i < exportDir->NumberOfNames; ++i)
{
const char* name = rvaToPointer(base, nameRvas[i]);
WORD ordinal = nameOrdinals[i];
if (ordinal >= exportDir->NumberOfFunctions)
{
continue;
}
std::uint32_t functionRva = functionRvas[ordinal];
if (functionRva == 0)
{
continue;
}
// A forwarder export's "RVA" actually points at a string
// like "OtherDll.OtherFunction" living inside the export
// directory itself, not at real code -- never classified
// as a patchable function.
bool isForwarder = functionRva >= exportDirBegin && functionRva < exportDirEnd;
const SectionInfo* section = sectionFor(functionRva);
bool isFunction = !isForwarder && section != nullptr && section->executable;
candidates.push_back(Candidate{name, functionRva, isFunction});
}
// Best-effort size estimate: distance to the next function
// export in the same section. This can OVER-estimate (there
// may be non-exported code, or alignment padding, between two
// exported functions, so a large gap does not guarantee a
// 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.
std::vector<std::uint32_t> functionRvasSorted;
for (const Candidate& candidate : candidates)
{
if (candidate.isFunction)
{
functionRvasSorted.push_back(candidate.rva);
}
}
std::sort(functionRvasSorted.begin(), functionRvasSorted.end());
for (const Candidate& candidate : candidates)
{
ExportedSymbol symbol;
symbol.name = candidate.name;
symbol.address = base + candidate.rva;
symbol.isFunction = candidate.isFunction;
symbol.size = 0;
if (candidate.isFunction)
{
const SectionInfo* section = sectionFor(candidate.rva);
std::uint32_t upperBound = section != nullptr ? section->rvaEnd : candidate.rva;
auto it = std::upper_bound(functionRvasSorted.begin(), functionRvasSorted.end(), candidate.rva);
if (it != functionRvasSorted.end())
{
upperBound = std::min(upperBound, *it);
}
symbol.size = upperBound > candidate.rva ? (upperBound - candidate.rva) : 0;
}
result.push_back(std::move(symbol));
}
return result;
}
#else
// ---------------------------------------------------------------
// ELF / POSIX: read imagePath on disk rather than the mapped
// image. Reading from disk means walking the ordinary section
// header table (.dynsym / .dynstr, plus every section's sh_flags
// for the executable check) instead of re-deriving the same
// information from the .dynamic section's DT_* tags the way the
// dynamic linker itself does internally -- simpler to get right,
// at the cost of one known gap: a shared object with its section
// header table deliberately stripped (rare -- an explicit, extra
// step beyond an ordinary `strip`, essentially never done to a
// library you are actively iterating on) will read back as having
// no exported functions at all, handled the same as any other
// unparseable image: an empty result, never a crash.
//
// st_value in the file is relative to the module's own link-time
// base, so it is loadedBase (the actual runtime load address --
// the "load bias" ModuleHandle reads via dlinfo) that turns it
// into a real, callable address.
// ---------------------------------------------------------------
#include <elf.h>
namespace
{
std::vector<char> readWholeFile(const std::string& path)
{
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file)
{
return {};
}
std::streamsize length = file.tellg();
if (length <= 0)
{
return {};
}
file.seekg(0, std::ios::beg);
std::vector<char> buffer(static_cast<std::size_t>(length));
if (!file.read(buffer.data(), length))
{
return {};
}
return buffer;
}
/// 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 <typename Ehdr, typename Shdr, typename Sym, typename SymType, typename SymBind>
std::vector<ExportedSymbol> parseElf(
const std::vector<char>& file, void* loadedBase, SymType symType, SymBind symBind)
{
std::vector<ExportedSymbol> result;
if (file.size() < sizeof(Ehdr))
{
return result;
}
const auto* ehdr = reinterpret_cast<const Ehdr*>(file.data());
if (ehdr->e_shoff == 0 || ehdr->e_shnum == 0)
{
return result; // no section header table -- see file comment above
}
if (static_cast<std::uint64_t>(ehdr->e_shoff) + static_cast<std::uint64_t>(ehdr->e_shnum) * sizeof(Shdr) >
file.size())
{
return result; // truncated/corrupt file
}
const auto* sections = reinterpret_cast<const Shdr*>(file.data() + ehdr->e_shoff);
// Section header string table, to find sections by name.
if (ehdr->e_shstrndx >= ehdr->e_shnum)
{
return result;
}
const char* shstrtab = file.data() + sections[ehdr->e_shstrndx].sh_offset;
const Shdr* dynsymSection = nullptr;
const Shdr* dynstrSection = nullptr;
for (std::size_t i = 0; i < ehdr->e_shnum; ++i)
{
const char* name = shstrtab + sections[i].sh_name;
if (std::strcmp(name, ".dynsym") == 0)
{
dynsymSection = &sections[i];
}
else if (std::strcmp(name, ".dynstr") == 0)
{
dynstrSection = &sections[i];
}
}
if (dynsymSection == nullptr || dynstrSection == nullptr)
{
return result;
}
const char* dynstr = file.data() + dynstrSection->sh_offset;
std::size_t symbolCount = dynsymSection->sh_size / sizeof(Sym);
const auto* symbols = reinterpret_cast<const Sym*>(file.data() + dynsymSection->sh_offset);
for (std::size_t i = 0; i < symbolCount; ++i)
{
const Sym& sym = symbols[i];
if (sym.st_shndx == SHN_UNDEF || sym.st_name == 0)
{
continue; // imported/unresolved, not defined here
}
unsigned char bind = symBind(sym.st_info);
if (bind != STB_GLOBAL && bind != STB_WEAK)
{
continue; // not part of this module's visible surface
}
ExportedSymbol exported;
exported.name = dynstr + sym.st_name;
exported.address = static_cast<char*>(loadedBase) + sym.st_value;
exported.size = static_cast<std::size_t>(sym.st_size);
bool isFuncType = symType(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)
{
sectionExecutable = (sections[sym.st_shndx].sh_flags & SHF_EXECINSTR) != 0;
}
exported.isFunction = isFuncType && sectionExecutable;
result.push_back(std::move(exported));
}
return result;
}
}
std::vector<ExportedSymbol> readExportedSymbols(const std::string& imagePath, void* loadedBase)
{
std::vector<char> file = readWholeFile(imagePath);
if (file.size() < EI_NIDENT)
{
return {};
}
unsigned char elfClass = static_cast<unsigned char>(file[EI_CLASS]);
if (elfClass == ELFCLASS64)
{
return parseElf<Elf64_Ehdr, Elf64_Shdr, Elf64_Sym>(
file, loadedBase, [](unsigned char info) { return ELF64_ST_TYPE(info); },
[](unsigned char info) { return ELF64_ST_BIND(info); });
}
if (elfClass == ELFCLASS32)
{
return parseElf<Elf32_Ehdr, Elf32_Shdr, Elf32_Sym>(
file, loadedBase, [](unsigned char info) { return ELF32_ST_TYPE(info); },
[](unsigned char info) { return ELF32_ST_BIND(info); });
}
return {};
}
#endif
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
namespace hot_reload::detail
{
/// One exported symbol found in a loaded module, already classified.
struct ExportedSymbol
{
std::string name;
void* address = nullptr;
/// 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.
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).
std::vector<ExportedSymbol> readExportedSymbols(const std::string& imagePath, void* loadedBase);
}
+48
View File
@@ -0,0 +1,48 @@
---
# source/HotReload/src/c-api/.clang-tidy
#
# hot_reload.h's implementation (HotReloadApi.cpp) is Part II (C API)
# style -- snake_case, per style guide §13 -- even though it is a
# .cpp file living inside a C++ library. Copied from the repo-root
# c-api/.clang-tidy template (see that file's own header comment for
# the full explanation of how clang-tidy's directory-scoped config
# resolution makes this work); the sibling directory one level up
# (source/HotReload/src/) has no override of its own, so its Part I
# (C++) files keep inheriting the repo-root .clang-tidy's PascalCase/
# camelCase rules exactly as normal.
InheritParentConfig: true
Checks: >
readability-identifier-naming,
bugprone-exception-escape
CheckOptions:
- key: readability-identifier-naming.FunctionCase
value: lower_case
- key: readability-identifier-naming.VariableCase
value: lower_case
- key: readability-identifier-naming.ParameterCase
value: lower_case
- key: readability-identifier-naming.LocalVariableCase
value: lower_case
- key: readability-identifier-naming.StructCase
value: CamelCase
- key: readability-identifier-naming.EnumCase
value: CamelCase
- key: readability-identifier-naming.EnumConstantCase
value: UPPER_CASE
- key: readability-identifier-naming.GlobalConstantCase
value: UPPER_CASE
- key: readability-identifier-naming.MacroDefinitionCase
value: UPPER_CASE
- key: readability-identifier-naming.GlobalVariableCase
value: lower_case
- key: readability-identifier-naming.GlobalVariablePrefix
value: g_
- key: readability-identifier-naming.StaticVariableCase
value: lower_case
- key: readability-identifier-naming.StaticVariablePrefix
value: s_
+90
View File
@@ -0,0 +1,90 @@
/* Demangle.cpp -- hot_reload_demangle() / hot_reload_free_string().
*
* Pure string utility, no HotReloadContext involved, so it gets its
* own file alongside HotReloadApi.cpp rather than crowding that one's
* single responsibility (the opaque-pointer shim). Part II (C API)
* style, like everything else in this directory -- see .clang-tidy
* here and cpp-style-guide.md section 13.
*
* Every C++ symbol name in this library's events/lookups is always
* the REAL symbol-table name (whatever hot_reload_get_symbol() needs
* to find it again) -- this function is an opt-in convenience for
* DISPLAYING that name, never applied automatically, so there is
* never a mismatch between what an event reports and what you'd pass
* back in to look the symbol up.
*/
#include "hot_reload/hot_reload.h"
#include <cstring>
#include <string>
#if defined(__GNUC__) || defined(__clang__)
// Covers GCC and Clang on every OS this library targets, MinGW
// included: what selects the demangling scheme is the C++ ABI the
// 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.
#include <cxxabi.h>
#elif defined(_MSC_VER)
#include <dbghelp.h>
#include <windows.h>
#endif
extern "C"
{
char* hot_reload_demangle(const char* name)
{
if (name == nullptr)
{
return nullptr;
}
std::string text = name; // fallback: unchanged, for anything that isn't a mangled C++ name at all
try
{
#if defined(__GNUC__) || defined(__clang__)
int status = 0;
char* demangled = abi::__cxa_demangle(name, nullptr, nullptr, &status);
if (status == 0 && demangled != nullptr)
{
text = demangled;
}
std::free(demangled); // __cxa_demangle always malloc()s or leaves this null either way
#elif defined(_MSC_VER)
// UnDecorateSymbolName operates on the string alone (no
// SymInitialize/session needed, unlike most of
// DbgHelp) -- but DbgHelp as a whole is documented as not
// thread-safe, so serialize calls to this function
// yourself if you call it from more than one thread.
// Written against Microsoft's documented behavior; unlike
// most of this library, this specific function was not
// exercised on real Windows -- see README.md's toolchain
// verification notes.
char buffer[2048];
if (UnDecorateSymbolName(name, buffer, sizeof(buffer), UNDNAME_COMPLETE) != 0)
{
text = buffer;
}
#endif
char* result = new char[text.size() + 1];
std::memcpy(result, text.c_str(), text.size() + 1);
return result;
}
catch (...)
{
return nullptr;
}
}
void hot_reload_free_string(char* str)
{
delete[] str;
}
}
+130
View File
@@ -0,0 +1,130 @@
#include "hot_reload/hot_reload.h"
#include "../HotReloadContext.hpp"
namespace
{
/// The opaque ::HotReloadContext the public header declares is
/// never actually instantiated as itself -- every instance really
/// 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.
hot_reload::HotReloadContext* impl(::HotReloadContext* ctx)
{
return reinterpret_cast<hot_reload::HotReloadContext*>(ctx);
}
}
// Per cpp-style-guide.md section 11: a C++ exception must never cross
// an extern "C" boundary -- doing so is undefined behavior for any
// caller that is not itself compiled to expect one, which a plain C
// caller of this API never is. Every function here is a thin,
// non-throwing wrapper for exactly that reason: std::string/vector/
// unordered_map construction is the only thing in hot_reload::
// HotReloadContext that could throw at all (std::bad_alloc under
// memory pressure), so catching (...) and mapping it to this API's
// existing failure values is sufficient without needing per-exception-
// type handling.
extern "C"
{
HotReloadContext* hot_reload_context_create(const HotReloadOptions* options)
{
try
{
return reinterpret_cast<::HotReloadContext*>(new hot_reload::HotReloadContext(options));
}
catch (...)
{
return nullptr;
}
}
void hot_reload_context_destroy(HotReloadContext* ctx)
{
delete impl(ctx);
}
HotReloadStatus hot_reload_load(HotReloadContext* ctx, const char* module_name, const char* path)
{
if (ctx == nullptr || module_name == nullptr || path == nullptr)
{
return HOT_RELOAD_ERROR_INVALID_ARGUMENT;
}
try
{
return impl(ctx)->load(module_name, path);
}
catch (...)
{
return HOT_RELOAD_ERROR_LOAD_FAILED;
}
}
HotReloadStatus hot_reload_reload(HotReloadContext* ctx, const char* module_name, const char* new_path)
{
if (ctx == nullptr || module_name == nullptr || new_path == nullptr)
{
return HOT_RELOAD_ERROR_INVALID_ARGUMENT;
}
try
{
return impl(ctx)->reload(module_name, new_path);
}
catch (...)
{
return HOT_RELOAD_ERROR_LOAD_FAILED;
}
}
void* hot_reload_get_symbol(HotReloadContext* ctx, const char* module_name, const char* symbol_name)
{
if (ctx == nullptr || module_name == nullptr || symbol_name == nullptr)
{
return nullptr;
}
try
{
return impl(ctx)->getSymbol(module_name, symbol_name);
}
catch (...)
{
return nullptr;
}
}
const char* hot_reload_last_error(HotReloadContext* ctx)
{
if (ctx == nullptr)
{
return "";
}
try
{
return impl(ctx)->lastError().c_str();
}
catch (...)
{
return "";
}
}
const char* hot_reload_status_string(HotReloadStatus status)
{
switch (status)
{
case HOT_RELOAD_OK:
return "ok";
case HOT_RELOAD_ERROR_INVALID_ARGUMENT:
return "invalid argument";
case HOT_RELOAD_ERROR_LOAD_FAILED:
return "load failed";
case HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION:
return "no previous version loaded for this module name";
case HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE:
return "no patch backend for this CPU architecture";
}
return "unknown hot reload status";
}
}