Skip to content

nut-scanner: look up dynamically loaded libraries by SONAME first - #3654

Open
com6056 wants to merge 8 commits into
networkupstools:masterfrom
com6056:nut-scanner-soname-pr
Open

com6056 wants to merge 8 commits into
networkupstools:masterfrom
com6056:nut-scanner-soname-pr

Conversation

@com6056

@com6056 com6056 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

nut-scanner loads libusb, Net-SNMP, neon and the rest with lt_dlopen() from a short list of names fixed at build time, and that list is missing the one name that is actually stable: the library's SONAME. This is #3474 (Fedora 43) and the same thing just happened on Alpine 3.24 (https://gitlab.alpinelinux.org/alpine/aports/-/work_items/18437). I maintain nut for Alpine, so I went and looked at what the list really contains.

For libusb 1.0 the candidates in tools/nut-scanner/nutscan-init.c are SOFILE_LIBUSB1 (libusb-1.0.so.0.6.0 on the build box), libusb-1.0.so, SOPATH_LIBUSB1 (/usr/lib/libusb-1.0.so.0.6.0) and libusb.so. The first and third come from AX_REALPATH_LIB in m4/nut_check_libusb.m4:389, which follows the symlink chain to the real file, so they can change on any libusb release, including one that keeps the ABI. libusb-1.0.so only exists with the -dev/-devel package installed and libusb.so is the 0.1 compat library. libusb-1.0.so.0, the name every driver in the same package is linked against and the only versioned name a runtime-only install has, is never tried. So a distro updating libusb 1.0.29 to 1.0.30 (.so.0.5.0 to .so.0.6.0) leaves usbhid-ups working and nut-scanner saying Cannot load USB library (libusb-1.0.so.0.5.0) until NUT is rebuilt. neon (libneon.so.27.9.0) and Net-SNMP (libnetsnmp.so.45.0.0) are pinned the same way, their file names just haven't moved yet.

What the change does:

  • m4/ax_realpath_lib.m4 gets AX_SONAME_LIB, which reads the SONAME recorded in the shared object that AX_REALPATH_LIB resolved, using readelf/eu-readelf, then objdump, then elfdump on Solaris/illumos, and otool -D on macOS (base name of the install name). Nothing on mingw, a DLL is loaded by its own file name. If no tool is around it just leaves the value empty.
  • Every nut_check_lib*.m4 that already calls AX_REALPATH_LIB (libusb 0.1 and 1.0, Net-SNMP, neon, Avahi, GLib and GIO, FreeIPMI) calls it right after and defines SONAME_LIB* next to the existing SOPATH_LIB*/SOFILE_LIB*, only when non-empty.
  • nutscan-init.c tries SONAME_LIB* first in both the get_libname() pass and the lt_dlopen() fallback pass, for each of those libraries. The search order comment at the top of that block explains why. libupsclient is untouched, its SOFILE already comes from libtool's dlname and is the SONAME.
  • NEWS.adoc and UPGRADING.adoc entries, the latter because packagers can stop rebuilding NUT for these updates, plus eight words in docs/nut.dict (SONAME, soname, readelf, objdump, otool, elfdump, and SOFILE/SOPATH, which the docs had not used in prose before).

One behaviour change worth calling out rather than burying. The comment above that block says SOPATH is tried last so a bundled installer can override through LD_LIBRARY_PATH, and get_libname() searches LD_LIBRARY_PATH before the system paths. A bundle that ships only a fully versioned file with no SONAME link, on a host that also has a system copy, would now get the system one where it previously got the bundled one. Anything libtool installed has the SONAME link so I think this is very narrow, but you wrote that comment and should get to weigh it.

Tested on Alpine (musl, GCC 15, binutils readelf), everything below in a memory-capped container.

autoreconf -fi on the 2.8.5 tarball takes 14 s at about 22 MB peak RSS, and ./autogen.sh on master takes 8 s at about 23 MB. configure reports:

checking for SONAME recorded in /usr/lib/libusb-1.0.so.0.6.0... libusb-1.0.so.0
checking for SONAME recorded in /usr/lib/libnetsnmp.so.45.0.0... libnetsnmp.so.45
checking for SONAME recorded in /usr/lib/libneon.so.27.9.0... libneon.so.27

