Skip to content

[native] Replace the AndroidSystem path statics with a POD buffer - #12552

Open
simonrozsival wants to merge 74 commits into
dev/simonrozsival/clr-bundled-propertiesfrom
dev/simonrozsival/clr-android-system-paths
Open

[native] Replace the AndroidSystem path statics with a POD buffer#12552
simonrozsival wants to merge 74 commits into
dev/simonrozsival/clr-bundled-propertiesfrom
dev/simonrozsival/clr-android-system-paths

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Aug 27, 2026

Copy link
Copy Markdown
Member

Part of #12533 (drop the libc++ dependency), stacked on #12551.

AndroidSystem kept five of its members in std::string / std::array<std::string, 1>: primary_override_dir, native_libraries_dir, app_code_cache_dir, single_app_lib_directory and override_dirs.

Because they are inline static with dynamic initialization, the compiler emits a guard variable and an atexit registration for them in every translation unit that includes android-system.hh — even in ones that never touch them. logger.cc, internal-pinvokes-clr.cc, internal-pinvokes-shared.cc and android-system-shared.cc each paid four libc++ references (~basic_string, operator delete, __cxa_guard_acquire, __cxa_guard_release) without using a single one of these directories:

$ llvm-nm --undefined-only logger.cc.o | llvm-cxxfilt
std::__ndk1::basic_string<...>::~basic_string()
operator delete(void*)
__cxa_guard_acquire
__cxa_guard_release

$ llvm-objdump -r logger.cc.o | grep _ZGV | llvm-cxxfilt
guard variable for xamarin::android::AndroidSystem::override_dirs
guard variable for xamarin::android::AndroidSystem::app_code_cache_dir
guard variable for xamarin::android::AndroidSystem::native_libraries_dir
guard variable for xamarin::android::AndroidSystem::primary_override_dir
guard variable for xamarin::android::AndroidSystem::single_app_lib_directory

What changed

All five become plain pointers. The three path members are const char* initialized to "" and assigned once, early during startup, with a copy made by a new Util::duplicate_string() helper that aborts if the allocation fails. Pointers to a string literal are constant-initialized, so neither a guard variable nor an atexit registration is emitted.

The two directory arrays become plain const char* arrays whose entries are malloced, which also drops an operator new[] from the non-split-APK path.

Since there is no longer a fixed-size buffer anywhere, there is also no hard limit on the path length and no abort when it is exceeded — which is what NativeAOT's char[SENSIBLE_PATH_MAX] primary_override_dir used to do. That lets primary_override_dir be shared by all three hosts, removing three #if defined (XA_HOST_NATIVEAOT) blocks and determine_primary_override_dir() entirely.

Results

Undefined libc++ references in the three CoreCLR archives — 58 → 31:

object before after
assembly-store.cc.o 13 11
host.cc.o 11 11
android-system.cc.o 11 5
timing-internal.cc.o 5 2
logger.cc.o 4 0
internal-pinvokes-shared.cc.o 4 0
internal-pinvokes-clr.cc.o 4 0
android-system-shared.cc.o 4 0
typemap.cc.o 2 2

Every __cxa_guard_* reference coming from this header is gone; the only ones left are host.cc's own function-local statics.

libnet-android.release.so: 539,464 → 536,368 bytes (−3,096).

The DEBUG-only code paths were compile-checked separately (there is no Debug ninja directory) and go from 12 to 7 references; llvm-nm confirms add_system_property, find_bundled_property and setup_environment_from_override_file are genuinely emitted rather than silently #if'd out.

CoreCLR, NativeAOT and MonoVM all build clean.

