Summary
libLinuxTray.so is dynamically linked against libsystemd.so.0 (via pkg-config --libs libsystemd in src/native/linux/build.sh). On any distribution that ships without systemd, that soname does not exist, so the library fails to load the moment System.load() hits it — and because the failure happens inside the native loader, nothing surfaces to the user. The tray simply never appears. The rest of the app keeps running, which makes this especially hard to diagnose from the outside: on a Compose app that only exposes its window through the tray, the whole GUI becomes unreachable while the process looks healthy.
This is not exotic hardware. It affects every mainstream systemd-free distro: Artix, Void, Gentoo/OpenRC, Alpine, Devuan, plus musl and container/minimal images that omit libsystemd.
Environment
- ComposeNativeTray consumed via CrossPaste 2.1.5 (the tray lib is byte-identical to a fresh
build.sh output).
- Artix Linux (Arch-based, no systemd — s6/OpenRC),
elogind 257.14.
- KDE Plasma 6 / KWin, Wayland session. StatusNotifierItem host is present and working (other apps' tray icons show fine).
gcc 16.1.1, x86_64.
Impact
The tray never registers a StatusNotifierItem. For an app whose primary (or only) way to surface its window is the tray, this is effectively a total loss of the GUI — the process runs, background work continues, but the user has no way in. The failure is effectively silent to the user: System.load() throws UnsatisfiedLinkError (the surrounding catch only guards the earlier System.loadLibrary attempt), which surfaces as an ExceptionInInitializerError from LinuxNativeBridge and propagates out of LinuxTrayInitializer.initialize() (no error handling there). Nothing actionable is logged, so the observable result is simply a missing tray.
Root cause
The tray talks StatusNotifierItem over sd-bus, and the build links sd-bus at load time:
# src/native/linux/build.sh
SDBUS_LIBS=$(pkg-config --libs libsystemd) # -> -lsystemd
...
gcc -shared -o .../libLinuxTray.so sni.o jni_bridge.o $SDBUS_LIBS -lpthread -lm -ldl
That produces a hard DT_NEEDED on libsystemd.so.0:
$ readelf -d libLinuxTray.so | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libsystemd.so.0]
$ ldd libLinuxTray.so
libsystemd.so.0 => not found
On a systemd-free host there is no libsystemd.so.0, so the loader can never satisfy the dependency and the library fails to load before any of your code runs.
The important detail: sd-bus is not systemd-specific. The exact same symbols are exported by elogind (libelogind.so.0) and by basu (libbasu.so.0), which exist specifically to provide sd-bus on non-systemd systems. Every sd_bus_* symbol libLinuxTray.so imports is present in libelogind.so.0:
$ comm -23 <(nm -D -u libLinuxTray.so | grep -o 'sd_bus_[a-z_]*' | sort -u) \
<(nm -D --defined-only /usr/lib/libelogind.so.0 | grep -o 'sd_bus_[a-z_]*' | sort -u)
# (empty — all satisfied)
So the code is already portable; only the link strategy isn't. The library hard-binds to one specific provider's soname when it should bind to whichever provider is present at runtime.
Reproduction
- On any systemd-free distro (or a container without libsystemd), build or run the shipped
libLinuxTray.so.
ldd libLinuxTray.so → libsystemd.so.0 => not found.
- Run a ComposeNativeTray app → no tray icon registers;
org.kde.StatusNotifierWatcher never receives a new item. No error is shown.
build.sh also hard-fails at build time on these systems (pkg-config --exists libsystemd returns false), so building from source is blocked too — but the shipped-binary failure is the one that hits end users.
Proposed fix
Resolve the sd-bus entry points at runtime via dlopen/dlsym with a provider fallback list, instead of linking -lsystemd at build time. One prebuilt .so then runs everywhere:
libsystemd.so.0 -> libelogind.so.0 -> libbasu.so.0
Concretely (this is what I implemented and tested):
- New
sdbus_compat.{h,c}: a small loader that dlopens the first available provider and dlsyms the ~24 sd_bus_* functions sni.c uses into function pointers. A set of #defines reroutes the existing call sites, so sni.c is touched in exactly two places (an include, and a one-line sdbus_compat_init() guard at the top of sni_tray_run).
- One data symbol needs care:
SD_BUS_VTABLE_START bakes &sd_bus_object_vtable_format into the static vtable as a load-time relocation, which is too early for a runtime dlopen. Defining that symbol locally resolves it; the provider only compares the pointer to enable optional parameter-name introspection metadata, and falls back to the (fully functional) legacy vtable format when it differs.
build.sh: accept headers from libsystemd or libelogind or basu, drop -lsystemd from the link, and compile the new TU.
The result has no systemd dependency at all:
$ readelf -d libLinuxTray.so | grep -c systemd # was 1 (NEEDED libsystemd.so.0)
0
$ nm -D -u libLinuxTray.so | grep -c sd_bus # no unresolved sd-bus symbols
0
I considered a build-time-only fix (make build.sh pick libsystemd/libelogind/basu via pkg-config). It's smaller, but it only helps people who build from source — the binary you ship from CI is still hard-linked to whatever the CI image has (libsystemd), so end users on systemd-free distros stay broken. The runtime loader is what actually fixes the shipped artifact, which is why I went that route. As a lighter alternative, weak-linking the sd-bus symbols plus an RTLD_GLOBAL dlopen at init would also work, but explicit dlsym is unambiguous and independent of lazy-binding/-z now behavior.
Verification
Tested on the affected platform (Artix, no systemd, KDE Plasma 6 Wayland), using the patched library dropped into a real ComposeNativeTray consumer with no LD_LIBRARY_PATH tricks:
readelf -d shows no libsystemd DT_NEEDED; nm -D -u shows zero unresolved sd_bus_* symbols.
- At runtime the process
dlopens /usr/lib/libelogind.so.0 (confirmed in /proc/<pid>/maps) and registers a StatusNotifierItem — the tray icon appears and is clickable. Baseline SNI count went from 3 to 4 with the app's item present.
- No regression expected on systemd hosts: the fallback list tries
libsystemd.so.0 first, so those systems behave exactly as before.
Patch
A ready-to-review branch with the change is here: fix/sdbus-runtime-loader (commit message and diff included). Happy to open it as a PR if the approach looks right, or to adjust — e.g. gate the loader behind a build flag, add a logged warning when no provider is found (the loader already returns a clean error that sni_tray_run can surface), or switch to the weak-symbol variant if you prefer fewer moving parts.
Summary
libLinuxTray.sois dynamically linked againstlibsystemd.so.0(viapkg-config --libs libsystemdinsrc/native/linux/build.sh). On any distribution that ships without systemd, that soname does not exist, so the library fails to load the momentSystem.load()hits it — and because the failure happens inside the native loader, nothing surfaces to the user. The tray simply never appears. The rest of the app keeps running, which makes this especially hard to diagnose from the outside: on a Compose app that only exposes its window through the tray, the whole GUI becomes unreachable while the process looks healthy.This is not exotic hardware. It affects every mainstream systemd-free distro: Artix, Void, Gentoo/OpenRC, Alpine, Devuan, plus musl and container/minimal images that omit libsystemd.
Environment
build.shoutput).elogind 257.14.gcc 16.1.1,x86_64.Impact
The tray never registers a StatusNotifierItem. For an app whose primary (or only) way to surface its window is the tray, this is effectively a total loss of the GUI — the process runs, background work continues, but the user has no way in. The failure is effectively silent to the user:
System.load()throwsUnsatisfiedLinkError(the surrounding catch only guards the earlierSystem.loadLibraryattempt), which surfaces as anExceptionInInitializerErrorfromLinuxNativeBridgeand propagates out ofLinuxTrayInitializer.initialize()(no error handling there). Nothing actionable is logged, so the observable result is simply a missing tray.Root cause
The tray talks StatusNotifierItem over sd-bus, and the build links sd-bus at load time:
That produces a hard
DT_NEEDEDonlibsystemd.so.0:On a systemd-free host there is no
libsystemd.so.0, so the loader can never satisfy the dependency and the library fails to load before any of your code runs.The important detail: sd-bus is not systemd-specific. The exact same symbols are exported by
elogind(libelogind.so.0) and bybasu(libbasu.so.0), which exist specifically to provide sd-bus on non-systemd systems. Everysd_bus_*symbollibLinuxTray.soimports is present inlibelogind.so.0:So the code is already portable; only the link strategy isn't. The library hard-binds to one specific provider's soname when it should bind to whichever provider is present at runtime.
Reproduction
libLinuxTray.so.ldd libLinuxTray.so→libsystemd.so.0 => not found.org.kde.StatusNotifierWatchernever receives a new item. No error is shown.build.shalso hard-fails at build time on these systems (pkg-config --exists libsystemdreturns false), so building from source is blocked too — but the shipped-binary failure is the one that hits end users.Proposed fix
Resolve the sd-bus entry points at runtime via
dlopen/dlsymwith a provider fallback list, instead of linking-lsystemdat build time. One prebuilt.sothen runs everywhere:Concretely (this is what I implemented and tested):
sdbus_compat.{h,c}: a small loader thatdlopens the first available provider anddlsyms the ~24sd_bus_*functionssni.cuses into function pointers. A set of#defines reroutes the existing call sites, sosni.cis touched in exactly two places (an include, and a one-linesdbus_compat_init()guard at the top ofsni_tray_run).SD_BUS_VTABLE_STARTbakes&sd_bus_object_vtable_formatinto the static vtable as a load-time relocation, which is too early for a runtime dlopen. Defining that symbol locally resolves it; the provider only compares the pointer to enable optional parameter-name introspection metadata, and falls back to the (fully functional) legacy vtable format when it differs.build.sh: accept headers fromlibsystemdorlibelogindorbasu, drop-lsystemdfrom the link, and compile the new TU.The result has no systemd dependency at all:
I considered a build-time-only fix (make
build.shpicklibsystemd/libelogind/basuvia pkg-config). It's smaller, but it only helps people who build from source — the binary you ship from CI is still hard-linked to whatever the CI image has (libsystemd), so end users on systemd-free distros stay broken. The runtime loader is what actually fixes the shipped artifact, which is why I went that route. As a lighter alternative, weak-linking the sd-bus symbols plus anRTLD_GLOBALdlopen at init would also work, but explicit dlsym is unambiguous and independent of lazy-binding/-z nowbehavior.Verification
Tested on the affected platform (Artix, no systemd, KDE Plasma 6 Wayland), using the patched library dropped into a real ComposeNativeTray consumer with no
LD_LIBRARY_PATHtricks:readelf -dshows nolibsystemdDT_NEEDED;nm -D -ushows zero unresolvedsd_bus_*symbols.dlopens/usr/lib/libelogind.so.0(confirmed in/proc/<pid>/maps) and registers a StatusNotifierItem — the tray icon appears and is clickable. Baseline SNI count went from 3 to 4 with the app's item present.libsystemd.so.0first, so those systems behave exactly as before.Patch
A ready-to-review branch with the change is here:
fix/sdbus-runtime-loader(commit message and diff included). Happy to open it as a PR if the approach looks right, or to adjust — e.g. gate the loader behind a build flag, add a logged warning when no provider is found (the loader already returns a clean error thatsni_tray_runcan surface), or switch to the weak-symbol variant if you prefer fewer moving parts.