Files
Dario LipicarandClaude Opus 5 b2a9a0ba9d feat(shared-runtime): import the runtime instead of providing it (#182)
* feat(shared-runtime): import the runtime instead of providing it

PR-4 of the shared-runtime migration. liblogos_core stops being the single
provider of types it does not own, and becomes a consumer of the libraries that
do: logos-protocol#65 and logos-plugin-qt#22.

WHAT GOES AWAY. The whole if(WIN32) block that absorbed liblogos_protocol.a and
liblogos_qt_host.a with --whole-archive and re-published their symbols through a
generated .def, plus cmake/gen-shared-exports.sh itself. That scheme existed
because PE exports nothing it is not told to export and the definitions lived in
archives that every other image also linked; now they live in shared libraries
that export their own tables, so there is nothing for this repo to re-publish.

WHAT REPLACES IT is one line: logos_sdk carries logos_qt_host_shared rather than
the static archive. logos_qt_host_shared PUBLIC-links logos_protocol_shared, so
the protocol half arrives transitively and correctly layered.

THE INVARIANT IS UNCHANGED. 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. Measured, aarch64-darwin:

    liblogos_core.dylib       defines 0   (was 32 TokenManager symbols)
                              imports 8
    liblogos_protocol.dylib   defines 78  (TokenManager, LogosAPIClient, ...)
    liblogos_qt_host.dylib    defines 23  (LogosAPI)

OUT-OF-PROCESS CONSUMERS ARE DELIBERATELY UNAFFECTED. Module plugins and ui_qml
backends keep linking the STATIC archive: each runs in its own process where its
own copy is the CORRECT per-process singleton, and a .lgx records an empty nix
closure so it could not carry a shared library anyway.

TWO DEPLOYMENT FAILURES THIS ALSO FIXES, both of which built green.

nix/lib.nix now STAGES liblogos_protocol and liblogos_qt_host beside
liblogos_core, and asserts it did. liblogos_core records
@rpath/liblogos_qt_host.dylib with @loader_path as its only rpath, so the loader
looks for them in that directory and nowhere else. Before this:

    nix build .#default        OK
    logos_host --help          exit 0
    dlopen liblogos_core.dylib Library not loaded: @rpath/liblogos_qt_host.dylib

A help-text smoke test never touches the library, so nothing in the build or in
a boot check would have caught it. Hence the assertion rather than trust in the
copy loop.

CMakeLists.txt adds both to CMAKE_BUILD_RPATH, mirroring what
LOGOS_PACKAGE_MANAGER_ROOT already does. Without it logos_core_tests aborted at
dyld time, before main(), while .#default had already succeeded.

VERIFIED, aarch64-darwin: .#default OK, dlopen OK, checks.tests PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(shared-runtime): install RPATH too, or the installed test binary cannot load

BUILD_RPATH covers binaries run from the build tree; the test derivation runs the
INSTALLED one, which uses INSTALL_RPATH. Only Linux said so -- on macOS the
installed test binary resolved the libraries anyway and checks.tests passed,
while the same commit on Linux died before main() with

    error while loading shared libraries: liblogos_qt_host.so

A macOS-green run is not evidence for this class of failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tests): put the shared runtime on the hand-set Linux RPATH

nix/tests.nix applies its RPATH with `patchelf --set-rpath`, which REPLACES
whatever CMake wrote. So CMAKE_BUILD_RPATH and CMAKE_INSTALL_RPATH have no
effect on the installed test binaries on Linux, and that list is the entire
search path: anything absent from it is absent at runtime.

Measured: adding both libraries to CMAKE_*_RPATH changed nothing and the suite
still died before main() with

    error while loading shared libraries: liblogos_qt_host.so

while the same commit passed on macOS, which does not go through this code path
at all. Two platforms, two independent rpath mechanisms, and only one of them
was wired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:51:26 -03:00

131 lines
6.1 KiB
Nix

# Extracts libraries from the shared build
{ pkgs, common, build }:
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}"
{
inherit (common) meta;
}
''
# Copy libraries from the shared build
mkdir -p $out/lib
if [ -d ${build}/lib ]; then
cp -r ${build}/lib/* $out/lib/
fi
# Fail loudly rather than shipping a lib output with no RUNTIME artifact.
# Verified failure this guards against: drop `RUNTIME DESTINATION lib` from
# install(TARGETS logos_core) and the Windows build still exits 0 while
# liblogos_core.dll appears nowhere in the package -- only the
# liblogos_core.dll.a import library does. Match the loadable image
# explicitly; a `liblogos_core.*` glob is satisfied by the import library
# and catches nothing.
#
# Tested explicitly with `for`/`-f` rather than a glob array: bash's
# nullglob only drops patterns that CONTAIN a wildcard, so a literal
# "$out/lib/liblogos_core.dll" survives into the array even when the file
# does not exist and the check passes vacuously. (Observed: the first
# version of this guard did exactly that and let the broken build through.)
found=""
for cand in $out/lib/liblogos_core.so $out/lib/liblogos_core.dylib $out/lib/liblogos_core.dll; do
[ -f "$cand" ] && found="$cand"
done
if [ -z "$found" ]; then
echo "Error: no loadable logos_core library in ${build}/lib" >&2
ls -la ${build}/lib ${build}/bin >&2 || true
exit 1
fi
# Bundle package_manager_lib alongside logos_core (logos_core links against it)
for f in ${logosPackageManagerRoot}/lib/libpackage_manager_lib*; do
if [ -f "$f" ]; then
cp -L "$f" $out/lib/
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.
#
# An ELF or Mach-O consumer never needs this -- the copied
# libpackage_manager_lib carries an RPATH/install-name pointing back at
# the store path liblgx lives in, so the loader finds it there. PE has no
# rpath: an import table carries the DLL BASE NAME and Windows resolves
# it from the loading executable's directory. Whatever bundles this lib
# output can only stage what is IN it, so a dependency left behind here
# is a dependency that cannot be staged later.
#
# Measured, and the reason this exists: logosctl.exe with every other DLL
# correctly beside it exited 53 with NO OUTPUT AT ALL -- the loader
# failing on liblgx.dll before main() ran. Nothing in the build, the
# package, or the run says which DLL is missing.
#
# Every DLL the package-manager output carries, not just liblgx: liblgx
# has its own imports (libsodium, ICU, zlib), and package-manager stages
# that whole closure for exactly this reason. Copying the set rather than
# naming members keeps it transitive instead of a list that rots.
#
# Explicit `for` + `-f` rather than a nullglob array: nullglob only drops
# patterns that CONTAIN a wildcard, so a fully interpolated literal path
# survives into the array and a guard over it passes vacuously.
lgx=0
for f in ${logosPackageManagerRoot}/lib/*.dll ${logosPackageManagerRoot}/lib/*.dll.a; do
[ -f "$f" ] || continue
[ -e "$out/lib/$(basename "$f")" ] && continue
cp -L "$f" $out/lib/
case "$(basename "$f")" in liblgx.dll) lgx=$((lgx + 1));; esac
done
if [ "$lgx" -eq 0 ] && [ ! -f "$out/lib/liblgx.dll" ]; then
echo "Error: liblgx.dll not found under ${logosPackageManagerRoot}/lib;" >&2
echo " libpackage_manager_lib.dll imports it, so any .exe loading" >&2
echo " liblogos_core.dll would fail with no diagnostic." >&2
ls -la ${logosPackageManagerRoot}/lib >&2 || true
exit 1
fi
''}
''