From a07be8e7c946b4661d921b72eb5a06ea8ee6febd Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 18 Jun 2026 15:09:01 -0500 Subject: [PATCH 1/4] [Mono.Android] CoreCLR: file/line in stack traces with FastDev When CoreCLR runs an Android app with FastDev, app assemblies live on disk in `files/.__override__//` with their portable PDBs alongside. But we resolved them through `host_runtime_contract.external_assembly_probe`, which reads the .dll into a heap buffer and hands the bytes to the runtime. The CLR never opens the file itself, so `Assembly.Location` is empty and `StackTraceSymbols` has no anchor for finding the sibling .pdb. The result: `Console.WriteLine(Environment.StackTrace)` and `Exception.StackTrace` print method names only, no `in File.cs:line N` info. Fix: in Debug startup, append `TRUSTED_PLATFORM_ASSEMBLIES` to the properties passed to `coreclr_initialize`, listing the full on-disk path of every assembly from the typemap that is also present in the override directory. The CLR then mmap's those files itself via `PEImage::OpenImage`, which records `m_path`, populates `Assembly.Location`, and lets `StackTraceSymbols.TryOpenAssociatedPortablePdb` find the matching .pdb via simple sibling-file lookup. No DebugType change required, no opt-in property, no impact on Release. The TPA list is bounded by `type_map_unique_assemblies` (the build-time set of assemblies contributing typemap entries), so we never pass arbitrary files. BCL assemblies still flow through the existing FastDev/AssemblyStore probe path unchanged. Test: adds `StackTraceContainsLineNumbers` to `InstallAndRunTests` (CoreCLR, Debug, FastDev) which runs an app emitting `Environment.StackTrace` and asserts logcat contains a frame like `at ...MainActivity.OnCreate ... in ...MainActivity.cs:line N`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/native/clr/host/fastdev-assemblies.cc | 51 +++++++++++++++++++ src/native/clr/host/host.cc | 37 ++++++++++++-- .../clr/include/host/fastdev-assemblies.hh | 7 +++ .../Tests/InstallAndRunTests.cs | 51 +++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/native/clr/host/fastdev-assemblies.cc b/src/native/clr/host/fastdev-assemblies.cc index dea16523aaf..e96d483833f 100644 --- a/src/native/clr/host/fastdev-assemblies.cc +++ b/src/native/clr/host/fastdev-assemblies.cc @@ -6,8 +6,10 @@ #include #include #include +#include #include +#include #include #include #include @@ -111,3 +113,52 @@ auto FastDevAssemblies::open_assembly (std::string_view const& name, int64_t &si return reinterpret_cast(buffer); } + +auto FastDevAssemblies::build_tpa_list (std::string &tpa_list) noexcept -> bool +{ + tpa_list.clear (); + + std::string const& override_dir_path = AndroidSystem::get_primary_override_dir (); + if (!Util::dir_exists (override_dir_path)) { + return false; + } + + DIR *dir = opendir (override_dir_path.c_str ()); + if (dir == nullptr) { + log_warn (LOG_ASSEMBLY, "FastDev: failed to open override dir '{}'. {}"sv, override_dir_path, std::strerror (errno)); + return false; + } + int dir_fd = dirfd (dir); + + size_t count = 0; + uint64_t expected_count = type_map.unique_assemblies_count; + for (uint64_t i = 0; i < expected_count; i++) { + TypeMapAssembly const &asm_entry = type_map_unique_assemblies[i]; + std::string_view name { + &type_map_assembly_names[asm_entry.name_offset], + static_cast(asm_entry.name_length) + }; + + // `Name` is the simple assembly name (e.g. "Mono.Android"), no extension. + std::string file_name; + file_name.reserve (name.size () + 4); + file_name.append (name); + file_name.append (".dll"); + + if (!Util::file_exists (dir_fd, file_name)) { + continue; + } + + if (!tpa_list.empty ()) { + tpa_list.append (":"); + } + tpa_list.append (override_dir_path); + tpa_list.append ("/"); + tpa_list.append (file_name); + count++; + } + closedir (dir); + + log_debug (LOG_ASSEMBLY, "FastDev: built TPA list with {} assemblies from '{}'"sv, count, override_dir_path); + return count > 0; +} diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index 3e84ef930d8..8193474eb96 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include @@ -450,12 +452,41 @@ void Host::Java_mono_android_Runtime_initInternal ( // The first entry in the property arrays is for the host contract pointer. Application build makes sure // of that. init_runtime_property_values[0] = host_contract_ptr_buffer.data (); + + const char **prop_names = init_runtime_property_names; + const char **prop_values = const_cast(init_runtime_property_values); + int prop_count = static_cast(application_config.number_of_runtime_properties); + + // In Debug builds with FastDev, append `TRUSTED_PLATFORM_ASSEMBLIES` with full + // paths to the assemblies pushed into `.__override__//`. CoreCLR then + // opens those files from disk so `Assembly.Location` is populated and + // `StackTraceSymbols` can find sibling `.pdb` files for runtime-rendered + // managed stack traces (file/line). + if constexpr (Constants::is_debug_build) { + // Storage must outlive `coreclr_initialize`; function-local statics + // give us process lifetime without polluting global namespace. + static std::string fastdev_tpa_list; + static std::vector fastdev_prop_names; + static std::vector fastdev_prop_values; + + if (FastDevAssemblies::build_tpa_list (fastdev_tpa_list)) { + fastdev_prop_names.assign (prop_names, prop_names + prop_count); + fastdev_prop_values.assign (prop_values, prop_values + prop_count); + fastdev_prop_names.push_back (HOST_PROPERTY_TRUSTED_PLATFORM_ASSEMBLIES); + fastdev_prop_values.push_back (fastdev_tpa_list.c_str ()); + + prop_names = fastdev_prop_names.data (); + prop_values = fastdev_prop_values.data (); + prop_count = static_cast(fastdev_prop_names.size ()); + } + } + int hr = FastTiming::time_call ("coreclr_initialize"sv, coreclr_initialize, application_config.android_package_name, "Xamarin.Android", - (int)application_config.number_of_runtime_properties, - init_runtime_property_names, - const_cast(init_runtime_property_values), + prop_count, + prop_names, + prop_values, &clr_host, &domain_id ); diff --git a/src/native/clr/include/host/fastdev-assemblies.hh b/src/native/clr/include/host/fastdev-assemblies.hh index 51f1945fce3..fffcca1e650 100644 --- a/src/native/clr/include/host/fastdev-assemblies.hh +++ b/src/native/clr/include/host/fastdev-assemblies.hh @@ -4,6 +4,7 @@ #include #include +#include #include namespace xamarin::android { @@ -12,11 +13,17 @@ namespace xamarin::android { public: #if defined(DEBUG) static auto open_assembly (std::string_view const& name, int64_t &size) noexcept -> void*; + static auto build_tpa_list (std::string &tpa_list) noexcept -> bool; #else static auto open_assembly ([[maybe_unused]] std::string_view const& name, [[maybe_unused]] int64_t &size) noexcept -> void* { return nullptr; } + + static auto build_tpa_list ([[maybe_unused]] std::string &tpa_list) noexcept -> bool + { + return false; + } #endif private: diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 322a8d5034b..cc4bd84b0a6 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Xml.Linq; using System.Xml.XPath; @@ -2227,6 +2228,56 @@ public void FastDeployEnvironmentFiles ([Values] bool isRelease, [Values] bool e } } + [Test] + public void StackTraceContainsLineNumbers () + { + // FastDev (Debug + assemblies on disk in .__override__) wires up + // portable PDB lookup for runtime-rendered stack traces on CoreCLR + // via the TPA list passed to coreclr_initialize. + AndroidRuntime runtime = AndroidRuntime.CoreCLR; + if (IgnoreUnsupportedConfiguration (runtime, release: false)) { + return; + } + + var proj = new XamarinAndroidApplicationProject (packageName: PackageUtils.MakePackageName (runtime)) { + ProjectName = nameof (StackTraceContainsLineNumbers), + RootNamespace = nameof (StackTraceContainsLineNumbers), + IsRelease = false, + EmbedAssembliesIntoApk = false, + EnableDefaultItems = true, + }; + proj.SetRuntime (runtime); + proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", @" + Console.WriteLine (""#STACKTRACE-BEGIN#""); + Console.WriteLine (Environment.StackTrace); + Console.WriteLine (""#STACKTRACE-END#""); + "); + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Install (proj), "App should have installed."); + RunProjectAndAssert (proj, builder); + + var appStartupLogcatFile = Path.Combine (Root, builder.ProjectDirectory, "logcat.log"); + Assert.IsTrue ( + WaitForActivityToStart (proj.PackageName, "MainActivity", appStartupLogcatFile, ActivityStartTimeoutInSeconds), + "MainActivity should have launched!" + ); + + var logcatOutput = File.ReadAllText (appStartupLogcatFile); + StringAssert.Contains ("#STACKTRACE-BEGIN#", logcatOutput, "Stack trace marker not found in logcat"); + + // Expect a frame in MainActivity.OnCreate to include + // "in MainActivity.cs:line ". + var match = Regex.Match ( + logcatOutput, + @"at\s+\S*MainActivity\.OnCreate.*\sin\s+\S+MainActivity\.cs:line\s+\d+", + RegexOptions.Singleline + ); + Assert.IsTrue ( + match.Success, + $"Expected MainActivity.OnCreate frame to include file/line info. Logcat:\n{logcatOutput}" + ); + } + [Test] public void DotNetRunEnvironmentVariables () { From f43e3de6ed1077ab11022a7292fd116aca6df7b0 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 18 Jun 2026 15:17:53 -0500 Subject: [PATCH 2/4] Address review feedback - Bail out cleanly if dirfd() fails - Assert #STACKTRACE-END# marker to catch truncated output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/native/clr/host/fastdev-assemblies.cc | 5 +++++ tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/native/clr/host/fastdev-assemblies.cc b/src/native/clr/host/fastdev-assemblies.cc index e96d483833f..8cab1128c51 100644 --- a/src/native/clr/host/fastdev-assemblies.cc +++ b/src/native/clr/host/fastdev-assemblies.cc @@ -129,6 +129,11 @@ auto FastDevAssemblies::build_tpa_list (std::string &tpa_list) noexcept -> bool return false; } int dir_fd = dirfd (dir); + if (dir_fd < 0) { + log_warn (LOG_ASSEMBLY, "FastDev: failed to obtain fd for override dir '{}'. {}"sv, override_dir_path, std::strerror (errno)); + closedir (dir); + return false; + } size_t count = 0; uint64_t expected_count = type_map.unique_assemblies_count; diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index cc4bd84b0a6..942951c5934 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -2263,7 +2263,8 @@ public void StackTraceContainsLineNumbers () ); var logcatOutput = File.ReadAllText (appStartupLogcatFile); - StringAssert.Contains ("#STACKTRACE-BEGIN#", logcatOutput, "Stack trace marker not found in logcat"); + StringAssert.Contains ("#STACKTRACE-BEGIN#", logcatOutput, "Stack trace start marker not found in logcat"); + StringAssert.Contains ("#STACKTRACE-END#", logcatOutput, "Stack trace end marker not found in logcat (output may be truncated)"); // Expect a frame in MainActivity.OnCreate to include // "in MainActivity.cs:line ". From 4e0ee56016e4eeabe08c65569396c94ef7bcee5c Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 18 Jun 2026 15:39:20 -0500 Subject: [PATCH 3/4] Address review feedback: deterministic test capture - Use MonitorAdbLogcat to wait for #STACKTRACE-END# marker instead of WaitForActivityToStart, which races the async stdout->logcat flush and could finish capturing before the trace lands. - Drop RegexOptions.Singleline so the OnCreate frame and file:line must appear on the same line, ensuring the assertion is specific to the OnCreate frame. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/InstallAndRunTests.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 942951c5934..d68827b10b1 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -2256,22 +2256,20 @@ public void StackTraceContainsLineNumbers () Assert.IsTrue (builder.Install (proj), "App should have installed."); RunProjectAndAssert (proj, builder); - var appStartupLogcatFile = Path.Combine (Root, builder.ProjectDirectory, "logcat.log"); + var appStartupLogcatFile = Path.Combine (Root, builder.ProjectDirectory, "stacktrace-logcat.log"); Assert.IsTrue ( - WaitForActivityToStart (proj.PackageName, "MainActivity", appStartupLogcatFile, ActivityStartTimeoutInSeconds), - "MainActivity should have launched!" + MonitorAdbLogcat (line => line.Contains ("#STACKTRACE-END#"), appStartupLogcatFile, timeout: 60), + "Stack trace end marker not found in logcat (output may be missing or truncated)." ); var logcatOutput = File.ReadAllText (appStartupLogcatFile); StringAssert.Contains ("#STACKTRACE-BEGIN#", logcatOutput, "Stack trace start marker not found in logcat"); - StringAssert.Contains ("#STACKTRACE-END#", logcatOutput, "Stack trace end marker not found in logcat (output may be truncated)"); // Expect a frame in MainActivity.OnCreate to include - // "in MainActivity.cs:line ". + // "in MainActivity.cs:line " on a single line. var match = Regex.Match ( logcatOutput, - @"at\s+\S*MainActivity\.OnCreate.*\sin\s+\S+MainActivity\.cs:line\s+\d+", - RegexOptions.Singleline + @"at\s+\S*MainActivity\.OnCreate.*\sin\s+\S+MainActivity\.cs:line\s+\d+" ); Assert.IsTrue ( match.Success, From a439e1745d967663709d94e744be88a2d6f999d7 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 18 Jun 2026 16:40:58 -0500 Subject: [PATCH 4/4] Address review feedback: doc note and raw string literal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/native/clr/host/fastdev-assemblies.cc | 5 +++++ .../MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/native/clr/host/fastdev-assemblies.cc b/src/native/clr/host/fastdev-assemblies.cc index 8cab1128c51..dbc2097a7d7 100644 --- a/src/native/clr/host/fastdev-assemblies.cc +++ b/src/native/clr/host/fastdev-assemblies.cc @@ -136,6 +136,11 @@ auto FastDevAssemblies::build_tpa_list (std::string &tpa_list) noexcept -> bool } size_t count = 0; + // NOTE: The TPA list is sourced from `type_map_unique_assemblies`, which is + // only populated when `_AndroidTypeMapImplementation=llvm-ir` (the Debug + // default). With `managed` or `trimmable` typemaps the native typemap is + // empty, so no TPA paths are added and stack frames won't carry file/line + // info even under FastDev. uint64_t expected_count = type_map.unique_assemblies_count; for (uint64_t i = 0; i < expected_count; i++) { TypeMapAssembly const &asm_entry = type_map_unique_assemblies[i]; diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index d68827b10b1..6cbb273088c 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -2247,11 +2247,11 @@ public void StackTraceContainsLineNumbers () EnableDefaultItems = true, }; proj.SetRuntime (runtime); - proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", @" - Console.WriteLine (""#STACKTRACE-BEGIN#""); + proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", """ + Console.WriteLine ("#STACKTRACE-BEGIN#"); Console.WriteLine (Environment.StackTrace); - Console.WriteLine (""#STACKTRACE-END#""); - "); + Console.WriteLine ("#STACKTRACE-END#"); + """); using var builder = CreateApkBuilder (); Assert.IsTrue (builder.Install (proj), "App should have installed."); RunProjectAndAssert (proj, builder);