Skip to content

0.19.2: musl's empty archives, so -lm is answered by the C library and never by the host - #43

Merged
Sunrisepeak merged 2 commits into
mainfrom
fix/musl-empty-archives
Sep 25, 2026
Merged

Sunrisepeak merged 2 commits into
mainfrom
fix/musl-empty-archives

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Summary

mcpp-community/mcpp#696 measured that a link whose C library comes from the dependency graph (this package) still lets clang search the build machine's own library directories, because the graph branch passes no --sysroot. A -l name the graph does not answer is then looked up on the host: silently, by pulling a glibc object into a musl static image on x86_64-linux-musl (the report's fmaximum fixture resolved through /usr/lib/x86_64-linux-gnu/libm-2.39.a); loudly, by a foreign linker script or an outright unable to find library, on aarch64-linux-musl. mcpp's own repair hands such a link an empty --sysroot, which removes the accidental answer along with every host directory, so -lm becomes a hard failure everywhere unless the graph itself answers it.

musl's own make install already answers it: EMPTY_LIB_NAMES = m rt pthread crypt util xnet resolv dl are installed next to libc.a as archives with no members, because libc.a already holds everything POSIX ascribes to those eight names. This package never ran musl's install and never shipped them. This PR:

  • Adds port/lib, holding the same eight files (libm.a librt.a libpthread.a libcrypt.a libutil.a libxnet.a libresolv.a libdl.a), each exactly the eight bytes !<arch>\n — an ar archive with no members, built the way musl's own Makefile builds them.
  • Adds a package-relative -Lport/lib to [target.'cfg(os = "linux")'.build] ldflags. mcpp already resolves a dependency's relative -L against the dependency's own root and forwards it to the consumer's link line, so this needs no engine change — which is why this half must land, and be registered in mcpp-index, before mcpp's own sysroot fix (mcpp#696's engine half) ships.
  • Explains all of this in the manifest, beside port/lib, in the repository's own style, with a link to mcpp#696.
  • Adds examples/link-stubs and two Linux-only CI steps that assert the fix.
  • Bumps the version to 0.19.2. The dependency pins (openkal-linux, openkal-macos, openkal-opensbi, openkal-windows) are unchanged, matching what the previous release commit (f1f789f) touched.

Scope

Only cfg(os = "linux") gains the -L. musl's own interface makes libc the answer to these eight names on every target this package supports, so extending this to cfg(windows) and cfg(os = "macos") is very likely correct — but I only measured Linux (see below), so only Linux changes. Extending it is left for whoever next measures a graph link on those targets.

What I measured

Toolchains: llvm 22.1.8 and gcc 16.1.0, from a local mcpp home. Engine: the currently released mcpp 2026.9.25.1 (no --sysroot fix yet — that is mcpp#696's still-open engine half). This package was reached as a path dependency from a scratch consumer and from examples/link-stubs.

  • x86_64-linux-musl (--target x86_64-linux-musl, and natively with no --target at all — this package answers mcpp:c-abi=musl either way): -lm, -lpthread, -ldl, -lrt each resolve to this package's own port/lib/lib*.a. From ld.lld --verbose:
    ld.lld: <checkout>/port/lib/libm.a
    ld.lld: <checkout>/port/lib/libpthread.a
    ld.lld: <checkout>/port/lib/libdl.a
    ld.lld: <checkout>/port/lib/librt.a
    
    From GNU ld --verbose (via gcc@16.1.0), the same four names, each as attempt to open <checkout>/port/lib/lib<name>.a succeeded, with no /usr or /lib attempt for any of them. The resulting binary is a static ELF with no dynamic section and runs correctly (fmax, pthread_create/join, clock_gettime, dlopen all behave as expected).
  • aarch64-linux-musl, cross-built from the same x86_64 host and run under qemu-aarch64 (through openkal-llvm-runtime for a target-native libc++, so the link isn't blocked by an unrelated host/target architecture mismatch in the C++ runtime): the same four names resolve to the same port/lib archives (ld.lld --verbose), and the binary runs correctly under qemu with the same output as the x86_64 build.
  • The mechanism, directly: clang -### shows clang collecting every -L — this package's own, then the host directories it still adds because no --sysroot is passed — ahead of every -l name in the actual invocation to the linker, regardless of where -l/-L tokens sit in the ldflags text. This package's -L is the only explicit one a graph link has today, so it is checked, and matches, first.
  • A decisive negative check: a small program declaring extern double fmaximum(double, double) (musl 1.2.5 does not implement it; glibc does — the exact symbol mcpp#696's own fixture used) now fails to link with ld.lld: error: undefined symbol: fmaximum instead of silently resolving it from the host's glibc archive. Once -lm is answered by this package's empty stub, the linker does not fall through to a later -L directory for the same name.

Not done: macOS and Windows (cfg(windows)) were not measured, so their ldflags are unchanged, per Scope above. A genuinely disk-heavy full ecosystem sweep (e.g. building the whole mcpp-index openkal matrix against this branch) was out of scope for a single-package PR.

CI

.github/workflows/ci.yml, programs job, Linux rows only (if: runner.os == 'Linux'):

  • "The C library answers its own link names, not the host's" — builds examples/link-stubs with mcpp build --verbose (the flag that makes mcpp show the underlying linker's own output even for a build that succeeds), and asserts that every log line naming libm.a, libpthread.a, libdl.a or librt.a names this package's own port/lib and nothing else.
  • "The four archives are empty and still correct" — runs examples/link-stubs through tools/run-probe.sh, checking the same four calls actually behave correctly at run time.

Both were verified locally against the linux, gcc and linux, llvm matrix rows before pushing.

Test plan

  • port/lib/*.a are each exactly 8 bytes, 21 3c 61 72 63 68 3e 0a (!<arch>\n), verified with ar t/nm and confirmed byte-identical to ar rc <name> with no members.
  • Manifest is valid TOML after the edit (python3 -c 'import tomllib; ...').
  • git diff origin/main --diff-filter=D --name-only is empty; git diff origin/main --stat lists only the files in this PR; git log --oneline origin/main..HEAD shows only this PR's one commit.
  • CI (below) — waiting on the checks for this PR.

…d never by the host

mcpp-community/mcpp#696 measured that a link whose C library comes from the
dependency graph still lets clang search the build machine's own library
directories, because the graph branch passes no --sysroot. A -l name the
graph does not answer is then looked up on the host: silently, by pulling a
glibc object into a musl static image on x86_64-linux-musl (a real fixture,
`fmaximum`, resolved through `/usr/lib/x86_64-linux-gnu/libm-2.39.a`);
loudly, by a foreign linker script or an outright `unable to find library`,
on aarch64-linux-musl. mcpp's own repair closes this by handing such a link
an empty --sysroot, which removes every host directory a link searches --
including the accidental answer, so `-lm` becomes a hard failure everywhere
unless the graph itself answers it.

musl's own `make install` already answers it: `EMPTY_LIB_NAMES = m rt
pthread crypt util xnet resolv dl` are installed next to libc.a as archives
with no members, because libc already holds everything POSIX ascribes to
those eight names. This package never ran musl's install and never shipped
them. This change adds `port/lib`, holding the same eight files (each
exactly the eight bytes `!<arch>\n`, an ar archive with no members, built
the same way musl's own Makefile builds them), and a package-relative
`-Lport/lib` on the Linux link line. mcpp already resolves a dependency's
relative `-L` in `[build] ldflags` against the dependency's own root and
forwards it to the consumer, so no engine change is needed for this half --
which is why this half must land, and be registered, before mcpp's own
sysroot fix ships.

Measured (clang 22.1.8 and gcc 16.1.0; x86_64-linux-musl natively and
cross-built for aarch64-linux-musl under qemu-aarch64; this package's own
worktree as a path dependency): `-lm`, `-lpthread`, `-ldl` and `-lrt` each
resolve to this package's own stub archive on every one of those
configurations, ahead of every host directory clang and gcc still add
today, and the resulting programs build, link and run correctly. Separately,
a program declaring `fmaximum` (the exact symbol #696's fixture used, which
musl does not implement) now fails to link with `undefined symbol:
fmaximum` instead of silently resolving it from the host's glibc: the stub
answers `-lm` first, and the linker does not fall through to a later
directory once one has answered a name.

`.github/workflows/ci.yml` gains `examples/link-stubs` and two Linux-only
steps: one reads `mcpp build --verbose`'s own linker trace and asserts that
each of the four names resolved inside this package and nowhere under
`/usr` or `/lib`, the other runs the program and checks that the four calls
it makes still behave correctly. The assertion holds under the mcpp release
in use today (this package's `-L` precedes the host directories) and
continues to hold once mcpp#696's own engine fix ships (this package's `-L`
is then the only one left).

Only `cfg(os = "linux")` gains the `-L`: it is the target #696 measured and
this CI now asserts against. Extending it to `cfg(windows)` and
`cfg(os = "macos")` is very likely correct -- musl's own interface makes
libc the answer to these eight names on every target -- but is left for
whoever next measures a graph link on those targets, per the manifest
comment beside `port/lib`.

Dependency pins (openkal-linux, openkal-macos, openkal-opensbi,
openkal-windows) are unchanged.
The ordering paragraph beside port/lib attributed a clang-specific -###
reading to "both toolchain families this package supports on Linux", but
-### was only run against clang; the gcc row's evidence is GNU ld's own
--verbose trace, already cited two sentences later. Restated to claim only
what was run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant