diff --git a/CMakeLists.txt b/CMakeLists.txt index 1bf954a..f91319e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,39 @@ if(DEFINED LOGOS_PACKAGE_MANAGER_ROOT) list(APPEND CMAKE_BUILD_RPATH "${LOGOS_PACKAGE_MANAGER_ROOT}/lib") endif() +# The shared C++ runtime, which logos_core now IMPORTS rather than absorbing. +# Both libraries carry an @rpath install name, so a consumer that does not know +# where they live cannot load at all -- and the failure is at dyld time, before +# main(), with no hint from the build. +# +# Measured before this existed: logos_core_tests aborted with +# dyld: Library not loaded: @rpath/liblogos_qt_host.dylib +# while `nix build .#default` had already succeeded. The library builds and +# installs whether or not anything can load it. +# +# The INSTALLED liblogos_core is covered separately by INSTALL_RPATH $ORIGIN in +# src/CMakeLists.txt plus staging both libraries beside it (nix/lib.nix); this +# entry is what makes the BUILD tree -- tests especially -- resolve them. +# BUILD_RPATH covers binaries run from the build tree; INSTALL_RPATH covers the +# ones that get installed and run from $out -- which is what the test derivation +# actually executes. Both are needed, and only Linux says so: on macOS the +# installed test binary resolved the libraries anyway and the suite passed, while +# the same commit on Linux died with +# error while loading shared libraries: liblogos_qt_host.so +# A macOS-green test run is not evidence here. +# +# logos_core itself is unaffected by the INSTALL_RPATH lines: it sets its own +# INSTALL_RPATH ($ORIGIN / @loader_path in src/CMakeLists.txt), which overrides +# the directory-level default, and nix/lib.nix stages both libraries beside it. +if(DEFINED LOGOS_PROTOCOL_ROOT) + list(APPEND CMAKE_BUILD_RPATH "${LOGOS_PROTOCOL_ROOT}/lib") + list(APPEND CMAKE_INSTALL_RPATH "${LOGOS_PROTOCOL_ROOT}/lib") +endif() +if(DEFINED LOGOS_QT_HOST_ROOT) + list(APPEND CMAKE_BUILD_RPATH "${LOGOS_QT_HOST_ROOT}/lib") + list(APPEND CMAKE_INSTALL_RPATH "${LOGOS_QT_HOST_ROOT}/lib") +endif() + # Build src first to ensure logos_core is built before modules add_subdirectory(src) diff --git a/cmake/gen-shared-exports.sh b/cmake/gen-shared-exports.sh deleted file mode 100644 index 23cfdbc..0000000 --- a/cmake/gen-shared-exports.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/bin/sh -# Generate the PE module-definition file that makes liblogos_core.dll the single -# provider of the shared C++ runtime (TokenManager, LogosAPI, LogosAPIClient and -# the LogosResult stream operators). -# -# WHY a generated .def rather than __declspec(dllexport) in the headers: the -# definitions live in liblogos_protocol.a / liblogos_qt_host.a (the Qt half was -# liblogos_qt_sdk.a until the host runtime moved into logos-qt-host; same -# objects, new home), which are also -# linked by logos_host.exe, ui-host.exe, every module plugin and every native -# platform. Annotating them for export would mean a second, Windows-only, -# export-annotated build of both archives, kept in sync forever, to solve a -# Windows-only problem. Exporting at liblogos_core's link instead leaves those -# archives compiled byte-identically to before; the consumer side is a pure -# opt-in (-DLOGOS_SHARED_USE_DLL, see logos-protocol/cpp/logos_shared_api.h). -# -# WHY the whole archive and not a curated class list: ld chooses archive members -# by object file, for reasons that have nothing to do with our symbols. Measured -# on this build, main_ui referenced std::string's move constructor and ld -# satisfied it out of the Qt archive's logos_api.cpp.obj — which then dragged -# LogosAPI, LogosAPIClient and TokenManager in behind it. The consumers -# therefore link an EMPTY archive (logos-basecamp/cmake/LogosSharedFromDll.cmake) -# and take everything from the DLL, which only works if the DLL really does -# provide everything. Hence --whole-archive at the link and "*" here: a partial -# export set turns into an undefined reference in a downstream repo, far from -# the cause. -# -# 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) - 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/flake.lock b/flake.lock index 2d9f170..a3a69d9 100644 --- a/flake.lock +++ b/flake.lock @@ -2239,11 +2239,11 @@ ] }, "locked": { - "lastModified": 1787165449, - "narHash": "sha256-X/TaIJA+5o1VLfizu3NLPp/fhisIph8sXG0h0yHDlpo=", + "lastModified": 1787275742, + "narHash": "sha256-/5FwGSIV80CNdc52qYIb1lx1VXWaOZcnnU8zAbKlfSw=", "owner": "logos-co", "repo": "logos-plugin-qt", - "rev": "9b2c64e5a480245b5333e20183a4a3c572d543cc", + "rev": "1aa3e31c029062a8288e5ef4da1f25ca14a8a0dd", "type": "github" }, "original": { @@ -2378,11 +2378,11 @@ ] }, "locked": { - "lastModified": 1787107309, - "narHash": "sha256-oNfr0T6OrD1D56rf1brXH+9hJoJvTaPpvUdy/d62SPs=", + "lastModified": 1787275122, + "narHash": "sha256-VfnbFZO7cF+pTuFlw+VIagB1JBbXYH+VjgDVF0f2UG4=", "owner": "logos-co", "repo": "logos-protocol", - "rev": "f4407ff4854bdaf486182547af5b55f4a0f55229", + "rev": "2e3344acccfba14cf2bd51019c62b7290dad10ca", "type": "github" }, "original": { diff --git a/nix/lib.nix b/nix/lib.nix index e53a4df..e1ec3c8 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -4,6 +4,9 @@ let # Extract the package-manager root from CMake flags logosPackageManagerRoot = common.env.LOGOS_PACKAGE_MANAGER_ROOT; + # The shared runtime liblogos_core now IMPORTS rather than defines. + logosProtocolRoot = common.env.LOGOS_PROTOCOL_ROOT; + logosQtHostRoot = common.env.LOGOS_QT_HOST_ROOT; in pkgs.runCommand "${common.pname}-lib-${common.version}" { @@ -46,6 +49,45 @@ pkgs.runCommand "${common.pname}-lib-${common.version}" fi done + # The shared C++ runtime, which liblogos_core now IMPORTS instead of + # absorbing. This is NOT optional packaging: liblogos_core records + # @rpath/liblogos_qt_host.dylib and @rpath/liblogos_protocol.dylib, and its + # only LC_RPATH is @loader_path -- so the loader looks for them BESIDE + # itself, i.e. in this directory, and nowhere else. + # + # Measured before this existed: the build succeeded, `logos_host --help` + # exited 0, and dlopen of the library failed outright with + # Library not loaded: @rpath/liblogos_qt_host.dylib + # A help-text smoke test does not touch the library, so nothing in the build + # or in a boot check would have caught it. + # + # Explicit `for` + `-f` rather than a glob array, for the reason spelled out + # in the Windows block below: a fully interpolated literal path survives into + # an array even under nullglob, so a guard over it passes vacuously. + for f in ${logosProtocolRoot}/lib/liblogos_protocol.so* \ + ${logosProtocolRoot}/lib/liblogos_protocol.dylib \ + ${logosProtocolRoot}/bin/liblogos_protocol.dll \ + ${logosQtHostRoot}/lib/liblogos_qt_host.so* \ + ${logosQtHostRoot}/lib/liblogos_qt_host.dylib \ + ${logosQtHostRoot}/bin/liblogos_qt_host.dll; do + if [ -f "$f" ]; then + cp -L "$f" $out/lib/ + fi + done + + # Assert it, rather than trusting the copy above. The failure mode is a + # library that builds and installs and cannot be loaded. + _shared_found=0 + for f in $out/lib/liblogos_protocol.* $out/lib/liblogos_qt_host.*; do + [ -f "$f" ] && _shared_found=$((_shared_found + 1)) + done + if [ "$_shared_found" -lt 2 ]; then + echo "ERROR: liblogos_core imports the shared runtime, but only $_shared_found" >&2 + echo " of liblogos_protocol / liblogos_qt_host were staged into \$out/lib." >&2 + echo " liblogos_core resolves them through @loader_path and will fail to load." >&2 + exit 1 + fi + ${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isWindows '' # Windows only: libpackage_manager_lib's OWN dependency, liblgx. # diff --git a/nix/tests.nix b/nix/tests.nix index 7c3a587..5806161 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -86,13 +86,21 @@ pkgs.stdenv.mkDerivation { ${pkgs.lib.optionalString pkgs.stdenv.isLinux '' # Fix RPATH on Linux to avoid /build/ references and include all dependencies. # spdlog links libfmt; both must be on RPATH because patchelf replaces the default search paths. + # + # THIS LIST IS THE WHOLE SEARCH PATH. `patchelf --set-rpath` REPLACES what + # CMake wrote, so CMAKE_BUILD_RPATH / CMAKE_INSTALL_RPATH have no effect on + # the installed binaries here -- anything missing from this string is + # missing at runtime, full stop. Measured: adding both to CMAKE_*_RPATH + # changed nothing and the suite still died with + # error while loading shared libraries: liblogos_qt_host.so + # while the same commit passed on macOS, which does not go through here. # OpenSSL (libssl, libcrypto) is needed because the SDK's plain-C++ TLS # transport links it transitively — without this the wrapped binary # dies with `libssl.so.3: cannot open shared object`. - _rpath="$out/lib:${pkgs.boost}/lib:${common.env.LOGOS_PACKAGE_MANAGER_ROOT}/lib:${pkgs.gtest}/lib:${pkgs.qt6.qtbase}/lib:${pkgs.qt6.qtremoteobjects}/lib:${pkgs.spdlog}/lib:${pkgs.fmt}/lib:${pkgs.openssl.out}/lib:${pkgs.stdenv.cc.cc.lib}/lib" + _rpath="$out/lib:${common.env.LOGOS_PROTOCOL_ROOT}/lib:${common.env.LOGOS_QT_HOST_ROOT}/lib:${pkgs.boost}/lib:${common.env.LOGOS_PACKAGE_MANAGER_ROOT}/lib:${pkgs.gtest}/lib:${pkgs.qt6.qtbase}/lib:${pkgs.qt6.qtremoteobjects}/lib:${pkgs.spdlog}/lib:${pkgs.fmt}/lib:${pkgs.openssl.out}/lib:${pkgs.stdenv.cc.cc.lib}/lib" patchelf --set-rpath "$_rpath" $out/bin/logos_core_tests || true # Fix RPATH on liblogos_core.so so it can find its transitive deps (e.g. libboost_process, spdlog, fmt, libssl) - _rpath_lib="$out/lib:${pkgs.boost}/lib:${common.env.LOGOS_PACKAGE_MANAGER_ROOT}/lib:${pkgs.qt6.qtbase}/lib:${pkgs.qt6.qtremoteobjects}/lib:${pkgs.spdlog}/lib:${pkgs.fmt}/lib:${pkgs.openssl.out}/lib:${pkgs.stdenv.cc.cc.lib}/lib" + _rpath_lib="$out/lib:${common.env.LOGOS_PROTOCOL_ROOT}/lib:${common.env.LOGOS_QT_HOST_ROOT}/lib:${pkgs.boost}/lib:${common.env.LOGOS_PACKAGE_MANAGER_ROOT}/lib:${pkgs.qt6.qtbase}/lib:${pkgs.qt6.qtremoteobjects}/lib:${pkgs.spdlog}/lib:${pkgs.fmt}/lib:${pkgs.openssl.out}/lib:${pkgs.stdenv.cc.cc.lib}/lib" patchelf --set-rpath "$_rpath_lib" $out/lib/liblogos_core.so || true ''} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4e32cf6..c8ac6d4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -40,7 +40,16 @@ if(EXISTS "${LOGOS_PROTOCOL_ROOT}/lib/cmake/logos-protocol" PATHS "${LOGOS_QT_HOST_ROOT}/lib/cmake/logos-qt-host" NO_DEFAULT_PATH) if(NOT TARGET logos_sdk) add_library(logos_sdk INTERFACE IMPORTED) - target_link_libraries(logos_sdk INTERFACE logos-qt-host::logos_qt_host) + # The SHARED Qt host runtime, not the static archive. This is the line that + # replaces the whole-archive + generated-.def block further down: it makes + # liblogos_core a CONSUMER of the runtime rather than its provider, so + # TokenManager / LogosAPIClient / LogosAPI each have exactly one definition + # in the process because there is exactly one library defining them. + # + # logos_qt_host_shared itself PUBLIC-links logos_protocol_shared, so the + # protocol half arrives transitively and correctly layered -- it is asserted + # in logos-plugin-qt by checks.shared-runtime-layering. + target_link_libraries(logos_sdk INTERFACE logos-qt-host::logos_qt_host_shared) endif() else() message(FATAL_ERROR "logos-protocol / logos-qt-host not found. Set " @@ -207,106 +216,41 @@ target_compile_definitions(logos_core PRIVATE LOGOS_CORE_LIBRARY) # Do not reinstate it; if the C API ever stops being exported, the cause is a # missing LOGOS_CORE_EXPORT on a declaration, not a missing linker flag. # -# The C API is not the whole story, though. Narrowing the export table to it was -# what made main_ui link again, but it also left every in-process image with its +# The C API is not the whole story, and the rest of it is no longer solved here. +# +# Narrowing the export table to the C API left every in-process image with its # own statically linked copy of the shared C++ runtime -- and therefore its own # TokenManager singleton, so a capability token saved by the host was invisible -# to the UI plugin and every cross-module call was refused. ELF and Mach-O -# interpose these symbols across the process and get one instance for free; PE -# does not. The block below closes that gap the only way PE allows: liblogos_core -# publishes the shared types EXPLICITLY, and the in-process consumers import them -# instead of re-linking them. Neither horn of the old dilemma -- export -# everything and collide, or export nothing and duplicate -- is taken. +# to the UI plugin and every cross-module call was refused. This file used to +# close that gap by absorbing liblogos_protocol.a and liblogos_qt_host.a whole +# (--whole-archive) and re-publishing their symbols through a generated .def, so +# that liblogos_core became the single provider for types it does not own. +# +# It is not the provider any more. logos-protocol and logos-qt-host now ship +# REAL SHARED LIBRARIES that own their own symbols -- liblogos_protocol exports +# its table from a generated .def of its own (logos-protocol#65), and +# liblogos_qt_host owns LogosAPI (logos-plugin-qt#22). liblogos_core links them +# like any other dependency and IMPORTS the runtime instead of defining it. +# +# So this whole block is gone, along with cmake/gen-shared-exports.sh. What +# replaces it is one line: logos_sdk carries logos_qt_host_shared rather than +# the static archive, near the top of this file. +# +# WHAT DID NOT CHANGE is the invariant. The runtime must still exist exactly +# once per process; it is now enforced by there being one shared library per +# type rather than by one image absorbing everything. Consumers still empty +# their static archives (logos-basecamp/cmake/LogosSharedFromDll.cmake) so ld +# cannot pull an archive member that would redefine an imported symbol, and +# logos-basecamp/nix/symbol-gate.nix still asserts the result. +# +# OUT-OF-PROCESS consumers are deliberately unaffected: module plugins and +# ui_qml backends keep linking the STATIC archive, because each runs in its own +# process where its own copy is the CORRECT per-process singleton, and because a +# .lgx records an empty nix closure and could not carry a shared library anyway. # # Full rationale, including why the consumer-side __declspec(dllimport) is the # load-bearing half rather than the export, lives in # logos-protocol/cpp/logos_shared_api.h. -if(WIN32) - # The two archives that make up the shared C++ runtime. The Qt half used to - # be logos-qt-sdk::logos_qt_sdk; it is logos-qt-host::logos_qt_host now -- - # logos-qt-sdk handed the CODE to logos-qt-host, so it is logos-qt-host that - # owns liblogos_qt_host.a and $ only resolves on that target. - # find_package(logos-qt-host) at the top of this file imports it DIRECTLY, - # so the target is unconditionally here -- it is not inherited through - # logos-qt-sdk, and does not depend on logos-qt-sdk keeping a dependency on - # the host runtime. - # - # A missing target is FATAL rather than a skipped block, for the same reason - # a missing `nm` is fatal below: every failure mode of this block is silent. - # The old guard had the target tests in the `if`, so the day one of them was - # renamed, liblogos_core.dll would have been built with no export list and - # no diagnostic -- and the split-brain it exists to prevent (one - # TokenManager per image, tokens written into one and read from another) - # shows up much later as refused calls in an app, not as a build failure. - foreach(_logos_shared_target logos-protocol::logos_protocol logos-qt-host::logos_qt_host) - if(NOT TARGET ${_logos_shared_target}) - message(FATAL_ERROR - "${_logos_shared_target} is not a target, so liblogos_core.dll cannot " - "be given the shared-runtime export list. Windows requires it: PE has no " - "symbol interposition, so without this every in-process image links its " - "own copy of TokenManager and cross-module calls are refused at runtime. " - "Check that find_package(logos-qt-host) resolved -- i.e. that " - "LOGOS_QT_HOST_ROOT points at a built logos-qt-host prefix.") - endif() - endforeach() - - # Ask the imported targets where their archives actually are rather than - # rebuilding the path by hand, so a rename or a layout change fails at - # generate time instead of producing an empty export list. - set(_logos_protocol_archive "$") - set(_logos_qt_host_archive "$") - - # --whole-archive, i.e. the whole of both libraries goes into the DLL even - # where liblogos itself never calls it. - # - # This is not gold-plating; it is what makes "single provider" true rather - # than approximate. The consumers link an EMPTY stand-in for these archives - # (logos-basecamp/cmake/LogosSharedFromDll.cmake) because ld would otherwise - # pull members for incidental reasons -- it was observed pulling - # logos_api.cpp.obj to satisfy std::string's move constructor -- and each - # pulled object collides with our export. Once the consumer has no archive to - # fall back on, anything the DLL failed to include becomes an undefined - # reference in a downstream repo. Linking normally would include only the - # objects liblogos happens to reference, which is a moving target. - target_link_options(logos_core PRIVATE - "-Wl,--whole-archive" - "${_logos_protocol_archive}" - "${_logos_qt_host_archive}" - "-Wl,--no-whole-archive") - - # 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 and restoring the split-brain. - set(_logos_nm "${CMAKE_NM}") - if(NOT _logos_nm) - find_program(_logos_nm NAMES "${CMAKE_CXX_COMPILER_TARGET}-nm" x86_64-w64-mingw32-nm nm) - endif() - if(NOT _logos_nm) - message(FATAL_ERROR "nm not found; cannot generate the liblogos_core export definition") - endif() - - set(_logos_shared_def "${CMAKE_CURRENT_BINARY_DIR}/logos_core_shared_exports.def") - add_custom_command( - OUTPUT "${_logos_shared_def}" - COMMAND ${CMAKE_COMMAND} -E env sh - "${CMAKE_CURRENT_SOURCE_DIR}/../cmake/gen-shared-exports.sh" - "${_logos_nm}" "${_logos_shared_def}" - "${_logos_protocol_archive}" "*" - "${_logos_qt_host_archive}" "*" - DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/../cmake/gen-shared-exports.sh" - "${_logos_protocol_archive}" "${_logos_qt_host_archive}" - COMMENT "Generating liblogos_core shared-runtime export definition" - VERBATIM - ) - add_custom_target(logos_core_shared_exports DEPENDS "${_logos_shared_def}") - add_dependencies(logos_core logos_core_shared_exports) - - # A .def passed to the link ADDS to the export table; the ~18 - # __declspec(dllexport) logos_core_* C API entries survive alongside it. - target_link_options(logos_core PRIVATE "${_logos_shared_def}") - set_property(TARGET logos_core APPEND PROPERTY LINK_DEPENDS "${_logos_shared_def}") -endif() # Portable build: selects portable LGX variants instead of dev variants option(LOGOS_PORTABLE_BUILD "Build for portable variant selection" OFF)