Added ARM32 support
This commit is contained in:
1 parent
df792729ef
commit
1212cb9dbe
36 files changed
+2381
-783
No files matched your search
@@ -1,12 +1,4 @@
|
|||||||
<div align="center">
|
|
||||||
<img src="res/icon.png" alt="Logo" width="auto" height="80">
|
|
||||||
|
|
||||||
# Volkan
|
# Volkan
|
||||||
**C++ Hot-Reload library**
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
|
|
||||||
A cross-platform, cross-compiler C/C++ library that patches a running
|
A cross-platform, cross-compiler C/C++ library that patches a running
|
||||||
process's own code in place, so a new build of a function takes effect
|
process's own code in place, so a new build of a function takes effect
|
||||||
@@ -15,16 +7,24 @@ function pointer cached somewhere) picks it up automatically.
|
|||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
// host.cpp -- loads a plugin, calls into it, then hot-reloads it later
|
// host.cpp -- loads a plugin, calls into it, then hot-reloads it later
|
||||||
HotReloadContext* ctx = hot_reload_context_create(nullptr);
|
hot_reload::Context ctx;
|
||||||
hot_reload_load(ctx, "plugin", "plugin.so");
|
ctx.load("plugin", "plugin.so");
|
||||||
|
|
||||||
auto tick = reinterpret_cast<int (*)(int)>(hot_reload_get_symbol(ctx, "plugin", "tick"));
|
auto tick = reinterpret_cast<int (*)(int)>(ctx.findSymbol("plugin", "tick"));
|
||||||
tick(1); // runs V1's code
|
tick(1); // runs V1's code
|
||||||
|
|
||||||
hot_reload_reload(ctx, "plugin", "plugin.so"); // plugin.so was rebuilt in between
|
ctx.reload("plugin", "plugin.so"); // plugin.so was rebuilt in between
|
||||||
tick(1); // same pointer, now runs V2's code
|
tick(1); // same pointer, now runs V2's code
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This is `hot_reload::Context`, the primary, modern C++ API
|
||||||
|
([`Context.hpp`](source/HotReload/include/hot_reload/Context.hpp)) --
|
||||||
|
RAII, a `Result` instead of a status-code-plus-side-channel, and safe to
|
||||||
|
call from multiple threads at once. A separate, independently stable C
|
||||||
|
API ([`hot_reload.h`](source/HotReload/include/hot_reload/hot_reload.h))
|
||||||
|
wraps it for anything that needs plain C linkage; see ["Pick an
|
||||||
|
API"](#pick-an-api) below.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Works on a real function pointer, not just a lookup table.** Patching
|
- **Works on a real function pointer, not just a lookup table.** Patching
|
||||||
@@ -59,9 +59,20 @@ tick(1); // same pointer, now runs V2's c
|
|||||||
reader trusts is bounds-checked against the file's actual size, confirmed
|
reader trusts is bounds-checked against the file's actual size, confirmed
|
||||||
by feeding it 1500 randomly corrupted variants of a real plugin — see
|
by feeding it 1500 randomly corrupted variants of a real plugin — see
|
||||||
"What's actually been tested" below.
|
"What's actually been tested" below.
|
||||||
- **x86, x86-64, and ARM64, across GCC, Clang, MinGW, and MSVC.** See
|
- **x86, x86-64, ARM64, and ARM32 (both its ARM and Thumb-2 instruction
|
||||||
"What's actually been tested" below for exactly how each combination was
|
sets), across GCC, Clang, MinGW, and MSVC.** See "What's actually been
|
||||||
verified — natively, under QEMU, or under Wine, not just reasoned about.
|
tested" below for exactly how each combination was verified —
|
||||||
|
natively, under QEMU, or under Wine, not just reasoned about.
|
||||||
|
- **Safe to call from multiple threads at once, verified under
|
||||||
|
ThreadSanitizer, not just locked and hoped.** `hot_reload::Context`
|
||||||
|
uses a reader-writer lock internally: concurrent `findSymbol()` calls
|
||||||
|
run in parallel with each other, while `load()`/`reload()` briefly
|
||||||
|
take exclusive access. This covers the library's own bookkeeping —
|
||||||
|
it does not, and architecturally cannot, make it safe to reload a
|
||||||
|
module some other thread is at that exact moment executing inside;
|
||||||
|
see ["Pick an API"](#pick-an-api) for the precise guarantee and
|
||||||
|
`source/HotReload/README.md`'s "What this cannot detect" for why that
|
||||||
|
one specific case is out of reach for any tool like this one.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -69,9 +80,22 @@ tick(1); // same pointer, now runs V2's c
|
|||||||
full reference: exactly how patching works, both safety guarantees in
|
full reference: exactly how patching works, both safety guarantees in
|
||||||
detail, the complete C++ interoperability story, per-platform scope and
|
detail, the complete C++ interoperability story, per-platform scope and
|
||||||
how each was verified, and a growing list of real bugs found (and
|
how each was verified, and a growing list of real bugs found (and
|
||||||
fixed) by actually running this — on Windows/MSVC, under fuzzing, and
|
fixed) by actually running this — on Windows/MSVC, under fuzzing, under
|
||||||
on every other toolchain in the table below — read this before relying
|
ThreadSanitizer, and on every other toolchain in the table below — read
|
||||||
on any specific edge case.
|
this before relying on any specific edge case.
|
||||||
|
- **[`Context.hpp`](source/HotReload/include/hot_reload/Context.hpp) is
|
||||||
|
the primary, modern C++ API**, with the rest of its surface --
|
||||||
|
[`Result`](source/HotReload/include/hot_reload/Result.hpp),
|
||||||
|
[`Status`](source/HotReload/include/hot_reload/Status.hpp),
|
||||||
|
[`Options`](source/HotReload/include/hot_reload/Options.hpp),
|
||||||
|
[`SymbolEvent`](source/HotReload/include/hot_reload/SymbolEvent.hpp),
|
||||||
|
[`SymbolEventInfo`](source/HotReload/include/hot_reload/SymbolEventInfo.hpp),
|
||||||
|
[`demangle()`](source/HotReload/include/hot_reload/Demangle.hpp) --
|
||||||
|
one type or function per header, each with its own matching `.cpp`.
|
||||||
|
`hot_reload.h`'s C API is a separate, loosely coupled wrapper built on
|
||||||
|
top of this one's public methods only (`c-api/HotReloadApi.cpp`), not
|
||||||
|
the other way around -- see that file's own file comment, and ["Pick
|
||||||
|
an API"](#pick-an-api) below for which to reach for.
|
||||||
- **Every source file, not just the header, is Doxygen-documented** —
|
- **Every source file, not just the header, is Doxygen-documented** —
|
||||||
`hot_reload.h`'s public API (every function, status code, and event
|
`hot_reload.h`'s public API (every function, status code, and event
|
||||||
kind has a `@brief`/`@param`/`@return`) and every internal class and
|
kind has a `@brief`/`@param`/`@return`) and every internal class and
|
||||||
@@ -103,12 +127,41 @@ scripts\build.bat
|
|||||||
scripts\build.bat Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON
|
scripts\build.bat Release -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON
|
||||||
```
|
```
|
||||||
|
|
||||||
This produces `hotreload_lib`, a `STATIC` library with a plain C API
|
This produces `hotreload_lib`, a `STATIC` library exposing both a
|
||||||
(`hot_reload.h`) over a C++ implementation. Link your host application
|
modern C++ API (`Context.hpp` and the headers alongside it) and a
|
||||||
against it (`target_link_libraries(your_app PRIVATE hotreload_lib)` if
|
separate, independently stable plain C API (`hot_reload.h`) built on
|
||||||
you're using CMake; see [`source/HotReload/CMakeLists.txt`](source/HotReload/CMakeLists.txt)
|
top of it. Link your host application against it
|
||||||
|
(`target_link_libraries(your_app PRIVATE hotreload_lib)` if you're
|
||||||
|
using CMake; see [`source/HotReload/CMakeLists.txt`](source/HotReload/CMakeLists.txt)
|
||||||
for what it links on each platform if you're not).
|
for what it links on each platform if you're not).
|
||||||
|
|
||||||
|
### Pick an API
|
||||||
|
|
||||||
|
Both are first-class, both stay supported, and both end up patching the
|
||||||
|
exact same way underneath (the C API calls the C++ one's own public
|
||||||
|
methods, nothing more privileged) -- this is about which fits what
|
||||||
|
you're writing:
|
||||||
|
|
||||||
|
- **`hot_reload::Context` (`Context.hpp`)** — a C++ host. RAII (no
|
||||||
|
separate create/destroy calls), a `Result` returned directly from
|
||||||
|
`load()`/`reload()` instead of a status code plus a separate
|
||||||
|
`last_error()` side-channel to remember to check, `std::function`
|
||||||
|
callbacks instead of a raw function pointer plus manually-threaded
|
||||||
|
`void*` user data, and the thread-safety guarantee described above —
|
||||||
|
there is no equivalent of the C API's stateful `last_error()` to race
|
||||||
|
on in the first place, since every call's outcome comes back in its
|
||||||
|
own `Result`. Prefer this for any new C++ code.
|
||||||
|
- **`hot_reload.h`** — plain C linkage itself is the requirement: a C
|
||||||
|
host, or a different language's FFI boundary (most FFI story the way
|
||||||
|
a C header does; few can call a C++ class directly at all). The
|
||||||
|
opaque `HotReloadContext*` it hands out is a small wrapper struct
|
||||||
|
(`c-api/HotReloadApi.cpp`) that owns a real `hot_reload::Context` by
|
||||||
|
composition — never a cast between unrelated types — and
|
||||||
|
`hot_reload_last_error()` keeps working exactly as documented by
|
||||||
|
tracking each calling thread's own most recent error internally, so
|
||||||
|
two threads' errors can never be confused with each other even
|
||||||
|
though the underlying `Context` itself keeps none of that state.
|
||||||
|
|
||||||
### See it work — a scripted, deterministic demo
|
### See it work — a scripted, deterministic demo
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -154,12 +207,12 @@ for bugs the first time you hit them.
|
|||||||
it in your own module" for the full version of this list, including
|
it in your own module" for the full version of this list, including
|
||||||
why this specific step doesn't (and can't) quiet `std::string`/
|
why this specific step doesn't (and can't) quiet `std::string`/
|
||||||
`std::vector` noise, and why that's fine.
|
`std::vector` noise, and why that's fine.
|
||||||
4. Have your host `hot_reload_load()` it once at startup and
|
4. Have your host `load()` it once at startup (`hot_reload::Context`,
|
||||||
`hot_reload_reload()` it after every rebuild, from a point where
|
or `hot_reload_load()` through the C API) and `reload()` it after
|
||||||
nothing else is actively calling into it. A path with no `/` in it is
|
every rebuild, from a point where nothing else is actively calling
|
||||||
still resolved against the current working directory (never a system
|
into it. A path with no `/` in it is still resolved against the
|
||||||
library path), so a plain `plugin.so` next to your host works exactly
|
current working directory (never a system library path), so a plain
|
||||||
as you'd expect.
|
`plugin.so` next to your host works exactly as you'd expect.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
@@ -193,12 +246,15 @@ than assumed:
|
|||||||
|
|
||||||
| Toolchain | How it was verified |
|
| Toolchain | How it was verified |
|
||||||
|---|---|
|
|---|---|
|
||||||
| GCC + Clang, native Linux/x86-64 | Both compilers, full demo suite and test suite run to completion, including a CET (`endbr64`) landing pad this toolchain turns out to emit by default, and again explicitly forcing one with `-fcf-protection=full` |
|
| GCC + Clang, native Linux/x86-64 | Both compilers, the full demo and both test suites (`hotreload_tests` for the C API, `context_tests` for `hot_reload::Context` directly) run to completion, including a CET (`endbr64`) landing pad this toolchain turns out to emit by default, and again explicitly forcing one with `-fcf-protection=full` |
|
||||||
| GCC, native Linux/x86 (32-bit) | Full demo and test suite run to completion under `-m32`. This is also where the suite's own "too small to patch" plugin function turned out not to be small enough at `-O2` on this one target — a real, previously-unverified gap between what the test assumed and what the compiler actually emitted, closed by making that function require no instructions at all |
|
| GCC, native Linux/x86 (32-bit) | Full demo and test suite run to completion under `-m32`. This is also where the suite's own "too small to patch" plugin function turned out not to be small enough at `-O2` on this one target — a real, previously-unverified gap between what the test assumed and what the compiler actually emitted, closed by making that function require no instructions at all |
|
||||||
| ARM64 (GCC cross-compiled) | Run under `qemu-aarch64` — twice, once plain and once with `-mbranch-protection=standard`, to exercise the BTI landing-pad path on real ARM64 semantics |
|
| ARM64 (GCC cross-compiled) | Run under `qemu-aarch64` — twice, once plain and once with `-mbranch-protection=standard`, to exercise the BTI landing-pad path on real ARM64 semantics. Re-run, both test suites included, after `hot_reload::Context` and its reader-writer lock were introduced, to confirm a genuinely multi-threaded C++ host works correctly under real (if emulated) ARM64 hardware too, not just natively |
|
||||||
|
| ARM32 (GCC cross-compiled, hard-float ABI) | Run under `qemu-arm` — both instruction sets (the default Thumb-2, and ARM state forced with `-marm`), across `-O0` through `-O3`. This architecture needed a genuinely new backend, not just a recompile: a CPU in this family is always in one of two incompatible instruction-decoding states, detected from the same bit the ARM EABI already marks every function symbol with. The one case that depends on runtime layout rather than just compiler flags was built and run deliberately, not just reasoned about: Thumb-2's literal-load jump needs its own address 4-byte aligned, which a Thumb function is only ever guaranteed to be 2-byte aligned for, so a plugin with a function confirmed landing 2 bytes off that boundary was built and reloaded under qemu to prove the one-instruction realignment this backend adds for exactly that case actually works. Also found here: the suite's own "too small to patch" function needed a third implementation (hand-written, not compiler-generated) — even an empty C++ body's unoptimized frame-pointer prologue reaches this architecture's own jump size at `-O0` |
|
||||||
| MinGW | Cross-compiled and run under Wine, including the PE export-table parser and a real `LoadLibrary` failure (confirmed `hot_reload_last_error()` reports "Bad EXE format" correctly) |
|
| MinGW | Cross-compiled and run under Wine, including the PE export-table parser and a real `LoadLibrary` failure (confirmed `hot_reload_last_error()` reports "Bad EXE format" correctly) |
|
||||||
| MSVC | Built and live-tested on real Windows by someone using this library — not by the environment that wrote it. Four real fixes came out of that and are folded in: `windows.h`/`dbghelp.h` include order, `NOMINMAX`, explicit `dbghelp` linking, and a reload-retry widening for Windows' slower post-rebuild file-availability timing |
|
| MSVC | Built and live-tested on real Windows by someone using this library — not by the environment that wrote it. Four real fixes came out of that and are folded in: `windows.h`/`dbghelp.h` include order, `NOMINMAX`, explicit `dbghelp` linking, and a reload-retry widening for Windows' slower post-rebuild file-availability timing. The same person has since reported it still working after the rewrite to `hot_reload::Context` (reported, not independently re-run here) |
|
||||||
| AddressSanitizer + UndefinedBehaviorSanitizer, and Valgrind with self-modifying-code checking enabled | Full test suite, clean under all three — the self-modifying-code checking matters specifically here, since patching a function's own bytes at runtime is exactly the pattern a memory checker's JIT otherwise mishandles by default |
|
| Clang (GNU-style `clang++`) targeting the MSVC ABI, Windows | **Two real build failures reported against this configuration (Clang 22.1, Windows SDK 10.0.26100), both fixed — but not yet confirmed by a successful end-to-end build on that exact toolchain.** Nothing in the environment this was written in can build for Windows natively, so everything below is a proxy check, not that confirmation. (1) `Demangle.cpp` tested `__clang__` before `_MSC_VER`, but Clang defines both when targeting the MSVC ABI, so it went looking for `<cxxabi.h>`, which that configuration does not have; it now keys on `_MSC_VER` first, since that tracks the mangling scheme in use rather than which compiler frontend produced it. (2) `windows.h` without `WIN32_LEAN_AND_MEAN` pulls in COM headers (`ole2.h` → `combaseapi.h` → …) that failed to compile there; all four files that include it now define it. A third was found by reading the same build log rather than waiting for it to fail: the CMake condition that links `dbghelp` asked whether the compiler takes MSVC *command-line syntax* (the `MSVC` variable, false for GNU-style `clang++`) when it needed to ask whether it produces the MSVC *ABI* — `CMAKE_CXX_SIMULATE_ID`, which CMake sets from the very `_MSC_VER` the C++ code now keys on. What was actually checked: each branch condition under simulated `_MSC_VER` and under plain Clang/GCC; every library source compiled for Windows with mingw-w64 (plus `Demangle.cpp` with its MSVC branch forced on); that `WIN32_LEAN_AND_MEAN` removes exactly the `ole2.h`/`combaseapi.h`/`objidlbase.h`/`servprov.h`/`urlmon.h` chain from the failing log (against mingw-w64's headers, not the real SDK's); the new CMake condition under four compiler configurations, including a `clang++` made to define `_MSC_VER` so CMake reports `MSVC` false and `SIMULATE_ID` `MSVC`, as that log implies; and that Clang accepts `-fpatchable-function-entry=32,0` for the Windows/MSVC target and emits the entry padding this library needs (while `-fms-hotpatch` emits none on x64, so it is not a substitute). The actual `__uuidof` failure itself could not be reproduced here |
|
||||||
|
| AddressSanitizer + UndefinedBehaviorSanitizer, and Valgrind with self-modifying-code checking enabled | Both test suites, clean under all three — the self-modifying-code checking matters specifically here, since patching a function's own bytes at runtime is exactly the pattern a memory checker's JIT otherwise mishandles by default |
|
||||||
|
| ThreadSanitizer | `context_tests`' own concurrency stress test (several threads calling `findSymbol()` against others calling `reload()`, some on the very same module name as each other, plus several more each `load()`-ing their own distinct module name, all against one shared `Context`) -- five separate runs, to cover more than one possible thread interleaving, zero races reported each time. Exactly what `hot_reload::Context`'s reader-writer lock exists to make true, confirmed rather than assumed, the same standard this project holds every other claim in this table to |
|
||||||
| Fuzzing (ELF section-header parsing) | 1500 randomly corrupted variants of a real plugin's section header table — each still a file the OS loader itself accepts via `dlopen()` — all handled cleanly. An earlier version of this same reader crashed on 69 of an initial 300 |
|
| Fuzzing (ELF section-header parsing) | 1500 randomly corrupted variants of a real plugin's section header table — each still a file the OS loader itself accepts via `dlopen()` — all handled cleanly. An earlier version of this same reader crashed on 69 of an initial 300 |
|
||||||
|
|
||||||
Three more real, reproducible bugs came out of this same pass, each
|
Three more real, reproducible bugs came out of this same pass, each
|
||||||
@@ -212,10 +268,7 @@ staging path used to make each reload load from a fresh copy; and the
|
|||||||
ELF section-header hardening above.
|
ELF section-header hardening above.
|
||||||
|
|
||||||
**Deliberately not implemented, rather than implemented and untested:**
|
**Deliberately not implemented, rather than implemented and untested:**
|
||||||
32-bit ARM (its ARM/Thumb instruction-mode ambiguity fails silently if
|
macOS/Mach-O (never requested, and would need a third image format
|
||||||
gotten wrong, and there was no way to verify it on real or emulated
|
reader alongside the existing ELF and PE ones) — a documented, bounded
|
||||||
hardware) and macOS/Mach-O (never requested, and would need a third image
|
gap; see `source/HotReload/README.md`'s "Scope" section for the
|
||||||
format reader alongside the existing ELF and PE ones). Both are
|
reasoning and the extension point if you need it.
|
||||||
documented, bounded gaps — see `source/HotReload/README.md`'s "Scope"
|
|
||||||
section for the reasoning behind each, and the extension point if you
|
|
||||||
need either.
|
|
||||||
@@ -17,7 +17,10 @@
|
|||||||
# branch protection) plus a 16-byte jump sequence = 20 bytes; x86-64's
|
# branch protection) plus a 16-byte jump sequence = 20 bytes; x86-64's
|
||||||
# is a 4-byte ENDBR64 landing pad (on by default on some toolchains --
|
# is a 4-byte ENDBR64 landing pad (on by default on some toolchains --
|
||||||
# see Architecture.cpp's file comment) plus its 14-byte jump = 18;
|
# see Architecture.cpp's file comment) plus its 14-byte jump = 18;
|
||||||
# plain x86 needs at most 4 + 5 = 9. 32 leaves headroom over the
|
# plain x86 needs at most 4 + 5 = 9; ARM32 has no landing pad but, in
|
||||||
|
# its Thumb state specifically, can need one realigning NOP in front
|
||||||
|
# of its 8-byte jump = 10 (see Architecture.cpp's ARM32 file comment).
|
||||||
|
# 32 leaves headroom over the
|
||||||
# largest of those without meaningfully affecting code size -- this is
|
# largest of those without meaningfully affecting code size -- this is
|
||||||
# NOP padding, skipped in a few cycles by the branch predictor on
|
# NOP padding, skipped in a few cycles by the branch predictor on
|
||||||
# every call until a function is actually reloaded.
|
# every call until a function is actually reloaded.
|
||||||
|
|||||||
@@ -138,17 +138,29 @@ if(NOT TARGET hotreload_lib)
|
|||||||
OUTPUT_NAME hotreload
|
OUTPUT_NAME hotreload
|
||||||
POSITION_INDEPENDENT_CODE ON)
|
POSITION_INDEPENDENT_CODE ON)
|
||||||
|
|
||||||
|
# hot_reload::Context uses std::shared_mutex internally (for the thread-safety guarantee
|
||||||
|
# Context.hpp's own doc comment describes), and Demangle.cpp's MSVC branch uses std::mutex --
|
||||||
|
# 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), matching
|
||||||
|
# source/HotReload/CMakeLists.txt exactly.
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
target_link_libraries(hotreload_lib PUBLIC Threads::Threads)
|
||||||
|
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
# dlopen/dlclose/dlinfo (ModuleHandle.cpp). PUBLIC: for a STATIC
|
# dlopen/dlclose/dlinfo (ModuleHandle.cpp). PUBLIC: for a STATIC
|
||||||
# library this needs to reach whatever executable ultimately
|
# library this needs to reach whatever executable ultimately
|
||||||
# links hotreload_lib, matching source/HotReload/CMakeLists.txt.
|
# links hotreload_lib, matching source/HotReload/CMakeLists.txt.
|
||||||
target_link_libraries(hotreload_lib PUBLIC ${CMAKE_DL_LIBS})
|
target_link_libraries(hotreload_lib PUBLIC ${CMAKE_DL_LIBS})
|
||||||
endif()
|
endif()
|
||||||
if(MSVC)
|
# The MSVC *ABI*, not just MSVC command-line syntax: a GNU-style clang++ targeting the
|
||||||
|
# MSVC ABI has MSVC=false but CMAKE_CXX_SIMULATE_ID=MSVC (CMake sets that exactly when the
|
||||||
|
# compiler defines _MSC_VER, the macro Demangle.cpp's own #if keys on) -- see the comment on
|
||||||
|
# the same condition in source/HotReload/CMakeLists.txt for the full reasoning.
|
||||||
|
if(MSVC OR CMAKE_CXX_SIMULATE_ID STREQUAL "MSVC")
|
||||||
# UnDecorateSymbolName (Demangle.cpp) lives in dbghelp.lib, which
|
# UnDecorateSymbolName (Demangle.cpp) lives in dbghelp.lib, which
|
||||||
# MSVC does not link by default. Guarded on MSVC specifically
|
# MSVC does not link by default. Guarded on the MSVC ABI
|
||||||
# (true for cl.exe and clang-cl, false for MinGW), matching
|
# (cl.exe, clang-cl, or GNU-style clang++ targeting MSVC; false
|
||||||
# source/HotReload/CMakeLists.txt exactly.
|
# for MinGW), matching source/HotReload/CMakeLists.txt exactly.
|
||||||
target_link_libraries(hotreload_lib PUBLIC dbghelp)
|
target_link_libraries(hotreload_lib PUBLIC dbghelp)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -5,6 +5,16 @@
|
|||||||
# independent of the project-wide BUILD_SHARED_LIBS default.
|
# independent of the project-wide BUILD_SHARED_LIBS default.
|
||||||
add_lib_module(hotreload_lib TYPE STATIC OUTPUT_NAME "hotreload")
|
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)
|
if(NOT WIN32)
|
||||||
# dlopen/dlclose/dlinfo (ModuleHandle.cpp). PUBLIC, not PRIVATE:
|
# dlopen/dlclose/dlinfo (ModuleHandle.cpp). PUBLIC, not PRIVATE:
|
||||||
# for a STATIC library this needs to reach whatever executable
|
# 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})
|
target_link_libraries(hotreload_lib PUBLIC ${CMAKE_DL_LIBS})
|
||||||
endif()
|
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 --
|
# UnDecorateSymbolName (Demangle.cpp) lives in dbghelp.lib, which --
|
||||||
# unlike kernel32/user32 -- MSVC does not link by default. Without
|
# unlike kernel32/user32 -- MSVC does not link by default. Without
|
||||||
# this, the final link fails with an undefined symbol for it;
|
# this, the final link fails with an undefined symbol for it;
|
||||||
# found via an actual Windows build of this library, not just
|
# found via an actual Windows build of this library, not just
|
||||||
# documentation. PUBLIC for the same reason as CMAKE_DL_LIBS above.
|
# documentation. PUBLIC for the same reason as CMAKE_DL_LIBS above.
|
||||||
# Guarded on MSVC (true for both real cl.exe and clang-cl, and
|
# Guarded on the MSVC ABI rather than WIN32, since MinGW's build of
|
||||||
# false for MinGW) rather than WIN32, since MinGW's build of
|
|
||||||
# Demangle.cpp takes the __GNUC__/__cxa_demangle branch instead and
|
# Demangle.cpp takes the __GNUC__/__cxa_demangle branch instead and
|
||||||
# never needs dbghelp at all.
|
# never needs dbghelp at all.
|
||||||
target_link_libraries(hotreload_lib PUBLIC dbghelp)
|
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
|
overview, feature summary, and build/run instructions if you haven't
|
||||||
already.
|
already.
|
||||||
|
|
||||||
`hot_reload.h` is the API (also fully Doxygen-documented -- see the
|
`hot_reload::Context` (`include/hot_reload/Context.hpp`) is the primary,
|
||||||
top-level README's "Documentation" section for how to build that
|
modern C++ API; `hot_reload.h` is a separate, independently stable plain
|
||||||
locally); `source/examples/HotReloadDemo` is a full worked example;
|
C API built on top of it, for anything that specifically needs C
|
||||||
this file is the overview `hot_reload.h`'s own doc comments don't have
|
linkage. Both are fully Doxygen-documented -- see the top-level README's
|
||||||
room for, especially the parts you should know are *not* guaranteed.
|
"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:
|
To see it run:
|
||||||
|
|
||||||
@@ -37,17 +43,20 @@ as you want while it's live.
|
|||||||
|
|
||||||
## How it works, in one paragraph
|
## How it works, in one paragraph
|
||||||
|
|
||||||
`hot_reload_load()` loads a shared library and reads its exported
|
`load()` loads a shared library and reads its exported symbol table.
|
||||||
symbol table. `hot_reload_reload()` loads a *new* build of that same
|
`reload()` loads a *new* build of that same library, reads its symbol
|
||||||
library, reads its symbol table too, and for every name both builds
|
table too, and for every name both builds export as a function,
|
||||||
export as a function, overwrites the first few bytes of the *old*
|
overwrites the first few bytes of the *old* function with an
|
||||||
function with an unconditional jump to the *new* one's address. Every
|
unconditional jump to the *new* one's address. Every existing call site
|
||||||
existing call site -- direct calls, calls through the PLT/GOT, raw
|
-- direct calls, calls through the PLT/GOT, raw function pointers
|
||||||
function pointers already sitting in some struct -- reaches the old
|
already sitting in some struct -- reaches the old address and
|
||||||
address and immediately redirects, because that's now all that's
|
immediately redirects, because that's now all that's there. Full
|
||||||
there. Full byte-level detail, verified against a real assembler
|
byte-level detail, verified against a real assembler rather than
|
||||||
rather than transcribed from memory, is in
|
transcribed from memory, is in `src/Architecture.cpp`'s file comment.
|
||||||
`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
|
## 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`
|
convention you have to follow -- it's mechanical. `src/SymbolTable.cpp`
|
||||||
classifies every exported symbol using the loader's own metadata (an
|
classifies every exported symbol using the loader's own metadata (an
|
||||||
ELF symbol's type + its section's executable flag; a PE export's
|
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
|
to patch anything not classified as a function, on *either* side of a
|
||||||
reload. `source/examples/HotReloadDemo` proves this concretely:
|
reload. `source/examples/HotReloadDemo` proves this concretely:
|
||||||
`plugin_build_marker` is an exported `int` sitting right next to real
|
`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
|
author has to remember to do correctly -- see "What this cannot
|
||||||
detect" below for the one important thing that's true in reverse.
|
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)
|
## Hot reloading C++ symbols (classes, virtual methods, operators)
|
||||||
|
|
||||||
Everything above works for a real, unmodified C++ mangled name with
|
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 |
|
| | 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 | -- |
|
| Landing pad handled | ENDBR32 | ENDBR64 | BTI | -- |
|
||||||
|
|
||||||
*"Any architecture"* was the ask; ARM32 is the one mainstream target
|
ARM32's own row needs a caveat the table can't carry: "yes" means a function's
|
||||||
deliberately left out, because it's the one case this project's
|
*address* decides ARM vs. Thumb state, not a build-wide setting, and both are
|
||||||
testing couldn't back up with confidence -- see below.
|
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,
|
**Compilers.** GCC and Clang share one backend (including MinGW,
|
||||||
which is one or the other targeting Windows); MSVC has its own
|
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
|
twice -- once plain, once with `-mbranch-protection=bti` -- to
|
||||||
exercise the BTI-landing-pad path on real (emulated) ARM64
|
exercise the BTI-landing-pad path on real (emulated) ARM64
|
||||||
semantics, not just have it compile.
|
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,
|
- Cross-compiled with MinGW and run under Wine: the full demo,
|
||||||
exercising the real Win32 `LoadLibrary`/`VirtualProtect`/
|
exercising the real Win32 `LoadLibrary`/`VirtualProtect`/
|
||||||
`FlushInstructionCache` calls and the PE export-table parser. This
|
`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
|
MinGW -- between the two, treat it as real-world-checked now, not
|
||||||
merely reasoned through, though obviously with less first-hand
|
merely reasoned through, though obviously with less first-hand
|
||||||
confidence than everything that was directly, repeatedly run in
|
confidence than everything that was directly, repeatedly run in
|
||||||
this project's own development loop (native GCC/Clang, ARM64 under
|
this project's own development loop (native GCC/Clang, ARM64 and
|
||||||
QEMU, MinGW under Wine).
|
ARM32 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.
|
|
||||||
- **macOS (Mach-O) was not implemented.** Nothing in the request named
|
- **macOS (Mach-O) was not implemented.** Nothing in the request named
|
||||||
it, and it would need a third format reader alongside
|
it, and it would need a third format reader alongside
|
||||||
`SymbolTable.cpp`'s ELF and PE branches. `readExportedSymbols()` is
|
`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
|
property of your own code across the reload boundary, not something a
|
||||||
symbol table (which carries no field-offset information) can check.
|
symbol table (which carries no field-offset information) can check.
|
||||||
If you need this checked, the extension point is the same
|
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
|
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
|
functions is real work, genuinely useful, and out of scope for what's
|
||||||
here now.
|
here now.
|
||||||
@@ -402,13 +522,16 @@ that file's comment for the full reasoning.
|
|||||||
|
|
||||||
## Diagnosing a failed load
|
## Diagnosing a failed load
|
||||||
|
|
||||||
`HOT_RELOAD_ERROR_LOAD_FAILED` alone doesn't say *why* -- a missing
|
`Status::LoadFailed` (`HOT_RELOAD_ERROR_LOAD_FAILED` through the C API)
|
||||||
file, a permissions problem, a corrupt or wrong-architecture image,
|
alone doesn't say *why* -- a missing file, a permissions problem, a
|
||||||
and a genuinely undefined symbol all look the same from the status
|
corrupt or wrong-architecture image, and a genuinely undefined symbol
|
||||||
code alone. Call `hot_reload_last_error(ctx)` right after a failing
|
all look the same from the status code alone. `Result::message()`
|
||||||
`hot_reload_load()`/`hot_reload_reload()` for the platform's own
|
carries the platform's own description (`dlerror()` on POSIX, a
|
||||||
description (`dlerror()` on POSIX, a formatted `GetLastError()` on
|
formatted `GetLastError()` on Windows) directly -- or, through the C
|
||||||
Windows).
|
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
|
The specific case that motivated adding this, reproduced directly
|
||||||
while writing it: a **non-inline `static` class member that is
|
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
|
plugin uses this style -- compare the two examples' exported symbol
|
||||||
lists (`nm -D --defined-only <path> | c++filt`) to see the
|
lists (`nm -D --defined-only <path> | c++filt`) to see the
|
||||||
difference directly.
|
difference directly.
|
||||||
4. Have your host `hot_reload_load()` it once at startup and
|
4. Have your host `load()` it once at startup (`hot_reload::Context`,
|
||||||
`hot_reload_reload()` it after every rebuild, from a point where
|
or `hot_reload_load()` through the C API) and `reload()` it after
|
||||||
nothing else is calling into it (see "What this cannot detect"
|
every rebuild, from a point where nothing else is calling into it
|
||||||
above).
|
(see "What this cannot detect" above).
|
||||||
5. Rebuilding to the *same output path* every time is fine --
|
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
|
copy internally specifically so your build system's exact
|
||||||
write pattern can't cause a stale reload (a known gotcha for this
|
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
|
`stagePathForLoad()`). On Windows, each staged copy backing a
|
||||||
still-loaded module can't be deleted for as long as that module
|
still-loaded module can't be deleted for as long as that module
|
||||||
stays mapped -- the rest of the process's lifetime, by design --
|
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.
|
* @brief Opaque handle to a hot-reload context.
|
||||||
*
|
*
|
||||||
* See `cpp-style-guide.md` section 16 ("Do: use opaque pointers ... to
|
* See `cpp-style-guide.md` section 16 ("Do: use opaque pointers ... to
|
||||||
* hide implementation details"). The real definition is the C++ class
|
* hide implementation details"). The real definition lives in
|
||||||
* `hot_reload::HotReloadContext` (`HotReloadContext.hpp`), never visible
|
* `c-api/HotReloadApi.cpp`: a small wrapper struct that owns a
|
||||||
* from this header.
|
* `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;
|
typedef struct HotReloadContext HotReloadContext;
|
||||||
|
|
||||||
@@ -237,7 +239,7 @@ typedef enum HotReloadStatus
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* This process's CPU architecture has no patch backend compiled in
|
* 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
|
* Nothing was patched; hot_reload_load() still succeeded, you just
|
||||||
* cannot hot_reload_reload() on this target.
|
* cannot hot_reload_reload() on this target.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @file Architecture.cpp
|
* @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"
|
#include "Architecture.hpp"
|
||||||
|
|
||||||
@@ -218,13 +219,151 @@ namespace hot_reload::detail
|
|||||||
std::atomic_thread_fence(std::memory_order_release);
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isArchitectureSupported()
|
bool isArchitectureSupported()
|
||||||
{
|
{
|
||||||
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
|
#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;
|
return true;
|
||||||
#else
|
#else
|
||||||
return false;
|
return false;
|
||||||
@@ -233,7 +372,14 @@ namespace hot_reload::detail
|
|||||||
|
|
||||||
PatchLayout planPatch(const void* entryAddress)
|
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)
|
defined(_M_ARM64)
|
||||||
const unsigned char* entry = static_cast<const unsigned char*>(entryAddress);
|
const unsigned char* entry = static_cast<const unsigned char*>(entryAddress);
|
||||||
PatchLayout layout;
|
PatchLayout layout;
|
||||||
@@ -248,7 +394,14 @@ namespace hot_reload::detail
|
|||||||
|
|
||||||
bool writeJump(void* entryAddress, const void* targetAddress)
|
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)
|
defined(_M_ARM64)
|
||||||
PatchLayout layout = planPatch(entryAddress);
|
PatchLayout layout = planPatch(entryAddress);
|
||||||
unsigned char* site = static_cast<unsigned char*>(entryAddress) + layout.patchOffset;
|
unsigned char* site = static_cast<unsigned char*>(entryAddress) + layout.patchOffset;
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
* The jump-patch backends (`Architecture.hpp`), the page-protection helper
|
* The jump-patch backends (`Architecture.hpp`), the page-protection helper
|
||||||
* (`MemoryProtection.hpp`), the loader wrapper (`ModuleHandle.hpp`) and the symbol-table readers
|
* (`MemoryProtection.hpp`), the loader wrapper (`ModuleHandle.hpp`) and the symbol-table readers
|
||||||
* (`SymbolTable.hpp`) all live here. They are implementation details of
|
* (`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::Context and may change freely; the supported surface is hot_reload::Context itself
|
||||||
* `hot_reload.h`.
|
* and the C API in `hot_reload.h`.
|
||||||
*/
|
*/
|
||||||
namespace hot_reload::detail
|
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.
|
* @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,
|
* Decided at compile time -- see the top of `Architecture.cpp`. x86,
|
||||||
* x86-64, and ARM64 are supported; anything else (including 32-bit
|
* x86-64, ARM64, and ARM32 (both its ARM and Thumb-2 instruction
|
||||||
* ARM, whose ARM/Thumb interworking this library does not attempt --
|
* sets) are supported; anything else returns false here, and
|
||||||
* see the "Scope" section of `source/HotReload/README.md`) returns false here, and
|
* `hot_reload::Context::reload()` surfaces that as
|
||||||
* `HotReloadContext` surfaces that as
|
* `Status::UnsupportedArchitecture` (`::HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE` through
|
||||||
* ::HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE.
|
* the C API).
|
||||||
*
|
*
|
||||||
* @return True if writeJump() can succeed on this architecture.
|
* @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
|
// would otherwise clash with std::min/std::max and anything else
|
||||||
// named min/max in this translation unit -- found via an actual
|
// named min/max in this translation unit -- found via an actual
|
||||||
// Windows build/run of this library, not just documentation.
|
// 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
|
#ifndef NOMINMAX
|
||||||
#define NOMINMAX
|
#define NOMINMAX
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef WIN32_LEAN_AND_MEAN
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#endif
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#else
|
#else
|
||||||
#include <sys/mman.h>
|
#include <sys/mman.h>
|
||||||
|
|||||||
@@ -8,12 +8,16 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#if defined(_WIN32)
|
#if defined(_WIN32)
|
||||||
// NOMINMAX: see MemoryProtection.cpp's identical comment -- same
|
// NOMINMAX, WIN32_LEAN_AND_MEAN: see MemoryProtection.cpp's identical
|
||||||
// windows.h, same reason, needed in every translation unit that
|
// comment -- same windows.h, same reasons, needed in every
|
||||||
// includes it, not just once for the library as a whole.
|
// translation unit that includes it, not just once for the library
|
||||||
|
// as a whole.
|
||||||
#ifndef NOMINMAX
|
#ifndef NOMINMAX
|
||||||
#define NOMINMAX
|
#define NOMINMAX
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef WIN32_LEAN_AND_MEAN
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#endif
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#else
|
#else
|
||||||
#include <dlfcn.h>
|
#include <dlfcn.h>
|
||||||
@@ -148,7 +152,7 @@ namespace hot_reload::detail
|
|||||||
// would fail with a baffling "cannot open shared object file"
|
// would fail with a baffling "cannot open shared object file"
|
||||||
// for a file that is sitting right there. Everything this
|
// for a file that is sitting right there. Everything this
|
||||||
// library loads is a real file on disk (the staged copy
|
// 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;
|
const std::string loadPath = path.find('/') == std::string::npos ? "./" + path : path;
|
||||||
void* module = dlopen(loadPath.c_str(), RTLD_NOW | RTLD_LOCAL);
|
void* module = dlopen(loadPath.c_str(), RTLD_NOW | RTLD_LOCAL);
|
||||||
if (module == nullptr)
|
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
|
* own metadata (ELF's symbol type + section flags, or a PE export's
|
||||||
* containing section's characteristics), never inferred from the
|
* containing section's characteristics), never inferred from the
|
||||||
* symbol's name or any convention the hot-reloadable module is
|
* 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
|
* refuses to patch anything this file marks isFunction = false, on
|
||||||
* either side of a reload -- so nothing downstream has to be trusted
|
* either side of a reload -- so nothing downstream has to be trusted
|
||||||
* to double-check that part.
|
* to double-check that part.
|
||||||
@@ -47,9 +47,19 @@ namespace hot_reload::detail
|
|||||||
// qualified name, and expands it regardless) and the file fails to
|
// qualified name, and expands it regardless) and the file fails to
|
||||||
// compile on MSVC. Found via an actual Windows build/run of this
|
// compile on MSVC. Found via an actual Windows build/run of this
|
||||||
// library, not just documentation.
|
// 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
|
#ifndef NOMINMAX
|
||||||
#define NOMINMAX
|
#define NOMINMAX
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef WIN32_LEAN_AND_MEAN
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#endif
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ namespace hot_reload::detail
|
|||||||
* executable code changes" guarantee in `hot_reload.h`: false
|
* executable code changes" guarantee in `hot_reload.h`: false
|
||||||
* for anything not mechanically identified as code (a data
|
* for anything not mechanically identified as code (a data
|
||||||
* object, a forwarder export, or a symbol kind this reader does
|
* 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.
|
* symbol where this is false, on either side of a reload.
|
||||||
*/
|
*/
|
||||||
bool isFunction = false;
|
bool isFunction = false;
|
||||||
|
|||||||
@@ -1,60 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* @file Demangle.cpp
|
* @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
|
* Pure string utility, no HotReloadContext involved, so it gets its own file alongside
|
||||||
* own file alongside HotReloadApi.cpp rather than crowding that one's
|
* HotReloadApi.cpp rather than crowding that one's single responsibility (the opaque-pointer
|
||||||
* single responsibility (the opaque-pointer shim). Part II (C API)
|
* shim). Part II (C API) style, like everything else in this directory -- see .clang-tidy here
|
||||||
* style, like everything else in this directory -- see .clang-tidy
|
* and cpp-style-guide.md section 13.
|
||||||
* here and cpp-style-guide.md section 13.
|
|
||||||
*
|
|
||||||
* Every C++ symbol name in this library's events/lookups is always
|
|
||||||
* the REAL symbol-table name (whatever hot_reload_get_symbol() needs
|
|
||||||
* to find it again) -- this function is an opt-in convenience for
|
|
||||||
* DISPLAYING that name, never applied automatically, so there is
|
|
||||||
* never a mismatch between what an event reports and what you'd pass
|
|
||||||
* back in to look the symbol up.
|
|
||||||
*/
|
*/
|
||||||
#include "hot_reload/hot_reload.h"
|
#include "hot_reload/hot_reload.h"
|
||||||
|
|
||||||
#include <cstdlib>
|
#include "hot_reload/Demangle.hpp"
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string>
|
#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"
|
extern "C"
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -64,40 +25,11 @@ extern "C"
|
|||||||
{
|
{
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string text = name; // fallback: unchanged, for anything that isn't a mangled C++ name at all
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
#if defined(__GNUC__) || defined(__clang__)
|
std::string demangled = hot_reload::demangle(name);
|
||||||
int status = 0;
|
char* result = new char[demangled.size() + 1];
|
||||||
char* demangled = abi::__cxa_demangle(name, nullptr, nullptr, &status);
|
std::memcpy(result, demangled.c_str(), demangled.size() + 1);
|
||||||
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);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
|
|||||||
@@ -1,23 +1,194 @@
|
|||||||
/**
|
/**
|
||||||
* @file HotReloadApi.cpp
|
* @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 "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
|
namespace
|
||||||
{
|
{
|
||||||
/// The opaque ::HotReloadContext the public header declares is
|
/// hot_reload.h's `hot_reload_last_error()` is a stateful call: "whatever the most recent
|
||||||
/// never actually instantiated as itself -- every instance really
|
/// load()/reload() on this ctx failed with". hot_reload::Result has no equivalent shared
|
||||||
/// is a hot_reload::HotReloadContext, following the same
|
/// state at all -- each call's outcome comes back in its own Result -- which is exactly what
|
||||||
/// opaque-pointer PIMPL pattern cpp-style-guide.md's NetManager
|
/// makes the C++ API safe to use concurrently without two threads' errors ever being
|
||||||
/// example uses. This is the one place that cast happens.
|
/// confused (see Context.hpp's thread-safety note). Reintroducing that stateful call for the
|
||||||
/// @param ctx A context previously returned by hot_reload_context_create(), or null.
|
/// C API's own backward compatibility, now safely, means tracking it per *(thread, context)*
|
||||||
/// @return The same object viewed as the C++ type it really is (null if @p ctx is null).
|
/// pair: a plain `thread_local` map needs no lock at all, since no other thread can ever see
|
||||||
hot_reload::HotReloadContext* impl(::HotReloadContext* ctx)
|
/// 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 that is not itself compiled to expect one, which a plain C
|
||||||
// caller of this API never is. Every function here is a thin,
|
// caller of this API never is. Every function here is a thin,
|
||||||
// non-throwing wrapper for exactly that reason: std::string/vector/
|
// non-throwing wrapper for exactly that reason: std::string/vector/
|
||||||
// unordered_map construction is the only thing in hot_reload::
|
// unordered_map construction (on the hot_reload::Context side of this
|
||||||
// HotReloadContext that could throw at all (std::bad_alloc under
|
// boundary) is the only thing that could throw at all (std::bad_alloc
|
||||||
// memory pressure), so catching (...) and mapping it to this API's
|
// under memory pressure), so catching (...) and mapping it to this
|
||||||
// existing failure values is sufficient without needing per-exception-
|
// API's existing failure values is sufficient without needing
|
||||||
// type handling.
|
// per-exception-type handling.
|
||||||
extern "C"
|
extern "C"
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -38,7 +209,7 @@ extern "C"
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return reinterpret_cast<::HotReloadContext*>(new hot_reload::HotReloadContext(options));
|
return new HotReloadContext(toCppOptions(options));
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
@@ -48,7 +219,17 @@ extern "C"
|
|||||||
|
|
||||||
void hot_reload_context_destroy(HotReloadContext* ctx)
|
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)
|
HotReloadStatus hot_reload_load(HotReloadContext* ctx, const char* module_name, const char* path)
|
||||||
@@ -59,7 +240,9 @@ extern "C"
|
|||||||
}
|
}
|
||||||
try
|
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 (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
@@ -75,7 +258,9 @@ extern "C"
|
|||||||
}
|
}
|
||||||
try
|
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 (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
@@ -91,7 +276,7 @@ extern "C"
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return impl(ctx)->getSymbol(module_name, symbol_name);
|
return ctx->impl.findSymbol(module_name, symbol_name);
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
@@ -107,7 +292,9 @@ extern "C"
|
|||||||
}
|
}
|
||||||
try
|
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 (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
@@ -117,20 +304,7 @@ extern "C"
|
|||||||
|
|
||||||
const char* hot_reload_status_string(HotReloadStatus status)
|
const char* hot_reload_status_string(HotReloadStatus status)
|
||||||
{
|
{
|
||||||
switch (status)
|
return hot_reload::toString(toCppStatus(status)).data(); // points into a static string literal either way
|
||||||
{
|
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file Plugin.cpp
|
* @file Plugin.cpp
|
||||||
* @brief PluginV1 -- "before your edit". See PluginV2/src/Plugin.cpp for
|
* @brief PluginV1 -- "before your edit". See PluginV2/src/Plugin.cpp for
|
||||||
* "after"; the two deliberately export the identical set of names
|
* "after"; the two deliberately export the identical set of names
|
||||||
* (minus plugin_legacy_feature, removed in V2) so HotReloadContext can
|
* (minus plugin_legacy_feature, removed in V2) so Context can
|
||||||
* line them up. See ../Shared/PluginApi.h for what each export is
|
* line them up. See ../Shared/PluginApi.h for what each export is
|
||||||
* there to demonstrate (and for the documentation of every function
|
* there to demonstrate (and for the documentation of every function
|
||||||
* defined here).
|
* defined here).
|
||||||
@@ -24,7 +24,16 @@ int plugin_legacy_feature(int input)
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(__GNUC__) || defined(__clang__)
|
#if defined(__arm__) || defined(_M_ARM)
|
||||||
|
// See Shared/PluginApi.h's doc comment on plugin_tiny_function for why ARM32 needs this, rather
|
||||||
|
// than the plain empty body every other architecture uses below.
|
||||||
|
__asm__(
|
||||||
|
".global plugin_tiny_function\n"
|
||||||
|
".type plugin_tiny_function, %function\n"
|
||||||
|
"plugin_tiny_function:\n"
|
||||||
|
"\tbx lr\n"
|
||||||
|
".size plugin_tiny_function, . - plugin_tiny_function\n");
|
||||||
|
#elif defined(__GNUC__) || defined(__clang__)
|
||||||
void plugin_tiny_function(void)
|
void plugin_tiny_function(void)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,16 @@ int plugin_new_feature(int input)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(__GNUC__) || defined(__clang__)
|
#if defined(__arm__) || defined(_M_ARM)
|
||||||
|
// See Shared/PluginApi.h's doc comment on plugin_tiny_function for why ARM32 needs this, rather
|
||||||
|
// than the plain empty body every other architecture uses below.
|
||||||
|
__asm__(
|
||||||
|
".global plugin_tiny_function\n"
|
||||||
|
".type plugin_tiny_function, %function\n"
|
||||||
|
"plugin_tiny_function:\n"
|
||||||
|
"\tbx lr\n"
|
||||||
|
".size plugin_tiny_function, . - plugin_tiny_function\n");
|
||||||
|
#elif defined(__GNUC__) || defined(__clang__)
|
||||||
void plugin_tiny_function(void)
|
void plugin_tiny_function(void)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ extern "C"
|
|||||||
* and the library can only estimate one from the distance to the next
|
* and the library can only estimate one from the distance to the next
|
||||||
* export. MSVC has no equivalent per-function override this demo could
|
* export. MSVC has no equivalent per-function override this demo could
|
||||||
* apply, so this one only exists on GCC/Clang.
|
* apply, so this one only exists on GCC/Clang.
|
||||||
|
*
|
||||||
|
* ARM32 needs a different implementation underneath this same
|
||||||
|
* declaration (see PluginV1/src/Plugin.cpp): an ordinary empty C++
|
||||||
|
* body there, even with patchable-function-entry overridden to zero,
|
||||||
|
* still picks up an unoptimized build's full frame-pointer prologue
|
||||||
|
* and epilogue -- confirmed directly, 8 bytes on this architecture at
|
||||||
|
* `-O0`, tying this library's own ARM-state jump size exactly rather
|
||||||
|
* than falling safely under it. A single hand-written `bx lr`, at file
|
||||||
|
* scope rather than inside a C++ function body (sidestepping a
|
||||||
|
* separate GCC quirk: an asm statement inside a naked function body
|
||||||
|
* is followed by an unconditional extra NOP, confirmed directly),
|
||||||
|
* reliably stays under every ARM32 jump size this library ever
|
||||||
|
* writes, in both instruction sets, at every optimization level.
|
||||||
*/
|
*/
|
||||||
__attribute__((patchable_function_entry(0))) void plugin_tiny_function(void);
|
__attribute__((patchable_function_entry(0))) void plugin_tiny_function(void);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ exact workflow (keep the Host running, rebuild the DLL it already
|
|||||||
loaded) fail with the linker unable to write its output. This example
|
loaded) fail with the linker unable to write its output. This example
|
||||||
avoids that for a reason that already exists elsewhere in this
|
avoids that for a reason that already exists elsewhere in this
|
||||||
library, not anything specific to this demo:
|
library, not anything specific to this demo:
|
||||||
`HotReloadContext::stagePathForLoad()` (`source/HotReload/src/
|
`Context::stagePathForLoad()` (`source/HotReload/src/
|
||||||
HotReloadContext.cpp`) copies a plugin to a fresh, uniquely-named file
|
Context.cpp`) copies a plugin to a fresh, uniquely-named file
|
||||||
before ever calling `LoadLibrary`/`dlopen` on it -- built originally to
|
before ever calling `LoadLibrary`/`dlopen` on it -- built originally to
|
||||||
sidestep a dlopen caching gotcha on POSIX (see that function's
|
sidestep a dlopen caching gotcha on POSIX (see that function's
|
||||||
comment) -- so the path this example's build actually writes to is
|
comment) -- so the path this example's build actually writes to is
|
||||||
@@ -108,7 +108,7 @@ genuine full clean.
|
|||||||
## Leftover `hotreload_live_plugin.dll.hotreload.live.N` files
|
## Leftover `hotreload_live_plugin.dll.hotreload.live.N` files
|
||||||
|
|
||||||
Each reload stages the plugin through a fresh, uniquely-named copy
|
Each reload stages the plugin through a fresh, uniquely-named copy
|
||||||
before loading it (`HotReloadContext::stagePathForLoad()`, needed for
|
before loading it (`Context::stagePathForLoad()`, needed for
|
||||||
reasons unrelated to this -- see that function's comment). On Linux/
|
reasons unrelated to this -- see that function's comment). On Linux/
|
||||||
macOS, the copy is deleted right after loading and nothing
|
macOS, the copy is deleted right after loading and nothing
|
||||||
accumulates. On Windows, that deletion fails for as long as the module
|
accumulates. On Windows, that deletion fails for as long as the module
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Exercises hot_reload::Context (the C++ API) directly, including a real multi-threaded stress
|
||||||
|
# test -- see Main.cpp's own file comment. Reuses PluginV1/PluginV2 from
|
||||||
|
# source/examples/HotReloadDemo as fixtures, exactly as HotReloadTests does and for the same
|
||||||
|
# reason (see that CMakeLists.txt's own comment); requires BUILD_EXAMPLES as well as BUILD_TESTS
|
||||||
|
# for that reason.
|
||||||
|
add_test_module(context_tests OUTPUT_NAME "context_tests" DEPENDS hotreload_lib)
|
||||||
|
|
||||||
|
if(BUILD_EXAMPLES)
|
||||||
|
add_dependencies(context_tests hotreload_demo_plugin_v1 hotreload_demo_plugin_v2)
|
||||||
|
target_compile_definitions(context_tests PRIVATE
|
||||||
|
"PLUGIN_V1_PATH=\"$<TARGET_FILE:hotreload_demo_plugin_v1>\""
|
||||||
|
"PLUGIN_V2_PATH=\"$<TARGET_FILE:hotreload_demo_plugin_v2>\"")
|
||||||
|
else()
|
||||||
|
message(WARNING "context_tests needs BUILD_EXAMPLES=ON too (it reuses the HotReloadDemo plugins as fixtures); skipping its checks.")
|
||||||
|
target_compile_definitions(context_tests PRIVATE HOTRELOAD_TESTS_SKIP=1)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* @file Main.cpp
|
||||||
|
* @brief Exercises hot_reload::Context directly -- the modern C++ API -- rather than through
|
||||||
|
* hot_reload.h, which HotReloadTests/Main.cpp already covers thoroughly. Two things only that
|
||||||
|
* test cannot check at all: that Context is genuinely usable without ever touching the C API,
|
||||||
|
* and that it is safe to call concurrently (see runConcurrencyStress() below, meant to be run
|
||||||
|
* under ThreadSanitizer -- see source/HotReload/README.md's "What's actually been tested").
|
||||||
|
*
|
||||||
|
* Reuses PluginV1/PluginV2 from source/examples/HotReloadDemo as fixtures, exactly as
|
||||||
|
* HotReloadTests/Main.cpp does and for the same reason (see that file's own comment).
|
||||||
|
*/
|
||||||
|
#include "hot_reload/Context.hpp"
|
||||||
|
#include "hot_reload/Demangle.hpp"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#ifndef HOTRELOAD_TESTS_SKIP
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int g_failures = 0;
|
||||||
|
|
||||||
|
/// Records a failed check to stderr and increments g_failures. Called only through CHECK().
|
||||||
|
/// @param condition The check's result.
|
||||||
|
/// @param expression The checked expression, stringified.
|
||||||
|
/// @param file The source file the check appears in.
|
||||||
|
/// @param line The line the check appears on.
|
||||||
|
void checkImpl(bool condition, const char* expression, const char* file, int line)
|
||||||
|
{
|
||||||
|
if (!condition)
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "CHECK FAILED: %s (%s:%d)\n", expression, file, line);
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks @p expr and records a failure (with its source location) if it is false, without
|
||||||
|
/// relying on assert(), which NDEBUG (this project's default Release build) compiles out.
|
||||||
|
#define CHECK(expr) checkImpl((expr), #expr, __FILE__, __LINE__)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
using PluginFunction = int (*)(int);
|
||||||
|
|
||||||
|
/// Basic single-threaded correctness: load, resolve, call, reload, call the same pointer
|
||||||
|
/// again -- the same story HotReloadTests/Main.cpp already tells through the C API, told
|
||||||
|
/// here entirely through hot_reload::Context instead, to confirm the C API is a wrapper
|
||||||
|
/// around this and not the only way to get this behavior.
|
||||||
|
void runBasicUsage()
|
||||||
|
{
|
||||||
|
hot_reload::Context ctx;
|
||||||
|
|
||||||
|
hot_reload::Result loaded = ctx.load("plugin", PLUGIN_V1_PATH);
|
||||||
|
CHECK(loaded.ok());
|
||||||
|
CHECK(static_cast<bool>(loaded));
|
||||||
|
CHECK(loaded.status() == hot_reload::Status::Ok);
|
||||||
|
CHECK(loaded.message().empty());
|
||||||
|
|
||||||
|
// Loading the same name twice is InvalidArgument, with a message that explains why --
|
||||||
|
// reload() is for this, not a second load().
|
||||||
|
hot_reload::Result reloadedAsLoad = ctx.load("plugin", PLUGIN_V1_PATH);
|
||||||
|
CHECK(!reloadedAsLoad.ok());
|
||||||
|
CHECK(reloadedAsLoad.status() == hot_reload::Status::InvalidArgument);
|
||||||
|
|
||||||
|
auto tick = reinterpret_cast<PluginFunction>(ctx.findSymbol("plugin", "plugin_tick"));
|
||||||
|
CHECK(tick != nullptr);
|
||||||
|
CHECK(tick(10) == 20); // V1: input * 2
|
||||||
|
|
||||||
|
int patchedCount = 0;
|
||||||
|
int removedCount = 0;
|
||||||
|
hot_reload::Options options;
|
||||||
|
options.onSymbolEvent = [&patchedCount, &removedCount](const hot_reload::SymbolEventInfo& event)
|
||||||
|
{
|
||||||
|
if (event.kind == hot_reload::SymbolEvent::Patched)
|
||||||
|
{
|
||||||
|
++patchedCount;
|
||||||
|
}
|
||||||
|
else if (event.kind == hot_reload::SymbolEvent::Removed)
|
||||||
|
{
|
||||||
|
++removedCount;
|
||||||
|
CHECK(event.name == "plugin_legacy_feature");
|
||||||
|
}
|
||||||
|
CHECK(!event.summary().empty()); // exercises SymbolEventInfo::summary() for real
|
||||||
|
};
|
||||||
|
|
||||||
|
hot_reload::Context ctxWithCallback(std::move(options));
|
||||||
|
CHECK(ctxWithCallback.load("plugin", PLUGIN_V1_PATH).ok());
|
||||||
|
hot_reload::Result reloadResult = ctxWithCallback.reload("plugin", PLUGIN_V2_PATH);
|
||||||
|
CHECK(reloadResult.ok());
|
||||||
|
CHECK(patchedCount > 0);
|
||||||
|
CHECK(removedCount == 1);
|
||||||
|
|
||||||
|
auto tickAfterReload = reinterpret_cast<PluginFunction>(ctxWithCallback.findSymbol("plugin", "plugin_tick"));
|
||||||
|
CHECK(tickAfterReload != nullptr);
|
||||||
|
CHECK(tickAfterReload(10) == 30); // V2: input * 3
|
||||||
|
|
||||||
|
// reload() for a name that was never load()'ed.
|
||||||
|
hot_reload::Context emptyCtx;
|
||||||
|
hot_reload::Result noPrevious = emptyCtx.reload("never-loaded", PLUGIN_V1_PATH);
|
||||||
|
CHECK(!noPrevious.ok());
|
||||||
|
CHECK(noPrevious.status() == hot_reload::Status::NoPreviousVersion);
|
||||||
|
|
||||||
|
// load() for a file that cannot possibly exist.
|
||||||
|
hot_reload::Result loadFailed = emptyCtx.load("bogus", "/this/path/does/not/exist.so");
|
||||||
|
CHECK(!loadFailed.ok());
|
||||||
|
CHECK(loadFailed.status() == hot_reload::Status::LoadFailed);
|
||||||
|
CHECK(!loadFailed.message().empty());
|
||||||
|
|
||||||
|
// findSymbol() for an unknown module, and for an unknown symbol in a known one.
|
||||||
|
CHECK(ctx.findSymbol("never-loaded", "anything") == nullptr);
|
||||||
|
CHECK(ctx.findSymbol("plugin", "this_symbol_does_not_exist") == nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result::success()/failure() constructed directly, outside any Context call at all --
|
||||||
|
/// confirms the type is a freely usable value type, not something only Context can produce.
|
||||||
|
void runResultAsValueType()
|
||||||
|
{
|
||||||
|
hot_reload::Result ok = hot_reload::Result::success();
|
||||||
|
CHECK(ok.ok());
|
||||||
|
CHECK(ok.status() == hot_reload::Status::Ok);
|
||||||
|
CHECK(ok.message().empty());
|
||||||
|
|
||||||
|
hot_reload::Result failed = hot_reload::Result::failure(hot_reload::Status::LoadFailed, "a specific reason");
|
||||||
|
CHECK(!failed.ok());
|
||||||
|
CHECK(failed.status() == hot_reload::Status::LoadFailed);
|
||||||
|
CHECK(failed.message() == "a specific reason");
|
||||||
|
|
||||||
|
for (hot_reload::Status status : {hot_reload::Status::Ok,
|
||||||
|
hot_reload::Status::InvalidArgument,
|
||||||
|
hot_reload::Status::LoadFailed,
|
||||||
|
hot_reload::Status::NoPreviousVersion,
|
||||||
|
hot_reload::Status::UnsupportedArchitecture})
|
||||||
|
{
|
||||||
|
CHECK(!hot_reload::toString(status).empty()); // every enumerator must map to something real
|
||||||
|
}
|
||||||
|
for (hot_reload::SymbolEvent event : {hot_reload::SymbolEvent::Patched,
|
||||||
|
hot_reload::SymbolEvent::Added,
|
||||||
|
hot_reload::SymbolEvent::Removed,
|
||||||
|
hot_reload::SymbolEvent::SkippedNotCode,
|
||||||
|
hot_reload::SymbolEvent::SkippedTooSmall,
|
||||||
|
hot_reload::SymbolEvent::SkippedUnwritable})
|
||||||
|
{
|
||||||
|
CHECK(!hot_reload::toString(event).empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context is movable even though its Impl holds a (non-movable) std::shared_mutex --
|
||||||
|
/// confirms the PIMPL actually buys the movability Context.hpp's doc comment promises.
|
||||||
|
void runMoveSemantics()
|
||||||
|
{
|
||||||
|
hot_reload::Context original;
|
||||||
|
CHECK(original.load("plugin", PLUGIN_V1_PATH).ok());
|
||||||
|
|
||||||
|
hot_reload::Context moved(std::move(original));
|
||||||
|
CHECK(moved.findSymbol("plugin", "plugin_tick") != nullptr);
|
||||||
|
|
||||||
|
hot_reload::Context another;
|
||||||
|
another = std::move(moved);
|
||||||
|
CHECK(another.findSymbol("plugin", "plugin_tick") != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hot_reload::demangle() used directly, not through hot_reload_demangle() -- see
|
||||||
|
/// Demangle.hpp. A real mangled name, not a hand-typed guess: taken from the demo's own
|
||||||
|
/// SymbolEvent log for CppWidget's constructor, which is exactly what a real caller would
|
||||||
|
/// have in hand (a name straight out of a SymbolEventInfo).
|
||||||
|
void runDemangle()
|
||||||
|
{
|
||||||
|
hot_reload::Context ctx;
|
||||||
|
CHECK(ctx.load("plugin", PLUGIN_V1_PATH).ok());
|
||||||
|
|
||||||
|
std::string realMangledName;
|
||||||
|
hot_reload::Options options;
|
||||||
|
options.onSymbolEvent = [&realMangledName](const hot_reload::SymbolEventInfo& event)
|
||||||
|
{
|
||||||
|
if (realMangledName.empty() && event.name.find("CppWidget") != std::string_view::npos)
|
||||||
|
{
|
||||||
|
realMangledName = event.name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
hot_reload::Context ctxForReload(std::move(options));
|
||||||
|
CHECK(ctxForReload.load("plugin", PLUGIN_V1_PATH).ok());
|
||||||
|
CHECK(ctxForReload.reload("plugin", PLUGIN_V2_PATH).ok());
|
||||||
|
|
||||||
|
CHECK(!realMangledName.empty());
|
||||||
|
std::string demangled = hot_reload::demangle(realMangledName);
|
||||||
|
CHECK(demangled.find("CppWidget") != std::string::npos);
|
||||||
|
|
||||||
|
// Something that is definitely not a mangled C++ name comes back unchanged, not empty
|
||||||
|
// and not garbage.
|
||||||
|
CHECK(hot_reload::demangle("plugin_tick") == "plugin_tick");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thread-safety claim in Context.hpp's own doc comment, actually exercised: several
|
||||||
|
/// threads calling findSymbol() on a shared Context concurrently with other threads
|
||||||
|
/// load()/reload()-ing (some on the SAME module name as each other -- a genuine race for
|
||||||
|
/// which build ends up active, which this makes no assertion about -- and some on
|
||||||
|
/// per-thread-unique names, which this does check resolve correctly). Run this binary under
|
||||||
|
/// ThreadSanitizer for the result that actually matters: zero reported data races, not just
|
||||||
|
/// "it didn't crash this time".
|
||||||
|
void runConcurrencyStress()
|
||||||
|
{
|
||||||
|
constexpr int kReaderThreads = 4;
|
||||||
|
constexpr int kReaderIterations = 500;
|
||||||
|
constexpr int kSharedReloaderThreads = 2;
|
||||||
|
constexpr int kSharedReloadIterations = 25;
|
||||||
|
constexpr int kUniqueLoaderThreads = 4;
|
||||||
|
|
||||||
|
hot_reload::Context ctx;
|
||||||
|
CHECK(ctx.load("plugin", PLUGIN_V1_PATH).ok());
|
||||||
|
|
||||||
|
std::atomic<bool> stop{false};
|
||||||
|
std::atomic<int> readSuccesses{0};
|
||||||
|
std::vector<std::thread> threads;
|
||||||
|
|
||||||
|
// Readers: hammer findSymbol() on the one module every writer thread below is also
|
||||||
|
// touching, for the whole stress run.
|
||||||
|
for (int i = 0; i < kReaderThreads; ++i)
|
||||||
|
{
|
||||||
|
threads.emplace_back(
|
||||||
|
[&ctx, &stop, &readSuccesses]()
|
||||||
|
{
|
||||||
|
int iterations = 0;
|
||||||
|
while (!stop.load(std::memory_order_relaxed) && iterations < kReaderIterations)
|
||||||
|
{
|
||||||
|
const void* symbol = ctx.findSymbol("plugin", "plugin_tick");
|
||||||
|
if (symbol != nullptr)
|
||||||
|
{
|
||||||
|
readSuccesses.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
++iterations;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writers: alternate reload()-ing the SAME module name between V1 and V2, concurrently
|
||||||
|
// with each other and with the readers above. No assertion on which build "wins" any
|
||||||
|
// one moment -- only that nothing crashes and the context stays internally consistent.
|
||||||
|
for (int i = 0; i < kSharedReloaderThreads; ++i)
|
||||||
|
{
|
||||||
|
threads.emplace_back(
|
||||||
|
[&ctx]()
|
||||||
|
{
|
||||||
|
for (int iteration = 0; iteration < kSharedReloadIterations; ++iteration)
|
||||||
|
{
|
||||||
|
const char* path = (iteration % 2 == 0) ? PLUGIN_V2_PATH : PLUGIN_V1_PATH;
|
||||||
|
hot_reload::Result result = ctx.reload("plugin", path);
|
||||||
|
checkImpl(result.ok(), "result.ok() during concurrent reload", __FILE__, __LINE__);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loaders: each thread load()s its OWN uniquely-named module (same underlying file,
|
||||||
|
// different logical names) -- concurrent INSERTS into the same map, from different
|
||||||
|
// threads, each then confirmed to resolve correctly afterward.
|
||||||
|
std::vector<std::thread> loaderThreads;
|
||||||
|
std::atomic<int> loadFailures{0};
|
||||||
|
for (int i = 0; i < kUniqueLoaderThreads; ++i)
|
||||||
|
{
|
||||||
|
threads.emplace_back(
|
||||||
|
[&ctx, &loadFailures, i]()
|
||||||
|
{
|
||||||
|
std::string name = "unique-module-" + std::to_string(i);
|
||||||
|
hot_reload::Result result = ctx.load(name, PLUGIN_V1_PATH);
|
||||||
|
if (!result.ok())
|
||||||
|
{
|
||||||
|
loadFailures.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const void* symbol = ctx.findSymbol(name, "plugin_tick");
|
||||||
|
if (symbol == nullptr)
|
||||||
|
{
|
||||||
|
loadFailures.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (std::thread& thread : threads)
|
||||||
|
{
|
||||||
|
thread.join();
|
||||||
|
}
|
||||||
|
stop.store(true, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
CHECK(readSuccesses.load() > 0); // readers actually found the symbol at least sometimes
|
||||||
|
CHECK(loadFailures.load() == 0); // every per-thread-unique load()+findSymbol() succeeded
|
||||||
|
|
||||||
|
// The context is still fully usable after all of that -- one more reload, one more call,
|
||||||
|
// for a final sanity check that nothing was left in a corrupted state.
|
||||||
|
CHECK(ctx.reload("plugin", PLUGIN_V2_PATH).ok());
|
||||||
|
auto tick = reinterpret_cast<PluginFunction>(ctx.findSymbol("plugin", "plugin_tick"));
|
||||||
|
CHECK(tick != nullptr);
|
||||||
|
CHECK(tick(10) == 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
runBasicUsage();
|
||||||
|
runResultAsValueType();
|
||||||
|
runMoveSemantics();
|
||||||
|
runDemangle();
|
||||||
|
runConcurrencyStress();
|
||||||
|
|
||||||
|
if (g_failures == 0)
|
||||||
|
{
|
||||||
|
std::printf("context_tests: all checks passed\n");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "context_tests: %d check(s) failed\n", g_failures);
|
||||||
|
}
|
||||||
|
return g_failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
std::printf("context_tests: skipped (HOTRELOAD_TESTS_SKIP set -- needs BUILD_EXAMPLES=ON too)\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
Reference in new issue
Block a user