simonrozsival and others added 14 commits August 27, 2026 09:25
Use fixed buffers for straightforward type-name, override-path, and system-property values, formatting composed strings with snprintf.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move the remaining logger, max-gref, and timing property consumers to explicit fixed buffers so the CLR dynamic-local-string property overload can be removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The logger interface no longer exposes local-string types, so keep the temporary include local to its remaining fallback-path implementation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Allocate managed type and timing strings to their exact sizes instead of treating the former local-string stack threshold as a maximum.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep exact-size type and timing strings independent of libc++ ownership.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep the existing NativeAOT fixed-storage limit while preserving unbounded CoreCLR path construction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use malloc only when typemap or override names exceed their sensible local buffer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Preserve stack storage for typical managed type and override paths while allocating the exact required capacity for larger values.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Route managed type and override path heap-buffer cleanup through Util.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rely on free(nullptr) and name stack-backed CLR string storage explicitly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Eliminate separate heap pointers and free generated CLR strings only when they differ from their stack buffers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Narrowing the `strings.hh` include in `logger.hh` also removed two symbols
that headers were picking up transitively through it:

* `strings.hh` included `shared/helpers.hh`, which is where `os-bridge.hh`
  was getting `abort_unless` from.
* `strings.hh` included `<unistd.h>`, which is where `bridge-processing.cc`
  was getting `gettid()` from.

Include both explicitly at their point of use.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Narrowing the strings.hh include in logger.hh removed the transitive
path that util.cc relied on for dynamic_local_string, breaking the
CoreCLR and NativeAOT builds. Include the header where it is used.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Addresses review feedback: the fixed-buffer overload returned -1 when a
bundled (build-time) property value did not fit into the caller's buffer,
so long values were treated as if the property were not set at all.  The
`dynamic_local_string` based overload it replaced grew onto the heap and
had no such limit.

Bundled properties come from `@(AndroidEnvironment)` files and are stored
as NUL-terminated strings in static application data, so they are neither
subject to Android's 92 byte property limit nor in need of copying.
Return a `std::string_view` instead of an `int`: for Android system
properties it views the caller's scratch buffer, for bundled properties it
points directly at the application data, which restores the previous
behaviour and avoids a copy.

`FastTiming::parse_options()` used to tokenize its argument in place, which
is not safe for a view over static data, so it now parses without mutating.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Copilot AI lite review requested due to automatic review settings August 27, 2026 22:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Lite
Findings: 1 High severity · 2 Medium severity

New issues introduced by this change (3)
Severity Finding
High severity src/​native/​clr/​runtime-base/​android-system.cc — ❌ error (security): dir_length + 1uz can overflow before malloc, which would lead to a…
Medium severity src/​native/​clr/​runtime-base/​android-system.cc — ❌ error: Allocating app_lib_directories_size * sizeof (const char*) should use overflow-checked…
Medium severity src/​native/​clr/​include/​runtime-base/​path-buffer.hh⚠️ warning: path_buffer is easy to default-initialize on the stack (e.g. path_buffer&lt;N&gt; p;),…
What changed in this PR

This PR reduces CoreCLR host libc++ dependencies and startup overhead by removing dynamically-initialized inline static std::string members from AndroidSystem, replacing them with a POD-style path buffer and const char* directory lists to avoid per-TU guard variables and atexit registrations.

Changes:

  • Introduces path_buffer<N> for constant-initialized, mostly stack-buffer path storage with heap fallback.
  • Converts AndroidSystem path members and directory lists away from std::string/std::array<std::string,...> to path_buffer / const char*, and adjusts DSO path formatting APIs to use std::string_view.
  • Updates CoreCLR host components to consume the new AndroidSystem getters returning const char*.
File Description
src/​native/​clr/​runtime-base/​android-system.cc Switches app/override directory storage and DSO load iteration to const char* + malloced storage; updates DSO path formatting to string_view.
src/​native/​clr/​include/​runtime-base/​path-buffer.hh Adds path_buffer<N> POD-style path storage abstraction with inline buffer + heap fallback.
src/​native/​clr/​include/​runtime-base/​android-system.hh Reworks AndroidSystem statics/getters/setters to use path_buffer and const char* directory arrays/spans.
src/​native/​clr/​host/​host.cc Updates native library directory usage to const char* from AndroidSystem.
src/​native/​clr/​host/​fastdev-assemblies.cc Updates override directory usage/logging to const char* from AndroidSystem.
src/​native/​clr/​host/​assembly-store.cc Updates code-cache-dir empty check for const char* API.

