diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 2370b4c..3358de4 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -27,11 +27,13 @@ if(EXISTS "${LOGOS_PROTOCOL_ROOT}/lib/cmake/logos-protocol") find_package(logos-protocol REQUIRED PATHS "${LOGOS_PROTOCOL_ROOT}/lib/cmake/logos-protocol" NO_DEFAULT_PATH) set(LP_TARGET logos-protocol::logos_protocol) + set(LP_SHARED_TARGET logos-protocol::logos_protocol_shared) elseif(EXISTS "${LOGOS_PROTOCOL_ROOT}/cpp/CMakeLists.txt") # Source checkout → build it as a subproject (dev convenience). add_subdirectory("${LOGOS_PROTOCOL_ROOT}/cpp" "${CMAKE_BINARY_DIR}/logos-protocol-build") set(LP_TARGET logos_protocol) + set(LP_SHARED_TARGET logos_protocol_shared) else() message(FATAL_ERROR "logos-protocol not found. Set LOGOS_PROTOCOL_ROOT to an " "installed logos-protocol prefix or a source checkout.") @@ -78,6 +80,86 @@ install(TARGETS logos_qt_host INCLUDES DESTINATION include ) +# --------------------------------------------------------------------------- +# logos_qt_host_shared — the Qt host runtime as a SHARED library. +# +# WHY. The types in here (LogosAPI) and in logos-protocol (TokenManager, +# LogosAPIClient, the per-identity StoreRegistry) must exist EXACTLY ONCE per +# process. Every image that links a static archive gets its own copy of every +# function-local static inside it, so the host writes a capability token into +# one store and another in-process image reads an empty one -- with no build +# diagnostic. Linking ONE shared library is what replaces the whole-archive + +# generated-.def scheme in which liblogos_core absorbed both archives and +# re-exported them. +# +# THE STATIC ARCHIVE STAYS, and not transitionally. Module plugins and ui_qml +# backends run in their OWN processes, so their own copy 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). +# In-process images link the shared one; out-of-process images link the archive. +if(NOT TARGET ${LP_SHARED_TARGET}) + # Hard failure rather than "skip the shared target", because every failure + # mode here is silent: a consumer that wanted the shared runtime would fall + # back to the archive, link cleanly, and reintroduce the duplicate singleton + # at runtime. + message(FATAL_ERROR + "${LP_SHARED_TARGET} is not a target, so logos_qt_host_shared cannot be " + "built. It requires a logos-protocol that EXPORTS its shared library " + "(logos-protocol#65). Check that LOGOS_PROTOCOL_ROOT points at a " + "protocol build new enough to provide it.") +endif() + +add_library(logos_qt_host_shared SHARED ${QT_HOST_SOURCES}) + +# PUBLIC-links the SHARED protocol, NOT ${LP_TARGET}. +# +# This single line is the whole correctness of this target. Linking the static +# archive here would embed a second copy of TokenManager INSIDE +# liblogos_qt_host.{so,dylib,dll} -- the exact bug this migration removes, one +# layer down, and invisible to a consumer-side symbol gate because the duplicate +# would live in a library the gate treats as a provider. +target_link_libraries(logos_qt_host_shared PUBLIC + ${LP_SHARED_TARGET} + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::RemoteObjects +) + +# LogosAPI is EXPORTED from this library (LOGOS_QT_HOST_API), while TokenManager +# and LogosAPIClient -- owned by logos-protocol -- must be IMPORTED from +# liblogos_protocol. That is why they are two macros and not one: a single macro +# cannot say "export" and "import" in the same translation unit. +# +# LOGOS_QT_HOST_BUILDING_SHARED is tested FIRST in logos_shared_api.h, so +# LogosAPI still resolves to dllexport here even with LOGOS_SHARED_USE_DLL set. +target_compile_definitions(logos_qt_host_shared PRIVATE LOGOS_QT_HOST_BUILDING_SHARED) +if(WIN32) + target_compile_definitions(logos_qt_host_shared PRIVATE LOGOS_SHARED_USE_DLL) +endif() + +target_include_directories(logos_qt_host_shared PUBLIC + $ + $ +) + +# Same basename as the archive; the LIBRARY install lands it in $out/lib beside +# liblogos_qt_host.a. +set_target_properties(logos_qt_host_shared PROPERTIES + OUTPUT_NAME logos_qt_host + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" +) + +# ARCHIVE DESTINATION is load-bearing on Windows and inert elsewhere: a shared +# library's IMPORT library (.dll.a) is the ARCHIVE artifact while the DLL is +# RUNTIME, so omitting it installs no import library and a consumer gets an +# imported target whose IMPORTED_IMPLIB does not exist. Invisible on ELF/Mach-O. +install(TARGETS logos_qt_host_shared + EXPORT logos-qt-hostTargets + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin + INCLUDES DESTINATION include +) + install(EXPORT logos-qt-hostTargets FILE logos-qt-hostTargets.cmake NAMESPACE logos-qt-host:: diff --git a/cpp/logos_api.h b/cpp/logos_api.h index a64172a..9bcaf0b 100644 --- a/cpp/logos_api.h +++ b/cpp/logos_api.h @@ -86,15 +86,22 @@ inline size_t qHash(const LogosAPIClientCacheKey& k, size_t seed = 0) noexcept * * This class initializes and keeps instances of the client provider and token manager. * - * LOGOS_SHARED_API because this is the object handed across the DLL boundary: - * the host constructs a LogosAPI inside liblogos_core and passes the pointer to - * the UI plugin through PluginInterface::logosAPI. Its constructor caches - * `&TokenManager::instance()`, so on PE a plugin that links its own copy of - * logos_api.cpp.obj caches a DIFFERENT singleton than the one the host wrote - * the token into. Importing instead of re-linking is what makes the two agree. + * Marked because this is the object handed across the DLL boundary: the host + * constructs a LogosAPI and passes the pointer to the UI plugin through + * PluginInterface::logosAPI. Its constructor caches `&TokenManager::instance()`, + * so an image that links its own copy of logos_api.cpp caches a DIFFERENT + * singleton than the one the host wrote the token into. Importing instead of + * re-linking is what makes the two agree. + * + * LOGOS_QT_HOST_API, not LOGOS_SHARED_API, because LogosAPI is owned by THIS + * library while TokenManager and LogosAPIClient are owned by logos-protocol. + * While building logos_qt_host_shared the first must be EXPORTED and the second + * two IMPORTED, and one macro cannot say both in the same translation unit. Off + * Windows the distinction is moot -- both resolve to default visibility -- which + * is exactly why getting it wrong would go unnoticed until a Windows build. * See logos_shared_api.h in logos-protocol. */ -class LOGOS_SHARED_API LogosAPI : public QObject +class LOGOS_QT_HOST_API LogosAPI : public QObject { Q_OBJECT diff --git a/flake.nix b/flake.nix index 36e5cbe..d5a6bd4 100644 --- a/flake.nix +++ b/flake.nix @@ -171,6 +171,18 @@ # The Qt host runtime compiles and installs a usable CMake package. qt-host = self.packages.${system}.logos-qt-host; + # logos_qt_host_shared must OWN LogosAPI and BORROW everything + # logos-protocol owns. The consumer-side symbol gate cannot see this: + # a shared qt-host that linked the STATIC protocol archive would embed + # its own TokenManager, and the gate would treat that library as a + # provider and pass, with the duplicate one layer below anything it + # inspects. One wrong word in target_link_libraries, and it builds, + # links, installs and loads. + shared-runtime-layering = import ./tests/test-shared-runtime-layering.nix { + inherit pkgs; + qtHost = self.packages.${system}.logos-qt-host; + }; + # Drive the glue generator over a real contract and assert on the # emitted C++. qt-host-generator = import ./tests/test-qt-host-generator.nix { diff --git a/tests/test-shared-runtime-layering.nix b/tests/test-shared-runtime-layering.nix new file mode 100644 index 0000000..ce27386 --- /dev/null +++ b/tests/test-shared-runtime-layering.nix @@ -0,0 +1,81 @@ +# Asserts the LAYERING of logos_qt_host_shared: it must OWN LogosAPI and BORROW +# everything logos-protocol owns. +# +# WHY THIS CHECK EXISTS SEPARATELY. The consumer-side symbol gate (logos-basecamp +# and logos-logoscore-cli nix/symbol-gate.nix) asserts that no in-process +# CONSUMER defines the runtime. It cannot see this failure: if +# liblogos_qt_host.dylib linked the STATIC protocol archive it would embed its +# own TokenManager, and the gate would treat that library as a provider and pass. +# The duplicate would then be one layer below anything the gate inspects. +# +# The failure is a single-word mistake -- ${LP_TARGET} instead of +# ${LP_SHARED_TARGET} in one target_link_libraries -- and it produces a library +# that builds, links, installs and loads. It surfaces only as refused +# cross-module calls at runtime, in a different repo. +# +# The positive assertion doubles as the validity control: LogosAPI must be +# DEFINED here, so a broken nm or a missing c++filt fails the check rather than +# reporting a reassuring zero. +{ pkgs, qtHost }: + +let + isDarwin = pkgs.stdenv.isDarwin; + definedCmd = if isDarwin then "nm -gU" else "nm -D --defined-only"; + undefCmd = if isDarwin then "nm -gu" else "nm -D --undefined-only"; + stripAddr = if isDarwin then "sed -E 's/^[0-9a-fA-F]+ [A-Za-z] //'" else "sed -E 's/^[0-9a-fA-F]* [A-Za-z] //'"; + stripUndef = if isDarwin then "sed -E 's/^ +U //'" else "sed -E 's/^ +U //'"; +in +pkgs.runCommand "logos-qt-host-shared-runtime-layering" { + nativeBuildInputs = [ pkgs.coreutils pkgs.gnugrep pkgs.gnused pkgs.stdenv.cc.bintools ]; +} '' + set -uo pipefail + FAIL=0 + note() { printf ' %-46s %s\n' "$1" "$2"; } + bad() { FAIL=1; printf ' %-46s %s\n' "$1" "$2"; } + + LIB="" + for c in ${qtHost}/lib/liblogos_qt_host.dylib ${qtHost}/lib/liblogos_qt_host.so; do + [ -e "$c" ] && LIB="$c" && break + done + [ -n "$LIB" ] || { echo "FATAL: no shared logos_qt_host under ${qtHost}/lib"; exit 1; } + echo "library = $LIB" + + defines() { ${definedCmd} "$LIB" 2>/dev/null | c++filt 2>/dev/null | ${stripAddr} | grep -cE "^$1::" || true; } + imports() { ${undefCmd} "$LIB" 2>/dev/null | c++filt 2>/dev/null | ${stripUndef} | grep -cE "^$1::" || true; } + + echo + echo "== it OWNS LogosAPI (expect >0; also the validity control) ==" + n=$(defines LogosAPI) + if [ "$n" -gt 0 ]; then note "LogosAPI:: defined" "$n OK" + else bad "LogosAPI:: defined" "$n EXPECTED >0 (or nm/c++filt is broken)"; fi + + echo + echo "== it does NOT define what logos-protocol owns (expect 0) ==" + for sym in TokenManager LogosAPIClient; do + n=$(defines "$sym") + if [ "$n" -eq 0 ]; then note "$sym:: defined" "0 OK" + else + bad "$sym:: defined" "$n LAYERING VIOLATION" + echo " logos_qt_host_shared linked the STATIC protocol archive." + echo " Use \''${LP_SHARED_TARGET}, not \''${LP_TARGET}, in target_link_libraries." + fi + done + + echo + echo "== it BORROWS them instead (expect >0) ==" + n=$(imports TokenManager) + if [ "$n" -gt 0 ]; then note "TokenManager:: imported" "$n OK" + else bad "TokenManager:: imported" "$n EXPECTED >0"; fi + + echo + echo "== and links the shared protocol ==" + if ${if isDarwin then ''otool -L "$LIB" 2>/dev/null'' else ''objdump -p "$LIB" 2>/dev/null''} | grep -qi 'liblogos_protocol\.\(dylib\|so\)'; then + note "liblogos_protocol on the link line" "OK" + else + bad "liblogos_protocol on the link line" "MISSING — it took the archive" + fi + + echo + if [ "$FAIL" -eq 0 ]; then echo "LAYERING: PASS"; mkdir -p $out; echo ok > $out/result + else echo "LAYERING: FAIL"; exit 1; fi +''