and config.h gains SONAME_LIBUSB1 "libusb-1.0.so.0", SONAME_LIBNETSNMP "libnetsnmp.so.45", SONAME_LIBNEON "libneon.so.27". Libraries that were not configured stay #undef, so those blocks compile out.

For the behaviour, I renamed each versioned file to a fake newer one, repointed the SONAME symlink and removed the -dev link, which is what a runtime-only box looks like after an ABI-compatible update. Same machine, same libraries, two binaries. Alpine's current nut-2.8.5-r1:

Cannot load USB library (libusb-1.0.so.0.6.0) : file not found. USB search disabled.
Cannot load SNMP library (libnetsnmp.so.45.0.0) : file not found. SNMP search disabled.
Cannot load XML library (libneon.so.27.9.0) : file not found. XML search disabled.

and the patched build on the same box:

Scanning USB bus.
No IP range(s) requested, skipping SNMP
Scanning XML/HTTP bus.

(SNMP is skipped there only because no IP range was given. The debug output shows libnetsnmp.so.45.1.0 resolved and loaded.)

with -D confirming the route, get_libname() resolved '/usr/lib/libusb-1.0.so.0.7.0' for LibUSB, i.e. it found the SONAME symlink and realpath() took it to the renamed file.

Two checks I could not get a clean result from, in both cases because they already fail the same way without my patch on the same machine. make spellcheck fails on NEWS.adoc for pristine master as well as for this branch, and make distcheck-light fails identically on pristine master, producing a 20 byte tarball after faking all 274 man pages, which I take to be a missing doc toolchain in my container rather than anything real. My own added prose contributes zero rejected words once the dictionary additions above are in, and UPGRADING.adoc goes from failing to passing with them. Worth a second opinion from someone whose environment gets those two green.

The otool branch is tested on macOS with Apple's otool against homebrew libusb 1.0.30 and glib 2.88.3. configure reports checking for SONAME recorded in /opt/homebrew/Cellar/libusb/1.0.30/lib/libusb-1.0.0.dylib... libusb-1.0.0.dylib and defines SONAME_LIBUSB1, SONAME_LIBGLIB and SONAME_LIBGIO accordingly. Worth noting that on macOS the base name of the install name is already what SOFILE_LIB* holds, so the SONAME entry is a harmless duplicate there rather than a fix. That run did not get as far as a full build, for an unrelated --with-libltdl header path problem on homebrew, so this is a configure-stage result only.

I have no illumos box, so the elfdump branch is written to the documented output format and remains untested. It falls back to leaving SONAME_LIB* undefined, which restores exactly the current behaviour.

Fixes #3474.

Disclosure per the template: written with Claude Code (Anthropic), using Claude Fable 5.1 for the m4 macro, the C change and a first draft of this text, and Claude Opus 5 for the investigation beforehand and the review after. I reviewed all of it, and the testing described above is mine.

…ibraries

AX_REALPATH_LIB resolves a library to the fully versioned file present
on the build system, e.g. /usr/lib/libusb-1.0.so.0.6.0, and that is the
name nut-scanner remembers as SOPATH_LIB* and SOFILE_LIB* to dlopen()
at run time. That file name can change on any upstream release of the
library, including one that keeps the ABI and with it the SONAME
(libusb-1.0.so.0).

Add AX_SONAME_LIB which reads the SONAME recorded in the shared object
(readelf or eu-readelf, objdump, elfdump on Solaris/illumos, otool -D on
macOS, nothing on mingw where a DLL is loaded by its own file name) and
call it from every nut_check_lib*.m4 that already uses AX_REALPATH_LIB,
defining SONAME_LIB* next to the existing SOPATH_LIB* and SOFILE_LIB*
values. A missing tool leaves SONAME_LIB* undefined.

