diff --git a/cmake/gen-shared-exports.sh b/cmake/gen-shared-exports.sh new file mode 100755 index 0000000..34eb1b7 --- /dev/null +++ b/cmake/gen-shared-exports.sh @@ -0,0 +1,152 @@ +#!/bin/sh +# Generate the PE module-definition file that makes liblogos_protocol.dll the +# single provider of the shared C++ runtime (TokenManager, LogosAPIClient, +# ModuleProxy, LogosProviderObject, the transports, and the lp_* C ABI). +# +# Adapted from logos-liblogos/cmake/gen-shared-exports.sh, which generated the +# same table one layer up when liblogos_core absorbed both static archives and +# re-exported them. The migration to real shared libraries moves that +# responsibility down to the library that OWNS the symbols; the mechanism is +# unchanged because the reasons for it are unchanged. It cannot be shared as a +# file: logos-liblogos depends on logos-protocol, not the other way round. +# +# WHY a generated .def rather than __declspec(dllexport) in the headers. +# Measured, not assumed: annotating the classes by hand exported 116 symbols and +# the Qt host runtime still failed to link against it with ELEVEN undefined +# references spanning five classes -- LogosProviderObject (and its vtable), +# ModuleProxy, ModuleHandshakeProxy, LogosTransportFactory -- plus free +# functions such as logos::qvariantToNlohmann. A curated list is a moving target: +# it is correct only until the next consumer touches a symbol nobody marked, and +# the failure lands in a downstream repo, far from the cause. +# +# CMake'"'"'s WINDOWS_EXPORT_ALL_SYMBOLS is not the alternative. It goes inert the +# moment a target contains any __declspec(dllexport), so it and the annotations +# are mutually exclusive -- measured here as 116 exports both with and without +# it. Hence: the .def owns the PE export table, and logos_shared_api.h resolves +# its "building the shared library" branch to NOTHING on Windows so the two +# mechanisms never compete. +# +# WHY the whole archive and not a curated member list: ld chooses archive +# members by object file, for reasons that have nothing to do with our symbols. +# Consumers link an EMPTY stand-in archive and take everything from the DLL, +# which only works if the DLL really does provide everything. A partial export +# set turns into an undefined reference in a downstream repo. +# +# WHY COMDAT symbols are filtered out: they come from inline functions and +# templates in headers, so every consumer TU emits its own copy regardless of +# what the DLL exports. Exporting them turns the import library into a STRONG +# definition that then collides with the consumer'"'"'s own copy. They are also +# precisely the symbols an export cannot deduplicate -- a function-local static +# inside an inline function stays per-image on PE no matter what. Do not +# "simplify" this filter away. +# +# Usage: gen-shared-exports.sh [...] + +set -eu + +NM="$1"; shift +OUT="$1"; shift + +TMP="${OUT}.tmp" +: > "$TMP" + +while [ "$#" -gt 0 ]; do + archive="$1"; members="$2"; shift 2 + [ -f "$archive" ] || { echo "gen-shared-exports: missing archive $archive" >&2; exit 1; } + "$NM" -A --defined-only "$archive" | awk -v members="$members" -v arch="$archive" ' + BEGIN { + if (members == "*") { all = 1 } + else { n = split(members, a, ","); for (i = 1; i <= n; i++) want[a[i]] = 1 } + } + { + # nm -A on an archive prints ":: ". + split($1, p, ":") + mem = p[2]; typ = $2; name = $3 + + # COMDAT sections are named ".text$" / ".rdata$" / + # etc. Record the mangled tail so the symbol itself can be dropped. + if (name ~ /^\.[a-z]+\$/) { + tail = name; sub(/^\.[a-z]+\$/, "", tail) + # EXCEPT vtables and typeinfo (_ZTV / _ZTI / _ZTS). + # + # PE has no weak symbols -- COMDAT IS the mechanism for weak and + # inline linkage -- so GCC emits a vtable into .rdata$_ZTV... even + # when the class has a key function and the vtable is a single + # strong definition. The section name therefore cannot distinguish + # "one definition that nobody duplicates" from "every TU emits its + # own", and dropping all of them drops these too. + # + # The reasoning above does not apply to them. A consumer of a class + # WITH a key function does not emit a copy: it emits a .refptr, an + # external reference, and needs ours. Measured here -- the Qt host + # runtime emits .refptr._ZTV19LogosProviderObject and failed to link + # with an undefined reference to the vtable for LogosProviderObject + # while this filter dropped the only definition. A class WITHOUT a + # key function emits its own copy in every TU and never references + # ours, so exporting it is inert rather than colliding. + # + # This did not matter while liblogos_core absorbed both archives: + # definition and consumer landed in ONE image and the reference + # never crossed a boundary. Splitting the runtime into real shared + # libraries is what made class vtables cross-image symbols. + if (tail !~ /^_ZT[VIS]/) comdat[mem SUBSEP tail] = 1 + next + } + if (!all && !(mem in want)) next + # T=text D=data R=rodata B=bss, all uppercase == external linkage. + # Weak (V/W) is COMDAT by another name; lowercase is file-local and + # cannot be exported at all (that includes the function-local statics + # themselves — we export the ACCESSOR so callers reach ours). + if (typ != "T" && typ != "D" && typ != "R" && typ != "B") next + # Itanium-mangled C++ (^_Z), PLUS the logos-protocol C ABI (^lp_). + # + # The ^_Z test alone keeps toolchain bookkeeping such as + # qt_version_tag_6_11_used — which every image legitimately defines — + # out of the export table. But `lp_*` is `extern "C"`, so it is + # UNMANGLED and the same test silently dropped the entire C ABI. + # + # That was invisible until B5: before it, only C++ callers reached the + # shared runtime. B5 re-emits every Qt-typed dependency wrapper as a + # VENEER over the lp path, so a consumer that compiles such a wrapper — + # logos-basecamp compiles package_manager_api.cpp into its own exe — + # now calls lp_invoke / lp_client_create / lp_token_save directly. With + # them absent from the .def the exe cannot import them, and the link + # fails with plain `undefined reference to 'lp_invoke'`. + # + # Exporting is the correct fix rather than letting the consumer link + # liblogos_protocol.a itself: lp_token_save and friends operate on the + # TokenManager singleton, so a static copy in the exe would reinstate + # exactly the split-brain token store this whole .def scheme exists to + # prevent. Module plugins are separate processes and keep their own + # per-image copy by design (see logos_module_grant_host_services). + # + # The prefix is deliberately tight: `lp_` only, not "anything unmangled". + if (name !~ /^_Z/ && name !~ /^lp_/) next + seenmem[mem] = 1 + k++; recmem[k] = mem; rectyp[k] = typ; recnam[k] = name + } + END { + if (!all) for (m in want) if (!(m in seenmem)) { + printf("gen-shared-exports: %s has no member %s\n", arch, m) > "/dev/stderr" + bad = 1 + } + if (bad) exit 1 + for (i = 1; i <= k; i++) { + if ((recmem[i] SUBSEP recnam[i]) in comdat) continue + print recnam[i] (rectyp[i] == "T" ? "" : " DATA") + } + }' >> "$TMP" +done + +# DATA matters: a data export reached without the DATA keyword hands the +# consumer the CONTENTS of the slot instead of its address, which corrupts at +# runtime rather than at link. The keyword is derived from nm's type letter +# above, never written by hand. +{ + echo "EXPORTS" + sort -u "$TMP" +} > "$OUT" +rm -f "$TMP" + +count=$(grep -c . "$OUT" || true) +echo "gen-shared-exports: wrote $OUT ($((count - 1)) symbols)" diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 260502a..5d6ea80 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -168,9 +168,23 @@ install(TARGETS logos_protocol # purely for OUT-OF-PLUGIN callers that bind lp_* at runtime via dlopen/FFI # (e.g. logos-js-sdk's koffi.load, logos-rust-sdk's callerBuildSupport) — # the role liblogos_module_client previously filled. In-plugin code keeps -# linking the STATIC `logos_protocol` archive through the EXPORT set below, -# so this target is intentionally NOT exported and NOT installed into the -# CMake package; `find_package(logos-protocol)` is unaffected. +# linking the STATIC `logos_protocol` archive through the EXPORT set below. +# +# This target is EXPORTED as of this commit, where it was previously installed +# but deliberately kept out of the CMake package. The reason it now has to be is +# a second class of consumer: IN-PROCESS C++ images that link it as the single +# provider of the runtime types which must exist exactly once per process +# (TokenManager, LogosAPIClient, the per-identity StoreRegistry). Linking one +# shared library is what replaces the whole-archive + generated-.def scheme, in +# which liblogos_core absorbed both static archives and re-exported them. A +# consumer cannot link what find_package() does not hand it. +# +# OUT-OF-PROCESS consumers keep linking the STATIC archive, and that is not a +# transitional state: module plugins and ui_qml backends run in their own +# processes, so their own copy of TokenManager is the CORRECT per-process +# singleton. Staying static also keeps a .lgx self-contained -- a .lgx records +# an empty nix closure, so a shared library would not travel with it -- and +# keeps those plugins immune to ABI skew against a separately-updated .so. add_library(logos_protocol_shared SHARED ${PROTOCOL_SOURCES}) target_link_libraries(logos_protocol_shared PUBLIC @@ -188,12 +202,21 @@ target_link_libraries(logos_protocol_shared PUBLIC # leaves LP_API empty so consumers linking it need no import library. target_compile_definitions(logos_protocol_shared PRIVATE LOGOS_PROTOCOL_BUILDING_SHARED) +# The INSTALL_INTERFACE half mirrors the static target exactly. Without it an +# exported target carries no include directories at all, and the failure is a +# confusing one: the target imports fine and the link succeeds, then the +# consumer fails to COMPILE on `#include "token_manager.h"`. target_include_directories(logos_protocol_shared PUBLIC $ $ $ $ $ + $ + $ + $ + $ + $ ) # Same basename as the archive (liblogos_protocol.{so,dylib}); the LIBRARY @@ -203,9 +226,61 @@ set_target_properties(logos_protocol_shared PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" ) +# ARCHIVE DESTINATION is NOT redundant here. On Windows a shared library's +# import library (.dll.a) is the ARCHIVE artifact and the DLL itself is RUNTIME, +# so without an ARCHIVE destination the import library is never installed and a +# consumer that find_package()s this target gets an imported SHARED target whose +# IMPORTED_IMPLIB does not exist. It links fine on ELF and Mach-O, where there is +# no import library at all, and fails only on Windows. +# PE export table for the shared library, generated from the objects. +# +# Not needed off Windows: ELF and Mach-O export a shared library's non-hidden +# symbols by default, which is exactly why the shortfall this replaces was +# invisible until a Windows link. +if(WIN32) + # CMAKE_NM is normally set by the toolchain file; fall back to the + # cross-prefixed binary so a plain `cmake -DCMAKE_TOOLCHAIN_FILE=...` still + # generates. A missing nm must fail HERE, loudly, rather than silently + # emitting an empty .def -- an empty export table links clean and fails in a + # downstream repo as an undefined reference. + set(_lp_nm "${CMAKE_NM}") + if(NOT _lp_nm) + find_program(_lp_nm NAMES "${CMAKE_CXX_COMPILER_TARGET}-nm" x86_64-w64-mingw32-nm nm) + endif() + if(NOT _lp_nm) + message(FATAL_ERROR + "nm not found, so liblogos_protocol.dll cannot be given an export " + "list. Windows requires one: PE exports nothing that is not named, " + "and a consumer linking the empty stand-in archive would then fail " + "with undefined references to the entire C++ runtime.") + endif() + + # Generated from the STATIC archive, which is built from the same sources. + # "*" means every member: ld chooses archive members for reasons unrelated to + # our symbols, so a partial set is a downstream undefined reference. + set(_lp_def "${CMAKE_CURRENT_BINARY_DIR}/logos_protocol_shared_exports.def") + add_custom_command( + OUTPUT "${_lp_def}" + COMMAND ${CMAKE_COMMAND} -E env sh + "${CMAKE_CURRENT_SOURCE_DIR}/../cmake/gen-shared-exports.sh" + "${_lp_nm}" "${_lp_def}" + "$" "*" + DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/../cmake/gen-shared-exports.sh" logos_protocol + COMMENT "Generating liblogos_protocol export definition" + VERBATIM + ) + add_custom_target(logos_protocol_shared_exports DEPENDS "${_lp_def}") + add_dependencies(logos_protocol_shared logos_protocol_shared_exports) + target_link_options(logos_protocol_shared PRIVATE "${_lp_def}") + set_property(TARGET logos_protocol_shared APPEND PROPERTY LINK_DEPENDS "${_lp_def}") +endif() + install(TARGETS logos_protocol_shared + EXPORT logos-protocolTargets + ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin + INCLUDES DESTINATION include ) install(EXPORT logos-protocolTargets diff --git a/cpp/logos_shared_api.h b/cpp/logos_shared_api.h index 8392f3b..da85faa 100644 --- a/cpp/logos_shared_api.h +++ b/cpp/logos_shared_api.h @@ -5,36 +5,51 @@ * @file logos_shared_api.h * @brief Marks the runtime types that must exist EXACTLY ONCE per process. * - * ELF and Mach-O give this for free. Both formats interpose symbols across the - * whole process image set, so when liblogos_core.{so,dylib} exports - * TokenManager::instance() every other image in the process — the host binary, - * the UI plugin — binds to that one definition and the function-local - * `static TokenManager instance;` inside it is genuinely a singleton. + * Every image that links liblogos_protocol.a / liblogos_qt_host.a statically + * gets its OWN copy of the code, and therefore its own copy of every + * function-local static inside it: TokenManager::instance, the per-identity + * StoreRegistry, the host-services grant, the deferred event-subscription + * registry. The host saves a capability token into its copy, another in-process + * image reads its own empty copy, and every cross-module call is refused + * ("ModuleProxy: rejecting unauthorized call") — the package manager never + * appears in the sidebar. * - * PE has no interposition. A symbol is either in a DLL's export table and - * reached through an import thunk, or it is resolved image-locally; there is no - * "first definition wins across the process" rule. So on Windows every image - * that links liblogos_protocol.a / liblogos_qt_sdk.a statically gets its OWN - * copy of the code, and therefore its own copy of every function-local static - * inside it. Measured on the Basecamp payload: nine images each define - * TokenManager::instance()::instance. The host saves a capability token into - * its copy, the UI plugin reads its own empty copy, and every cross-module call - * is refused ("ModuleProxy: rejecting unauthorized call") — the package manager - * never appears in the sidebar. + * WHICH PLATFORMS. This was long documented as Windows-only, on the premise + * that "ELF and Mach-O interpose symbols across the whole process image set". + * That is true of ELF and FALSE of Mach-O, and the false half was measured: + * + * - PE no interposition at all. A symbol is either in a DLL's export + * table and reached through an import thunk, or it is resolved + * image-locally. Measured on the Basecamp payload: NINE images each + * defining TokenManager::instance()::instance. + * - Mach-O two-level namespace, so it behaves like PE, not like ELF. It + * appears to work only while the consumer image has NO definition of + * its own, so ld binds the undefined symbol to the provider. The + * moment any reference drags an archive member in, that image gets + * its own copy, silently. Measured in logos-basecamp: ONE reference + * to LogosAPI::forIdentity pulled logos_api.cpp.o and + * token_manager.cpp.o into the executable, which then produced 31 + * refused calls against a baseline of 0. + * - ELF flat namespace, first definition wins process-wide. This one + * genuinely does collapse duplicates. + * + * The consumers empty their static archives on every platform anyway + * (logos-basecamp/cmake/LogosSharedFromDll.cmake), so the invariant is ONE rule + * everywhere rather than three — and nix/symbol-gate.nix can assert it + * uniformly instead of encoding a per-platform exception. * * The obvious fixes are both wrong, and the wrongness is not obvious, so: * * - Exporting everything from liblogos_core (-Wl,--export-all-symbols) makes - * its import library a second definition of symbols that liblogos_qt_sdk.a - * also defines, and the link dies with "multiple definition of + * its import library a second definition of symbols that the static + * archives also define, and the link dies with "multiple definition of * `LogosAPI::LogosAPI'". See the note in logos-liblogos/src/CMakeLists.txt. - * - Exporting nothing (today's C-API-only narrowing) links, and silently - * gives every image its own statics. That is the bug above. + * - Exporting nothing (a C-API-only narrowing) links, and silently gives + * every image its own statics. That is the bug above. * - * The resolution is ONE PROVIDER: liblogos_core.dll exports these types - * explicitly (a generated .def, see logos-liblogos/cmake/gen-shared-exports.sh) - * and the in-process consumers — LogosBasecamp.exe and main_ui.dll — compile - * with LOGOS_SHARED_USE_DLL so their references become `__declspec(dllimport)`. + * The resolution is ONE PROVIDER, and the macro below is how a symbol is + * assigned to one. On Windows the in-process consumers additionally compile + * with LOGOS_SHARED_USE_DLL so their references become __declspec(dllimport). * * The dllimport is the load-bearing half, not the export. It rewrites the * reference to go through `__imp_`, so the plain symbol is never undefined and @@ -42,20 +57,87 @@ * regardless of where the static archive sits on the link line. Without it the * link still succeeds and binds to the archive, with no diagnostic at all. * - * Everything here is deliberately a no-op unless a consumer opts in: - * - * - Off Windows the macro is empty; ELF/Mach-O already do the right thing. - * - Inside logos-protocol / logos-qt-sdk / liblogos_core the macro is empty, - * so the static archives are compiled byte-identically to before and the - * export side is driven purely by the .def at liblogos_core's link. - * - logos_host.exe, ui-host.exe and the module plugin DLLs do not define - * LOGOS_SHARED_USE_DLL. They are separate processes that do not load - * liblogos_core, so they keep their own (correct, per-process) statics. + * Note that logos_host, ui-host and the module plugins do NOT opt in. They are + * separate processes that do not load the provider, so they keep their own — + * correct, per-process — statics. */ -#if defined(_WIN32) && defined(LOGOS_SHARED_USE_DLL) -# define LOGOS_SHARED_API __declspec(dllimport) + +/* The primitives. Kept separate so the per-library macros below read as a + * three-state choice (export / import / neither) rather than as nested #ifdefs. + * Off Windows there is nothing to import: a shared library exports its + * non-hidden symbols by default, and consumers just reference them. */ +#if defined(_WIN32) +# define LOGOS_SHARED_EXPORT __declspec(dllexport) +# define LOGOS_SHARED_IMPORT __declspec(dllimport) +#else +# define LOGOS_SHARED_EXPORT __attribute__((visibility("default"))) +# define LOGOS_SHARED_IMPORT +#endif + +/* logos-protocol's own single-instance types: TokenManager, LogosAPIClient, + * and the LogosResult stream operators. + * + * EXPORT while building the shared library that owns them, IMPORT while + * consuming that library, and EMPTY for the static archive — which is what + * every current consumer gets, so this is a no-op until a build opts in. */ +#if defined(LOGOS_PROTOCOL_BUILDING_SHARED) +/* Building the library that owns the symbol. + * + * On Windows this resolves to NOTHING, and that is deliberate: the PE export + * table is generated from the objects (cmake/gen-shared-exports.sh), because a + * hand-marked list is a moving target -- it exported 116 symbols and the Qt host + * runtime still failed to link with eleven undefined references across five + * classes. Marking here as well would put two mechanisms on the same table for + * no gain, and __declspec(dllexport) additionally makes CMake's + * WINDOWS_EXPORT_ALL_SYMBOLS inert, so the annotation actively forecloses the + * automatic route. + * + * Off Windows the annotation is what a hidden-visibility build would need, and + * is harmless today because nothing sets -fvisibility=hidden. */ +# if defined(_WIN32) +# define LOGOS_SHARED_API +# else +# define LOGOS_SHARED_API LOGOS_SHARED_EXPORT +# endif +#elif defined(_WIN32) && defined(LOGOS_SHARED_USE_DLL) +# define LOGOS_SHARED_API LOGOS_SHARED_IMPORT #else # define LOGOS_SHARED_API #endif +/* logos-plugin-qt's LogosAPI, which lives in a DIFFERENT library. + * + * It needs its own macro rather than reusing LOGOS_SHARED_API, because the two + * are not the same choice in the same translation unit: while building the Qt + * host runtime shared library, LogosAPI must be EXPORTED while TokenManager — + * owned by logos-protocol — must be IMPORTED. One macro cannot say both, and on + * PE getting it wrong means the type is defined twice in the process. + * + * Off Windows this distinction is moot (both resolve to default visibility), + * which is exactly why it would go unnoticed until a Windows build. */ +#if defined(LOGOS_QT_HOST_BUILDING_SHARED) +/* Building the library that owns the symbol. + * + * On Windows this resolves to NOTHING, and that is deliberate: the PE export + * table is generated from the objects (cmake/gen-shared-exports.sh), because a + * hand-marked list is a moving target -- it exported 116 symbols and the Qt host + * runtime still failed to link with eleven undefined references across five + * classes. Marking here as well would put two mechanisms on the same table for + * no gain, and __declspec(dllexport) additionally makes CMake's + * WINDOWS_EXPORT_ALL_SYMBOLS inert, so the annotation actively forecloses the + * automatic route. + * + * Off Windows the annotation is what a hidden-visibility build would need, and + * is harmless today because nothing sets -fvisibility=hidden. */ +# if defined(_WIN32) +# define LOGOS_QT_HOST_API +# else +# define LOGOS_QT_HOST_API LOGOS_SHARED_EXPORT +# endif +#elif defined(_WIN32) && defined(LOGOS_SHARED_USE_DLL) +# define LOGOS_QT_HOST_API LOGOS_SHARED_IMPORT +#else +# define LOGOS_QT_HOST_API +#endif + #endif // LOGOS_SHARED_API_H