Skip to content

[CoreCLR] Remove libc++ from the CoreCLR host #12533

Closed

Description

@simonrozsival

Follow-up to #12139, which removed the libc++/libunwind link-time dependency from NativeAOT applications. This issue tracks doing the same for the CoreCLR host.

Current state

NativeRuntimeComponents.cs still links the C++ runtime into every CoreCLR app:

https://github.com/dotnet/android/blob/main/src/Xamarin.Android.Build.Tasks/Utilities/NativeRuntimeComponents.cs#L129-L140

// C++ standard library
new CplusPlusArchive ("libc++_static.a"),
new CplusPlusArchive ("libc++abi.a"),
...
new CplusPlusArchive ("libunwind.a"), // techically it's from clang

The mechanism already exists

Each runtime lane gets its own shared/log_types.hh via XA_RUNTIME_INCLUDE_DIR. The NativeAOT one is a 5-line stub; the CoreCLR one is 119 lines of std::format wrappers.

The consequence is measurable. I audited freshly built android-arm64 Release archives with llvm-nm --undefined-only. bridge-processing.cc — literally the same source file, compiled into both lanes — has zero libc++ dependencies under NativeAOT and the full std::format payload under CoreCLR:

Lane libc++/ABI undefined symbols in bridge-processing.cc.o
NativeAOT none
CoreCLR to_chars ×9 (float/double/long double), std::locale ×4, numpunct<char>::id, use_facet, basic_string::append/push_back, __libcpp_verbose_abort, operator new, operator delete

The whole NativeAOT host archive has no real libc++ undefined symbols. So this is a tractable, already-proven path — not a rewrite.

What actually blocks CoreCLR

Measured across libnet-android, libruntime-base, libruntime-base-common (arm64, Release):

# Blocker Where Proposed fix
1 std::format — 21 sites. Drags to_chars<float/double/long double> (Ryu tables), locale/numpunct/use_facet, std::string assembly-store.cc, host.cc, fastdev-assemblies.cc, pinvoke-override/precompiled.cc, timing-internal.cc, mainthread-dso-loader.hh 18 of 21 are Helpers::abort_application (...) error paths → fixed char [] + snprintf. No perf concern
2 std::string (43 refs) mostly format results + assembly-store.cc path building (std::to_string (getpid ()), std::format ("{:x}")) Fixed buffers + the format_X ()/build_X () stack→heap retry convention
3 std::function AssemblyStore::configure_from_payload, FastTiming::dump Function pointer + void* context, or a template parameter
4 std::shared_ptr<Timing> host.hh Single owner → raw pointer / static
5 std::mutex ×9 assembly-store.cc, monodroid-dl.hh, startup-aware-lock.hh pthread_mutex_t (Bionic)
6 std::unordered_map<std::string, std::string> bundled_properties clr/include/runtime-base/android-system.hh Currently fully inlined (emits no symbols) but drags <string> → sorted static array + binary search
7 std::stack / std::vector / steady_clock timing-internal.hh, timing.hh Fixed-capacity array + clock_gettime (CLOCK_MONOTONIC)
8 Residue: operator new/delete, __cxa_guard_*, __cxa_thread_atexit, __libcpp_verbose_abort, __cxx_atomic_notify_all all TUs Small shim TU (malloc/free) — unless CoreCLR already provides them

Amplifier worth fixing early

common/include/runtime-base/mainthread-dso-loader.hh uses std::format in a header, reaching android-system.cc and typemap.cc through dso-loader.hh → monodroid-dl.hh. Fixing that one header shrinks the blast radius considerably.

Good news

There are no C++ exceptions anywhere in the host — zero _Unwind_*, __cxa_throw, typeinfo or vtable symbols. -fno-exceptions is doing its job, so libunwind.a is only a question for CoreCLR itself, not for our code.

Gating unknown ⚠️

Does libcoreclr_static.a itself require libc++? CoreCLR's VM and PAL are heavily C++, so it may, regardless of what we do to our host.