Signed-off-by: Jordan Rodgers <com6056@gmail.com>
nut-scanner only knew the file names it saw at build time, such as
libusb-1.0.so.0.6.0 and the -dev link libusb-1.0.so, plus a few
hardcoded variants. The SONAME libusb-1.0.so.0, the one name that stays
valid across ABI-compatible updates and the only versioned name a
runtime-only package installs, was not in the list. When Fedora 43 and
Alpine 3.24 moved libusb from 1.0.29 to 1.0.30 (file renamed from
.so.0.5.0 to .so.0.6.0) nut-scanner reported "Cannot load USB library
(libusb-1.0.so.0.5.0)" until the NUT package was rebuilt, while the
drivers, which link libusb-1.0.so.0 normally, kept working.

Try SONAME_LIB* first in both the get_libname() pass and the lt_dlopen()
fallback pass for every library that provides one (libusb 0.1 and 1.0,
Net-SNMP, neon, Avahi, GLib/GIO, FreeIPMI). neon and Net-SNMP had the
same latent problem (libneon.so.27.9.0, libnetsnmp.so.45.0.0).

Also adds the words this introduces into docs prose to docs/nut.dict.

See issue networkupstools#3474.

Signed-off-by: Jordan Rodgers <com6056@gmail.com>
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

A ZIP file with standard source tarball and another tarball with pre-built docs for commit 78b13ce is temporarily available: NUT-tarballs-PR-3654.zip.

@AppVeyorBot

Copy link
Copy Markdown

Build nut 2.8.5.5328-master completed (commit 453ab10bf0 by @com6056)

@jimklimov jimklimov added bug packaging nut-scanner portability We want NUT to build and run everywhere possible AI For good or bad, machine tools are upon us. Humans are still the responsible ones. impacts-release-2.8.5 Issues reported against NUT release 2.8.5 (maybe vanilla or with minor packaging tweaks) labels Sep 16, 2026
@jimklimov jimklimov added this to the 2.8.6 milestone Sep 16, 2026
@jimklimov

Copy link
Copy Markdown
Member

Great catch, investigation and all, thanks!

