feat(qt-host): add logos_qt_host_shared, and assert its layering

PR-3 of the shared-runtime migration. Requires logos-protocol#65, which exports
logos_protocol_shared from the CMake package; without it this build hard-fails
by design rather than falling back.

WHY. LogosAPI (here) and TokenManager / LogosAPIClient / StoreRegistry (in
logos-protocol) must exist EXACTLY ONCE per process. Every image linking 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, since a
.lgx records an empty nix closure and a shared library would not travel with it.
In-process images link the shared target; out-of-process images link the archive.

THE ONE LINE THAT MATTERS is that logos_qt_host_shared PUBLIC-links
${LP_SHARED_TARGET}, not ${LP_TARGET}. Linking the static archive there would
embed a second TokenManager INSIDE liblogos_qt_host itself -- the same bug one
layer down, and invisible to the consumer-side symbol gate, which would treat
this library as a provider and pass. It is a one-word mistake that builds,
links, installs and loads, and surfaces only as refused calls at runtime in a
different repo. So this PR carries its own check:

  checks.<system>.shared-runtime-layering

asserting liblogos_qt_host.{dylib,so} DEFINES LogosAPI, DEFINES NEITHER
TokenManager NOR LogosAPIClient, IMPORTS TokenManager, and has
liblogos_protocol on its link line. The positive assertion doubles as the
validity control: if nm or c++filt were broken it reports 0 for LogosAPI and
fails, rather than reporting a reassuring zero for the other two.

LogosAPI switches from LOGOS_SHARED_API to LOGOS_QT_HOST_API (logos-protocol#64)
because the two are not the same choice in one translation unit: building this
library, LogosAPI must be EXPORTED while TokenManager must be IMPORTED. On
Windows the shared target also compiles with LOGOS_SHARED_USE_DLL so the
protocol-owned types become dllimport; LOGOS_QT_HOST_BUILDING_SHARED is tested
FIRST in logos_shared_api.h, so LogosAPI still resolves to dllexport there.

ARCHIVE DESTINATION on the install 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.

VERIFIED, aarch64-darwin:

  liblogos_qt_host.dylib   defines LogosAPI:: 23
                           defines TokenManager:: 0, LogosAPIClient:: 0
                           imports TokenManager:: 5, LogosAPIClient:: 1
                           links @rpath/liblogos_protocol.dylib
  static archive           symbol tables IDENTICAL to master (1494 lines), one
                           byte differing -- ar metadata, not content. The macro
                           switch is behaviour-preserving for every existing
                           consumer.
  all 7 checks             PASS

The layering check was confirmed discriminating rather than vacuous by running
its logic against liblogos_core.dylib, which defines TokenManager 32 times and
is correctly rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-20 20:55:00 -03:00
co-authored by Claude Opus 5
parent 55713b9c79
commit 6842e91063
4 changed files with 189 additions and 7 deletions
+82
View File
@@ -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
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
$<INSTALL_INTERFACE:include>
)
# 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::
+14 -7
View File
@@ -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
+12
View File
@@ -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 {
+81
View File
@@ -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
''