Skip to content

feat(0.0.50): macOS min-version support — static LLVM libc++, lld link, explicit deployment target#116

Merged
Sunrisepeak merged 19 commits into
mainfrom
feat/macos-static-libcxx
Jun 5, 2026
Merged

feat(0.0.50): macOS min-version support — static LLVM libc++, lld link, explicit deployment target#116
Sunrisepeak merged 19 commits into
mainfrom
feat/macos-static-libcxx

Conversation

@Sunrisepeak
Copy link
Copy Markdown
Member

Goal

macosx-arm64 release binaries should run on macOS 11.0+ (first Apple Silicon release) instead of only macOS 15. Verified end-to-end on CI (#115, runs A→B5).

Evidence chain (temp PR #115)

run config result
A minos 14 + dynamic system libc++ compiles, dies at launch on macOS 14.8: dyld: Symbol not found: __ZNSt3__119__is_posix_terminalEP7__sFILE (std::print support symbol; macOS 14's libc++ predates LLVM 18) — lowering minos alone is not viable
B2/B3 [build] ldflags injection of static archives minos 11 OK but still dynamically linked: the macOS link path hardcodes -lc++ before user ldflags
B initial mixed deployment targets in one sandbox std.pcm config-mismatch — BMI fingerprint didn't cover the deployment target
B4 native staticStdlib (this PR), two-stage self-host static + minos 11 ✅, but macos-14 e2e hit Xcode 15.4's system ld aborting at launch (its libc++ resolution diverted to the LLVM payload dylib, missing __ZdaPv)
B5 + -fuse-ld=lld all green: macos-15 two-stage build (minos 11.0, no libc++ dylib dep, clean exit) + macos-14 control/refusal, binary startup, full mcpp new/build/run incl. llvm/ninja install

Changes

  • flags.cppm — implement staticStdlib (the manifest default, previously silently ignored on the clang route) for the macOS link path: -nostdlib++ <llvm>/lib/libc++.a <llvm>/lib/libc++abi.a instead of -lc++ (falls back when archives absent); link via -fuse-ld=lld (same as the Linux clang path — removes the host-Xcode ld dependency); mirror MACOSX_DEPLOYMENT_TARGET onto compile+link command lines.
  • cli.cppm — fold MACOSX_DEPLOYMENT_TARGET into the BMI fingerprint (deployment target changes the effective compile triple; stale std.pcm reuse otherwise hard-fails).
  • main.cpp__APPLE__ _Exit guard after stream flush (static libc++ static-dtor abort prevention; same guard xlings uses).
  • release.yml (macos job)MACOSX_DEPLOYMENT_TARGET=11.0 + two-stage self-host (stage 1 bootstrap-built dynamic → stage 2 rebuilds itself static) + minos/no-dylib assertions.
  • version 0.0.50.

Design doc

xlings .agents/docs/2026-06-05-macos-min-version-support.md (kept updated through the experiments).

Compatibility

  • Linux/Windows paths untouched (needs_explicit_libcxx branch is macOS-only; gcc -static-libstdc++ behavior unchanged).
  • User projects on macOS now also link static libc++ by default (staticStdlib default) and via lld — verified by the macos-14 mcpp new/build/run e2e. Host-SDK-default minos for user binaries unchanged.

…it deployment target

Released macosx-arm64 binaries carried LC_BUILD_VERSION minos=15.0 and
dynamically linked the SYSTEM /usr/lib/libc++.1.dylib, so they only ran
on macOS 15: dyld refuses older minos, and lowering minos alone dies at
launch on macOS 14 with a missing libc++ symbol
(__ZNSt3__119__is_posix_terminalEP7__sFILE — std::print support added in
LLVM-18-era libc++; verified on macos-14 CI).

- flags.cppm: implement staticStdlib (the manifest default, previously
  silently ignored on the clang route) for the macOS link path — link
  LLVM's own libc++.a/libc++abi.a via -nostdlib++ instead of -lc++,
  falling back to -lc++ when the archives are absent. Mirror
  MACOSX_DEPLOYMENT_TARGET onto compile and link command lines so ninja
  commands don't depend on env propagation.
- cli.cppm: fold MACOSX_DEPLOYMENT_TARGET into the BMI fingerprint —
  the deployment target changes the effective compile triple
  (arm64-apple-macosxNN), and a std.pcm built for one target cannot be
  loaded by a TU compiled for another (config-mismatch observed on CI).
- main.cpp: __APPLE__ exit guard (_Exit after stream flush) — static
  libc++'s static destruction can SIGABRT on exit; same guard xlings
  uses.
- release.yml (macos job): MACOSX_DEPLOYMENT_TARGET=11.0 + staged-archive
  ldflags injection for the self-build (the bootstrap mcpp predates this
  change), with minos/no-dylib assertions.
- version 0.0.50.

Design: xlings .agents/docs/2026-06-05-macos-min-version-support.md
Run B2 evidence: -nostdlib++ in user ldflags cannot remove the
hardcoded -lc++ that precedes it in the bootstrap mcpp's link line.
Stage the archives in a dylib-free dir instead and -L it so -lc++
resolves to libc++.a.
…njection)

The bootstrap's hardcoded -lc++ precedes user ldflags, so manifest
injection cannot produce the static link (runs B2/B3). Stage 1
(bootstrap-built, dynamic) rebuilds itself as stage 2 with the native
staticStdlib implementation from this release.
Xcode's system ld floats with the host: on macos-14 CI, Xcode 15.4's ld
aborted at launch (dyld resolved its libc++ against the LLVM payload's
libc++.1.0.dylib, missing __ZdaPv). lld ships with the exact LLVM
toolchain doing the compile and removes the host-Xcode dependency from
the link step.
First-class manifest support for the macOS minimum supported OS version
of produced binaries (LC_BUILD_VERSION minos), following ecosystem
conventions: the MACOSX_DEPLOYMENT_TARGET env var (which cargo/rustc and
the cc crate honor) stays as the explicit per-invocation override, the
new [build] macos_deployment_target field provides the project default
(SwiftPM platforms: style), and the toolchain/SDK default applies when
neither is set.

The resolved value feeds the same paths the env var already did:
explicit -mmacosx-version-min on compile+link command lines and the BMI
fingerprint (switching targets rebuilds the module cache). No effect off
macOS. Unit tests + docs/05-mcpp-toml.md.
@Sunrisepeak
Copy link
Copy Markdown
Member Author

Added on top: [build] macos_deployment_target — first-class manifest field for the minimum supported macOS version of produced binaries.

Design follows ecosystem conventions: MACOSX_DEPLOYMENT_TARGET env (the knob cargo/rustc/cc honor) remains the explicit per-invocation override; the manifest field is the project default (SwiftPM platforms: style); toolchain/SDK default otherwise. The resolved value flows into the explicit -mmacosx-version-min compile/link flags and the BMI fingerprint introduced earlier in this PR. Unit tests + docs/05-mcpp-toml.md included.

[build]
macos_deployment_target = "11.0"

Linking the archives by path left their symbols with default
visibility; the system libc++/libc++abi that libSystem pulls in
indirectly then clashes with the static copy and processes abort
during static destruction — every gtest binary exited 6 on macos CI
(the mcpp/xlings entry points only survived via their _Exit guards).
-hidden-l is the ld64 feature built for static libc++: it links the
archive (never the sibling dylib) and gives its symbols hidden
visibility, so the two copies can coexist.
Sunrisepeak added a commit to openxlings/xlings that referenced this pull request Jun 4, 2026
…match) (#318)

The workflow pins MACOSX_DEPLOYMENT_TARGET=11.0, but the cached
~/.mcpp BMI store contains a std.pcm built for arm64-apple-macosx15
under the same fingerprint — mcpp <= 0.0.49 does not include the
deployment target in the BMI fingerprint, so the stale module is
reused and every TU fails with a config mismatch. Salt the cache so a
coherent (all-11.0) store is rebuilt. mcpp 0.0.50 folds the deployment
target into the fingerprint, fixing this class permanently
(mcpp-community/mcpp#116).
…system libc++ for tests

Statically linked libc++ SIGABRTs during static destruction unless the
entry point guards with _Exit (mcpp/xlings do; gtest main does not):
with the static link applied globally, every mcpp-test binary on macOS
exited 6 (-hidden-l visibility isolation did not change that — the
abort is in libc++'s own exit path, not a symbol clash).

Split the stdlib choice per link unit via unit_ldflags: distributable
targets (Binary/SharedLibrary) keep the static LLVM libc++ (-hidden-l
archive form, portable across macOS versions), TestBinary targets link
the system -lc++ — they only ever run on the build host. Other
platforms unaffected (fields empty).
CI forensics (3-mode matrix with a std::cout probe) overturned the
working theory:

- -Wl,-hidden-l under lld resolves like a plain -l and picks the
  SIBLING DYLIB in the same directory: binaries carried
  @rpath/libc++.1.dylib with no rpath and died at load (dyld 'Library
  not loaded' -> Abort trap 6). That — not static destruction — was
  what killed every gtest/e2e binary. Link the archives by path
  (the form already verified end-to-end on macos-14/15 in PR #115
  run B5), keeping the per-unit split (tests use system -lc++).
- The official LLVM-20.1.7-macOS-ARM64 static archives are built for
  macOS 14 (ld64.lld: 'has version 14.0.0, newer than target minimum
  of 11.0.0'): claiming a 11.0 floor would be false. Deployment target
  is now 14.0 everywhere — still fully covering the macOS 14 goal;
  11-13 would need a custom libc++ build (follow-up).
cargo's key portability win on macOS is that every Apple target carries
a built-in default deployment target (aarch64 = 11.0) instead of
floating with the host SDK — artifacts run on older systems with zero
configuration. Adopt the same: when neither MACOSX_DEPLOYMENT_TARGET
nor [build] macos_deployment_target is set and staticStdlib is on,
default the floor to 14.0 (the official LLVM static libc++ archives'
own floor). Mirrored in the fingerprint rule. Without staticStdlib the
toolchain/SDK default still applies (a dynamically-linked binary can't
promise a lower floor than the host libc++ anyway).
The built-in default deployment floor (rustc-style) trips a std-module
prebuild/fingerprint boundary in the dev/test pipeline (the test
fingerprint changes but its std.pcm staging does not follow — import
std fails wholesale in the test build). Reverted for now: release
artifact floors are pinned by MACOSX_DEPLOYMENT_TARGET=14.0 in the
release workflow, and the env var + [build] macos_deployment_target
manifest field cover all explicit cases. Re-land the default once the
stdmod staging honors the same resolution (tracked in the design doc).
…loor

Semantics: declaring a minimum macOS (env var or [build]
macos_deployment_target) is what opts a build into the static LLVM
libc++ — that's the mechanism that makes the declared floor real. With
no declared floor the link stays on the dynamic system libc++ exactly
as in 0.0.49 (zero behavior change for existing users), which also
sidesteps a still-open SIGSEGV in mixed C/C++ static binaries
(e2e 36_llvm_toolchain; investigation tracked in the design doc).
Release pipelines declare 14.0, so the shipped mcpp/xlings binaries
remain static + portable.
@Sunrisepeak
Copy link
Copy Markdown
Member Author

Deferred work registry (tracked, not in this PR)

Marked as TODO(...) in code and registered in the design doc (xlings .agents/docs/2026-06-05-macos-min-version-support.md §5):

# item code marker unblocks
1 Mixed C/C++ static binaries SIGSEGV at runtime (e2e 36_llvm_toolchain: answer.c + std::cout main, exit 139; pure-C++ fine; root cause not yet isolated) flags.cppm TODO(macos-static-default) flipping static libc++ to the unconditional default
2 std-module staging vs fingerprint boundary — a built-in default floor injected into canonical flags alone leaves the test build's std.pcm unstaged (import std fails wholesale). Fix: one resolver shared by flags/fingerprint/stdmod cli.cppm TODO(macos-default-floor) re-landing the rustc-style built-in default floor
3 Custom libc++ build (floor 11.0–13.0) via xlings-res; data-only swap of the archive source flags.cppm TODO(macos-floor-11) floor below 14

End state when #1+#2 land: static libc++ + built-in 14.0 floor become the unconditional macOS defaults (cargo-style portable-by-default); the declared-floor gate stays only as the opt-out boundary.

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