Added ARM32 support

This commit is contained in:
alejo committed 2026-11-03 15:22:24 -03:00
1 parent df792729ef
commit 1212cb9dbe
36 files changed
+2381 -783

No files matched your search

+157 -4
View File
@@ -1,6 +1,7 @@
/**
* @file Architecture.cpp
* @brief Per-architecture jump-patch backends for x86, x86-64, and ARM64.
* @brief Per-architecture jump-patch backends for x86, x86-64, ARM64, and ARM32 (both the ARM
* and Thumb-2 instruction sets).
*/
#include "Architecture.hpp"
@@ -218,13 +219,151 @@ namespace hot_reload::detail
std::atomic_thread_fence(std::memory_order_release);
}
#elif defined(__arm__) || defined(_M_ARM)
/* 32-bit ARM is a genuinely different shape from the three backends above: every
* other architecture here picks one instruction encoding and a single fixed
* kJumpBytes. ARM32 cannot, for a reason specific to this architecture: a CPU in
* this family is always in one of two incompatible instruction-decoding states, ARM
* (fixed 4-byte instructions) or Thumb (2-byte and 4-byte Thumb-2 instructions), and
* a function's own compiled state -- never a free choice this library gets to make,
* only something to detect and match -- decides which encoding is even legal to
* write at its address. The ARM EABI records this on every function symbol: bit 0 of
* its value is set if and only if the function is Thumb code (so that loading the
* symbol's own value into pc, exactly what this backend's jump does, switches the
* CPU to the right state automatically) -- SymbolTable.cpp's `loadedBase + st_value`
* already preserves that bit unmodified for every architecture, so detecting the
* mode here is as simple as reading it back off the address this backend is handed.
*
* A second, smaller wrinkle is specific to Thumb state alone: its `ldr pc, [...]`
* literal-load form can only read from a 4-byte-aligned address, computed from the
* instruction's OWN address rounded down to 4 bytes -- but a Thumb function is only
* ever guaranteed 2-byte alignment, not 4 (confirmed directly: building a plugin
* with -falign-functions=1 reliably produces functions starting 2 bytes off a
* 4-byte boundary). So Thumb state alone needs a one-instruction fork: a 2-byte NOP
* realigns first when the entry isn't already on a 4-byte boundary, nothing extra
* when it already is. ARM state needs no such fork -- that state requires 4-byte
* alignment architecture-wide, so every ARM-mode function already has it.
*
* No landing pad exists on this architecture in any mainstream toolchain or CPU
* extension in common use (unlike x86 CET or ARM64 BTI) -- confirmed directly:
* -fpatchable-function-entry's reserved padding here is plain NOPs in both states,
* nothing else, so there is never anything to detect or preserve in front of a
* function's real body.
*
* Every byte sequence below was derived the same way as the other three backends:
* assembled with `as` from the exact mnemonics named in each comment, and the
* resulting bytes read back with objdump -- including, for the Thumb realignment
* case specifically, assembling it at both a 4-byte-aligned and a 2-mod-4 address
* and confirming objdump's own computed literal-load target lands exactly on the
* embedded address both times.
*/
/// No landing pad exists on this architecture (see the file comment above), so unlike
/// the three backends above, there is no hasLandingPad()/kLandingPadBytes pair here:
/// the outer planPatch() below sets PatchLayout::patchOffset to 0 directly instead.
/// @param address A function's address as this library carries it: `loadedBase +
/// st_value`, Thumb's mode bit (bit 0) included unmodified.
/// @return True if @p address names Thumb code, per the ARM EABI convention.
bool isThumb(const void* address)
{
return (reinterpret_cast<std::uintptr_t>(address) & std::uintptr_t{1}) != 0;
}
/// @param address A function's address, Thumb mode bit possibly included.
/// @return The real, even byte address of its first instruction -- bit 0 is a mode
/// marker on this architecture, never part of the actual memory location.
unsigned char* realAddress(const void* address)
{
return reinterpret_cast<unsigned char*>(reinterpret_cast<std::uintptr_t>(address) & ~std::uintptr_t{1});
}
/// `ldr pc, [pc, #-4]`: ARM state's literal-load jump. ARM state is always 4-byte
/// aligned architecture-wide, so this one fixed form always applies; the embedded
/// address follows immediately, 8 bytes total.
constexpr unsigned char kArmJump[4] = {0x04, 0xF0, 0x1F, 0xE5};
/// `ldr.w pc, [pc, #0]`: Thumb-2's literal-load jump, valid only when this
/// instruction's own address is 4-byte aligned (see the file comment above); the
/// embedded address follows immediately, 8 bytes total from wherever this is placed.
constexpr unsigned char kThumbJump[4] = {0xDF, 0xF8, 0x00, 0xF0};
/// The Thumb-2 `nop` hint instruction -- matches the padding GCC's own
/// -fpatchable-function-entry emits on this target -- used only to realign kThumbJump
/// onto a 4-byte boundary when the function's own entry isn't already on one.
constexpr unsigned char kThumbNop[2] = {0x00, 0xBF};
/// @param entryAddress The function's entry address, Thumb bit included.
/// @return 8 for ARM state or an already-4-byte-aligned Thumb entry; 10 for a Thumb
/// entry starting 2 bytes off a 4-byte boundary (room for the one extra
/// realigning NOP in front of kThumbJump -- see the file comment above).
/// Unlike every other backend in this file, this one genuinely depends on
/// the entry address itself, not just the architecture.
std::size_t jumpBytes(const void* entryAddress)
{
if (!isThumb(entryAddress))
{
return 8;
}
bool misaligned = (reinterpret_cast<std::uintptr_t>(realAddress(entryAddress)) & std::uintptr_t{2}) != 0;
return misaligned ? 10 : 8;
}
/// Writes the ARM32 jump at @p site: the ARM-state form if @p site is ARM code, the
/// Thumb-2 form (with a realigning NOP first, if needed) if it is Thumb -- @p site's
/// own bit 0 says which, exactly as isThumb() reads it, and there is no separate
/// landing pad to skip past on this architecture (see hasLandingPad()), so @p site is
/// always the function's own entry address, unmodified.
/// @param site Where the jump goes.
/// @param target Address the jump transfers control to, Thumb bit preserved so the
/// `ldr` that loads it into pc correctly switches state at the destination,
/// too, exactly as calling this address normally would.
void writeJumpAt(const void* site, const void* target)
{
std::uint32_t targetValue = static_cast<std::uint32_t>(reinterpret_cast<std::uintptr_t>(target));
unsigned char* bytes = realAddress(site);
if (!isThumb(site))
{
// Payload first, opcode last -- same reasoning as every other backend above.
std::memcpy(bytes + 4, &targetValue, sizeof(targetValue));
std::atomic_thread_fence(std::memory_order_release);
std::memcpy(bytes, kArmJump, sizeof(kArmJump));
std::atomic_thread_fence(std::memory_order_release);
return;
}
bool misaligned = (reinterpret_cast<std::uintptr_t>(bytes) & std::uintptr_t{2}) != 0;
unsigned char* jumpAt = misaligned ? bytes + 2 : bytes;
// The realigning NOP, if there is one, is pure filler -- inert whether a reader
// sees it before or after this write, and byte-for-byte identical on every
// reload of this same (fixed) address, so it is written first, unconditionally,
// with no fence of its own: it never participates in the old-jump/new-jump
// transition the fences below protect.
if (misaligned)
{
std::memcpy(bytes, kThumbNop, sizeof(kThumbNop));
}
// Payload next, opcode last -- same reasoning as every other backend above: until
// kThumbJump lands, jumpAt still reads as whatever it was (old NOP padding, or a
// previous ldr.w, on a later reload), and the fully-written literal sitting
// ahead of it is inert until something actually loads it.
std::memcpy(jumpAt + 4, &targetValue, sizeof(targetValue));
std::atomic_thread_fence(std::memory_order_release);
std::memcpy(jumpAt, kThumbJump, sizeof(kThumbJump));
std::atomic_thread_fence(std::memory_order_release);
}
#endif
}
bool isArchitectureSupported()
{
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
defined(_M_ARM64)
defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
return true;
#else
return false;
@@ -233,7 +372,14 @@ namespace hot_reload::detail
PatchLayout planPatch(const void* entryAddress)
{
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
#if defined(__arm__) || defined(_M_ARM)
// ARM32's own kJumpBytes genuinely depends on entryAddress (ARM state vs. Thumb state,
// and for Thumb, alignment) -- see Architecture.cpp's ARM32 file comment above -- so it
// cannot share the single-constant shape the other three architectures below do; there
// is never a landing pad to skip past on this architecture either (hasLandingPad() is
// always false), so patchOffset is always 0.
return PatchLayout{jumpBytes(entryAddress), 0};
#elif defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
defined(_M_ARM64)
const unsigned char* entry = static_cast<const unsigned char*>(entryAddress);
PatchLayout layout;
@@ -248,7 +394,14 @@ namespace hot_reload::detail
bool writeJump(void* entryAddress, const void* targetAddress)
{
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
#if defined(__arm__) || defined(_M_ARM)
// entryAddress is passed through unmodified (not cast to unsigned char* the way the
// generic path below does): its own bit 0, the Thumb/ARM mode marker, is exactly what
// writeJumpAt() needs to pick the right encoding -- see Architecture.cpp's ARM32 file
// comment above. There is no landing pad on this architecture to offset past first.
writeJumpAt(entryAddress, targetAddress);
return true;
#elif defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
defined(_M_ARM64)
PatchLayout layout = planPatch(entryAddress);
unsigned char* site = static_cast<unsigned char*>(entryAddress) + layout.patchOffset;
+7 -7
View File
@@ -9,8 +9,8 @@
* The jump-patch backends (`Architecture.hpp`), the page-protection helper
* (`MemoryProtection.hpp`), the loader wrapper (`ModuleHandle.hpp`) and the symbol-table readers
* (`SymbolTable.hpp`) all live here. They are implementation details of
* hot_reload::HotReloadContext and may change freely; the supported surface is the C API in
* `hot_reload.h`.
* hot_reload::Context and may change freely; the supported surface is hot_reload::Context itself
* and the C API in `hot_reload.h`.
*/
namespace hot_reload::detail
{
@@ -44,11 +44,11 @@ namespace hot_reload::detail
* @brief Whether this build has a jump-patch backend for the current CPU architecture.
*
* Decided at compile time -- see the top of `Architecture.cpp`. x86,
* x86-64, and ARM64 are supported; anything else (including 32-bit
* ARM, whose ARM/Thumb interworking this library does not attempt --
* see the "Scope" section of `source/HotReload/README.md`) returns false here, and
* `HotReloadContext` surfaces that as
* ::HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE.
* x86-64, ARM64, and ARM32 (both its ARM and Thumb-2 instruction
* sets) are supported; anything else returns false here, and
* `hot_reload::Context::reload()` surfaces that as
* `Status::UnsupportedArchitecture` (`::HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE` through
* the C API).
*
* @return True if writeJump() can succeed on this architecture.
*/
+479
View File
@@ -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);
}
}
+122
View File
@@ -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;
}
}
-374
View File
@@ -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);
}
}
-168
View File
@@ -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;
};
}
+18
View File
@@ -11,9 +11,27 @@
// would otherwise clash with std::min/std::max and anything else
// named min/max in this translation unit -- found via an actual
// Windows build/run of this library, not just documentation.
//
// WIN32_LEAN_AND_MEAN: without it, windows.h also pulls in COM/OLE
// headers (ole2.h, combaseapi.h, objidlbase.h, servprov.h, ...)
// this library has no use for at all. A real build report (GNU-style
// clang++ 22.1 targeting the MSVC ABI, Windows SDK 10.0.26100)
// failed inside those headers' own __uuidof()-based
// QueryInterface<Q>() template helper, unrelated to anything this
// library does itself. Defining this keeps windows.h to the core
// Win32 surface this library actually needs (LoadLibrary,
// VirtualProtect, the PE/COFF IMAGE_* structures, ...) and keeps
// that whole header chain out of the translation unit -- the
// standard fix for this class of windows.h/COM interaction. What
// has and has not been checked about it is recorded in the
// top-level README's "What's actually been tested" table, under the
// Clang-targeting-the-MSVC-ABI row.
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#else
#include <sys/mman.h>
+8 -4
View File
@@ -8,12 +8,16 @@
#include <utility>
#if defined(_WIN32)
// NOMINMAX: see MemoryProtection.cpp's identical comment -- same
// windows.h, same reason, needed in every translation unit that
// includes it, not just once for the library as a whole.
// NOMINMAX, WIN32_LEAN_AND_MEAN: see MemoryProtection.cpp's identical
// comment -- same windows.h, same reasons, needed in every
// translation unit that includes it, not just once for the library
// as a whole.
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#else
#include <dlfcn.h>
@@ -148,7 +152,7 @@ namespace hot_reload::detail
// would fail with a baffling "cannot open shared object file"
// for a file that is sitting right there. Everything this
// library loads is a real file on disk (the staged copy
// HotReloadContext makes), so always force path semantics.
// Context makes), so always force path semantics.
const std::string loadPath = path.find('/') == std::string::npos ? "./" + path : path;
void* module = dlopen(loadPath.c_str(), RTLD_NOW | RTLD_LOCAL);
if (module == nullptr)
+20
View File
@@ -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));
}
}
+28
View File
@@ -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
}
}
+30
View File
@@ -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
}
}
+50
View File
@@ -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();
}
}
+11 -1
View File
@@ -21,7 +21,7 @@
* own metadata (ELF's symbol type + section flags, or a PE export's
* containing section's characteristics), never inferred from the
* symbol's name or any convention the hot-reloadable module is
* expected to follow. HotReloadContext (the only caller) simply
* expected to follow. Context (the only caller) simply
* refuses to patch anything this file marks isFunction = false, on
* either side of a reload -- so nothing downstream has to be trusted
* to double-check that part.
@@ -47,9 +47,19 @@ namespace hot_reload::detail
// qualified name, and expands it regardless) and the file fails to
// compile on MSVC. Found via an actual Windows build/run of this
// library, not just documentation.
//
// WIN32_LEAN_AND_MEAN: see MemoryProtection.cpp's comment for the
// full reasoning. This is the file the reported Clang-on-the-MSVC-ABI
// build actually failed in first (inside windows.h's own COM headers,
// reached through ole2.h -- nothing this file itself does): it needs
// only the PE/COFF IMAGE_* structures, which come from winnt.h and
// stay in place under WIN32_LEAN_AND_MEAN.
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
namespace
+1 -1
View File
@@ -20,7 +20,7 @@ namespace hot_reload::detail
* executable code changes" guarantee in `hot_reload.h`: false
* for anything not mechanically identified as code (a data
* object, a forwarder export, or a symbol kind this reader does
* not recognize at all). `HotReloadContext` never patches a
* not recognize at all). `Context` never patches a
* symbol where this is false, on either side of a reload.
*/
bool isFunction = false;
+12 -80
View File
@@ -1,60 +1,21 @@
/**
* @file Demangle.cpp
* @brief hot_reload_demangle() / hot_reload_free_string() implementation.
* @brief hot_reload_demangle() / hot_reload_free_string() -- a thin C wrapper over
* hot_reload::demangle(), which carries the real logic (see that function's own file,
* `src/Demangle.cpp`, directly under `source/HotReload/src/`, not this directory).
*
* Pure string utility, no HotReloadContext involved, so it gets its
* own file alongside HotReloadApi.cpp rather than crowding that one's
* single responsibility (the opaque-pointer shim). Part II (C API)
* style, like everything else in this directory -- see .clang-tidy
* here and cpp-style-guide.md section 13.
*
* Every C++ symbol name in this library's events/lookups is always
* the REAL symbol-table name (whatever hot_reload_get_symbol() needs
* to find it again) -- this function is an opt-in convenience for
* DISPLAYING that name, never applied automatically, so there is
* never a mismatch between what an event reports and what you'd pass
* back in to look the symbol up.
* Pure string utility, no HotReloadContext involved, so it gets its own file alongside
* HotReloadApi.cpp rather than crowding that one's single responsibility (the opaque-pointer
* shim). Part II (C API) style, like everything else in this directory -- see .clang-tidy here
* and cpp-style-guide.md section 13.
*/
#include "hot_reload/hot_reload.h"
#include <cstdlib>
#include "hot_reload/Demangle.hpp"
#include <cstring>
#include <string>
#if defined(__GNUC__) || defined(__clang__)
// Covers GCC and Clang on every OS this library targets, MinGW
// included: what selects the demangling scheme is the C++ ABI the
// COMPILER implements (Itanium here), not the operating system --
// MinGW is still GCC/Clang generating Itanium-mangled names even
// though the OS is Windows. MSVC, on the same OS, uses its own,
// unrelated scheme (the #elif below). See the "Hot reloading C++
// symbols" section of source/HotReload/README.md for why this
// distinction matters beyond just this function.
#include <cxxabi.h>
#elif defined(_MSC_VER)
// windows.h must come first: dbghelp.h uses types (HANDLE, DWORD,
// BOOL, ...) it assumes are already defined rather than including
// their defining headers itself -- reversing this order fails to
// compile. NOMINMAX prevents windows.h from defining min/max
// macros that would otherwise clash with std::min/std::max and
// anything else named min/max in this translation unit. Both
// fixes below came from an actual Windows build/run of this
// library, not just documentation -- see README.md's toolchain
// verification notes.
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <dbghelp.h>
// Belt-and-suspenders for anyone building this file outside this
// project's own CMake (source/HotReload/CMakeLists.txt links
// dbghelp explicitly for every MSVC-family build, which is the
// primary fix and the one actually required -- confirmed directly
// that omitting it leaves UnDecorateSymbolName undefined at link
// time regardless of this pragma).
#pragma comment(lib, "dbghelp.lib")
#endif
extern "C"
{
@@ -64,40 +25,11 @@ extern "C"
{
return nullptr;
}
std::string text = name; // fallback: unchanged, for anything that isn't a mangled C++ name at all
try
{
#if defined(__GNUC__) || defined(__clang__)
int status = 0;
char* demangled = abi::__cxa_demangle(name, nullptr, nullptr, &status);
if (status == 0 && demangled != nullptr)
{
text = demangled;
}
std::free(demangled); // __cxa_demangle always malloc()s or leaves this null either way
#elif defined(_MSC_VER)
// UnDecorateSymbolName operates on the string alone (no
// SymInitialize/session needed, unlike most of
// DbgHelp) -- but DbgHelp as a whole is documented as not
// thread-safe, so serialize calls to this function
// yourself if you call it from more than one thread.
// Exercised on a real Windows build (see README.md's
// toolchain verification notes) -- getting the
// windows.h/dbghelp.h include order and NOMINMAX right
// (see the #include block above) and linking dbghelp
// explicitly (source/HotReload/CMakeLists.txt) were both
// needed to get here.
char buffer[2048];
if (UnDecorateSymbolName(name, buffer, sizeof(buffer), UNDNAME_COMPLETE) != 0)
{
text = buffer;
}
#endif
char* result = new char[text.size() + 1];
std::memcpy(result, text.c_str(), text.size() + 1);
std::string demangled = hot_reload::demangle(name);
char* result = new char[demangled.size() + 1];
std::memcpy(result, demangled.c_str(), demangled.size() + 1);
return result;
}
catch (...)
+210 -36
View File
@@ -1,23 +1,194 @@
/**
* @file HotReloadApi.cpp
* @brief The extern "C" shim implementing hot_reload.h by delegating to hot_reload::HotReloadContext.
* @brief The extern "C" shim implementing hot_reload.h on top of hot_reload::Context.
*
* Deliberately loose coupling, not the tight opaque-pointer-reinterpret_cast PIMPL
* cpp-style-guide.md's own NetManager example uses elsewhere in this codebase: `::HotReloadContext`
* (the struct this file defines, matching the opaque forward declaration in `hot_reload.h`) *owns*
* a `hot_reload::Context` by value, and every function below reaches it only through Context's own
* public methods -- exactly as any other C++ caller of `Context.hpp` would, with no special access
* and no reinterpret_cast between unrelated types. `hot_reload::Context` has no idea this file, or
* the C API it implements, exists at all.
*/
#include "hot_reload/hot_reload.h"
#include "../HotReloadContext.hpp"
#include "hot_reload/Context.hpp"
#include "hot_reload/Result.hpp"
#include "hot_reload/Status.hpp"
#include "hot_reload/SymbolEventInfo.hpp"
#include <atomic>
#include <cstdint>
#include <string>
#include <unordered_map>
/// Defines the type `hot_reload.h` only forward-declares (`typedef struct HotReloadContext
/// HotReloadContext;`) -- every function in this file sees the real definition, so ordinary
/// member access replaces the reinterpret_cast a true opaque pointer would otherwise need.
struct HotReloadContext
{
explicit HotReloadContext(hot_reload::Options options)
: impl(std::move(options))
{
}
/// A process-wide, never-reused identifier for this context, used only to key
/// t_lastErrorByContextId() below -- see that function's own comment for why a raw `this`
/// pointer is not used for that instead.
std::uint64_t id = nextId();
/// The real implementation. Composition, not inheritance or a cast: this file uses it
/// exactly the way any other hot_reload::Context caller would.
hot_reload::Context impl;
private:
static std::uint64_t nextId()
{
static std::atomic<std::uint64_t> counter{1};
return counter.fetch_add(1, std::memory_order_relaxed);
}
};
namespace
{
/// The opaque ::HotReloadContext the public header declares is
/// never actually instantiated as itself -- every instance really
/// is a hot_reload::HotReloadContext, following the same
/// opaque-pointer PIMPL pattern cpp-style-guide.md's NetManager
/// example uses. This is the one place that cast happens.
/// @param ctx A context previously returned by hot_reload_context_create(), or null.
/// @return The same object viewed as the C++ type it really is (null if @p ctx is null).
hot_reload::HotReloadContext* impl(::HotReloadContext* ctx)
/// hot_reload.h's `hot_reload_last_error()` is a stateful call: "whatever the most recent
/// load()/reload() on this ctx failed with". hot_reload::Result has no equivalent shared
/// state at all -- each call's outcome comes back in its own Result -- which is exactly what
/// makes the C++ API safe to use concurrently without two threads' errors ever being
/// confused (see Context.hpp's thread-safety note). Reintroducing that stateful call for the
/// C API's own backward compatibility, now safely, means tracking it per *(thread, context)*
/// pair: a plain `thread_local` map needs no lock at all, since no other thread can ever see
/// this one's instance of it.
///
/// Keyed by HotReloadContext::id rather than the context's own address: a destroyed
/// context's memory can be reused by an unrelated, later one, and keying by address would
/// risk a brand new context momentarily reading a stale error string left behind by
/// whatever used to live at that same address.
std::unordered_map<std::uint64_t, std::string>& t_lastErrorByContextId()
{
return reinterpret_cast<hot_reload::HotReloadContext*>(ctx);
thread_local std::unordered_map<std::uint64_t, std::string> errors;
return errors;
}
/// Records @p result's message, if it has one, as this thread's last error for @p ctx.
/// @param ctx The context the call was made on.
/// @param result The call's own outcome.
void recordLastError(const HotReloadContext& ctx, const hot_reload::Result& result)
{
if (result.message().empty())
{
return;
}
t_lastErrorByContextId()[ctx.id] = result.message();
}
/// @param status A hot_reload::Status.
/// @return The equivalent HotReloadStatus.
HotReloadStatus toCStatus(hot_reload::Status status)
{
switch (status)
{
case hot_reload::Status::Ok:
return HOT_RELOAD_OK;
case hot_reload::Status::InvalidArgument:
return HOT_RELOAD_ERROR_INVALID_ARGUMENT;
case hot_reload::Status::LoadFailed:
return HOT_RELOAD_ERROR_LOAD_FAILED;
case hot_reload::Status::NoPreviousVersion:
return HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION;
case hot_reload::Status::UnsupportedArchitecture:
return HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE;
}
return HOT_RELOAD_ERROR_LOAD_FAILED; // unreachable for any enumerator; -Wswitch catches a missing one above
}
/// @param status A HotReloadStatus.
/// @return The equivalent hot_reload::Status.
hot_reload::Status toCppStatus(HotReloadStatus status)
{
switch (status)
{
case HOT_RELOAD_OK:
return hot_reload::Status::Ok;
case HOT_RELOAD_ERROR_INVALID_ARGUMENT:
return hot_reload::Status::InvalidArgument;
case HOT_RELOAD_ERROR_LOAD_FAILED:
return hot_reload::Status::LoadFailed;
case HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION:
return hot_reload::Status::NoPreviousVersion;
case HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE:
return hot_reload::Status::UnsupportedArchitecture;
}
return hot_reload::Status::LoadFailed; // unreachable for any enumerator; -Wswitch catches a missing one above
}
/// @param kind A hot_reload::SymbolEvent.
/// @return The equivalent HotReloadSymbolEvent.
HotReloadSymbolEvent toCEvent(hot_reload::SymbolEvent kind)
{
switch (kind)
{
case hot_reload::SymbolEvent::Patched:
return HOT_RELOAD_SYMBOL_PATCHED;
case hot_reload::SymbolEvent::Added:
return HOT_RELOAD_SYMBOL_ADDED;
case hot_reload::SymbolEvent::Removed:
return HOT_RELOAD_SYMBOL_REMOVED;
case hot_reload::SymbolEvent::SkippedNotCode:
return HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE;
case hot_reload::SymbolEvent::SkippedTooSmall:
return HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL;
case hot_reload::SymbolEvent::SkippedUnwritable:
return HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE;
}
return HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE; // unreachable for any enumerator; -Wswitch catches one missing above
}
/// Builds the hot_reload::Options this file's two C callback adapters below run through,
/// from a (possibly null) HotReloadOptions*.
/// @param options The options hot_reload_context_create() was called with; null means none.
/// @return Options wrapping @p options's two callbacks (each a no-op if @p options is null,
/// or if the specific callback field itself is null), capturing nothing but the
/// plain C function pointer and user_data they were given, so this is safe to store
/// for the Context's whole lifetime.
hot_reload::Options toCppOptions(const HotReloadOptions* options)
{
hot_reload::Options result;
if (options == nullptr)
{
return result;
}
if (options->on_symbol_event != nullptr)
{
HotReloadSymbolEventFn callback = options->on_symbol_event;
void* userData = options->user_data;
result.onSymbolEvent = [callback, userData](const hot_reload::SymbolEventInfo& event)
{
// name.data() is safe to hand out as a NUL-terminated const char* here: it
// ultimately points into a detail::ExportedSymbol's own std::string (see
// Context.cpp's emitEvent()), which is always NUL-terminated.
HotReloadSymbolEventInfo cEvent;
cEvent.name = event.name.data();
cEvent.kind = toCEvent(event.kind);
cEvent.old_address = event.oldAddress;
cEvent.new_address = event.newAddress;
callback(&cEvent, userData);
};
}
if (options->on_deleted_symbol != nullptr)
{
HotReloadDeletedSymbolFn callback = options->on_deleted_symbol;
void* userData = options->user_data;
result.onDeletedSymbol = [callback, userData](std::string_view deletedSymbolName) -> void*
{
// Same reasoning as above for passing .data() directly as a C string.
return callback(deletedSymbolName.data(), userData);
};
}
return result;
}
}
@@ -26,11 +197,11 @@ namespace
// caller that is not itself compiled to expect one, which a plain C
// caller of this API never is. Every function here is a thin,
// non-throwing wrapper for exactly that reason: std::string/vector/
// unordered_map construction is the only thing in hot_reload::
// HotReloadContext that could throw at all (std::bad_alloc under
// memory pressure), so catching (...) and mapping it to this API's
// existing failure values is sufficient without needing per-exception-
// type handling.
// unordered_map construction (on the hot_reload::Context side of this
// boundary) is the only thing that could throw at all (std::bad_alloc
// under memory pressure), so catching (...) and mapping it to this
// API's existing failure values is sufficient without needing
// per-exception-type handling.
extern "C"
{
@@ -38,7 +209,7 @@ extern "C"
{
try
{
return reinterpret_cast<::HotReloadContext*>(new hot_reload::HotReloadContext(options));
return new HotReloadContext(toCppOptions(options));
}
catch (...)
{
@@ -48,7 +219,17 @@ extern "C"
void hot_reload_context_destroy(HotReloadContext* ctx)
{
delete impl(ctx);
if (ctx == nullptr)
{
return;
}
// Best-effort: removes this (calling) thread's own entry for ctx, if it has one. Another
// thread's thread_local entry for the same id, if it made calls on this ctx too, is left
// in place -- there is no way to reach another thread's thread_local storage from here --
// a small, bounded-by-distinct-destroyed-context-count leak, accepted as the cost of
// hot_reload_last_error() needing no lock at all on the hot (every-call) path.
t_lastErrorByContextId().erase(ctx->id);
delete ctx;
}
HotReloadStatus hot_reload_load(HotReloadContext* ctx, const char* module_name, const char* path)
@@ -59,7 +240,9 @@ extern "C"
}
try
{
return impl(ctx)->load(module_name, path);
hot_reload::Result result = ctx->impl.load(module_name, path);
recordLastError(*ctx, result);
return toCStatus(result.status());
}
catch (...)
{
@@ -75,7 +258,9 @@ extern "C"
}
try
{
return impl(ctx)->reload(module_name, new_path);
hot_reload::Result result = ctx->impl.reload(module_name, new_path);
recordLastError(*ctx, result);
return toCStatus(result.status());
}
catch (...)
{
@@ -91,7 +276,7 @@ extern "C"
}
try
{
return impl(ctx)->getSymbol(module_name, symbol_name);
return ctx->impl.findSymbol(module_name, symbol_name);
}
catch (...)
{
@@ -107,7 +292,9 @@ extern "C"
}
try
{
return impl(ctx)->lastError().c_str();
auto& errors = t_lastErrorByContextId();
auto found = errors.find(ctx->id);
return found == errors.end() ? "" : found->second.c_str();
}
catch (...)
{
@@ -117,20 +304,7 @@ extern "C"
const char* hot_reload_status_string(HotReloadStatus status)
{
switch (status)
{
case HOT_RELOAD_OK:
return "ok";
case HOT_RELOAD_ERROR_INVALID_ARGUMENT:
return "invalid argument";
case HOT_RELOAD_ERROR_LOAD_FAILED:
return "load failed";
case HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION:
return "no previous version loaded for this module name";
case HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE:
return "no patch backend for this CPU architecture";
}
return "unknown hot reload status";
return hot_reload::toString(toCppStatus(status)).data(); // points into a static string literal either way
}
}