Comment thread src/native/clr/runtime-base/android-system.cc
Comment thread src/native/clr/runtime-base/android-system.cc
Comment thread src/native/clr/include/runtime-base/path-buffer.hh Outdated
@simonrozsival simonrozsival added the drop-libcpp Work to remove the libc++ dependency from Android NativeAOT label Aug 28, 2026
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-android-system-paths branch from 13c53e9 to 8707bd1 Compare August 28, 2026 06:10
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-android-system-paths branch from 8707bd1 to 43bdbbb Compare August 28, 2026 07:14
…perty

The previous commit changed `monodroid_get_system_property ()` to return a
`std::string_view` so that bundled properties, whose length is not limited by
`PROPERTY_VALUE_BUFFER_LEN`, could be returned without copying them into the
caller's scratch buffer.

That works, but `std::string_view` deliberately makes no promise about
NUL-termination, while every value this function can return happens to be
NUL-terminated: `__system_property_get ()` terminates what it writes, and
bundled properties are NUL-terminated strings in static application data. The
header had to document that invariant in a comment ("The returned value is
always NUL-terminated") precisely because the type denies it, and callers such
as `get_max_gref_count_from_system ()` silently relied on it by passing
`.data ()` to `strtol ()` and to a `%s` format specifier.

Return `const char*` instead (and `nullptr` when the property is not set). The
lifetime rule is unchanged and still uniform - the result is valid for at least
as long as the caller's buffer - but NUL-termination is now guaranteed by the
type rather than by a comment, so `.data ()` no longer has to be laundered
through a `std::string_view`. Callers that need to tokenize the value construct
a `std::string_view` explicitly, which is honest about what they are doing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-android-system-paths branch from 43bdbbb to cc2c3a8 Compare August 28, 2026 07:54
- `format_managed_type_name ()` builds the name with a single `snprintf ()`
  instead of three `memcpy ()` calls and hand-rolled length arithmetic. The
  negative-required-capacity retry contract is unchanged, and the helper is now
  guarded by `#if defined (DEBUG)` like its only caller, which removes the
  unused-function warning this pull request introduced.

- `FastTiming::parse_options ()` takes a `const char*` again and tokenizes with
  `strchr ()`/`strncmp ()`/`strtoull ()`. It had been rewritten around
  `std::string_view`, which added a C++ layer to code that was already plain C.
  The parser still cannot NUL-terminate in place - the value may point at
  immortal bundled property data - so each parameter is bounded by its length
  instead. The `duration=` and `filename=` edge cases behave as they did before.

- The property lookup chain (`monodroid_get_system_property ()`,
  `monodroid__system_property_get ()` and `lookup_system_property ()`) takes
  `const char *name`, matching the other overloads. Previously it took a
  `std::string_view` and immediately called `.data ()` on it, which is the same
  NUL-termination laundering that motivated changing the return type. This also
  lets `HostEnvironment::lookup_system_property ()` use `strcmp ()` directly and
  drops `<string_view>` from `android-system-shared.cc` entirely.

- Shorten the comments added by this pull request, and drop the `strings.hh`
  include from `logger.cc`, which no longer uses `dynamic_local_string`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-android-system-paths branch from cc2c3a8 to 6b74b72 Compare August 28, 2026 08:47
simonrozsival and others added 27 commits August 28, 2026 14:02
Reuse a single pthread mutex wrapper instead of adding a second one.

`mono/shared/cppcompat.hh` already contained `xamarin::android::mutex` and
`xamarin::android::lock_guard`, added for exactly the same reason: `<mutex>`
makes the runtime depend on libc++. Rather than maintain two wrappers with
the same purpose, delete `cppcompat.hh` and move the MonoVM host over to the
shared `Mutex`/`MutexGuard` in `common/include/runtime-base/mutex.hh`.

`Mutex` is the stricter of the two: it deletes the copy and move operations,
which the old `mutex` left implicitly defined even though copying a
`pthread_mutex_t` is never correct. It is also explicitly `constexpr`
default constructible, so static instances stay constant-initialized.

Also refresh the two comments explaining why `NDEBUG` is defined before
including `robin_map.h`. They claimed `<mutex>` "conflicts with our
std::mutex definition in cppcompat.hh", which stopped being true once the
wrapper moved into the `xamarin::android` namespace. The hack is still worth
keeping, but the real reason is that `<iostream>` and `<mutex>` would both
pull in libc++.

Finally, value-initialize `dso_handle_write_lock` for consistency with the
other static `Mutex` instances.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Drop the RAII guard in favour of calling pthread_mutex_lock/unlock
through Mutex directly. Critical sections that used to return, break or
continue while holding the lock now delegate to a `_locked` helper that
holds the branching logic, so each locked region has exactly one entry
and one exit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Drop the Mutex class and use pthread_mutex_t with
pthread_mutex_lock/unlock at the call sites. PTHREAD_MUTEX_INITIALIZER
keeps the static instances constant-initialized, so they still need no
thread-safe initialization guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Keep cppcompat.hh and the MonoVM hosts's use of it as they were. The
CoreCLR host no longer shares a mutex wrapper with MonoVM, so there is
no reason for this change to reach into src/native/mono.

Timing lives in the shared common sources, so it is still converted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The previous commit split every locked region that contained an early
exit into an outer method (which locks) and an inner `_locked` method
(which holds the logic). That kept the lock/unlock pairing obvious, but
it introduced six new methods and a helper enum purely to work around
`return` statements.

Restructure the locked regions in place instead: use a result flag plus
`break`/`if`-`else` so control always falls through to the unlock, and
move the early `return` after it. This drops all six helpers along with
the `ReserveResult` enum and keeps the diff against the original code
much smaller.

* `writer_loop` uses `have_request` / `write_failed`
* `enqueue_write` restores the original `queue_full` bool and adds
  `writes_allowed`
* `get_available_sequence` uses `ret == nullptr` + `break`
* `open_assembly` inverts the `opendir` check and re-tests
  `override_dir_fd` after unlocking

No behavioural change. libc++ references are unchanged at 40 (CoreCLR)
and 0 (NativeAOT), and `__cxa_guard_*` stays at 8, confirming the
statics are still constant-initialized.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The previous two commits reshaped the locked regions to avoid returning
while the lock was held: first by splitting them into `_locked` helpers,
then by threading result flags so control fell through to the unlock.
Both worked, but both restructured code that did not otherwise need to
change.

Just call `pthread_mutex_unlock` immediately before the early `return`
instead. The regions keep their original shape, so the diff against the
pre-existing code is now purely mechanical -- a type change, a
`std::lock_guard` turning into a `pthread_mutex_lock`, and an added
unlock. Churn against the base drops from 145 changed lines to 62.

This is safe because the native runtime is built with `-fno-exceptions`
(verified in `compile_commands.json`), so there is no unwind path that
`std::lock_guard` would have covered and a manual unlock would miss.

Verified that every `return` inside a locked region is immediately
preceded by an unlock of that mutex, across all 13 regions. libc++
references remain 40 (CoreCLR) and 0 (NativeAOT), and `__cxa_guard_*`
stays at 8.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`Timing::sequence_pool` was a `std::vector<managed_timing_sequence>` that
`get_available_sequence` scanned for a free entry, growing it with
`emplace_back` when every entry was in use.

Returning pointers into a vector's buffer is unsound. The constructor
does `resize (16)`, which leaves capacity at exactly 16, so the
seventeenth concurrent sequence reallocates the buffer -- and every
pointer already handed out to managed code (held as an `IntPtr` across
the `TimingLogger.Start`/`Stop` window) is left dangling.
`monodroid_timing_stop` then writes `sequence->end` and `in_use = false`
into freed memory, and the measurement is silently lost. Because those
entries are never marked free again, the pool also grows on every
subsequent call.

Replace the vector with an intrusive free list. Entries are allocated
individually with `malloc`, so they never move, and `release_sequence`
pushes them back onto the list instead of freeing them. Nothing is ever
freed, so no pointer can dangle; the total allocation is bounded by the
peak number of concurrent sequences. Acquire and release are now O(1)
rather than an O(n) scan under the lock.

`in_use` is kept purely as a guard: a double release would otherwise
push an entry onto the list twice and hand it to two callers at once.
Today a double release is harmless, and it stays harmless.

`Timing` is left with two constant-initialized POD members, so it no
longer needs a constructor and can be a plain `static inline` instance
in BSS, removing the `new Timing ()` as well. This only pays off on top
of the `pthread_mutex_t` change: while `sequence_lock` was a `std::mutex`
its non-trivial destructor forced `__cxa_atexit` registration behind a
guard variable, which cost two more symbols than the `operator new` it
saved.

Real libc++ references in the CoreCLR archive drop from 40 to 38
(one `operator new`, one `__libcpp_verbose_abort` from the vector's
length check). `__cxa_guard_*` stays at 8 and NativeAOT stays at 0.

MonoVM also uses this class and gets the same fix without any change to
`src/native/mono/`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`Host::_timing` was a pointer whose only job was to encode "fast timing is
disabled" as `nullptr`. `FastTiming::enabled ()` already answers that
question, so the pointer was redundant indirection over a static instance
that always exists.

Keep just the object, have `get_timing ()` return a reference, and gate both
P/Invokes on `FastTiming::enabled ()`. This also closes a window where
`enabled ()` was true but the pointer had not been assigned yet.

Also null-check `get_available_sequence ()` in `monodroid_timing_start ()`:
it can now return `nullptr` when `malloc` fails, which the previous
vector-backed implementation never did.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The free list gave every sequence its own `malloc`, and threaded the recycling
through a `next_free` pointer inside the sequence itself. That works, but a
double release would put an entry on the list twice and hand it to two callers
at once, so `release_sequence ()` had to guard against it.

Allocate in chunks of 16 instead and go back to recycling through `in_use`, the
way the original vector-backed code did. `get_available_sequence ()` scans the
chunks for an unused entry and chains on a new chunk when it finds none.
Chunks are never freed, so every address handed to managed code stays valid for
the lifetime of the process, and a double release is just a redundant store.

MonoVM shares `Timing` and dereferenced `get_available_sequence ()` without
checking it, which was safe while the pool was a vector but is not now that
allocation can fail. Add the missing check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Returning `nullptr` on allocation failure pushed the problem onto every
caller, and both `monodroid_timing_start ()` implementations had to grow a
check they never needed while the pool was a `std::vector`.

Abort instead, which is what the rest of the runtime does when it cannot
allocate. `get_available_sequence ()` can no longer fail, so both checks go
away again and `src/native/mono/` is untouched by this PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The previous commits replaced the `std::vector` backing `Timing`'s sequence
pool with chunks allocated by `calloc` and chained together. `FastTiming`'s
`TimingEventChunk` is a structurally identical pool that was left using
`new`/`delete`, so apply the same treatment to it.

This does not change the `libc++` reference count on its own, because the
same translation units still reference `operator new`/`operator delete` for
the `std::string` that `TimingEvent::more_info` points to. Removing those
strings is done in the next commit of the stack, and only then does the
count actually drop.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`FastTiming::open_sequences` was a `thread_local std::stack<TimingEvent*>`,
which defaults to `std::deque` as its container.  `std::deque` has both a
non-trivial constructor and a non-trivial destructor, so every translation
unit including `timing-internal.hh` emitted a guarded dynamic initializer
plus a `__cxa_thread_atexit` registration for the thread-local instance.

The stack only ever needs `push`, `top`, `pop` and `empty`, and its depth is
bounded by how deeply the instrumented calls nest (currently 3) because every
`start_event` is matched by exactly one `end_event` or `store_more_info`.
Replace it with a fixed `TimingEvent*` array plus a depth counter, both of
which are trivially constructible and destructible and therefore constant
initialized.

`open_sequences` is `thread_local`, so it is private to each thread and needs
no locking - that remains true here, as no state is shared between threads.

The depth counter is incremented even when the array is full, so a push past
the bound only loses that one entry instead of misaligning the pairing of the
events below it.  Once the depth drops back within bounds the remaining
entries are still correct.

Removes all 4 `__cxa_thread_atexit` references and one
`__libcpp_verbose_abort`, taking the CoreCLR host's libc++ references from
64 to 59.  As a side effect, pushing a timing event no longer allocates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The fixed array capped the nesting depth of timing events, which is not a
limit the timing code should impose - any number of events may be open on a
thread at once.  Replace it with a naive singly linked list used as a stack,
with one malloc'd node per open sequence:

    struct OpenSequence
    {
        TimingEvent *event;
        OpenSequence *next;
    };

    static inline thread_local OpenSequence *open_sequences = nullptr;

The head pointer is still a trivially destructible thread-local, so this keeps
the property that motivated the change: no guarded dynamic initializer and no
`__cxa_thread_atexit` registration.

Nodes are freed as they are popped rather than being recycled, so a thread
that balances its `start_event` and `end_event` calls leaves nothing behind
when it exits.  That matters here because, unlike the process-wide timing
sequence pool, this list is per thread and threads come and go.

Allocation failure aborts, matching how the timing sequence chunks behave.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`FastTiming` kept two heap-allocated `std::string`s that the earlier pass
over the timing code missed: the per-event `TimingEvent::more_info` and the
output file name parsed out of the `debug.mono.timing` property.

`more_info` becomes a plain NUL-terminated `char*`. It was always built from
one or two `std::string_view`s whose total length is known up front, so a
single `malloc` and one or two `memcpy`s replace the string entirely. When
the allocation fails we simply drop the extra information instead of
aborting - timing is a diagnostic facility and must not take the application
down with it.

The output file name comes from a system property, whose value is limited to
`PROP_VALUE_MAX` (92) bytes, so it now lives in a fixed 128 byte buffer
inside `FastTiming` rather than in a `std::unique_ptr<std::string>`. Keeping
it inline also means the global `internal_timing` instance stays
constant-initialized and needs no guard variable. Names that do not fit are
rejected with a warning and the default is used.

Together with the previous commit this removes the last `operator new` and
`operator delete` references from `timing-internal.cc.o` and, as a side
effect, all of them from `typemap.cc.o`, which had been inheriting them from
the inlined `new TimingEventChunk` in `FastTiming::get_event`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`std::function` is a type-erasing wrapper which needs to store, copy and
destroy an arbitrary callable, and it pulls `<functional>` into every
translation unit that sees the declaration.  Neither of the two uses in the
CoreCLR host needs any of that.

`FastTiming::dump` took its line writer as `std::function<void(std::string_view const&)>`
by value.  Of its two callers one passes a captureless lambda and the other
captures a single `FILE*`, so a plain function pointer plus an opaque
`void *context` covers both:

    using LineWriter = void (*) (void *context, std::string_view const& line);

`AssemblyStore::configure_from_payload` took a `const std::function<std::string()>&`
used only to produce a path for diagnostics.  Its only caller wrapped a
`const char *` in a `std::string` just so that the callee could call
`c_str ()` on it again, and the callback is invoked unconditionally in the
success path, so this allocated a string on every startup.  It now takes the
`const char *` directly.

This does not change the number of undefined libc++ references, since both
uses were fully inlined by the optimizer, but it removes the generated
machinery: `libnet-android.release.so` shrinks by 6,976 bytes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Both `dump` callers either write to a file or ignore the context entirely, so
there is no need for the context to be `void*`.  Typing it as `FILE*` removes
the `static_cast` in the file line writer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The two line writers were captureless lambdas converted to function pointers
at the call site.  That conversion goes through a compiler generated static
invoker, so making them plain functions in an anonymous namespace removes a
level of indirection: `libnet-android.release.so` shrinks by a further 56
bytes.

The remaining lambdas inside `dump` are called directly rather than converted
to function pointers, so the optimizer already inlines them completely -
replacing those measured 2 bytes *larger*, so they are left alone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Addresses review feedback: `configure_from_payload()` takes a raw `const char*`
and every use of it goes through `optional_string ()`, so the header comment now
says explicitly that passing `nullptr` is allowed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`FastTiming::get_time()` already read the clock with `clock_gettime()`;
`std::chrono::steady_clock` was only used as the type tag of the
`chrono::time_point` the result was wrapped in.  Store the timestamps as a
plain `uint64_t` nanosecond count instead and drop `<chrono>` from the four
files that included it (it was entirely unused in mainthread-dso-loader.hh).

All four places that formatted an interval repeated the same
seconds/milliseconds/nanoseconds split, so they now share a `time_interval`
helper.  The split is reproduced exactly as `chrono::duration_cast` computed
it, so the timing output is unchanged - this matters because the format after
the first colon is parsed by our performance measuring utilities.

Also read `CLOCK_MONOTONIC` rather than `CLOCK_MONOTONIC_RAW`, so that we keep
using the same clock `steady_clock` was documented to use.  The two differ only
in that `CLOCK_MONOTONIC` is slewed by NTP, which is irrelevant at the
granularity we measure.

This does not remove any undefined libc++ symbols - `<chrono>` is header only
- but it does shrink libnet-android.release.so by 80 bytes and removes one
more libc++ header from the build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
…tals

Addresses review feedback. Both fields are totals for the whole interval and both
are printed, so `milliseconds` is not milliseconds-within-the-second. The output
format is consumed by performance measuring utilities, so spell this out to keep a
future change from "correcting" it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`AndroidSystem::bundled_properties` was an
`std::unordered_map<std::string, std::string>`, which is the only user of
`<unordered_map>` in the CoreCLR host.  The properties are read from the
environment override files at run time, so the set is not known at build time
and cannot be a static sorted array - but the map buys us nothing either: the
entries are added once at startup, looked up a handful of times and there are
only a few of them.

Use the same malloc'd singly linked list MonoVM has always used for this
(`BundledProperty`), with the name allocated together with the node and the
value allocated separately so that setting a property twice can replace it.

This also fixes a real bug.  The lookup returned the map key rather than the
value:

    value_len = prop_iter->second.length ();
    return prop_iter->first.c_str ();

so every bundled property resolved to its own *name*, reported with the
*value's* length - which over-reads past the end of the name whenever the value
is longer than the name.

Release builds are unaffected, this code is `#if defined (DEBUG)` only.  In a
Debug build of android-system.cc it removes the last reference to
`std::__next_prime()` (12 undefined libc++ symbols instead of 13) and shrinks
the object file from 83,400 to 77,984 bytes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`AndroidSystem` kept five of its members in `std::string`/`std::array<std::string>`:
`primary_override_dir`, `native_libraries_dir`, `app_code_cache_dir`,
`single_app_lib_directory` and `override_dirs`.

Because they are `inline static` with dynamic initialization, the compiler emits a
guard variable *and* an `atexit` registration for them in **every** translation unit
that includes `android-system.hh` - even in ones that never touch them. `logger.cc`,
`internal-pinvokes-clr.cc`, `internal-pinvokes-shared.cc` and
`android-system-shared.cc` each paid four libc++ references (`~basic_string`,
`operator delete`, `__cxa_guard_acquire`, `__cxa_guard_release`) without using a
single one of these directories.

Replace them with `path_buffer<N>`, a trivial aggregate holding an inline buffer plus
an optional heap buffer. Being a POD, static instances are constant-initialized, so
neither a guard variable nor an `atexit` registration is emitted. Paths that fit in
`SENSIBLE_PATH_MAX` need no allocation at all and longer ones are moved to the heap,
so - unlike the fixed `char[]` array NativeAOT used for `primary_override_dir` - there
is no hard limit on the path length and no abort when it is exceeded.

The directory arrays become plain `const char*` arrays whose entries are `malloc`ed,
which also drops an `operator new[]` from the non-split-APK path.

This lets `primary_override_dir` be shared by all three hosts, removing three
`#if defined (XA_HOST_NATIVEAOT)` blocks and `determine_primary_override_dir()`.

Undefined libc++ references in the CoreCLR archives: 58 -> 31.
`libnet-android.release.so`: 539,464 -> 536,184 bytes (-3,280).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The inline-buffer-plus-heap-fallback `path_buffer` was more machinery than these
three values need. They are assigned exactly once, early during startup, and only
read afterwards, so the inline buffer only ever saved a single `malloc` per value
while costing 3 KB of `.bss`.

Replace it with plain `const char*` members initialized to `""`. Pointers to a
string literal are constant-initialized just like the aggregate was, so the guard
variables and `atexit` registrations stay gone, which was the whole point of the
change. The values are duplicated with a new `Util::duplicate_string()` helper,
which aborts if the allocation fails.

Also format the APK library directory with `snprintf` instead of open-coded
`memcpy` calls - the exact length is computed up front, so the buffer is already
known to be the right size.

Undefined libc++ references are unchanged at 31.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Addresses review feedback:

- `app_lib_directories_size * sizeof (const char*)` is now computed with
  `Helpers::multiply_with_overflow_check`.
- A zero-length array is handled explicitly. `malloc (0)` may legitimately return
  `nullptr`, which the previous code would have misreported as an allocation
  failure; `setup_apk_directories ()` already aborts with a more accurate message
  when no directory ends up being added.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The view returned by `get_string_view ()` pointed at the UTF characters owned
by the wrapper, so it dangled as soon as the wrapper released them. Nothing
relied on the view being a view: two of the three callers immediately passed it
to a path helper, and the third only needed a suffix comparison. Return the C
string instead and let the callers build a view when they need one.

`setup_apk_directories ()` used `std::string_view::ends_with ()`, so add a
`Util::ends_with ()` that works on plain C strings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The hand-written copy existed to support a caller that passed a pointer and a
length rather than a C string, but that caller formats its buffer with
`snprintf ()` and only reaches the call when the result fits, so the buffer is
already NUL terminated. With every caller passing a C string there is nothing
left for `std::string_view` to do and the copy is just `strdup ()`.

Keep the wrapper rather than calling `strdup ()` directly: it aborts on
allocation failure, which saves each of the four callers from checking for
null.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The only caller of `get_full_dso_path ()` iterates over a container of
`const char*` directories and wrapped each one in a `std::string_view` purely
to satisfy the signature. Take a C string instead and measure it once inside
`format_full_dso_path ()`.

`dso_path` stays a view: it originates in the DSO cache lookup, which compares
name mutations built with `substr ()`, so a view is the right type there.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-android-system-paths branch from f9a1d0e to d4409ce Compare August 28, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drop-libcpp Work to remove the libc++ dependency from Android NativeAOT

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants