← Back · relates to nextbsd #278 (pkg/ports lib resolution), #277 (path_helper), and the live-root design

NextBSD library resolution: finding /usr/local/lib without a boot service

NextBSD runs the stock FreeBSD ports tree (pkg) on an Apple-shaped system — launchd PID 1, Apple userland, no rc.d. pkg binaries link bare sonames (libpcre2-8.so.0) and rely on the runtime linker to find them in /usr/local/lib — but NextBSD registers no such path, so git dies at exec: ld-elf.so.1: Shared object "libpcre2-8.so.0" not found. The reflexive fix — a boot-time ldconfig one-shot — is exactly the rc.d pattern we're leaving behind, and it turns out it is not how Apple does it either. This doc maps the real solution space from three angles: FreeBSD's rtld internals, NextBSD's current build, and Apple's dyld model.

2026-06-10. Synthesized from three parallel source investigations: freebsd-src@releng/15.0 (rtld + ldconfig), the nextbsd build (build.sh, overlay, vendored launchd), and Apple's dyld / dyld-shared-cache model. Citations are file:line unless noted. A design spike for #278.

Recommendation in three moves

Explicitly rejected: a boot-time ldconfig one-shot LaunchDaemon. Apple has no equivalent, it's the rc.d pattern, and it's unnecessary given the above.

Locked after review what the back-and-forth settled:

Contents

  1. The problem, precisely
  2. The Apple model — no service, self-describing, cache-at-install
  3. FreeBSD's rtld — the search order and the four levers
  4. Where NextBSD is today
  5. Option matrix
  6. Recommendation & how it maps to Apple
  7. The setuid caveat (why Lever B is mandatory)
  8. Implementation
  9. Open questions to confirm
  10. References

1. The problem, precisely

A FreeBSD ports binary records each dependency as a bare DT_NEEDED soname — libpcre2-8.so.0 — with no path. It is not self-describing; it trusts the system to know where /usr/local/lib is. On stock FreeBSD that knowledge comes from the ld-elf.so.hints file that rc.d/ldconfig rebuilds at every boot. NextBSD has no rc.d and never builds those hints, so the runtime linker only searches its compiled-in default (/lib:/usr/lib) and the lookup fails.

Observed on a booted T460s: ldd git shows libpcre2-8.so.0 => not found and libintl.so.8 => not found — both are installed in /usr/local/lib — while the base libs in /lib resolve fine. The one-line confirmation: ldconfig -m /usr/local/lib makes git --version run. The question is not whether to point rtld at /usr/local/lib, but how — without a boot-time scan.

2. The Apple model — no service, self-describing, cache-at-install

macOS does not use ld-elf; it uses dyld, which has no ldconfig, no hints file, and no ld.so.cache. Resolution is performed in-process at exec/dlopen from paths baked into each Mach-O:

The dyld shared cache is the closest thing to "a service," but it holds OS libraries only, is built by the installer (update_dyld_shared_cache) at install/update time (not boot, and only effective after reboot), and is never a registry for third-party libraries. Homebrew (/usr/local or /opt/homebrew) and MacPorts (/opt/local) resolve their libs by rewriting install names to absolute prefix paths at package/pour time with install_name_tool (Homebrew's keg_relocate). The binaries are made self-describing; nothing scans the disk.

Takeaway A dislike of boot one-shots matches Apple exactly. macOS has zero boot-time linker indexing — no daemon is consulted for routine resolution at all. The only "cache" is an OS-image artifact rebuilt by the installer, never at boot, never for third-party libs. So the Apple-faithful instinct is: self-describing binaries, fixed at package time; any global cache is an event-driven, install-time, persistent artifact — not a boot step.

3. FreeBSD's rtld — the search order and the four levers

For a bare soname (driver find_library(), rtld.c:1915), rtld searches in order (rtld.c:1969–2005): DT_RPATHLD_LIBRARY_PATHDT_RUNPATHLD_LIBRARY_PATH_FDSthe hints file (rtld.c:1996)the compiled standard path (rtld.c:2001). With no rpath/runpath and no LD_LIBRARY_PATH, only the last two can save a pkg binary. Four levers fall out:

(a) Relocate the hints to a persistent file — LD_ELF_HINTS_PATH

The hints path defaults to /var/run/ld-elf.so.hints (rtld_paths.h:48–54) but is overridable at runtime by the LD_ELF_HINTS_PATH environment variable (rtld.c:385, read at 756, opened at 2148). Set it globally (e.g. in launchd's PID 1 environment, inherited by every child) to a non-tmpfs path and rtld reads persistent hints. caveat it's flagged unsecure and is purged for setuid/setgid binaries (rtld.c:385,738–743), which then fall back to the compiled default path.

(b) Bake /usr/local/lib into the compiled default path

STANDARD_LIBRARY_PATH is "/lib:/usr/lib" (rtld_paths.h:76–78) and is guarded by #ifndef — so it is overridable by a -DSTANDARD_LIBRARY_PATH=... in CFLAGS at build time, with no source edit. Baking "/lib:/usr/lib:/usr/local/lib" resolves the bare-soname case at search step 7 with zero hints, zero service, and it covers setuid binaries too. The most robust single lever.

(c) Per-binary $ORIGIN / RUNPATH (the self-describing route)

rtld supports $ORIGIN-relative runpaths (rtld.c:1251,1264) — the FreeBSD analog of Apple's @loader_path. This is the genuinely Apple-native answer, but it requires every binary to carry the runpath; the stock ports tree links bare sonames by design, so it can't be retrofitted wholesale (and $ORIGIN is also disabled for setuid). Right for first-party binaries only — which NextBSD already does (§4).

(d) Regenerate persistent hints on package change, never at boot

The hints format is a position-independent header plus a single colon-separated dir string — no inodes, timestamps, or host state (elfhints.c:266–319; elf-hints.h:35–46), little-endian by default. So a hints file is bakeable and cross-boot stable, and ldconfig can be told to write any path. Run it from a pkg install/deinstall trigger to a persistent file; pair with lever (a) so rtld reads it. Event-driven, persisted, no boot one-shot — the structural twin of update_dyld_shared_cache.

For completeness, stock FreeBSD's rc.d/ldconfig default ldconfig_paths is /usr/lib/compat ${localbase}/lib ${localbase}/lib/compat/pkg and ldconfig_local_dirs=${localbase}/libdata/ldconfig (the per-package drop-in convention) — the dir set any replacement must reproduce.

4. Where NextBSD is today

5. Option matrix

OptionVerdictApple-alignmentCovers setuid?Boot step?
Boot-time ldconfig LaunchDaemon (one-shot)rejectNone — Apple has no boot linker stepn/ayes (the rc.d pattern)
B — compiled default incl. /usr/local/libprimaryNeutral (a sane default; no registry)yesno
D+A — persistent hints, rebuilt on pkg change, read via LD_ELF_HINTS_PATHsecondary (the tail)Strong — twin of update_dyld_shared_cache (install-time, persistent)no (env purged)no (event-driven)
C — self-describing first-party binaries (rpath/$ORIGIN)already shippedExact — the dyld modelnono
Self-describe the whole ports tree (rpath fixup at pkg time)north star, impracticalExact — Homebrew keg_relocatenono
Static baked hints only (no rebuild trigger)half-measureWeaknono, but stale on user pkg installs

6. Recommendation & how it maps to Apple

NextBSD ships the stock ports tree, so the pure-Apple answer (self-describe every binary) isn't retrofittable. The faithful-and-pragmatic shape is a layered one, none of it a boot service:

  1. Lever B (foundation). Bake /usr/local/lib (and /usr/lib/system) into rtld's compiled default in nextbsd-freebsd-compat. Config-free, boot-free, covers setuid, survives the union and tmpfs entirely. This alone fixes the reported libpcre2-8.so.0 failure for the common case (libs in /usr/local/lib).
  2. Lever D+A (the tail). A pkg install/deinstall trigger regenerates a persistent hints file (e.g. /var/db/ld-elf.so.hints) covering the full ports dir set (/usr/local/lib/compat/pkg + every libdata/ldconfig drop-in); rtld reads it via LD_ELF_HINTS_PATH set once in launchd's PID 1 environment. This is the macOS analogy made literal: pkg is the "installer," the trigger is update_dyld_shared_cache, the file is the shared cache — rebuilt on package change, persisted, never at boot.
  3. Lever C (keep). First-party/vendored components stay self-describing via -Wl,-rpath (and $ORIGIN where relative) — the existing convention, which is the truest Apple model.

In practice Lever B may be all most users ever need (almost every ports lib lands in /usr/local/lib); D+A is the completeness layer for packages that scatter libs elsewhere. Crucially, the user's instinct is vindicated twice over: a boot one-shot is both un-Apple and unnecessary.

7. The setuid caveat (why Lever B is mandatory)

Both the LD_ELF_HINTS_PATH env lever (a) and $ORIGIN runpaths (c) are purged for setuid/setgid binaries (rtld.c:738–743, 1269) — a tainted binary ignores them and falls back to the compiled default path. So any setuid pkg binary that needs a ports lib can only be served by Lever B (the compiled default) or by a real file at the literal compiled hints path. This is the decisive reason B is the non-negotiable foundation and D+A is an augmentation, not a substitute.

8. Implementation

9. Open questions to confirm

10. References

freebsd-src@releng/15.0: libexec/rtld-elf/rtld.c (search order 1969–2005; LD_ELF_HINTS_PATH 385/756/2148; $ORIGIN 1251/1264; setuid purge 738–743/1269), libexec/rtld-elf/rtld_paths.h (_PATH_ELF_HINTS 48–54; STANDARD_LIBRARY_PATH 76–78), sbin/ldconfig/elfhints.c (hints writer 266–319), include/elf-hints.h (format 35–46), libexec/rc/rc.d/ldconfig + libexec/rc/rc.conf (defaults).

nextbsd: build.sh:748–758 (priming + rpath philosophy), the -Wl,-rpath,/usr/lib/system sites, overlays/etc/fstab, src/launchd/src/launchd.c:313,424 (make-writable + ordering); rtld/ldconfig sourced from nextbsd-freebsd-compat.

Apple: Run-Path Dependent Libraries, dyld(1), install_name_tool(1), dyld_shared_cache, Homebrew keg_relocate.

Spike for nextbsd #278. Investigations: FreeBSD rtld/ldconfig, NextBSD build/overlay/launchd, Apple dyld — run in parallel and synthesized here. The throughline: NextBSD already does the Apple thing for its own binaries (self-describing rpath); the gap is only the stock ports tree, and the fix is a compiled default + an install-time persistent cache — never a boot service.