For NativeAOT the trick was that dotnet/runtime ships libstdc++compat.a, which satisfied operator new (see #12523 (comment)). CoreCLR has no equivalent, so we would likely need our own shim TU.

This needs to be answered before step 5 below, otherwise we could do all the work and still link libc++:

llvm-nm --undefined-only libcoreclr_static.a \
  | grep -cE '__ndk1|^_ZSt|^_ZNSt|^_Zn[wa]m|^_Zd[la]Pv'

(libcoreclr_static.a is not in the CoreCLR runtime pack — microsoft.netcore.app.runtime.android-arm64 ships only the BCL natives — so this needs a build that produces it.)

Proposed sequencing

Mirroring the stack that worked for NativeAOT (#12513 → #12524):

  1. Helpers::abort_application non-format overload + convert the 18 abort sites
  2. mainthread-dso-loader.hh + assembly-store.cc path building
  3. Timing: std::stack / std::vector / std::function / std::shared_ptr / steady_clock
  4. std::mutex + bundled_properties
  5. Shims + drop the archives from NativeRuntimeComponents.cs and CMake

Steps 1–4 are worth doing on their own merits (they remove allocation and std::string from startup paths). Step 5 is the one gated on the question above.

Also spotted

format_managed_type_name in clr/host/typemap.cc is dead code — it produces an -Wunused-function warning on every CoreCLR build.

Activity

  1. added
    Area: App+Library BuildIssues when building Library projects or Application projects.
    drop-libcppWork to remove the libc++ dependency from Android NativeAOT
    on Aug 27, 2026
  2. simonrozsival commented on Aug 27, 2026

    @simonrozsival
    MemberAuthor

    First step: #12534 removes std::format from the CoreCLR host.

    Measured on the arm64 Release archive, undefined libc++ refs drop 144 → 78 (−46%), and the std::format fingerprint (to_chars<float/double/long double> + locale/numpunct/use_facet) is gone from every object file.

    It also fixes a latent bug where bridge-processing.cc logged a literal {} in NativeAOT builds.

    Remaining work tracked here:

    • Convert common/ + mono/ call sites (must land together)
    • Delete the std::format machinery from clr//mono/ log_types.hh
    • Remove std::string/std::function/std::mutex from host.cc + assembly-store.cc (the 78 remaining refs)
    • Determine whether libcoreclr_static.a itself needs libc++
    • Drop the CplusPlusArchive entries from NativeRuntimeComponents.cs
  3. simonrozsival commented on Aug 27, 2026

    @simonrozsival
    MemberAuthor

    #12534 now also deletes the std::format machinery from clr/include/shared/log_types.hh (that file is now identical to the NativeAOT stub) and removes the std::string_view overload of log_write entirely.

    std::format is now completely absent from the clr/, common/ and nativeaot/ trees:

    lane std::format #include <format>
    clr/ 0 0
    common/ (shared) 0 0
    nativeaot/ 0 0
    mono/ 45 2

    Only MonoVM still uses it.


    Unrelated dead-code finding while building this: format_managed_type_name in clr/host/typemap.cc (introduced in #12518) sits outside the #if defined(DEBUG) block, but its only caller managed_to_java_debug is inside it. So it's compiled but unused in Release builds and trips -Wunused-function. Moving it inside the guard would fix it — worth folding into #12518.

  4. simonrozsival commented on Aug 27, 2026

    @simonrozsival
    MemberAuthor

    Correction to my previous comment: the format_managed_type_name dead-code warning was introduced by b5b42f9eb ("Retry formatting oversized CLR strings"), which belongs to #12517, not #12518. Sorry for the noise.

    While auditing the stack for stack-buffer overflows I found one more thing in the same commit, in clr/include/runtime-base/android-system.hh:

    static void set_primary_override_dir (jstring_wrapper& home) noexcept
    {
    #if defined (XA_HOST_NATIVEAOT)
    	ssize_t result = format_primary_override_dir (home, primary_override_dir, sizeof (primary_override_dir));
    	abort_unless (result >= 0, "Primary override directory path is too long");
    #else
    	primary_override_dir = determine_primary_override_dir (home);
    #endif
    }

    The two lanes diverge in behaviour. determine_primary_override_dir() (CoreCLR/MonoVM) does the usual stack-buffer-then-malloc retry, so an overlong path is handled. The NativeAOT branch writes into a fixed char primary_override_dir[Constants::SENSIBLE_PATH_MAX] and aborts the app instead of falling back.

    This is memory-safe — snprintf bounds the write and format_primary_override_dir returns the required capacity, so abort_unless catches the overflow rather than smashing the buffer. And with SENSIBLE_PATH_MAX == 1024 against a path of <app files dir>/.__override__/<abi>, it should never trigger in practice. But it is an unnecessary difference in behaviour between the runtimes, and the fixed buffer is only there because the NativeAOT lane avoids std::string.

    Not fixing it as part of #12534, since it is unrelated to the std::format work.

  5. simonrozsival commented on Aug 27, 2026

    @simonrozsival
    MemberAuthor

    Correction to the earlier symbol counts, and a roadmap

    The "76 libc++ references" figure I posted earlier was inflated. The measurement script matched any demangled symbol containing std::__ndk1, which also catches our own functions whose mangled names merely embed std:: parameter types — e.g. xamarin::android::Helpers::abort_application(..., std::string_view) accounted for 9 of them on its own. Those are ordinary cross-TU references resolved inside our archive, not libc++ dependencies.

    Filtering to symbols that actually start with std::__ndk1::, operator new/delete, __cxa_guard, __cxa_thread_atexit or __libcpp_:

    host real undefined libc++ symbols
    NativeAOT 0 ← proof the target is reachable
    CoreCLR (before this work) 55

    The 55 are six root causes, not six files

    A partial removal buys nothing: the link requirement only drops when a whole cause reaches zero. So the follow-up PRs are sliced by cause rather than by file.

    cause refs status
    std::shared_ptr (__shared_weak_count) 5 ✅ #12540
    std::mutex 8 ✅ #12541
    operator new / delete[] 14 todo
    __cxa_guard_* (function-local statics) 10 todo
    std::string (+ to_string) 11 todo
    __libcpp_verbose_abort / __cxa_thread_atexit 7 todo (falls out of the above)

    Progress

    55 → 48 (#12540) → 40 (#12541).

    Remaining plan

    1. thread_local std::stack<TimingEvent*> in timing-internal.hh → fixed-capacity array stack. Kills __cxa_thread_atexit and std::deque's allocations.
    2. std::function<std::string()> in AssemblyStore::configure_from_payload → plain callback + caller-provided buffer.
    3. std::string — the big one. Note TimingEvent::more_info is a std::string*, so this needs a decision on ownership.
    4. new/delete and the remaining unique_ptr<T[]> allocations → malloc/free.
    5. Function-local statics → constant-initialized or explicitly-initialized state.

    Once CoreCLR reaches 0, the CplusPlusArchive entries can come out of NativeRuntimeComponents.cs. MonoVM's 45 std::format uses remain a separate track.

  6. 6 remaining items

  7. added this to the .NET 11 milestone on Sep 5, 2026
  8. locked and limited conversation to collaborators on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: App+Library BuildIssues when building Library projects or Application projects.drop-libcppWork to remove the libc++ dependency from Android NativeAOT

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions