Skip to content

Linux tray fails silently on systemd-free distros: libLinuxTray.so hard-links libsystemd.so.0 #1

Description

@jdmanring

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

  1. On any systemd-free distro (or a container without libsystemd), build or run the shipped libLinuxTray.so.
  2. ldd libLinuxTray.solibsystemd.so.0 => not found.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions