Added ARM32 support
This commit is contained in:
1 parent
df792729ef
commit
1212cb9dbe
36 files changed
+2381
-783
No files matched your search
@@ -5,6 +5,16 @@
|
||||
# independent of the project-wide BUILD_SHARED_LIBS default.
|
||||
add_lib_module(hotreload_lib TYPE STATIC OUTPUT_NAME "hotreload")
|
||||
|
||||
# hot_reload::Context (Context.cpp) uses std::shared_mutex internally for its thread-safety
|
||||
# guarantee (see Context.hpp's own doc comment), and the MSVC branch of Demangle.cpp uses
|
||||
# std::mutex to serialize calls into DbgHelp. Both need pthread linked explicitly on Linux with
|
||||
# glibc older than 2.34 (newer glibc merged libpthread into libc itself, making this a no-op
|
||||
# there rather than a requirement) -- Threads::Threads is CMake's own portable way to ask for
|
||||
# whatever that platform actually needs. PUBLIC for the same reason as CMAKE_DL_LIBS below: for a
|
||||
# STATIC library this needs to reach whatever executable ultimately links hotreload_lib.
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(hotreload_lib PUBLIC Threads::Threads)
|
||||
|
||||
if(NOT WIN32)
|
||||
# dlopen/dlclose/dlinfo (ModuleHandle.cpp). PUBLIC, not PRIVATE:
|
||||
# for a STATIC library this needs to reach whatever executable
|
||||
@@ -13,14 +23,25 @@ if(NOT WIN32)
|
||||
target_link_libraries(hotreload_lib PUBLIC ${CMAKE_DL_LIBS})
|
||||
endif()
|
||||
|
||||
if(MSVC)
|
||||
# Two different questions, easy to conflate: "does this compiler take
|
||||
# MSVC-style command-line syntax" (the MSVC variable: real cl.exe and
|
||||
# clang-cl only) versus "does it produce MSVC-ABI output -- MSVC name
|
||||
# mangling, the Microsoft CRT, DbgHelp as its demangler" (also true of a
|
||||
# GNU-style clang++ pointed at the MSVC target, which is what a plain
|
||||
# Windows LLVM install gives you: no /W4-style flags, but still MSVC
|
||||
# mangling). This condition is the second question, and CMake derives
|
||||
# CMAKE_CXX_SIMULATE_ID from whether the compiler defines _MSC_VER --
|
||||
# the very macro Demangle.cpp's own #if keys on, so the two stay in
|
||||
# agreement by construction rather than by someone remembering to update
|
||||
# both. (cmake/BuildHelpers.cmake's /W4 and the root CMakeLists.txt's
|
||||
# /MP /EHsc are the first question, correctly keyed on MSVC alone.)
|
||||
if(MSVC OR CMAKE_CXX_SIMULATE_ID STREQUAL "MSVC")
|
||||
# UnDecorateSymbolName (Demangle.cpp) lives in dbghelp.lib, which --
|
||||
# unlike kernel32/user32 -- MSVC does not link by default. Without
|
||||
# this, the final link fails with an undefined symbol for it;
|
||||
# found via an actual Windows build of this library, not just
|
||||
# documentation. PUBLIC for the same reason as CMAKE_DL_LIBS above.
|
||||
# Guarded on MSVC (true for both real cl.exe and clang-cl, and
|
||||
# false for MinGW) rather than WIN32, since MinGW's build of
|
||||
# Guarded on the MSVC ABI rather than WIN32, since MinGW's build of
|
||||
# Demangle.cpp takes the __GNUC__/__cxa_demangle branch instead and
|
||||
# never needs dbghelp at all.
|
||||
target_link_libraries(hotreload_lib PUBLIC dbghelp)
|
||||
|
||||
+172
-49
@@ -8,11 +8,17 @@ reference; see this project's top-level `README.md` for a quicker
|
||||
overview, feature summary, and build/run instructions if you haven't
|
||||
already.
|
||||
|
||||
`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.
|
||||
`hot_reload::Context` (`include/hot_reload/Context.hpp`) is the primary,
|
||||
modern C++ API; `hot_reload.h` is a separate, independently stable plain
|
||||
C API built on top of it, for anything that specifically needs C
|
||||
linkage. Both are fully Doxygen-documented -- see the top-level README's
|
||||
"Documentation" section for how to build that locally, and its "Pick an
|
||||
API" section for which of the two fits what you're writing.
|
||||
`source/examples/HotReloadDemo` is a full worked example (through the C
|
||||
API; `source/tests/ContextTests/Main.cpp` is the equivalent through
|
||||
`Context` directly, concurrency stress test included); this file is the
|
||||
overview neither API's own doc comments have room for, especially the
|
||||
parts you should know are *not* guaranteed.
|
||||
|
||||
To see it run:
|
||||
|
||||
@@ -37,17 +43,20 @@ 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.
|
||||
`load()` loads a shared library and reads its exported symbol table.
|
||||
`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.
|
||||
(Written here as plain `load()`/`reload()` because the mechanism is the
|
||||
same regardless of which API calls it -- `hot_reload::Context::load()`
|
||||
directly, or `hot_reload_load()` through the C API, which calls that
|
||||
same method.)
|
||||
|
||||
## The two guarantees this library asked to make
|
||||
|
||||
@@ -55,7 +64,7 @@ rather than transcribed from memory, is in
|
||||
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
|
||||
containing section's characteristics), and `Context` 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
|
||||
@@ -77,6 +86,94 @@ 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.
|
||||
|
||||
## The C++ API: design and thread safety
|
||||
|
||||
`hot_reload::Context` (`include/hot_reload/Context.hpp`) is a thin
|
||||
public shell: one `std::unique_ptr<Impl>` member, with `Impl` defined
|
||||
entirely inside `Context.cpp` and never named in any header. Every
|
||||
other detail:: header this library has -- `ModuleHandle.hpp`,
|
||||
`SymbolTable.hpp`, the rest -- is included by `Context.cpp` alone,
|
||||
which is what lets `Context.hpp` stay free of all of them: a caller who
|
||||
only `#include`s that one header never sees any of this library's own
|
||||
internals, and a change to one of those internals never forces that
|
||||
caller to recompile. It also buys something PIMPL is well known for but
|
||||
worth saying plainly here: `Impl` holds a `std::shared_mutex`, which is
|
||||
neither copyable nor movable, yet `Context` itself is freely movable --
|
||||
moving one just moves the pointer -- confirmed directly in
|
||||
`source/tests/ContextTests/Main.cpp`'s own `runMoveSemantics()`. A
|
||||
moved-from `Context` is left in the same state a moved-from
|
||||
`std::unique_ptr` is: valid to destroy or move-assign to, nothing else.
|
||||
|
||||
**Thread safety is a property of `Impl`'s `std::shared_mutex`, nothing
|
||||
more exotic.** `load()` and `reload()` take it exclusively
|
||||
(`std::unique_lock`); `findSymbol()` takes it shared
|
||||
(`std::shared_lock`), so concurrent lookups run in parallel with each
|
||||
other and only block when a `load()`/`reload()` is actually in
|
||||
progress. This is a claim about *this object's own bookkeeping* --
|
||||
the `std::unordered_map` of loaded modules, specifically -- not about
|
||||
the bytes a reload patches: nothing can make it safe to rewrite a
|
||||
function's machine code while some other thread is, at that exact
|
||||
moment, executing inside it, on any architecture, by any tool. Reload
|
||||
from a quiescent point for the module involved, the same as a
|
||||
single-threaded caller would; see "What this cannot detect" below for
|
||||
the full reasoning. Verified, not just designed this way and assumed
|
||||
correct: `source/tests/ContextTests/Main.cpp`'s own
|
||||
`runConcurrencyStress()` runs several threads calling `findSymbol()`
|
||||
against others calling `reload()` -- some on the very same module name
|
||||
as each other, a genuine race for which build ends up active that the
|
||||
test makes no assertion about, only that nothing crashes or corrupts --
|
||||
plus several more threads each `load()`-ing their own distinct module
|
||||
name into the same shared `Context`, all run under ThreadSanitizer
|
||||
across five separate executions specifically to cover more than one
|
||||
possible thread interleaving. Zero races reported, every time; see the
|
||||
top-level README's "What's actually been tested" table.
|
||||
|
||||
**`Result` replaces the C API's split between a returned status and a
|
||||
separate, stateful `hot_reload_last_error()` call.** That split is
|
||||
awkward even single-threaded (an error you have to remember to go ask
|
||||
for, from a call that already finished) and genuinely ambiguous once
|
||||
more than one thread can be calling into the same context: whose
|
||||
"last" error is it? `Result::status()` and `Result::message()` are
|
||||
both bundled into the value `load()`/`reload()` themselves return, so
|
||||
there is no shared, stateful side-channel for two threads' calls to
|
||||
collide on in the first place -- not a mutex added around the old
|
||||
design, a different design that does not need one for this specific
|
||||
problem. The C API still offers `hot_reload_last_error()`, because
|
||||
that is part of its own, separately stable contract, and implements it
|
||||
correctly (each calling thread sees its own most recent error on a
|
||||
given context, never another thread's) with a `thread_local`
|
||||
`std::unordered_map` in `c-api/HotReloadApi.cpp`, keyed by a
|
||||
never-reused per-context ID rather than the context's own address --
|
||||
otherwise a destroyed context's memory being reused by an unrelated
|
||||
later one could, in a narrow window, read back a stale message that
|
||||
was never actually about it.
|
||||
|
||||
**The patch-writing mechanism in `Architecture.cpp` itself is
|
||||
unchanged.** Each backend's `writeJumpAt()` still uses a plain
|
||||
`memcpy()` plus `std::atomic_thread_fence()` for its write-ordering
|
||||
protocol (payload first, the instruction that actually arms the jump
|
||||
last), not `std::atomic_ref`. This was a deliberate choice, not an
|
||||
oversight: `std::atomic_ref` requires the referenced object to be
|
||||
suitably aligned for its type, which this code cannot promise in
|
||||
general -- an x86/x86-64 function's entry point, and the literal
|
||||
address embedded a few bytes into it, can legally sit at any byte
|
||||
offset at all, with no alignment guarantee whatsoever. Applying
|
||||
`atomic_ref` somewhere that precondition does not provably hold would
|
||||
be undefined behavior, which is strictly worse than the fence-based
|
||||
approach already here -- so rather than apply it selectively (correct
|
||||
on, say, ARM64's and ARM32's naturally-4-byte-aligned literals, wrong
|
||||
everywhere else) for an inconsistent, easy-to-misapply-later gain,
|
||||
every backend keeps the one mechanism that is actually guaranteed
|
||||
correct on all four.
|
||||
|
||||
**`hot_reload::demangle()`'s MSVC path gained a mutex.**
|
||||
`UnDecorateSymbolName`, and DbgHelp as a whole, is documented by
|
||||
Microsoft as unsafe to call from more than one thread at once --
|
||||
already true before this library had any concurrency story of its own,
|
||||
simply never written down. `Demangle.cpp` now serializes every MSVC-path
|
||||
call through one `std::mutex`; the GCC/Clang path (`abi::__cxa_demangle`)
|
||||
needs nothing equivalent, since it keeps no comparable shared state.
|
||||
|
||||
## Hot reloading C++ symbols (classes, virtual methods, operators)
|
||||
|
||||
Everything above works for a real, unmodified C++ mangled name with
|
||||
@@ -256,12 +353,13 @@ anything whose `hot_reload_demangle()`'d name starts with `"std::"`.
|
||||
|
||||
| | x86 | x86-64 | ARM64 | ARM32 |
|
||||
|---|---|---|---|---|
|
||||
| Jump-patch backend | yes | yes | yes | not implemented |
|
||||
| Jump-patch backend | yes | yes | yes | yes |
|
||||
| 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.
|
||||
ARM32's own row needs a caveat the table can't carry: "yes" means a function's
|
||||
*address* decides ARM vs. Thumb state, not a build-wide setting, and both are
|
||||
handled -- see the "What was actually run" bullet below for exactly how that
|
||||
was confirmed, not just reasoned through.
|
||||
|
||||
**Compilers.** GCC and Clang share one backend (including MinGW,
|
||||
which is one or the other targeting Windows); MSVC has its own
|
||||
@@ -276,6 +374,40 @@ export-table reader in `SymbolTable.cpp`.
|
||||
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 to ARM32 and run under `qemu-arm`: the full demo and
|
||||
test suite, across both of this architecture's incompatible
|
||||
instruction-decoding states (the default Thumb-2, and ARM state
|
||||
forced with `-marm`) and across `-O0` through `-O3`. This is the one
|
||||
architecture here whose jump-patch backend cannot pick a single
|
||||
instruction encoding at compile time the way the other three do --
|
||||
which of the two states a given function is in is a property of
|
||||
that function alone, read from the same bit the ARM EABI already
|
||||
marks every function symbol with (set for Thumb, clear for ARM; a
|
||||
`loadedBase + st_value` sum this library already computed the same
|
||||
generic way for every architecture, so nothing in `SymbolTable.cpp`
|
||||
needed to change for this). Thumb state carries a second wrinkle
|
||||
on top: its own literal-load jump instruction needs to sit on a
|
||||
4-byte boundary, which nothing guarantees for a Thumb function in
|
||||
general (only 2-byte alignment is). Rather than trust that reasoning
|
||||
alone, a plugin was built with a function whose compiled address was
|
||||
confirmed, via its own symbol table entry, to land 2 bytes off that
|
||||
boundary, then loaded, reloaded, and called through across the
|
||||
reload under `qemu-arm` -- proving the one realigning NOP this
|
||||
backend adds for exactly that case actually works, not just that it
|
||||
assembles. Building the test suite here also surfaced a real,
|
||||
architecture-specific gap in the suite itself: its "too small to
|
||||
patch" function, reliably minimal on every other architecture as a
|
||||
plain empty C++ body, picks up a big enough unoptimized
|
||||
frame-pointer prologue on this one at `-O0` to tie this backend's
|
||||
own jump size exactly -- closed with a third implementation of that
|
||||
one function, hand-written at file scope instead of compiler-
|
||||
generated, for this architecture specifically (see
|
||||
`source/examples/HotReloadDemo/Shared/PluginApi.h`'s doc comment on
|
||||
`plugin_tiny_function`).
|
||||
32-bit ARM running under MSVC was not attempted -- no Windows-on-ARM32
|
||||
environment was available to build or verify against, and it is a
|
||||
rare enough target in practice that, unlike x86/x86-64/ARM64, this
|
||||
library makes no claim about it either way.
|
||||
- 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
|
||||
@@ -313,20 +445,8 @@ export-table reader in `SymbolTable.cpp`.
|
||||
MinGW -- between the two, treat it as real-world-checked now, not
|
||||
merely reasoned through, though obviously with less first-hand
|
||||
confidence than everything that was directly, repeatedly run in
|
||||
this project's own development loop (native GCC/Clang, ARM64 under
|
||||
QEMU, MinGW under Wine).
|
||||
- **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.
|
||||
this project's own development loop (native GCC/Clang, ARM64 and
|
||||
ARM32 under QEMU, MinGW under Wine).
|
||||
- **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
|
||||
@@ -351,7 +471,7 @@ 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
|
||||
place: `Context::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.
|
||||
@@ -402,13 +522,16 @@ that file's comment for the full reasoning.
|
||||
|
||||
## 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).
|
||||
`Status::LoadFailed` (`HOT_RELOAD_ERROR_LOAD_FAILED` through the C API)
|
||||
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. `Result::message()`
|
||||
carries the platform's own description (`dlerror()` on POSIX, a
|
||||
formatted `GetLastError()` on Windows) directly -- or, through the C
|
||||
API, call `hot_reload_last_error(ctx)` right after the failing
|
||||
`hot_reload_load()`/`hot_reload_reload()` for the same thing (see "The
|
||||
C++ API: design and thread safety" above for exactly how that call
|
||||
stays correct even with more than one thread sharing a context).
|
||||
|
||||
The specific case that motivated adding this, reproduced directly
|
||||
while writing it: a **non-inline `static` class member that is
|
||||
@@ -478,15 +601,15 @@ fix this in any shared library, hot-reloaded or not.
|
||||
plugin uses this style -- compare the two examples' exported symbol
|
||||
lists (`nm -D --defined-only <path> | c++filt`) to see the
|
||||
difference directly.
|
||||
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).
|
||||
4. Have your host `load()` it once at startup (`hot_reload::Context`,
|
||||
or `hot_reload_load()` through the C API) and `reload()` it after
|
||||
every rebuild, from a point where nothing else is 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
|
||||
`Context` 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
|
||||
style of tool; see `Context.cpp`'s
|
||||
`stagePathForLoad()`). On Windows, each staged copy backing a
|
||||
still-loaded module can't be deleted for as long as that module
|
||||
stays mapped -- the rest of the process's lifetime, by design --
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#pragma once
|
||||
|
||||
#include "hot_reload/Options.hpp"
|
||||
#include "hot_reload/Result.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
|
||||
/**
|
||||
* @file Context.hpp
|
||||
* @brief hot_reload::Context -- the primary, modern C++ API this library offers.
|
||||
*
|
||||
* `hot_reload.h`'s plain C API is a separate, independently stable surface built *on top of*
|
||||
* this one (see `c-api/HotReloadApi.cpp`): it goes through Context's public methods exactly as
|
||||
* any other caller would, with no special access to this class's internals, so this header and
|
||||
* everything it includes has no idea the C API exists at all. Prefer this header for any new
|
||||
* C++ code; reach for `hot_reload.h` only where C linkage itself is the requirement (a plugin
|
||||
* boundary, a different language's FFI).
|
||||
*/
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Loads and hot-reloads shared libraries ("modules"), redirecting matching exported
|
||||
* functions from a module's previous build to its new one in place.
|
||||
*
|
||||
* A context owns every version of every module loaded through it. Destroying it closes all
|
||||
* of them, after which any function or data pointer obtained from any of those modules --
|
||||
* including an old, patched entry point that now only jumps into a newer version -- is
|
||||
* dangling.
|
||||
*
|
||||
* @par Thread safety
|
||||
* Safe to call load(), reload(), and findSymbol() on the same Context from multiple threads
|
||||
* at once: an internal reader-writer lock serializes load()/reload() against each other and
|
||||
* against findSymbol(), while multiple concurrent findSymbol() calls run in parallel against
|
||||
* each other. This covers this *object's own bookkeeping* -- it does not and architecturally
|
||||
* cannot cover a thread that is, at the exact moment of a reload(), already executing inside
|
||||
* the function being replaced; reload() from a quiescent point for the module involved
|
||||
* (nothing currently running inside it) the same as a single-threaded caller would. See
|
||||
* `source/HotReload/README.md`'s "What this cannot detect" for the full reasoning, including
|
||||
* exactly which write-ordering protections the underlying patch *does* carry regardless.
|
||||
* Unlike the C API's `hot_reload_last_error()`, there is no shared, stateful "last error" to
|
||||
* race on here at all: every call's outcome comes back in its own Result, so two threads'
|
||||
* calls can never be confused with each other.
|
||||
*
|
||||
* @code
|
||||
* hot_reload::Context ctx;
|
||||
* if (hot_reload::Result result = ctx.load("plugin", "plugin.so"); !result)
|
||||
* {
|
||||
* std::cerr << result.message() << '\n';
|
||||
* }
|
||||
* auto tick = reinterpret_cast<int (*)(int)>(ctx.findSymbol("plugin", "tick"));
|
||||
* @endcode
|
||||
*/
|
||||
class Context
|
||||
{
|
||||
public:
|
||||
/**
|
||||
* @brief Creates an empty context.
|
||||
* @param options Callbacks to use; defaults to no callbacks at all (every event is
|
||||
* simply not observed, and every removed symbol keeps its previous build
|
||||
* running, exactly as if Options::onDeletedSymbol had returned null).
|
||||
*/
|
||||
explicit Context(Options options = {});
|
||||
|
||||
/// Unloads every module this context loaded (see this class's own doc comment).
|
||||
~Context();
|
||||
|
||||
Context(const Context&) = delete;
|
||||
Context& operator=(const Context&) = delete;
|
||||
|
||||
/// Leaves @p other in a valid but unspecified state, the same contract
|
||||
/// `std::unique_ptr` itself carries after a move: safe to destroy or to move-assign to
|
||||
/// (including moving something else into it), but calling load(), reload(), or
|
||||
/// findSymbol() on it afterwards is undefined behavior.
|
||||
Context(Context&& other) noexcept;
|
||||
|
||||
/// Unloads this context's own modules first, exactly as the destructor would, then
|
||||
/// takes over @p other's; @p other is left in the same moved-from state the move
|
||||
/// constructor leaves it in (see that constructor's own doc comment).
|
||||
Context& operator=(Context&& other) noexcept;
|
||||
|
||||
/**
|
||||
* @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 and
|
||||
* that copy is loaded, which keeps a rebuild that overwrites @p 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. This means the
|
||||
* directory containing @p path must be writable.
|
||||
*
|
||||
* The module's static constructors run as part of loading it.
|
||||
*
|
||||
* @param moduleName An arbitrary caller-chosen key used to look the module back up in
|
||||
* later reload()/findSymbol() calls; it does not need to match anything on disk.
|
||||
* @param path Path to the shared library (`.so`/`.dll`) to load. A relative path is
|
||||
* always resolved against the current working directory (never a system library
|
||||
* search path), exactly as a path you typed yourself would be.
|
||||
* @return Status::Ok, or Status::InvalidArgument if @p moduleName was already loaded
|
||||
* (call reload() instead of load() again for the same name), or
|
||||
* Status::LoadFailed (see the returned Result's message() for why).
|
||||
*/
|
||||
[[nodiscard]] Result load(std::string_view moduleName, const std::filesystem::path& path);
|
||||
|
||||
/**
|
||||
* @brief Loads a new build of a module, diffs its exported functions against the
|
||||
* previous version, and patches every one that is safe to patch in place.
|
||||
*
|
||||
* 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 through Options::onSymbolEvent as SymbolEvent::Removed.
|
||||
*
|
||||
* @param moduleName The same logical name passed to load().
|
||||
* @param newPath Path to the new build of the shared library; follows the same rules as
|
||||
* @p path in load().
|
||||
* @return Status::Ok, or a failing Status. On any failure, nothing was changed: the
|
||||
* previous version keeps running exactly as it was.
|
||||
*/
|
||||
[[nodiscard]] Result reload(std::string_view moduleName, const std::filesystem::path& newPath);
|
||||
|
||||
/**
|
||||
* @brief Resolves a symbol by name, against a module's newest loaded build.
|
||||
*
|
||||
* Always resolves against the freshest build loaded for @p moduleName, patched or not
|
||||
* -- useful for calling a function that did not exist yet when the host first started,
|
||||
* such as one reported through Options::onSymbolEvent as SymbolEvent::Added. Any
|
||||
* exported symbol resolves, data included; only functions are ever patched.
|
||||
*
|
||||
* @param moduleName The module to resolve @p symbolName in.
|
||||
* @param symbolName The symbol's real (possibly mangled) name.
|
||||
* @return The symbol's current address, or null if @p moduleName is unknown or
|
||||
* @p symbolName is not exported by its newest build.
|
||||
*/
|
||||
[[nodiscard]] void* findSymbol(std::string_view moduleName, std::string_view symbolName) const noexcept;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> m_impl;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
/**
|
||||
* @file Demangle.hpp
|
||||
* @brief hot_reload::demangle() -- a human-readable form of a real (possibly mangled) C++ name.
|
||||
*
|
||||
* Every C++ symbol name this library's events and lookups ever use is always the real
|
||||
* symbol-table name (whatever Context::findSymbol() 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 would pass back in to look the
|
||||
* symbol up.
|
||||
*/
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Demangles @p name if it looks like a mangled C++ name; returns it unchanged
|
||||
* otherwise.
|
||||
*
|
||||
* Replaces hot_reload.h's `hot_reload_demangle()` / `hot_reload_free_string()` pair: the
|
||||
* result is returned by value, so there is nothing to separately free.
|
||||
*
|
||||
* @param name The real (possibly mangled) symbol name -- e.g. from
|
||||
* SymbolEventInfo::name.
|
||||
* @return The demangled form (GCC/Clang: Itanium ABI via `abi::__cxa_demangle`; MSVC:
|
||||
* `UnDecorateSymbolName`), or @p name itself, unchanged, if it does not demangle
|
||||
* (not a mangled C++ name at all, or memory ran out while demangling it) or this
|
||||
* build has no demangler for the compiler it was built with.
|
||||
*/
|
||||
[[nodiscard]] std::string demangle(std::string_view name);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include "hot_reload/SymbolEventInfo.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string_view>
|
||||
|
||||
/**
|
||||
* @file Options.hpp
|
||||
* @brief hot_reload::Options -- callbacks a Context is constructed with.
|
||||
*/
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Callbacks and configuration a Context is constructed with.
|
||||
*
|
||||
* Replaces hot_reload.h's `HotReloadOptions`: `std::function` in place of a raw function
|
||||
* pointer plus a manually-threaded `void* user_data` -- a capturing lambda plays the role
|
||||
* `user_data` used to, so there is no separate field for it here at all.
|
||||
*
|
||||
* A pure aggregate with no behavior of its own, so -- unlike every other new public type in
|
||||
* this library -- there is no matching Options.cpp; see Context.hpp for the type whose
|
||||
* constructor actually reads these.
|
||||
*
|
||||
* @code
|
||||
* hot_reload::Options options;
|
||||
* options.onSymbolEvent = [](const hot_reload::SymbolEventInfo& event)
|
||||
* {
|
||||
* std::clog << event.summary() << '\n';
|
||||
* };
|
||||
* hot_reload::Context ctx(std::move(options));
|
||||
* @endcode
|
||||
*/
|
||||
struct Options
|
||||
{
|
||||
/**
|
||||
* @brief Called once per symbol on every Context::reload() call, synchronously, on the
|
||||
* calling thread, while the reload is in progress.
|
||||
*
|
||||
* @warning Do not call load(), reload(), or findSymbol() on the same Context from
|
||||
* inside this callback (see Context.hpp's thread-safety note: that Context's
|
||||
* own lock is already held, in a non-reentrant way, by the reload() call this
|
||||
* callback runs inside of -- doing so deadlocks rather than corrupts anything).
|
||||
*/
|
||||
std::function<void(const SymbolEventInfo& event)> onSymbolEvent;
|
||||
|
||||
/**
|
||||
* @brief Called instead of patching, once per SymbolEvent::Removed event, so the caller
|
||||
* can redirect old callers of a deleted function somewhere deliberate instead of
|
||||
* leaving its previous build running untouched (the default with no callback, or
|
||||
* with one that returns null).
|
||||
*
|
||||
* Called synchronously, on the calling thread, before the corresponding onSymbolEvent
|
||||
* call for the same symbol; the same warning about not calling load()/reload()/
|
||||
* findSymbol() applies.
|
||||
*
|
||||
* @param deletedSymbolName The removed function's real (possibly mangled) name.
|
||||
* @return The address of a replacement to redirect old callers to, or null to leave the
|
||||
* previous build's implementation running (the default).
|
||||
*/
|
||||
std::function<void*(std::string_view deletedSymbolName)> onDeletedSymbol;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include "hot_reload/Status.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
/**
|
||||
* @file Result.hpp
|
||||
* @brief hot_reload::Result -- what Context::load() and Context::reload() return.
|
||||
*/
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief The outcome of one Context::load() or Context::reload() call: a Status, and the
|
||||
* platform's own message when that status is a failure worth explaining.
|
||||
*
|
||||
* This replaces the C API's split between a returned `HotReloadStatus` and a separate,
|
||||
* stateful `hot_reload_last_error()` call: every Result is self-contained, which is what
|
||||
* makes it safe to use from multiple threads without the two ever referring to different
|
||||
* calls -- see Context.hpp's own thread-safety note for why that split is not just a style
|
||||
* preference here.
|
||||
*
|
||||
* @code
|
||||
* hot_reload::Result result = ctx.load("plugin", "plugin.so");
|
||||
* if (!result) // or !result.ok()
|
||||
* {
|
||||
* std::cerr << hot_reload::toString(result.status()) << ": " << result.message() << '\n';
|
||||
* }
|
||||
* @endcode
|
||||
*/
|
||||
class Result
|
||||
{
|
||||
public:
|
||||
/**
|
||||
* @brief Constructs a successful result.
|
||||
* @return A Result whose status() is Status::Ok and whose message() is empty.
|
||||
*/
|
||||
[[nodiscard]] static Result success() noexcept;
|
||||
|
||||
/**
|
||||
* @brief Constructs a failed result.
|
||||
* @param status The failure's status; must not be Status::Ok (use ok() for that).
|
||||
* @param message Why, in the platform's own words where there is one (see each
|
||||
* Status enumerator's own doc comment for what it carries); may be empty for a
|
||||
* failure that is fully explained by its status alone.
|
||||
* @return A Result whose status() is @p status and whose message() is @p message.
|
||||
*/
|
||||
[[nodiscard]] static Result failure(Status status, std::string message = {}) noexcept;
|
||||
|
||||
/**
|
||||
* @brief Whether this result represents success.
|
||||
* @return True if status() is Status::Ok.
|
||||
*/
|
||||
[[nodiscard]] bool ok() const noexcept
|
||||
{
|
||||
return m_status == Status::Ok;
|
||||
}
|
||||
|
||||
/// Equivalent to ok() -- lets a Result be used directly as `if (result)`.
|
||||
[[nodiscard]] explicit operator bool() const noexcept
|
||||
{
|
||||
return ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief This result's status.
|
||||
* @return The status passed to failure(), or Status::Ok for ok().
|
||||
*/
|
||||
[[nodiscard]] Status status() const noexcept
|
||||
{
|
||||
return m_status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief This result's message.
|
||||
* @return The message passed to failure(), or empty for ok() or a status that needed no
|
||||
* further explanation.
|
||||
*/
|
||||
[[nodiscard]] const std::string& message() const noexcept
|
||||
{
|
||||
return m_message;
|
||||
}
|
||||
|
||||
private:
|
||||
Result(Status status, std::string message) noexcept
|
||||
: m_status(status)
|
||||
, m_message(std::move(message))
|
||||
{
|
||||
}
|
||||
|
||||
Status m_status;
|
||||
std::string m_message;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <string_view>
|
||||
|
||||
/**
|
||||
* @file Status.hpp
|
||||
* @brief hot_reload::Status -- the C++ API's outcome code, and toString() for logging it.
|
||||
*
|
||||
* Carries the same meaning as hot_reload.h's `HotReloadStatus`, which exists side by side with
|
||||
* this one: the plain C API is a wrapper around the C++ API this file is part of (see
|
||||
* `Context.hpp`), not the other way around, but both are supported, stable public surfaces --
|
||||
* `HotReloadStatus`'s own values are not going away. A Status is never constructed on its own;
|
||||
* see Result.hpp for the type that actually carries one back from a call.
|
||||
*/
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief The outcome of a Context::load() or Context::reload() call.
|
||||
*
|
||||
* @see Result, which pairs a Status with the human-readable message a failure deserves.
|
||||
*/
|
||||
enum class Status
|
||||
{
|
||||
/** The call did exactly what it says: loaded, or diffed-and-patched. */
|
||||
Ok,
|
||||
|
||||
/**
|
||||
* A required argument was empty or otherwise invalid, or Context::load() was called for
|
||||
* a module name that is already loaded -- call Context::reload() for that name instead.
|
||||
*/
|
||||
InvalidArgument,
|
||||
|
||||
/**
|
||||
* The platform could not load the image at all. Result::message() carries the
|
||||
* platform's own explanation -- this one status covers everything from a missing file,
|
||||
* to a genuinely corrupt image, to a shared library with an unresolved symbol
|
||||
* reference, and those need very different fixes. On Context::reload(), this is
|
||||
* non-destructive: the previously loaded version is left running untouched, exactly as
|
||||
* if reload had not been called.
|
||||
*/
|
||||
LoadFailed,
|
||||
|
||||
/** Context::reload() was called for a module name nothing has ever loaded. */
|
||||
NoPreviousVersion,
|
||||
|
||||
/**
|
||||
* This process's CPU architecture has no patch backend compiled in (see
|
||||
* `Architecture.hpp` -- currently x86, x86-64, ARM64, and ARM32). Nothing was patched;
|
||||
* Context::load() still succeeded, you just cannot Context::reload() on this target.
|
||||
*/
|
||||
UnsupportedArchitecture,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief A short, human-readable label for @p status, suitable for logging.
|
||||
*
|
||||
* The C API's equivalent, `hot_reload_status_string()`, calls this.
|
||||
*
|
||||
* @param status The status to describe.
|
||||
* @return A `string_view` into a static string -- valid for the life of the program, safe to
|
||||
* copy out or hold onto indefinitely, never null.
|
||||
*/
|
||||
[[nodiscard]] std::string_view toString(Status status) noexcept;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <string_view>
|
||||
|
||||
/**
|
||||
* @file SymbolEvent.hpp
|
||||
* @brief hot_reload::SymbolEvent -- what happened to one symbol during a Context::reload() call.
|
||||
*
|
||||
* Carries the same meaning as hot_reload.h's `HotReloadSymbolEvent`; see Status.hpp's file
|
||||
* comment for how the C and C++ APIs relate.
|
||||
*/
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief One symbol's outcome during a Context::reload() call, reported through
|
||||
* Options::onSymbolEvent.
|
||||
*
|
||||
* Reload diffs the previous build's exported functions against the new build's by name;
|
||||
* every name on either side gets exactly one of these.
|
||||
*/
|
||||
enum class SymbolEvent
|
||||
{
|
||||
/**
|
||||
* Present, and a function, in both builds, with a different compiled body -- and
|
||||
* successfully redirected: every existing caller of the old address, including one
|
||||
* that cached the pointer directly, now runs the new build's code.
|
||||
*/
|
||||
Patched,
|
||||
|
||||
/** A function in the new build with no same-named symbol in the previous one at all. */
|
||||
Added,
|
||||
|
||||
/**
|
||||
* In the previous build, not in the new one. The default: the old implementation keeps
|
||||
* running as-is unless Options::onDeletedSymbol supplies a stub for it.
|
||||
*/
|
||||
Removed,
|
||||
|
||||
/**
|
||||
* Present in both builds, but not classified as a function (ELF `STT_FUNC` in an
|
||||
* executable section, or a PE export whose address falls inside one) on at least one
|
||||
* side -- the mechanical half of this library's "only executable code is ever touched"
|
||||
* guarantee. Most often an exported variable; never patched, in either direction.
|
||||
*/
|
||||
SkippedNotCode,
|
||||
|
||||
/**
|
||||
* Present, and a function, in both builds -- but the previous build's compiled body
|
||||
* was not large enough to safely hold the jump this library writes over a function's
|
||||
* entry. 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.
|
||||
*/
|
||||
SkippedTooSmall,
|
||||
|
||||
/**
|
||||
* Present, and a function, and large enough -- but the operating system refused to make
|
||||
* that page of memory temporarily writable (an unusually locked-down environment).
|
||||
*/
|
||||
SkippedUnwritable,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief A short, human-readable label for @p event, suitable for logging.
|
||||
* @param event The event to describe.
|
||||
* @return A `string_view` into a static string -- valid for the life of the program.
|
||||
*/
|
||||
[[nodiscard]] std::string_view toString(SymbolEvent event) noexcept;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include "hot_reload/SymbolEvent.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
/**
|
||||
* @file SymbolEventInfo.hpp
|
||||
* @brief hot_reload::SymbolEventInfo -- the argument Options::onSymbolEvent is called with.
|
||||
*/
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief One symbol's outcome during a Context::reload() call.
|
||||
*
|
||||
* Carries the same fields as hot_reload.h's `HotReloadSymbolEventInfo`; see Status.hpp's
|
||||
* file comment for how the C and C++ APIs relate.
|
||||
*
|
||||
* @note Valid only for the duration of the Options::onSymbolEvent call it was passed to --
|
||||
* in particular, @ref name points into storage Context owns, not this struct's own.
|
||||
* Copy out whatever you need (summary() is one convenient way to) before returning.
|
||||
*/
|
||||
struct SymbolEventInfo
|
||||
{
|
||||
/// The symbol's real (possibly mangled) name. See hot_reload::demangle() for a
|
||||
/// human-readable form.
|
||||
std::string_view name;
|
||||
|
||||
/// What happened to it. SymbolEvent::Removed is not a privileged "zero value" for this
|
||||
/// type the way, say, Status::Ok is for Status -- this default exists purely so a
|
||||
/// default-constructed SymbolEventInfo is well-defined rather than full of
|
||||
/// indeterminate values; every real one is fully populated by Context before use.
|
||||
SymbolEvent kind = SymbolEvent::Removed;
|
||||
|
||||
/// The symbol's address in the previous build, or null if not applicable (kind is
|
||||
/// SymbolEvent::Added).
|
||||
void* oldAddress = nullptr;
|
||||
|
||||
/// The symbol's address in the new build, or null if not applicable (kind is
|
||||
/// SymbolEvent::Removed).
|
||||
void* newAddress = nullptr;
|
||||
|
||||
/**
|
||||
* @brief A one-line, human-readable summary of this event -- e.g. for a log line.
|
||||
* @return Something like `"patched 'tick' (0x1234 -> 0x5678)"`; addresses are included
|
||||
* only where this event's kind() makes them meaningful.
|
||||
*/
|
||||
[[nodiscard]] std::string summary() const;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -182,9 +182,11 @@ extern "C"
|
||||
* @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.
|
||||
* hide implementation details"). The real definition lives in
|
||||
* `c-api/HotReloadApi.cpp`: a small wrapper struct that owns a
|
||||
* `hot_reload::Context` (`Context.hpp`) by composition -- never a cast between unrelated
|
||||
* types, and never visible from this header. New C++ code should generally prefer
|
||||
* `hot_reload::Context` directly; see that header's own doc comment for why.
|
||||
*/
|
||||
typedef struct HotReloadContext HotReloadContext;
|
||||
|
||||
@@ -237,7 +239,7 @@ typedef enum HotReloadStatus
|
||||
|
||||
/**
|
||||
* This process's CPU architecture has no patch backend compiled in
|
||||
* (see `Architecture.hpp` -- currently x86, x86-64, and ARM64).
|
||||
* (see `Architecture.hpp` -- currently x86, x86-64, ARM64, and ARM32).
|
||||
* Nothing was patched; hot_reload_load() still succeeded, you just
|
||||
* cannot hot_reload_reload() on this target.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Architecture.cpp
|
||||
* @brief Per-architecture jump-patch backends for x86, x86-64, and ARM64.
|
||||
* @brief Per-architecture jump-patch backends for x86, x86-64, ARM64, and ARM32 (both the ARM
|
||||
* and Thumb-2 instruction sets).
|
||||
*/
|
||||
#include "Architecture.hpp"
|
||||
|
||||
@@ -218,13 +219,151 @@ namespace hot_reload::detail
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
}
|
||||
|
||||
#elif defined(__arm__) || defined(_M_ARM)
|
||||
|
||||
/* 32-bit ARM is a genuinely different shape from the three backends above: every
|
||||
* other architecture here picks one instruction encoding and a single fixed
|
||||
* kJumpBytes. ARM32 cannot, for a reason specific to this architecture: a CPU in
|
||||
* this family is always in one of two incompatible instruction-decoding states, ARM
|
||||
* (fixed 4-byte instructions) or Thumb (2-byte and 4-byte Thumb-2 instructions), and
|
||||
* a function's own compiled state -- never a free choice this library gets to make,
|
||||
* only something to detect and match -- decides which encoding is even legal to
|
||||
* write at its address. The ARM EABI records this on every function symbol: bit 0 of
|
||||
* its value is set if and only if the function is Thumb code (so that loading the
|
||||
* symbol's own value into pc, exactly what this backend's jump does, switches the
|
||||
* CPU to the right state automatically) -- SymbolTable.cpp's `loadedBase + st_value`
|
||||
* already preserves that bit unmodified for every architecture, so detecting the
|
||||
* mode here is as simple as reading it back off the address this backend is handed.
|
||||
*
|
||||
* A second, smaller wrinkle is specific to Thumb state alone: its `ldr pc, [...]`
|
||||
* literal-load form can only read from a 4-byte-aligned address, computed from the
|
||||
* instruction's OWN address rounded down to 4 bytes -- but a Thumb function is only
|
||||
* ever guaranteed 2-byte alignment, not 4 (confirmed directly: building a plugin
|
||||
* with -falign-functions=1 reliably produces functions starting 2 bytes off a
|
||||
* 4-byte boundary). So Thumb state alone needs a one-instruction fork: a 2-byte NOP
|
||||
* realigns first when the entry isn't already on a 4-byte boundary, nothing extra
|
||||
* when it already is. ARM state needs no such fork -- that state requires 4-byte
|
||||
* alignment architecture-wide, so every ARM-mode function already has it.
|
||||
*
|
||||
* No landing pad exists on this architecture in any mainstream toolchain or CPU
|
||||
* extension in common use (unlike x86 CET or ARM64 BTI) -- confirmed directly:
|
||||
* -fpatchable-function-entry's reserved padding here is plain NOPs in both states,
|
||||
* nothing else, so there is never anything to detect or preserve in front of a
|
||||
* function's real body.
|
||||
*
|
||||
* Every byte sequence below was derived the same way as the other three backends:
|
||||
* assembled with `as` from the exact mnemonics named in each comment, and the
|
||||
* resulting bytes read back with objdump -- including, for the Thumb realignment
|
||||
* case specifically, assembling it at both a 4-byte-aligned and a 2-mod-4 address
|
||||
* and confirming objdump's own computed literal-load target lands exactly on the
|
||||
* embedded address both times.
|
||||
*/
|
||||
|
||||
/// No landing pad exists on this architecture (see the file comment above), so unlike
|
||||
/// the three backends above, there is no hasLandingPad()/kLandingPadBytes pair here:
|
||||
/// the outer planPatch() below sets PatchLayout::patchOffset to 0 directly instead.
|
||||
|
||||
/// @param address A function's address as this library carries it: `loadedBase +
|
||||
/// st_value`, Thumb's mode bit (bit 0) included unmodified.
|
||||
/// @return True if @p address names Thumb code, per the ARM EABI convention.
|
||||
bool isThumb(const void* address)
|
||||
{
|
||||
return (reinterpret_cast<std::uintptr_t>(address) & std::uintptr_t{1}) != 0;
|
||||
}
|
||||
|
||||
/// @param address A function's address, Thumb mode bit possibly included.
|
||||
/// @return The real, even byte address of its first instruction -- bit 0 is a mode
|
||||
/// marker on this architecture, never part of the actual memory location.
|
||||
unsigned char* realAddress(const void* address)
|
||||
{
|
||||
return reinterpret_cast<unsigned char*>(reinterpret_cast<std::uintptr_t>(address) & ~std::uintptr_t{1});
|
||||
}
|
||||
|
||||
/// `ldr pc, [pc, #-4]`: ARM state's literal-load jump. ARM state is always 4-byte
|
||||
/// aligned architecture-wide, so this one fixed form always applies; the embedded
|
||||
/// address follows immediately, 8 bytes total.
|
||||
constexpr unsigned char kArmJump[4] = {0x04, 0xF0, 0x1F, 0xE5};
|
||||
|
||||
/// `ldr.w pc, [pc, #0]`: Thumb-2's literal-load jump, valid only when this
|
||||
/// instruction's own address is 4-byte aligned (see the file comment above); the
|
||||
/// embedded address follows immediately, 8 bytes total from wherever this is placed.
|
||||
constexpr unsigned char kThumbJump[4] = {0xDF, 0xF8, 0x00, 0xF0};
|
||||
|
||||
/// The Thumb-2 `nop` hint instruction -- matches the padding GCC's own
|
||||
/// -fpatchable-function-entry emits on this target -- used only to realign kThumbJump
|
||||
/// onto a 4-byte boundary when the function's own entry isn't already on one.
|
||||
constexpr unsigned char kThumbNop[2] = {0x00, 0xBF};
|
||||
|
||||
/// @param entryAddress The function's entry address, Thumb bit included.
|
||||
/// @return 8 for ARM state or an already-4-byte-aligned Thumb entry; 10 for a Thumb
|
||||
/// entry starting 2 bytes off a 4-byte boundary (room for the one extra
|
||||
/// realigning NOP in front of kThumbJump -- see the file comment above).
|
||||
/// Unlike every other backend in this file, this one genuinely depends on
|
||||
/// the entry address itself, not just the architecture.
|
||||
std::size_t jumpBytes(const void* entryAddress)
|
||||
{
|
||||
if (!isThumb(entryAddress))
|
||||
{
|
||||
return 8;
|
||||
}
|
||||
bool misaligned = (reinterpret_cast<std::uintptr_t>(realAddress(entryAddress)) & std::uintptr_t{2}) != 0;
|
||||
return misaligned ? 10 : 8;
|
||||
}
|
||||
|
||||
/// Writes the ARM32 jump at @p site: the ARM-state form if @p site is ARM code, the
|
||||
/// Thumb-2 form (with a realigning NOP first, if needed) if it is Thumb -- @p site's
|
||||
/// own bit 0 says which, exactly as isThumb() reads it, and there is no separate
|
||||
/// landing pad to skip past on this architecture (see hasLandingPad()), so @p site is
|
||||
/// always the function's own entry address, unmodified.
|
||||
/// @param site Where the jump goes.
|
||||
/// @param target Address the jump transfers control to, Thumb bit preserved so the
|
||||
/// `ldr` that loads it into pc correctly switches state at the destination,
|
||||
/// too, exactly as calling this address normally would.
|
||||
void writeJumpAt(const void* site, const void* target)
|
||||
{
|
||||
std::uint32_t targetValue = static_cast<std::uint32_t>(reinterpret_cast<std::uintptr_t>(target));
|
||||
unsigned char* bytes = realAddress(site);
|
||||
|
||||
if (!isThumb(site))
|
||||
{
|
||||
// Payload first, opcode last -- same reasoning as every other backend above.
|
||||
std::memcpy(bytes + 4, &targetValue, sizeof(targetValue));
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
std::memcpy(bytes, kArmJump, sizeof(kArmJump));
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
|
||||
bool misaligned = (reinterpret_cast<std::uintptr_t>(bytes) & std::uintptr_t{2}) != 0;
|
||||
unsigned char* jumpAt = misaligned ? bytes + 2 : bytes;
|
||||
|
||||
// The realigning NOP, if there is one, is pure filler -- inert whether a reader
|
||||
// sees it before or after this write, and byte-for-byte identical on every
|
||||
// reload of this same (fixed) address, so it is written first, unconditionally,
|
||||
// with no fence of its own: it never participates in the old-jump/new-jump
|
||||
// transition the fences below protect.
|
||||
if (misaligned)
|
||||
{
|
||||
std::memcpy(bytes, kThumbNop, sizeof(kThumbNop));
|
||||
}
|
||||
|
||||
// Payload next, opcode last -- same reasoning as every other backend above: until
|
||||
// kThumbJump lands, jumpAt still reads as whatever it was (old NOP padding, or a
|
||||
// previous ldr.w, on a later reload), and the fully-written literal sitting
|
||||
// ahead of it is inert until something actually loads it.
|
||||
std::memcpy(jumpAt + 4, &targetValue, sizeof(targetValue));
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
std::memcpy(jumpAt, kThumbJump, sizeof(kThumbJump));
|
||||
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)
|
||||
defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
@@ -233,7 +372,14 @@ namespace hot_reload::detail
|
||||
|
||||
PatchLayout planPatch(const void* entryAddress)
|
||||
{
|
||||
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
|
||||
#if defined(__arm__) || defined(_M_ARM)
|
||||
// ARM32's own kJumpBytes genuinely depends on entryAddress (ARM state vs. Thumb state,
|
||||
// and for Thumb, alignment) -- see Architecture.cpp's ARM32 file comment above -- so it
|
||||
// cannot share the single-constant shape the other three architectures below do; there
|
||||
// is never a landing pad to skip past on this architecture either (hasLandingPad() is
|
||||
// always false), so patchOffset is always 0.
|
||||
return PatchLayout{jumpBytes(entryAddress), 0};
|
||||
#elif 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;
|
||||
@@ -248,7 +394,14 @@ namespace hot_reload::detail
|
||||
|
||||
bool writeJump(void* entryAddress, const void* targetAddress)
|
||||
{
|
||||
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
|
||||
#if defined(__arm__) || defined(_M_ARM)
|
||||
// entryAddress is passed through unmodified (not cast to unsigned char* the way the
|
||||
// generic path below does): its own bit 0, the Thumb/ARM mode marker, is exactly what
|
||||
// writeJumpAt() needs to pick the right encoding -- see Architecture.cpp's ARM32 file
|
||||
// comment above. There is no landing pad on this architecture to offset past first.
|
||||
writeJumpAt(entryAddress, targetAddress);
|
||||
return true;
|
||||
#elif 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;
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* 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`.
|
||||
* hot_reload::Context and may change freely; the supported surface is hot_reload::Context itself
|
||||
* and the C API in `hot_reload.h`.
|
||||
*/
|
||||
namespace hot_reload::detail
|
||||
{
|
||||
@@ -44,11 +44,11 @@ namespace hot_reload::detail
|
||||
* @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.
|
||||
* x86-64, ARM64, and ARM32 (both its ARM and Thumb-2 instruction
|
||||
* sets) are supported; anything else returns false here, and
|
||||
* `hot_reload::Context::reload()` surfaces that as
|
||||
* `Status::UnsupportedArchitecture` (`::HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE` through
|
||||
* the C API).
|
||||
*
|
||||
* @return True if writeJump() can succeed on this architecture.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* @file Context.cpp
|
||||
* @brief Context::Impl -- the diff-and-patch orchestration logic: load, reload, per-symbol
|
||||
* policy, and the reader-writer lock that makes all three safe to call concurrently.
|
||||
*/
|
||||
#include "hot_reload/Context.hpp"
|
||||
|
||||
#include "Architecture.hpp"
|
||||
#include "MemoryProtection.hpp"
|
||||
#include "ModuleHandle.hpp"
|
||||
#include "SymbolTable.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
/// The module name as it appears inside a staging copy's file name.
|
||||
///
|
||||
/// moduleName is an arbitrary caller-chosen key (Context::load()'s own doc comment
|
||||
/// 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(std::string_view 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 -- 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 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, std::string_view moduleName)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
fs::path source(sourcePath);
|
||||
fs::path dir = source.has_parent_path() ? source.parent_path() : fs::path(".");
|
||||
const std::string tag = stagingTag(moduleName);
|
||||
std::string prefix = source.filename().string() + ".hotreload." + tag + ".";
|
||||
|
||||
std::error_code dirError;
|
||||
fs::directory_iterator entries(dir, dirError);
|
||||
if (dirError)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fs::directory_entry& entry : entries)
|
||||
{
|
||||
std::error_code fileTypeError;
|
||||
if (!entry.is_regular_file(fileTypeError) || fileTypeError)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::string filename = entry.path().filename().string();
|
||||
if (filename.compare(0, prefix.size(), prefix) == 0)
|
||||
{
|
||||
std::error_code removeError;
|
||||
fs::remove(entry.path(), removeError); // best-effort; a failure here just means try again next launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Context's real implementation; see `Context.hpp`'s own doc comment for the public
|
||||
* contract this upholds (including its thread-safety guarantee, which m_mutex is what
|
||||
* actually provides: a `std::shared_mutex` locked exclusively for load()/reload() and
|
||||
* shared for findSymbol(), so concurrent lookups run in parallel with each other while a
|
||||
* reload briefly excludes all of them).
|
||||
*
|
||||
* Forward-declared only in Context.hpp (never defined there), which is what lets that header
|
||||
* stay free of every detail:: header this file includes -- a caller who only `#include
|
||||
* "hot_reload/Context.hpp"` never sees ModuleHandle.hpp, SymbolTable.hpp, or either of their
|
||||
* own dependencies, and a change to any of those never forces that caller to recompile.
|
||||
*/
|
||||
struct Context::Impl
|
||||
{
|
||||
/// Everything tracked for one logical module name. Has no meaning outside Impl, 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 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<detail::ModuleHandle> versions;
|
||||
|
||||
/// The exported symbols of the newest build (versions.back()), as reported by
|
||||
/// detail::readExportedSymbols(); what the next reload() diffs against.
|
||||
std::vector<detail::ExportedSymbol> currentSymbols;
|
||||
|
||||
/// How many staging copies have been made for this name so far; makes each copy's
|
||||
/// file name unique (see stagePathForLoad()).
|
||||
int reloadCounter = 0;
|
||||
};
|
||||
|
||||
explicit Impl(Options options)
|
||||
: m_options(std::move(options))
|
||||
{
|
||||
}
|
||||
|
||||
Result load(std::string_view moduleName, const std::filesystem::path& path)
|
||||
{
|
||||
std::unique_lock lock(m_mutex);
|
||||
|
||||
const std::string key(moduleName);
|
||||
if (m_modules.find(key) != 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 Result::failure(Status::InvalidArgument);
|
||||
}
|
||||
|
||||
const std::string sourcePath = path.string();
|
||||
cleanupOrphanedStagingFiles(sourcePath, moduleName);
|
||||
|
||||
LoadedModule module;
|
||||
std::string staged = stagePathForLoad(module, moduleName, sourcePath);
|
||||
if (staged.empty())
|
||||
{
|
||||
return Result::failure(
|
||||
Status::LoadFailed,
|
||||
"could not stage '" + sourcePath + "' for loading (source unreadable, or destination path unwritable)");
|
||||
}
|
||||
|
||||
detail::ModuleHandle handle = detail::ModuleHandle::load(staged);
|
||||
if (!handle.isValid())
|
||||
{
|
||||
std::string message = handle.errorMessage();
|
||||
std::remove(staged.c_str());
|
||||
return Result::failure(Status::LoadFailed, std::move(message));
|
||||
}
|
||||
|
||||
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(key, std::move(module));
|
||||
return Result::success();
|
||||
}
|
||||
|
||||
Result reload(std::string_view moduleName, const std::filesystem::path& newPath)
|
||||
{
|
||||
std::unique_lock lock(m_mutex);
|
||||
|
||||
const std::string key(moduleName);
|
||||
auto it = m_modules.find(key);
|
||||
if (it == m_modules.end())
|
||||
{
|
||||
return Result::failure(Status::NoPreviousVersion);
|
||||
}
|
||||
if (!detail::isArchitectureSupported())
|
||||
{
|
||||
return Result::failure(Status::UnsupportedArchitecture);
|
||||
}
|
||||
|
||||
LoadedModule& module = it->second;
|
||||
|
||||
const std::string sourcePath = newPath.string();
|
||||
std::string staged = stagePathForLoad(module, moduleName, sourcePath);
|
||||
if (staged.empty())
|
||||
{
|
||||
return Result::failure(
|
||||
Status::LoadFailed,
|
||||
"could not stage '" + sourcePath + "' for loading (source unreadable, or destination path unwritable)");
|
||||
}
|
||||
|
||||
detail::ModuleHandle newHandle = detail::ModuleHandle::load(staged);
|
||||
if (!newHandle.isValid())
|
||||
{
|
||||
std::string message = newHandle.errorMessage();
|
||||
std::remove(staged.c_str());
|
||||
return Result::failure(Status::LoadFailed, std::move(message)); // 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(SymbolEvent::SkippedNotCode, oldSymbol.name, oldSymbol.address, newSymbol.address);
|
||||
continue;
|
||||
}
|
||||
|
||||
patchOne(oldSymbol, newSymbol);
|
||||
}
|
||||
|
||||
for (const detail::ExportedSymbol& newSymbol : newSymbols)
|
||||
{
|
||||
if (matchedNewNames.find(newSymbol.name) == matchedNewNames.end())
|
||||
{
|
||||
emitEvent(SymbolEvent::Added, newSymbol.name, nullptr, newSymbol.address);
|
||||
}
|
||||
}
|
||||
|
||||
module.currentSymbols = std::move(newSymbols);
|
||||
module.versions.push_back(std::move(newHandle));
|
||||
return Result::success();
|
||||
}
|
||||
|
||||
void* findSymbol(std::string_view moduleName, std::string_view symbolName) const noexcept
|
||||
{
|
||||
std::shared_lock lock(m_mutex);
|
||||
|
||||
auto it = m_modules.find(std::string(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;
|
||||
}
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Stages a source file through a fresh, uniquely-named copy.
|
||||
*
|
||||
* Called before a path is ever passed to detail::ModuleHandle::load(): 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.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
static std::string stagePathForLoad(LoadedModule& module, std::string_view moduleName, const std::string& sourcePath)
|
||||
{
|
||||
++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 {};
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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)
|
||||
{
|
||||
detail::PatchLayout layout = detail::planPatch(oldSymbol.address);
|
||||
if (oldSymbol.size != 0 && oldSymbol.size < layout.totalBytes)
|
||||
{
|
||||
emitEvent(SymbolEvent::SkippedTooSmall, oldSymbol.name, oldSymbol.address, newSymbol.address);
|
||||
return;
|
||||
}
|
||||
|
||||
detail::ScopedCodeWriteAccess writable(oldSymbol.address, layout.totalBytes);
|
||||
if (!writable.isValid())
|
||||
{
|
||||
emitEvent(SymbolEvent::SkippedUnwritable, oldSymbol.name, oldSymbol.address, newSymbol.address);
|
||||
return;
|
||||
}
|
||||
|
||||
detail::writeJump(oldSymbol.address, newSymbol.address);
|
||||
writable.finishWrite();
|
||||
emitEvent(SymbolEvent::Patched, oldSymbol.name, oldSymbol.address, newSymbol.address);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Handles a symbol present in the old build but missing from the new one.
|
||||
*
|
||||
* Emits SymbolEvent::Removed, and first redirects to a caller-supplied stub if
|
||||
* Options::onDeletedSymbol 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)
|
||||
{
|
||||
// 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_options.onDeletedSymbol)
|
||||
{
|
||||
stub = m_options.onDeletedSymbol(oldSymbol.name);
|
||||
}
|
||||
|
||||
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
|
||||
// SymbolEvent::Removed below for what is already a rare, best-effort extra.
|
||||
}
|
||||
}
|
||||
|
||||
emitEvent(SymbolEvent::Removed, oldSymbol.name, oldSymbol.address, nullptr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Invokes Options::onSymbolEvent, 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(SymbolEvent kind, std::string_view name, void* oldAddress, void* newAddress)
|
||||
{
|
||||
if (!m_options.onSymbolEvent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SymbolEventInfo info;
|
||||
info.name = name;
|
||||
info.kind = kind;
|
||||
info.oldAddress = oldAddress;
|
||||
info.newAddress = newAddress;
|
||||
m_options.onSymbolEvent(info);
|
||||
}
|
||||
|
||||
/// Guards every member below. Exclusive (`std::unique_lock`) for load()/reload(),
|
||||
/// shared (`std::shared_lock`) for findSymbol() -- see this class's own doc comment.
|
||||
/// `mutable` because findSymbol() is logically const but still needs to lock it.
|
||||
mutable std::shared_mutex m_mutex;
|
||||
|
||||
/// Every module loaded so far, by logical name. Guarded by m_mutex.
|
||||
std::unordered_map<std::string, LoadedModule> m_modules;
|
||||
|
||||
/// Copied out of the constructor's argument once; never changes after that, so reading
|
||||
/// it (onSymbolEvent/onDeletedSymbol, both only ever called from inside a load()/
|
||||
/// reload() call that already holds m_mutex) needs no additional synchronization of its
|
||||
/// own.
|
||||
Options m_options;
|
||||
};
|
||||
|
||||
Context::Context(Options options)
|
||||
: m_impl(std::make_unique<Impl>(std::move(options)))
|
||||
{
|
||||
}
|
||||
|
||||
Context::~Context() = default;
|
||||
|
||||
Context::Context(Context&& other) noexcept = default;
|
||||
|
||||
Context& Context::operator=(Context&& other) noexcept = default;
|
||||
|
||||
Result Context::load(std::string_view moduleName, const std::filesystem::path& path)
|
||||
{
|
||||
return m_impl->load(moduleName, path);
|
||||
}
|
||||
|
||||
Result Context::reload(std::string_view moduleName, const std::filesystem::path& newPath)
|
||||
{
|
||||
return m_impl->reload(moduleName, newPath);
|
||||
}
|
||||
|
||||
void* Context::findSymbol(std::string_view moduleName, std::string_view symbolName) const noexcept
|
||||
{
|
||||
return m_impl->findSymbol(moduleName, symbolName);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @file Demangle.cpp
|
||||
* @brief hot_reload::demangle() -- the real demangling logic (hot_reload_demangle() wraps this).
|
||||
*/
|
||||
#include "hot_reload/Demangle.hpp"
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
// _MSC_VER, checked before __GNUC__/__clang__ below, and deliberately so: Clang defines
|
||||
// _MSC_VER too, alongside its own __clang__, whenever it is targeting the MSVC ABI (its
|
||||
// default on Windows unless pointed at a MinGW sysroot instead) -- confirmed directly by a
|
||||
// real failure this ordering used to produce, building with clang++.exe against the MSVC
|
||||
// runtime (_DLL/_MT/--dependent-lib=msvcrt on its own command line are the tell): checking
|
||||
// __clang__ first took the #else branch below and failed to find <cxxabi.h>, which does not
|
||||
// exist in that configuration at all -- Clang-targeting-MSVC emits MSVC-mangled names, the
|
||||
// same as real cl.exe, not Itanium ones, so UnDecorateSymbolName is what it actually needs.
|
||||
// _MSC_VER is the right thing to key this on precisely because it tracks the ABI/mangling
|
||||
// scheme in use, which is what this file cares about, rather than which compiler frontend
|
||||
// produced it, which on its own does not say that.
|
||||
//
|
||||
// windows.h must come first: dbghelp.h uses types (HANDLE, DWORD,
|
||||
// BOOL, ...) it assumes are already defined rather than including
|
||||
// their defining headers itself -- reversing this order fails to
|
||||
// compile. NOMINMAX prevents windows.h from defining min/max
|
||||
// macros that would otherwise clash with std::min/std::max and
|
||||
// anything else named min/max in this translation unit. Both
|
||||
// fixes below came from an actual Windows build/run of this
|
||||
// library, not just documentation -- see the top-level README's
|
||||
// "What's actually been tested" section.
|
||||
//
|
||||
// WIN32_LEAN_AND_MEAN: see MemoryProtection.cpp's comment for the
|
||||
// full reasoning. dbghelp.h itself needs only the core Win32 types
|
||||
// (HANDLE, DWORD, BOOL, ...) windows.h still provides with this
|
||||
// defined -- not the COM/OLE headers it strips.
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <dbghelp.h>
|
||||
// Belt-and-suspenders for anyone building this file outside this
|
||||
// project's own CMake (source/HotReload/CMakeLists.txt links
|
||||
// dbghelp explicitly for every MSVC-family build, which is the
|
||||
// primary fix and the one actually required -- confirmed directly
|
||||
// that omitting it leaves UnDecorateSymbolName undefined at link
|
||||
// time regardless of this pragma).
|
||||
#pragma comment(lib, "dbghelp.lib")
|
||||
#include <mutex>
|
||||
#elif defined(__GNUC__) || defined(__clang__)
|
||||
// GCC, or Clang NOT targeting the MSVC ABI (the #if above already claimed that case) --
|
||||
// real Linux/macOS builds, and MinGW, where Clang/GCC both still generate Itanium-mangled
|
||||
// names even though the OS is Windows, exactly because _MSC_VER is never defined there.
|
||||
// See the "Hot reloading C++ symbols" section of source/HotReload/README.md for why this
|
||||
// distinction matters beyond just this function.
|
||||
#include <cstdlib>
|
||||
#include <cxxabi.h>
|
||||
#include <memory>
|
||||
#endif
|
||||
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
namespace
|
||||
{
|
||||
/// DbgHelp -- UnDecorateSymbolName included, by Microsoft's own documentation -- is not
|
||||
/// safe to call from more than one thread at once. Demangle.cpp's own callers (the C++
|
||||
/// API directly, and hot_reload_demangle() through it) make no promise about which
|
||||
/// thread calls this, so this serializes every MSVC-path call through one mutex rather
|
||||
/// than pushing that requirement onto every caller. The GCC/Clang path below needs
|
||||
/// nothing equivalent: `abi::__cxa_demangle` keeps no shared mutable state of its own.
|
||||
std::mutex& dbgHelpMutex()
|
||||
{
|
||||
static std::mutex mutex;
|
||||
return mutex;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
std::string demangle(std::string_view name)
|
||||
{
|
||||
std::string text(name); // fallback: unchanged, for anything that isn't a mangled C++ name at all
|
||||
|
||||
try
|
||||
{
|
||||
#if defined(_MSC_VER)
|
||||
std::string nameCopy(name); // UnDecorateSymbolName also needs a NUL-terminated buffer
|
||||
char buffer[2048];
|
||||
std::lock_guard<std::mutex> lock(dbgHelpMutex());
|
||||
if (UnDecorateSymbolName(nameCopy.c_str(), buffer, sizeof(buffer), UNDNAME_COMPLETE) != 0)
|
||||
{
|
||||
text = buffer;
|
||||
}
|
||||
#elif defined(__GNUC__) || defined(__clang__)
|
||||
// __cxa_demangle requires a NUL-terminated C string; name is not guaranteed to be
|
||||
// one (a string_view over a substring, say), so this always goes through a
|
||||
// std::string first rather than risk reading past a non-terminated view.
|
||||
//
|
||||
// __cxa_demangle always malloc()s its result (or returns null), which Deleter below
|
||||
// exists to pair back up via RAII -- std::free itself isn't a type, so an explicit
|
||||
// function-pointer deleter type is what makes unique_ptr able to hold it at all.
|
||||
using Deleter = void (*)(void*);
|
||||
int status = 0;
|
||||
std::unique_ptr<char, Deleter> demangled(
|
||||
abi::__cxa_demangle(text.c_str(), nullptr, nullptr, &status), &std::free);
|
||||
if (status == 0 && demangled != nullptr)
|
||||
{
|
||||
text = demangled.get();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Out of memory while demangling, most plausibly -- text is still whatever it was
|
||||
// going in, so fall through and return that rather than propagate.
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
/**
|
||||
* @file HotReloadContext.cpp
|
||||
* @brief The diff-and-patch orchestration logic: load, reload, and per-symbol policy.
|
||||
*/
|
||||
#include "HotReloadContext.hpp"
|
||||
|
||||
#include "Architecture.hpp"
|
||||
#include "MemoryProtection.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <unordered_set>
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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(".");
|
||||
const std::string tag = stagingTag(moduleName);
|
||||
std::string prefix = source.filename().string() + ".hotreload." + tag + ".";
|
||||
|
||||
std::error_code dirError;
|
||||
fs::directory_iterator entries(dir, dirError);
|
||||
if (dirError)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fs::directory_entry& entry : entries)
|
||||
{
|
||||
std::error_code fileTypeError;
|
||||
if (!entry.is_regular_file(fileTypeError) || fileTypeError)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::string filename = entry.path().filename().string();
|
||||
if (filename.compare(0, prefix.size(), prefix) == 0)
|
||||
{
|
||||
std::error_code removeError;
|
||||
fs::remove(entry.path(), removeError); // best-effort; a failure here just means try again next launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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 {};
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
cleanupOrphanedStagingFiles(path, moduleName);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "ModuleHandle.hpp"
|
||||
#include "SymbolTable.hpp"
|
||||
#include "hot_reload/hot_reload.h"
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @namespace hot_reload
|
||||
* @brief The C++ implementation behind the C API declared in `hot_reload.h`.
|
||||
*
|
||||
* The one type meant to be used from outside is HotReloadContext, and even that only through the
|
||||
* opaque ::HotReloadContext pointer the C API hands out. The building blocks it is made of live in
|
||||
* hot_reload::detail.
|
||||
*/
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
/**
|
||||
* @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;
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
/**
|
||||
* @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;
|
||||
|
||||
/**
|
||||
* @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.
|
||||
struct LoadedModule
|
||||
{
|
||||
/// 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<detail::ModuleHandle> versions;
|
||||
|
||||
/// The exported symbols of the newest build (versions.back()), as reported by
|
||||
/// detail::readExportedSymbols(); what the next reload() diffs against.
|
||||
std::vector<detail::ExportedSymbol> currentSymbols;
|
||||
|
||||
/// 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<std::string, LoadedModule> 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;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -11,9 +11,27 @@
|
||||
// would otherwise clash with std::min/std::max and anything else
|
||||
// named min/max in this translation unit -- found via an actual
|
||||
// Windows build/run of this library, not just documentation.
|
||||
//
|
||||
// WIN32_LEAN_AND_MEAN: without it, windows.h also pulls in COM/OLE
|
||||
// headers (ole2.h, combaseapi.h, objidlbase.h, servprov.h, ...)
|
||||
// this library has no use for at all. A real build report (GNU-style
|
||||
// clang++ 22.1 targeting the MSVC ABI, Windows SDK 10.0.26100)
|
||||
// failed inside those headers' own __uuidof()-based
|
||||
// QueryInterface<Q>() template helper, unrelated to anything this
|
||||
// library does itself. Defining this keeps windows.h to the core
|
||||
// Win32 surface this library actually needs (LoadLibrary,
|
||||
// VirtualProtect, the PE/COFF IMAGE_* structures, ...) and keeps
|
||||
// that whole header chain out of the translation unit -- the
|
||||
// standard fix for this class of windows.h/COM interaction. What
|
||||
// has and has not been checked about it is recorded in the
|
||||
// top-level README's "What's actually been tested" table, under the
|
||||
// Clang-targeting-the-MSVC-ABI row.
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <sys/mman.h>
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
#include <utility>
|
||||
|
||||
#if defined(_WIN32)
|
||||
// NOMINMAX: see MemoryProtection.cpp's identical comment -- same
|
||||
// windows.h, same reason, needed in every translation unit that
|
||||
// includes it, not just once for the library as a whole.
|
||||
// NOMINMAX, WIN32_LEAN_AND_MEAN: see MemoryProtection.cpp's identical
|
||||
// comment -- same windows.h, same reasons, needed in every
|
||||
// translation unit that includes it, not just once for the library
|
||||
// as a whole.
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <dlfcn.h>
|
||||
@@ -148,7 +152,7 @@ namespace hot_reload::detail
|
||||
// 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.
|
||||
// Context 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)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file Result.cpp
|
||||
* @brief Result::success() and Result::failure() -- the only two ways a Result is ever constructed.
|
||||
*/
|
||||
#include "hot_reload/Result.hpp"
|
||||
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
Result Result::success() noexcept
|
||||
{
|
||||
return Result(Status::Ok, {});
|
||||
}
|
||||
|
||||
Result Result::failure(Status status, std::string message) noexcept
|
||||
{
|
||||
return Result(status, std::move(message));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @file Status.cpp
|
||||
* @brief toString(Status) -- one switch, kept in sync with Status.hpp by -Wswitch.
|
||||
*/
|
||||
#include "hot_reload/Status.hpp"
|
||||
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
std::string_view toString(Status status) noexcept
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case Status::Ok:
|
||||
return "ok";
|
||||
case Status::InvalidArgument:
|
||||
return "invalid argument";
|
||||
case Status::LoadFailed:
|
||||
return "load failed";
|
||||
case Status::NoPreviousVersion:
|
||||
return "no previous version loaded for this module name";
|
||||
case Status::UnsupportedArchitecture:
|
||||
return "no patch backend for this CPU architecture";
|
||||
}
|
||||
return "unknown hot reload status"; // unreachable for any enumerator; -Wswitch catches a missing one above
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @file SymbolEvent.cpp
|
||||
* @brief toString(SymbolEvent) -- one switch, kept in sync with SymbolEvent.hpp by -Wswitch.
|
||||
*/
|
||||
#include "hot_reload/SymbolEvent.hpp"
|
||||
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
std::string_view toString(SymbolEvent event) noexcept
|
||||
{
|
||||
switch (event)
|
||||
{
|
||||
case SymbolEvent::Patched:
|
||||
return "patched";
|
||||
case SymbolEvent::Added:
|
||||
return "added";
|
||||
case SymbolEvent::Removed:
|
||||
return "removed";
|
||||
case SymbolEvent::SkippedNotCode:
|
||||
return "skipped (not code)";
|
||||
case SymbolEvent::SkippedTooSmall:
|
||||
return "skipped (too small)";
|
||||
case SymbolEvent::SkippedUnwritable:
|
||||
return "skipped (unwritable)";
|
||||
}
|
||||
return "unknown symbol event"; // unreachable for any enumerator; -Wswitch catches a missing one above
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file SymbolEventInfo.cpp
|
||||
* @brief SymbolEventInfo::summary() -- a one-line, human-readable rendering of one event.
|
||||
*/
|
||||
#include "hot_reload/SymbolEventInfo.hpp"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
namespace hot_reload
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
/// @param address An address to render, possibly null.
|
||||
/// @return @p address as `0x...` hex, or `"(null)"`.
|
||||
std::string hex(const void* address)
|
||||
{
|
||||
if (address == nullptr)
|
||||
{
|
||||
return "(null)";
|
||||
}
|
||||
std::ostringstream stream;
|
||||
stream << address;
|
||||
return stream.str();
|
||||
}
|
||||
}
|
||||
|
||||
std::string SymbolEventInfo::summary() const
|
||||
{
|
||||
std::ostringstream stream;
|
||||
stream << toString(kind) << " '" << name << "'";
|
||||
switch (kind)
|
||||
{
|
||||
case SymbolEvent::Patched:
|
||||
stream << " (" << hex(oldAddress) << " -> " << hex(newAddress) << ")";
|
||||
break;
|
||||
case SymbolEvent::Added:
|
||||
stream << " (" << hex(newAddress) << ")";
|
||||
break;
|
||||
case SymbolEvent::Removed:
|
||||
case SymbolEvent::SkippedNotCode:
|
||||
case SymbolEvent::SkippedTooSmall:
|
||||
case SymbolEvent::SkippedUnwritable:
|
||||
stream << " (" << hex(oldAddress) << ")";
|
||||
break;
|
||||
}
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
* 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
|
||||
* expected to follow. Context (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.
|
||||
@@ -47,9 +47,19 @@ namespace hot_reload::detail
|
||||
// qualified name, and expands it regardless) and the file fails to
|
||||
// compile on MSVC. Found via an actual Windows build/run of this
|
||||
// library, not just documentation.
|
||||
//
|
||||
// WIN32_LEAN_AND_MEAN: see MemoryProtection.cpp's comment for the
|
||||
// full reasoning. This is the file the reported Clang-on-the-MSVC-ABI
|
||||
// build actually failed in first (inside windows.h's own COM headers,
|
||||
// reached through ole2.h -- nothing this file itself does): it needs
|
||||
// only the PE/COFF IMAGE_* structures, which come from winnt.h and
|
||||
// stay in place under WIN32_LEAN_AND_MEAN.
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
namespace
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace hot_reload::detail
|
||||
* 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
|
||||
* not recognize at all). `Context` never patches a
|
||||
* symbol where this is false, on either side of a reload.
|
||||
*/
|
||||
bool isFunction = false;
|
||||
|
||||
@@ -1,60 +1,21 @@
|
||||
/**
|
||||
* @file Demangle.cpp
|
||||
* @brief hot_reload_demangle() / hot_reload_free_string() implementation.
|
||||
* @brief hot_reload_demangle() / hot_reload_free_string() -- a thin C wrapper over
|
||||
* hot_reload::demangle(), which carries the real logic (see that function's own file,
|
||||
* `src/Demangle.cpp`, directly under `source/HotReload/src/`, not this directory).
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
#include "hot_reload/hot_reload.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include "hot_reload/Demangle.hpp"
|
||||
|
||||
#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 the "Hot reloading C++
|
||||
// symbols" section of source/HotReload/README.md for why this
|
||||
// distinction matters beyond just this function.
|
||||
#include <cxxabi.h>
|
||||
#elif defined(_MSC_VER)
|
||||
// windows.h must come first: dbghelp.h uses types (HANDLE, DWORD,
|
||||
// BOOL, ...) it assumes are already defined rather than including
|
||||
// their defining headers itself -- reversing this order fails to
|
||||
// compile. NOMINMAX prevents windows.h from defining min/max
|
||||
// macros that would otherwise clash with std::min/std::max and
|
||||
// anything else named min/max in this translation unit. Both
|
||||
// fixes below came from an actual Windows build/run of this
|
||||
// library, not just documentation -- see README.md's toolchain
|
||||
// verification notes.
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <dbghelp.h>
|
||||
// Belt-and-suspenders for anyone building this file outside this
|
||||
// project's own CMake (source/HotReload/CMakeLists.txt links
|
||||
// dbghelp explicitly for every MSVC-family build, which is the
|
||||
// primary fix and the one actually required -- confirmed directly
|
||||
// that omitting it leaves UnDecorateSymbolName undefined at link
|
||||
// time regardless of this pragma).
|
||||
#pragma comment(lib, "dbghelp.lib")
|
||||
#endif
|
||||
|
||||
extern "C"
|
||||
{
|
||||
|
||||
@@ -64,40 +25,11 @@ extern "C"
|
||||
{
|
||||
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.
|
||||
// Exercised on a real Windows build (see README.md's
|
||||
// toolchain verification notes) -- getting the
|
||||
// windows.h/dbghelp.h include order and NOMINMAX right
|
||||
// (see the #include block above) and linking dbghelp
|
||||
// explicitly (source/HotReload/CMakeLists.txt) were both
|
||||
// needed to get here.
|
||||
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);
|
||||
std::string demangled = hot_reload::demangle(name);
|
||||
char* result = new char[demangled.size() + 1];
|
||||
std::memcpy(result, demangled.c_str(), demangled.size() + 1);
|
||||
return result;
|
||||
}
|
||||
catch (...)
|
||||
|
||||
@@ -1,23 +1,194 @@
|
||||
/**
|
||||
* @file HotReloadApi.cpp
|
||||
* @brief The extern "C" shim implementing hot_reload.h by delegating to hot_reload::HotReloadContext.
|
||||
* @brief The extern "C" shim implementing hot_reload.h on top of hot_reload::Context.
|
||||
*
|
||||
* Deliberately loose coupling, not the tight opaque-pointer-reinterpret_cast PIMPL
|
||||
* cpp-style-guide.md's own NetManager example uses elsewhere in this codebase: `::HotReloadContext`
|
||||
* (the struct this file defines, matching the opaque forward declaration in `hot_reload.h`) *owns*
|
||||
* a `hot_reload::Context` by value, and every function below reaches it only through Context's own
|
||||
* public methods -- exactly as any other C++ caller of `Context.hpp` would, with no special access
|
||||
* and no reinterpret_cast between unrelated types. `hot_reload::Context` has no idea this file, or
|
||||
* the C API it implements, exists at all.
|
||||
*/
|
||||
#include "hot_reload/hot_reload.h"
|
||||
|
||||
#include "../HotReloadContext.hpp"
|
||||
#include "hot_reload/Context.hpp"
|
||||
#include "hot_reload/Result.hpp"
|
||||
#include "hot_reload/Status.hpp"
|
||||
#include "hot_reload/SymbolEventInfo.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
/// Defines the type `hot_reload.h` only forward-declares (`typedef struct HotReloadContext
|
||||
/// HotReloadContext;`) -- every function in this file sees the real definition, so ordinary
|
||||
/// member access replaces the reinterpret_cast a true opaque pointer would otherwise need.
|
||||
struct HotReloadContext
|
||||
{
|
||||
explicit HotReloadContext(hot_reload::Options options)
|
||||
: impl(std::move(options))
|
||||
{
|
||||
}
|
||||
|
||||
/// A process-wide, never-reused identifier for this context, used only to key
|
||||
/// t_lastErrorByContextId() below -- see that function's own comment for why a raw `this`
|
||||
/// pointer is not used for that instead.
|
||||
std::uint64_t id = nextId();
|
||||
|
||||
/// The real implementation. Composition, not inheritance or a cast: this file uses it
|
||||
/// exactly the way any other hot_reload::Context caller would.
|
||||
hot_reload::Context impl;
|
||||
|
||||
private:
|
||||
static std::uint64_t nextId()
|
||||
{
|
||||
static std::atomic<std::uint64_t> counter{1};
|
||||
return counter.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
/// @param ctx A context previously returned by hot_reload_context_create(), or null.
|
||||
/// @return The same object viewed as the C++ type it really is (null if @p ctx is null).
|
||||
hot_reload::HotReloadContext* impl(::HotReloadContext* ctx)
|
||||
/// hot_reload.h's `hot_reload_last_error()` is a stateful call: "whatever the most recent
|
||||
/// load()/reload() on this ctx failed with". hot_reload::Result has no equivalent shared
|
||||
/// state at all -- each call's outcome comes back in its own Result -- which is exactly what
|
||||
/// makes the C++ API safe to use concurrently without two threads' errors ever being
|
||||
/// confused (see Context.hpp's thread-safety note). Reintroducing that stateful call for the
|
||||
/// C API's own backward compatibility, now safely, means tracking it per *(thread, context)*
|
||||
/// pair: a plain `thread_local` map needs no lock at all, since no other thread can ever see
|
||||
/// this one's instance of it.
|
||||
///
|
||||
/// Keyed by HotReloadContext::id rather than the context's own address: a destroyed
|
||||
/// context's memory can be reused by an unrelated, later one, and keying by address would
|
||||
/// risk a brand new context momentarily reading a stale error string left behind by
|
||||
/// whatever used to live at that same address.
|
||||
std::unordered_map<std::uint64_t, std::string>& t_lastErrorByContextId()
|
||||
{
|
||||
return reinterpret_cast<hot_reload::HotReloadContext*>(ctx);
|
||||
thread_local std::unordered_map<std::uint64_t, std::string> errors;
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// Records @p result's message, if it has one, as this thread's last error for @p ctx.
|
||||
/// @param ctx The context the call was made on.
|
||||
/// @param result The call's own outcome.
|
||||
void recordLastError(const HotReloadContext& ctx, const hot_reload::Result& result)
|
||||
{
|
||||
if (result.message().empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
t_lastErrorByContextId()[ctx.id] = result.message();
|
||||
}
|
||||
|
||||
/// @param status A hot_reload::Status.
|
||||
/// @return The equivalent HotReloadStatus.
|
||||
HotReloadStatus toCStatus(hot_reload::Status status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case hot_reload::Status::Ok:
|
||||
return HOT_RELOAD_OK;
|
||||
case hot_reload::Status::InvalidArgument:
|
||||
return HOT_RELOAD_ERROR_INVALID_ARGUMENT;
|
||||
case hot_reload::Status::LoadFailed:
|
||||
return HOT_RELOAD_ERROR_LOAD_FAILED;
|
||||
case hot_reload::Status::NoPreviousVersion:
|
||||
return HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION;
|
||||
case hot_reload::Status::UnsupportedArchitecture:
|
||||
return HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE;
|
||||
}
|
||||
return HOT_RELOAD_ERROR_LOAD_FAILED; // unreachable for any enumerator; -Wswitch catches a missing one above
|
||||
}
|
||||
|
||||
/// @param status A HotReloadStatus.
|
||||
/// @return The equivalent hot_reload::Status.
|
||||
hot_reload::Status toCppStatus(HotReloadStatus status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case HOT_RELOAD_OK:
|
||||
return hot_reload::Status::Ok;
|
||||
case HOT_RELOAD_ERROR_INVALID_ARGUMENT:
|
||||
return hot_reload::Status::InvalidArgument;
|
||||
case HOT_RELOAD_ERROR_LOAD_FAILED:
|
||||
return hot_reload::Status::LoadFailed;
|
||||
case HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION:
|
||||
return hot_reload::Status::NoPreviousVersion;
|
||||
case HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE:
|
||||
return hot_reload::Status::UnsupportedArchitecture;
|
||||
}
|
||||
return hot_reload::Status::LoadFailed; // unreachable for any enumerator; -Wswitch catches a missing one above
|
||||
}
|
||||
|
||||
/// @param kind A hot_reload::SymbolEvent.
|
||||
/// @return The equivalent HotReloadSymbolEvent.
|
||||
HotReloadSymbolEvent toCEvent(hot_reload::SymbolEvent kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case hot_reload::SymbolEvent::Patched:
|
||||
return HOT_RELOAD_SYMBOL_PATCHED;
|
||||
case hot_reload::SymbolEvent::Added:
|
||||
return HOT_RELOAD_SYMBOL_ADDED;
|
||||
case hot_reload::SymbolEvent::Removed:
|
||||
return HOT_RELOAD_SYMBOL_REMOVED;
|
||||
case hot_reload::SymbolEvent::SkippedNotCode:
|
||||
return HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE;
|
||||
case hot_reload::SymbolEvent::SkippedTooSmall:
|
||||
return HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL;
|
||||
case hot_reload::SymbolEvent::SkippedUnwritable:
|
||||
return HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE;
|
||||
}
|
||||
return HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE; // unreachable for any enumerator; -Wswitch catches one missing above
|
||||
}
|
||||
|
||||
/// Builds the hot_reload::Options this file's two C callback adapters below run through,
|
||||
/// from a (possibly null) HotReloadOptions*.
|
||||
/// @param options The options hot_reload_context_create() was called with; null means none.
|
||||
/// @return Options wrapping @p options's two callbacks (each a no-op if @p options is null,
|
||||
/// or if the specific callback field itself is null), capturing nothing but the
|
||||
/// plain C function pointer and user_data they were given, so this is safe to store
|
||||
/// for the Context's whole lifetime.
|
||||
hot_reload::Options toCppOptions(const HotReloadOptions* options)
|
||||
{
|
||||
hot_reload::Options result;
|
||||
if (options == nullptr)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (options->on_symbol_event != nullptr)
|
||||
{
|
||||
HotReloadSymbolEventFn callback = options->on_symbol_event;
|
||||
void* userData = options->user_data;
|
||||
result.onSymbolEvent = [callback, userData](const hot_reload::SymbolEventInfo& event)
|
||||
{
|
||||
// name.data() is safe to hand out as a NUL-terminated const char* here: it
|
||||
// ultimately points into a detail::ExportedSymbol's own std::string (see
|
||||
// Context.cpp's emitEvent()), which is always NUL-terminated.
|
||||
HotReloadSymbolEventInfo cEvent;
|
||||
cEvent.name = event.name.data();
|
||||
cEvent.kind = toCEvent(event.kind);
|
||||
cEvent.old_address = event.oldAddress;
|
||||
cEvent.new_address = event.newAddress;
|
||||
callback(&cEvent, userData);
|
||||
};
|
||||
}
|
||||
|
||||
if (options->on_deleted_symbol != nullptr)
|
||||
{
|
||||
HotReloadDeletedSymbolFn callback = options->on_deleted_symbol;
|
||||
void* userData = options->user_data;
|
||||
result.onDeletedSymbol = [callback, userData](std::string_view deletedSymbolName) -> void*
|
||||
{
|
||||
// Same reasoning as above for passing .data() directly as a C string.
|
||||
return callback(deletedSymbolName.data(), userData);
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +197,11 @@ namespace
|
||||
// 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.
|
||||
// unordered_map construction (on the hot_reload::Context side of this
|
||||
// boundary) is the only thing 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"
|
||||
{
|
||||
|
||||
@@ -38,7 +209,7 @@ extern "C"
|
||||
{
|
||||
try
|
||||
{
|
||||
return reinterpret_cast<::HotReloadContext*>(new hot_reload::HotReloadContext(options));
|
||||
return new HotReloadContext(toCppOptions(options));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -48,7 +219,17 @@ extern "C"
|
||||
|
||||
void hot_reload_context_destroy(HotReloadContext* ctx)
|
||||
{
|
||||
delete impl(ctx);
|
||||
if (ctx == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Best-effort: removes this (calling) thread's own entry for ctx, if it has one. Another
|
||||
// thread's thread_local entry for the same id, if it made calls on this ctx too, is left
|
||||
// in place -- there is no way to reach another thread's thread_local storage from here --
|
||||
// a small, bounded-by-distinct-destroyed-context-count leak, accepted as the cost of
|
||||
// hot_reload_last_error() needing no lock at all on the hot (every-call) path.
|
||||
t_lastErrorByContextId().erase(ctx->id);
|
||||
delete ctx;
|
||||
}
|
||||
|
||||
HotReloadStatus hot_reload_load(HotReloadContext* ctx, const char* module_name, const char* path)
|
||||
@@ -59,7 +240,9 @@ extern "C"
|
||||
}
|
||||
try
|
||||
{
|
||||
return impl(ctx)->load(module_name, path);
|
||||
hot_reload::Result result = ctx->impl.load(module_name, path);
|
||||
recordLastError(*ctx, result);
|
||||
return toCStatus(result.status());
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -75,7 +258,9 @@ extern "C"
|
||||
}
|
||||
try
|
||||
{
|
||||
return impl(ctx)->reload(module_name, new_path);
|
||||
hot_reload::Result result = ctx->impl.reload(module_name, new_path);
|
||||
recordLastError(*ctx, result);
|
||||
return toCStatus(result.status());
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -91,7 +276,7 @@ extern "C"
|
||||
}
|
||||
try
|
||||
{
|
||||
return impl(ctx)->getSymbol(module_name, symbol_name);
|
||||
return ctx->impl.findSymbol(module_name, symbol_name);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -107,7 +292,9 @@ extern "C"
|
||||
}
|
||||
try
|
||||
{
|
||||
return impl(ctx)->lastError().c_str();
|
||||
auto& errors = t_lastErrorByContextId();
|
||||
auto found = errors.find(ctx->id);
|
||||
return found == errors.end() ? "" : found->second.c_str();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -117,20 +304,7 @@ extern "C"
|
||||
|
||||
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";
|
||||
return hot_reload::toString(toCppStatus(status)).data(); // points into a static string literal either way
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user