jimklimov added a commit to com6056/nut that referenced this pull request Sep 16, 2026
…SONAME_LIB<X> is same as SOFILE_LIB<X> [networkupstools#3654]

Signed-off-by: Jim Klimov <jimklimov+nut@gmail.com>
…SONAME_LIB<X> is same as SOFILE_LIB<X> [networkupstools#3654]

Signed-off-by: Jim Klimov <jimklimov+nut@gmail.com>
@jimklimov
jimklimov force-pushed the nut-scanner-soname-pr branch from 70dbf1c to 97d61b1 Compare September 16, 2026 08:47
@AppVeyorBot

This comment was marked as outdated.

@AppVeyorBot

This comment was marked as outdated.

@AppVeyorBot

Copy link
Copy Markdown

Build nut 2.8.5.5331-master completed (commit 0340885ce8 by @jimklimov)

@com6056

com6056 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Found one more thing while backporting this for Alpine edge. It's narrow, but I think it turns the bundled-installer caveat into a real regression rather than just an ordering question.

When lt_dlopen() or a symbol lookup fails, the err: path in each nutscan_load_*_library() sets dl_handle = (lt_dlhandle)1, and the check at the top then returns 0 for every later call without trying again, until nutscan_free(). So in the else branch of nutscan_init(), where get_libname() found nothing and bare names go to libtool, only the first name in each chain is ever actually tried. That part is older than this PR. Stock 2.8.5 never gets to libupsclient.so after libupsclient.so.7 fails, even when the loader could find it. But for the libraries that now have a recorded SONAME, the first name used to be SOFILE_LIB* and now it's SONAME_LIB*.

I put libusb-1.0.so.0.6.0 alone in /opt/bundle with no SONAME symlink and listed that dir in /etc/ld-musl-x86_64.path, so the dynamic loader can find it but get_libname() can't. Stock Alpine 2.8.5-r1 logs get_libname() did not resolve libname for LibUSB, loads it on the first attempt (which is SOFILE there) and nut-scanner -a lists USB. With this PR, both backported onto 2.8.5 and built from this branch at 97d61b1, it logs Cannot load USB library (libusb-1.0.so.0) : file not found. USB search disabled. and USB is gone.

It takes a fairly specific layout to hit. Anything get_libname() can see (LD_LIBRARY_PATH or the usual lib dirs) goes down the other branch with a single load call, so normal installs aren't affected, and a packaged system has the SONAME link anyway since the drivers need it. You'd need an RPATH, ld.so.conf or musl path entry pointing at a dir that only has the versioned file. The rename case from #3474 is fixed as intended.

I tried the smallest fix on top of this branch, calling nutscan_unload_*_library() after each failed attempt in that else branch. It treats (lt_dlhandle)1 as not loaded and puts it back to NULL without a second lt_dlexit(). With that the layout above loads USB on the second attempt, and the libupsclient case loads through libupsclient.so. The catch is noise. Each failed attempt prints its Cannot load ... line at level 0, so a box that just doesn't have libusb goes from one line to five. Not caching an lt_dlopen() failure in the loaders themselves would be fewer call sites and cover any future chain too, but the symbol-lookup path would then need an lt_dlclose(), since today it overwrites the open handle with 1. Either way I'd move the per-attempt message to level 1 and keep a single level-0 line per library after the chain.

I can send that as a separate PR or add it here, whatever works better for you!

@jimklimov

Copy link
Copy Markdown
Member

Thanks, a good catch too! I think this can be fixed in a separate PR, I want to fix up complaints from clang21+ above in this one with benefits #3650 (it detects that strcmp checks same strings on some builders and so code is known-unreachable at compile-time), and further fixes can be based on top of the resulting master branch then.

I wonder if it makes sense to add something nutscan_report_*_library() methods in these sources, either returning a char * (null or internal buffer) or with a debug-level argument right away, so that library-specific strings stay in that source file but end-user reporting vs. noise is more manageable?

@jimklimov

jimklimov commented Sep 17, 2026

Copy link
Copy Markdown
Member

UPDATE: I see this PR is mostly unfinished in NUT CI farm, so I'd rather update the branch with pragmas against that warning and you can follow up in this PR or elsewhere. I'll handle the clean-up for master branch later.

…code" for strcmp(SONAME_LIBX, SOFILE_LIBX) [networkupstools#3654]

Newer builders (notably those with clang-21 and clang-22) on some agents
correctly identify that we compare identical (macro) strings and the clause
would never run. Ideally the comparison would be pre-processor equality
check for strings, but as that is missing (or not portable), we compare
to avoid work in vain at run-time - and now quiesce the compiler warnings
about that on systems that do benefit from this check.

To be revised after PR networkupstools#3650 is merged, to simplify these pragma blocks.

Signed-off-by: Jim Klimov <jimklimov+nut@gmail.com>
@AppVeyorBot

Copy link
Copy Markdown

Build nut 2.8.5.5335-master completed (commit 83dbf63944 by @jimklimov)

…ed SONAME_LIB* attempt [networkupstools#3654]

Each nutscan_load_*_library() caches a failure by setting dl_handle to
(lt_dlhandle)1, and returns 0 for every later call until the module is
unloaded. In the lt_dlopen() fallback branch, that made the SOFILE_LIB*
candidate unreachable once the SONAME_LIB* attempt before it failed, so
a library reachable only under its versioned file name (a bundle with
no SONAME symlink on the run-time linker search path) stopped being
found. Unload the failed attempt first, which resets the handle without
an extra lt_dlexit(), so the next candidate is really tried.

Note that a library which is absent entirely now reports two failed
candidates at debug level 0 instead of one, since the second name is
really tried.

Signed-off-by: Jordan Rodgers <com6056@gmail.com>
@com6056

com6056 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Nice, thanks for pushing the pragmas! I'll keep the general fix out of this one then. The SONAME-first reorder is what makes the caching bite though, so I pushed the narrow half here. It unloads the failed SONAME_LIB* attempt before the SOFILE_LIB* one in the fallback branch, so that candidate really gets tried. Seven sites, each under the same #ifdef SONAME_LIB* condition as the strcmp() above it, nothing else touched. That way this PR doesn't land the regression while the wider fix is in review. Your farm run was still going when I pushed this, so it'll restart on my commit. Shout if you'd rather I drop it and repush after you have the clang verdict.

It deliberately stops there. Before this PR the fallback chain was SOFILE_LIB*, then the hardcoded names, then SOPATH_LIB*, and the caching meant only the first of those ever really ran. With this commit the really-tried set is SONAME_LIB* plus SOFILE_LIB*, so it's a strict superset of what master reaches today, and every candidate that still gets skipped was already being skipped before this PR. Those belong to the general fix, along with the get_libname() branch, where a failed load is still terminal with no fallback at all.

Tested on Alpine edge on top of your pragma commit. With the versioned libusb-1.0.so.0.6.0 reachable only through /etc/ld-musl-x86_64.path and no SONAME symlink, USB loads on the second attempt and nut-scanner -a lists it again. Same for libnetsnmp.so.45 and libneon.so.27 in that layout. The #3474 rename case still works and a normal install is unchanged. A library that is absent entirely now prints two level-0 lines instead of one, because the second candidate really runs. Your pragma commit builds clean here too, though that's gcc 15 and gcc says nothing about this shape even with -Wunreachable-code, so it's a no-regression check rather than a test of the pragmas. I don't have a clang-21/22 builder to hand.

That test also shows your reporting point nicely. The debug run prints Cannot load USB library (libusb-1.0.so.0) : file not found. USB search disabled. at level 0 and then loads USB on the next candidate, so the message is already false as printed. Before this PR that layout printed nothing, since the single candidate that really ran was the one that worked. The general fix makes it louder, five level-0 lines for a genuinely missing libusb in a prototype I ran, one per candidate.

So yes to the helpers, and I'd go with the char * option rather than the debug-level argument, mostly for ABI reasons. nutscan_load_*_library() and nutscan_unload_*_library() aren't in nutscan-init.h, but they do match the ^nutscan_ export regex in tools/nut-scanner/Makefile.am, so they're exported from libnutscan. Adding nutscan_report_*_library() next to them leaves those alone, where a new argument changes the signature of an already-exported symbol. The shape I had in mind is the loader holding on to its last failure text, so the library label and dl_error stay in their own source file, quiet or level 1 per attempt, and nutscan_init() printing one level-0 line per library once the chain is exhausted.

Two things to watch if you go that way. lt_dlerror() hands back a pointer into ltdl's own storage that gets cleared when read and overwritten by the next ltdl call, and the candidate name is the caller's buffer, which nutscan_init() frees right after the load, so the loader would have to copy both rather than keep pointers. And that level-0 line is currently the only failure report an external caller of the exported loaders gets, so it's probably worth leaving something at level 1 in the loader rather than moving the reporting out wholesale.

I'll do that together with the general retry reset in the follow-up PR, after your #3650 cleanup lands since it touches the same lines. Happy to split the reporting change out if you'd rather review it on its own.

Disclosure as in the PR body, this commit was written with Claude Code (Anthropic) using Claude Opus 5, which made the change, ran the tests above and drafted this text, with review passes from Claude Opus 5 and Claude Fable 5.1 subagents. I reviewed all of it.

@AppVeyorBot

Copy link
Copy Markdown

Build nut 2.8.5.5338-master completed (commit a7979338f1 by @com6056)

@jimklimov

Copy link
Copy Markdown
Member

Shouldn't these unloads also happen in further fallbacks (expected name + SOEXT and SOPATH_* cases)?

@AppVeyorBot

Copy link
Copy Markdown

Build nut 2.8.5.5352-master completed (commit a0e9de4ebf by @jimklimov)

@AppVeyorBot

Copy link
Copy Markdown

Build nut 2.8.5.5353-master completed (commit 0410bd322c by @jimklimov)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI For good or bad, machine tools are upon us. Humans are still the responsible ones. bug impacts-release-2.8.5 Issues reported against NUT release 2.8.5 (maybe vanilla or with minor packaging tweaks) nut-scanner packaging portability We want NUT to build and run everywhere possible

Projects

None yet

Development

Successfully merging this pull request may close these issues.

nut-scanner broken on Fedora 43 with latest libusb

3 participants