From bc7d2ec08992ec38b68adba956ba9cf6c075ffe9 Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 15:03:51 +0200 Subject: [PATCH 1/9] symbols: collect global variables on Linux, not just functions The ELF symbol collection filtered on STT_FUNC, so symbols() returned only functions on Linux, while Windows (export directory) and macOS (LC_SYMTAB) both return variables as well. As a result, get_symbol() could never resolve a namespaced global variable through its demangled name fallback on Linux, even though the README documents exactly that usage. Variables at global scope were unaffected because the Itanium ABI leaves them unmangled, so dlsym resolved them directly, which is why no test caught this. Also collect STT_OBJECT, and add a namespaced variable test. Signed-off-by: Martin Olivier --- src/symbols.cpp | 9 ++++++--- tests/lib.cpp | 2 ++ tests/tests.cpp | 7 +++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/symbols.cpp b/src/symbols.cpp index bc22ef6..e5c2dcb 100644 --- a/src/symbols.cpp +++ b/src/symbols.cpp @@ -388,10 +388,13 @@ std::vector get_symbols(void *handle, int fd) { size = strtab - (char *)symtab; for (int i = 0; i < size / symentries; ++i) { - ElfSym *sym = &symtab[i]; + unsigned char type = DYLIB_ELF_ST_TYPE(symtab[i].st_info); - if (DYLIB_ELF_ST_TYPE(symtab[i].st_info) == STT_FUNC) { - const char *name = &strtab[sym->st_name]; + /* + * Collect functions (STT_FUNC) and global variables (STT_OBJECT) + */ + if (type == STT_FUNC || type == STT_OBJECT) { + const char *name = &strtab[symtab[i].st_name]; add_symbol(symbols_list, name, !!dlsym(handle, name)); } diff --git a/tests/lib.cpp b/tests/lib.cpp index f6f9f05..241cbe6 100644 --- a/tests/lib.cpp +++ b/tests/lib.cpp @@ -44,6 +44,8 @@ LIB_EXPORT void list_add_string(std::vector &cont, std::string elem } namespace tools { +LIB_EXPORT double pi_value = 3.14159; + LIB_EXPORT double adder() { return 0; } diff --git a/tests/tests.cpp b/tests/tests.cpp index f4e6a1a..9f4c0e4 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -164,6 +164,13 @@ TEST(cpp_symbols, variables) { EXPECT_EQ(strcmp(secret, "12345"), 0); } +TEST(cpp_symbols, variables_namespace) { + dylib::library lib("./dynamic_lib", dylib::decorations::os_default()); + + auto pi = lib.get_variable("tools::pi_value"); + EXPECT_EQ(pi, 3.14159); +} + TEST(cpp_symbols, functions) { dylib::library lib("./dynamic_lib", dylib::decorations::os_default()); From 203b6d59d2999265c2a23f05d22b9a295ee10b83 Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 15:05:31 +0200 Subject: [PATCH 2/9] dylib: reuse demangled names already computed by symbols() get_symbol() re-demangled every symbol during its fallback lookup, even though symbols() already stores a demangled_name for each entry. On MSVC that was especially costly, since each demangle_symbol call performs two UnDecorateSymbolName calls. Reuse the stored demangled_name and skip C symbols explicitly. The previous code relied on demangle_symbol returning an empty string to skip them implicitly, whereas demangled_name falls back to the raw symbol name, so the filter on symbol_type::CPP preserves the behavior. Signed-off-by: Martin Olivier --- src/dylib.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/dylib.cpp b/src/dylib.cpp index 8c60f0a..dec41a5 100644 --- a/src/dylib.cpp +++ b/src/dylib.cpp @@ -42,7 +42,6 @@ struct internal_symbol_info { std::vector get_symbols(native_handle_type handle, int fd); std::vector get_sections(native_handle_type handle, int fd); -std::string demangle_symbol(const char *symbol); static native_handle_type open_lib(const char *path) noexcept { #ifdef _WIN32 @@ -186,10 +185,14 @@ native_symbol_type library::get_symbol(const char *symbol_name) const { initial_error = get_error_description(); for (const auto &sym : symbols()) { - if (!sym.loadable) + /* + * Only C++ symbols are considered here: a C symbol is not mangled, so it would + * already have been resolved by the locate_symbol call above. + */ + if (!sym.loadable || sym.type != symbol_type::CPP) continue; - std::string demangled = demangle_symbol(sym.name.c_str()); + const std::string &demangled = sym.demangled_name; if (demangled.find(symbol_name) == 0 && (demangled.size() == symbol_name_len || demangled[symbol_name_len] == '(')) From 284613888be0e1cae087d24902b16cbbda7ac46b Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 15:06:03 +0200 Subject: [PATCH 3/9] tests: remove stray unused iostream include Left over from the section collection work: it sat in the middle of the file rather than in the include block, and nothing in tests.cpp uses it. Signed-off-by: Martin Olivier --- tests/tests.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/tests.cpp b/tests/tests.cpp index 9f4c0e4..50d33a5 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -298,8 +298,6 @@ TEST(cpp_symbols, demangle) { } } -#include - TEST(sections, lookup) { dylib::library lib("./dynamic_lib", dylib::decorations::os_default()); std::vector sections; From 277313074ebfe4e6826730bdb9a99e8ef8661fe0 Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 15:10:11 +0200 Subject: [PATCH 4/9] dylib: make the header the single source of truth for the version The version was hardcoded in the header, CMakeLists.txt, the README badge, the conan and FetchContent snippets, and the example, with nothing keeping them in sync. Expose DYLIB_VERSION_MAJOR/MINOR/PATCH from dylib.hpp and have CMakeLists.txt parse them, so the build system can no longer disagree with the header. The macros also let users feature-detect an API at compile time, which was not possible before. Bump to 3.1.0: sections(), collection_error and section_collection_error are new public API. Signed-off-by: Martin Olivier --- CMakeLists.txt | 14 +++++++++++++- README.md | 17 ++++++++++++++--- example/CMakeLists.txt | 2 +- include/dylib.hpp | 28 ++++++++++++++++------------ 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c785428..b95a7fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,18 @@ cmake_minimum_required(VERSION 3.11...3.31) -project(dylib VERSION 3.0.1 LANGUAGES CXX) +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/include/dylib.hpp" DYLIB_HEADER) + +foreach(part MAJOR MINOR PATCH) + if(NOT DYLIB_HEADER MATCHES "#define DYLIB_VERSION_${part} ([0-9]+)") + message(FATAL_ERROR "Could not parse DYLIB_VERSION_${part} from include/dylib.hpp") + endif() + set(DYLIB_VERSION_${part} ${CMAKE_MATCH_1}) +endforeach() + +project(dylib + VERSION ${DYLIB_VERSION_MAJOR}.${DYLIB_VERSION_MINOR}.${DYLIB_VERSION_PATCH} + LANGUAGES CXX +) include(GNUInstallDirs) diff --git a/README.md b/README.md index b20df12..a6539f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # dylib -[![version](https://img.shields.io/badge/Version-3.0.1-blue.svg)](https://github.com/martin-olivier/dylib/releases/tag/v3.0.1) +[![version](https://img.shields.io/badge/Version-3.1.0-blue.svg)](https://github.com/martin-olivier/dylib/releases/tag/v3.1.0) [![license](https://img.shields.io/badge/License-MIT-orange.svg)](https://github.com/martin-olivier/dylib/blob/main/LICENSE) [![cpp](https://img.shields.io/badge/Compatibility-C++11-darkgreen.svg)](https://isocpp.org) [![ci](https://github.com/martin-olivier/dylib/actions/workflows/CI.yml/badge.svg)](https://github.com/martin-olivier/dylib/actions/workflows/CI.yml) @@ -28,7 +28,7 @@ vcpkg install dylib ``` ```sh -conan install --requires=dylib/3.0.1 +conan install --requires=dylib/3.1.0 ``` ### Using CMake Fetch @@ -41,7 +41,7 @@ include(FetchContent) FetchContent_Declare( dylib GIT_REPOSITORY "https://github.com/martin-olivier/dylib" - GIT_TAG "v3.0.1" + GIT_TAG "v3.1.0" ) FetchContent_MakeAvailable(dylib) @@ -219,6 +219,17 @@ try { } ``` +### Version + +`dylib.hpp` exposes its version as macros, which lets you detect the availability of a feature at compile time: + +```c++ +#if DYLIB_VERSION_MAJOR > 3 || (DYLIB_VERSION_MAJOR == 3 && DYLIB_VERSION_MINOR >= 1) + for (auto §ion : lib.sections()) + std::cout << section << std::endl; +#endif +``` + ## Example A full example about the usage of the `dylib` library is available [HERE](example) diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt index 0b9dbeb..d249b3c 100644 --- a/example/CMakeLists.txt +++ b/example/CMakeLists.txt @@ -15,7 +15,7 @@ include(FetchContent) FetchContent_Declare( dylib GIT_REPOSITORY "https://github.com/martin-olivier/dylib" - GIT_TAG "v3.0.1" + GIT_TAG "v3.1.0" ) FetchContent_MakeAvailable(dylib) diff --git a/include/dylib.hpp b/include/dylib.hpp index 42542a5..a73aa96 100644 --- a/include/dylib.hpp +++ b/include/dylib.hpp @@ -1,6 +1,6 @@ /** * @file dylib.hpp - * @version 3.0.1 + * @version 3.1.0 * @brief C++ cross-platform wrapper around dynamic loading of shared libraries * @link https://github.com/martin-olivier/dylib * @@ -12,6 +12,21 @@ #pragma once +#define DYLIB_VERSION_MAJOR 3 +#define DYLIB_VERSION_MINOR 1 +#define DYLIB_VERSION_PATCH 0 + +#ifdef _WIN32 +#define DYLIB_WIN_MAC_OTHER(win_def, mac_def, other_def) win_def +#define DYLIB_WIN_OTHER(win_def, other_def) win_def +#elif defined(__APPLE__) +#define DYLIB_WIN_MAC_OTHER(win_def, mac_def, other_def) mac_def +#define DYLIB_WIN_OTHER(win_def, other_def) other_def +#else +#define DYLIB_WIN_MAC_OTHER(win_def, mac_def, other_def) other_def +#define DYLIB_WIN_OTHER(win_def, other_def) other_def +#endif + #include #include #include @@ -42,17 +57,6 @@ #endif #endif -#ifdef _WIN32 -#define DYLIB_WIN_MAC_OTHER(win_def, mac_def, other_def) win_def -#define DYLIB_WIN_OTHER(win_def, other_def) win_def -#elif defined(__APPLE__) -#define DYLIB_WIN_MAC_OTHER(win_def, mac_def, other_def) mac_def -#define DYLIB_WIN_OTHER(win_def, other_def) other_def -#else -#define DYLIB_WIN_MAC_OTHER(win_def, mac_def, other_def) other_def -#define DYLIB_WIN_OTHER(win_def, other_def) other_def -#endif - namespace dylib { using native_handle_type = DYLIB_WIN_OTHER(HINSTANCE, void *); From 41b8dabe5c02d875ccf1320dc58a85eaeedb4657 Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 15:10:47 +0200 Subject: [PATCH 5/9] dylib: make native_handle() const It only reads m_handle, and every other accessor (get_symbol, symbols, sections) is already const, so a const library was unusable with it. Signed-off-by: Martin Olivier --- include/dylib.hpp | 2 +- src/dylib.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/include/dylib.hpp b/include/dylib.hpp index a73aa96..c836dca 100644 --- a/include/dylib.hpp +++ b/include/dylib.hpp @@ -320,7 +320,7 @@ class library { /** * @return the dynamic library handle */ - native_handle_type native_handle() noexcept; + native_handle_type native_handle() const noexcept; protected: native_handle_type m_handle{nullptr}; diff --git a/src/dylib.cpp b/src/dylib.cpp index dec41a5..dbc3051 100644 --- a/src/dylib.cpp +++ b/src/dylib.cpp @@ -218,7 +218,7 @@ native_symbol_type library::get_symbol(const std::string &symbol_name) const { return get_symbol(symbol_name.c_str()); } -native_handle_type library::native_handle() noexcept { +native_handle_type library::native_handle() const noexcept { return m_handle; } From 29e68c880de17474b32a5fb0d94275cd94125615 Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 15:12:47 +0200 Subject: [PATCH 6/9] symbols: harden the ELF symbol table size computation The size of the symbol table is deduced from the distance between DT_SYMTAB and DT_STRTAB, which assumes the string table directly follows the symbol table. Nothing in the ELF specification guarantees that ordering, and the reverse layout made the subtraction underflow into a huge unsigned value, walking far past the symbol table. Bail out when the layout does not hold, and iterate with an unsigned counter matching the type of the loop bound. Signed-off-by: Martin Olivier --- src/symbols.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/symbols.cpp b/src/symbols.cpp index e5c2dcb..6144f2f 100644 --- a/src/symbols.cpp +++ b/src/symbols.cpp @@ -385,9 +385,17 @@ std::vector get_symbols(void *handle, int fd) { if (!symtab || !strtab || symentries == 0) return symbols_list; - size = strtab - (char *)symtab; + /* + * The dynamic section does not record the size of the symbol table, so it is + * deduced from the fact that the string table usually directly follows it. + * Bail out instead of underflowing if a linker lays them out the other way. + */ + if (strtab <= (const char *)symtab) + return symbols_list; + + size = (unsigned long)(strtab - (char *)symtab); - for (int i = 0; i < size / symentries; ++i) { + for (unsigned long i = 0; i < size / symentries; ++i) { unsigned char type = DYLIB_ELF_ST_TYPE(symtab[i].st_info); /* From aa3856ab8bbc5dba5f7a127716ffc55078f4b108 Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 15:22:47 +0200 Subject: [PATCH 7/9] dylib: stop holding a file descriptor for the library lifetime The POSIX constructor opened the library file and kept the descriptor until destruction, even though it is only needed by symbols() on macOS and by sections() on macOS and Linux. A process loading many plugins paid one descriptor per loaded library for nothing. Keep the resolved path instead and open the file through an RAII guard only while it is read. This also keeps both methods const and free of mutable state, so concurrent const calls stay safe. Note that the constructor no longer reports an unreadable library file: that error now surfaces from symbols() or sections(), which are the operations that actually need to read it. Signed-off-by: Martin Olivier --- include/dylib.hpp | 2 +- src/dylib.cpp | 52 +++++++++++++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/include/dylib.hpp b/include/dylib.hpp index c836dca..a4750a1 100644 --- a/include/dylib.hpp +++ b/include/dylib.hpp @@ -325,7 +325,7 @@ class library { protected: native_handle_type m_handle{nullptr}; #ifndef _WIN32 - int m_fd{-1}; + std::string m_path; #endif }; diff --git a/src/dylib.cpp b/src/dylib.cpp index dbc3051..bc25fd1 100644 --- a/src/dylib.cpp +++ b/src/dylib.cpp @@ -89,10 +89,34 @@ static std::string get_error_description() noexcept { #endif } +#ifndef _WIN32 +class scoped_fd { +public: + explicit scoped_fd(const std::string &path) : m_fd(open(path.c_str(), O_RDONLY)) { + if (m_fd < 0) + throw std::runtime_error("Could not open file '" + path + "': " + strerror(errno)); + } + + scoped_fd(const scoped_fd &) = delete; + scoped_fd &operator=(const scoped_fd &) = delete; + + ~scoped_fd() { + close(m_fd); + } + + int get() const noexcept { + return m_fd; + } + +private: + int m_fd; +}; +#endif + library::library(library &&other) noexcept { std::swap(m_handle, other.m_handle); #ifndef _WIN32 - std::swap(m_fd, other.m_fd); + std::swap(m_path, other.m_path); #endif } @@ -100,7 +124,7 @@ library &library::operator=(library &&other) noexcept { if (this != &other) { std::swap(m_handle, other.m_handle); #ifndef _WIN32 - std::swap(m_fd, other.m_fd); + std::swap(m_path, other.m_path); #endif } return *this; @@ -140,9 +164,7 @@ library::library(const char *lib_path, dylib::decorations decorations) { throw load_error("Could not load library '" + lib + "':\n" + get_error_description()); #ifndef _WIN32 - m_fd = open(lib.c_str(), O_RDONLY); - if (m_fd < 0) - throw load_error("Could not open file '" + lib + "':\n" + strerror(errno)); + m_path = lib; #endif } @@ -157,10 +179,6 @@ library::library(const std::filesystem::path &lib_path, decorations decorations) library::~library() { if (m_handle) close_lib(m_handle); -#ifndef _WIN32 - if (m_fd > -1) - close(m_fd); -#endif } native_symbol_type library::get_symbol(const char *symbol_name) const { @@ -230,7 +248,13 @@ std::vector library::symbols() const { throw std::logic_error("Attempted to use a moved library object"); try { - internal_symbols = get_symbols(m_handle, DYLIB_WIN_MAC_OTHER(-1, m_fd, -1)); +#ifdef __APPLE__ + scoped_fd fd(m_path); + + internal_symbols = get_symbols(m_handle, fd.get()); +#else + internal_symbols = get_symbols(m_handle, -1); +#endif symbols.reserve(internal_symbols.size()); @@ -254,7 +278,13 @@ std::vector library::sections() const { throw std::logic_error("Attempted to use a moved library object"); try { - return get_sections(m_handle, DYLIB_WIN_MAC_OTHER(-1, m_fd, m_fd)); +#ifdef _WIN32 + return get_sections(m_handle, -1); +#else + scoped_fd fd(m_path); + + return get_sections(m_handle, fd.get()); +#endif } catch (const std::runtime_error &e) { throw section_collection_error(e.what()); } From 4b50716dd34eb9c8ee7f3cc21696ae7b0d061757 Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 15:27:32 +0200 Subject: [PATCH 8/9] ci: build and run the example against the branch sources The example was never built by CI, and its CMakeLists fetches a released tag rather than the local tree, so nothing kept it working. It calls get_variable("example::magic"), a namespaced variable, which was broken on Linux until the previous STT_OBJECT fix, without any job noticing. Add a job that overrides the fetch with FETCHCONTENT_SOURCE_DIR_DYLIB so the example is built and run against the current branch on the three supported platforms, and refresh the version string it prints. Signed-off-by: Martin Olivier --- .github/workflows/CI.yml | 33 +++++++++++++++++++++++++++++++++ example/lib.cpp | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index dcde239..1a4ea77 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,6 +8,7 @@ on: - include/* - tests/* - src/* + - example/* pull_request: paths: @@ -16,6 +17,7 @@ on: - include/* - tests/* - src/* + - example/* defaults: run: @@ -38,6 +40,37 @@ jobs: - name: Check formatting run: find src include tests -name '*.cpp' -o -name '*.hpp' | xargs clang-format --dry-run --Werror + example: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + name: example / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + # FETCHCONTENT_SOURCE_DIR_DYLIB makes FetchContent use the checked out + # sources instead of the released tag, so that the example is built + # against the code of the current branch. + - name: Generate project files + run: cmake example -B example/build -DFETCHCONTENT_SOURCE_DIR_DYLIB="${{ github.workspace }}" + + - name: Build example + run: cmake --build example/build --config Release + + # Multi config generators place the binaries in a per config subdirectory, + # and the example loads its dynamic library from the working directory. + - name: Run example + working-directory: example/build + run: | + if [ -d Release ]; then + cd Release + fi + ./dylib_example + windows_msvc: strategy: fail-fast: false diff --git a/example/lib.cpp b/example/lib.cpp index 758a53e..4b68aa3 100644 --- a/example/lib.cpp +++ b/example/lib.cpp @@ -26,7 +26,7 @@ namespace example { namespace dylib { LIB_EXPORT std::string info() { - return "dylib - v3.0.1"; + return "dylib - v3.1.0"; } } From 041eb31901a9a219739bc8e8db21b8207df77e58 Mon Sep 17 00:00:00 2001 From: Martin Olivier Date: Mon, 3 Aug 2026 18:01:51 +0200 Subject: [PATCH 9/9] example: sync the documented output with the bumped version The example README shows the expected output of a run, which includes the version string returned by example::dylib::info(). That string was bumped to 3.1.0 in lib.cpp, leaving the documented output stale. Signed-off-by: Martin Olivier --- example/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/README.md b/example/README.md index e2a8edd..980b273 100644 --- a/example/README.md +++ b/example/README.md @@ -34,7 +34,7 @@ You will have the following result: ```sh Hello World! -dylib - v3.0.1 +dylib - v3.1.0 pi value: 3.14159 magic value: cafebabe 10 + 10 = 20