From 3b1bd82c68ba711fe1264286f7add0ea50a5beba Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Jul 2026 08:39:16 -0500 Subject: [PATCH 1/2] Apply `*.runtimeconfig.dev.json` to CoreCLR apps Context: https://github.com/dotnet/sdk/pull/53715 Port of https://github.com/dotnet/android/pull/12249 to `main`. Starting with `11.0.100-preview.7.26376.106`, the .NET SDK emits a `.runtimeconfig.dev.json` file for `Debug` builds containing the Hot Reload feature switches: { "runtimeOptions": { "configProperties": { "System.Reflection.Metadata.MetadataUpdater.IsSupported": true, "System.StartupHookProvider.IsSupported": true } } } `hostfxr` layers this file on top of `*.runtimeconfig.json` at startup, but .NET for Android does not use `hostfxr`: it bakes the runtime properties into the application at build time. So the file was simply ignored, and the switches never reached the app. Do the same layering at build time for CoreCLR: read the dev file after `*.runtimeconfig.json` in `RuntimePropertiesParser`, so its `configProperties` win. This matches what Blazor WebAssembly does in https://github.com/dotnet/runtime/pull/130825. This does not conflict with the switches we set ourselves. `$(StartupHookSupport)` is only set to `false` when `'$(Optimize)' == 'true'`, and the SDK only generates the dev file for `Debug` builds, so the two are disjoint in practice. We never set `$(MetadataUpdaterSupport)`. Mono is unchanged: it reads runtime properties from the `rc.bin` blob produced by `RuntimeConfigParserTask`, which only accepts a single input file. Teaching that task about multiple files requires a change in dotnet/runtime. While here, add both `runtimeconfig` files to `_GetGeneratePackageManagerJavaInputs`. Neither was an input before, so editing them would not trigger a rebuild. `main` is still on an SDK that sets `$(GenerateRuntimeConfigDevFile)` to `false` for `net6.0`+, so the new `RuntimeConfigDevJsonIsApplied` device test writes the dev file and sets `$(ProjectRuntimeConfigDevFilePath)` itself. Both can be dropped once we pick up the newer SDK, at which point the `BuildTest.DotNetBuild` assertion for `*.runtimeconfig.dev.json` from #12249 can be added too. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b158ad70-1e5e-44cd-a1f2-60353f3bb560 --- .../GenerateNativeApplicationConfigSources.cs | 3 +- .../Tasks/RuntimePropertiesParserTests.cs | 105 ++++++++++++++++++ .../Utilities/RuntimePropertiesParser.cs | 49 ++++++-- .../Xamarin.Android.Common.targets | 4 + .../Tests/InstallAndRunTests.cs | 51 +++++++++ 5 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs index 17a4748d6fa..7d7b2eab820 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs @@ -61,6 +61,7 @@ public class GenerateNativeApplicationConfigSources : AndroidTask public bool AndroidEnableAssemblyStoreDecompressionCache { get; set; } public string? RuntimeConfigBinFilePath { get; set; } public string ProjectRuntimeConfigFilePath { get; set; } = String.Empty; + public string? ProjectRuntimeConfigDevFilePath { get; set; } public string? BoundExceptionType { get; set; } public string? PackageNamingPolicy { get; set; } @@ -266,7 +267,7 @@ static bool ShouldSkipAssembly (ITaskItem assembly) LLVMIR.LlvmIrComposer appConfigAsmGen; if (TargetsCLR) { - Dictionary? runtimeProperties = RuntimePropertiesParser.ParseConfig (ProjectRuntimeConfigFilePath); + Dictionary? runtimeProperties = RuntimePropertiesParser.ParseConfig (ProjectRuntimeConfigFilePath, ProjectRuntimeConfigDevFilePath); appConfigAsmGen = new ApplicationConfigNativeAssemblyGeneratorCLR (envBuilder.EnvironmentVariables, envBuilder.SystemProperties, runtimeProperties, Log) { UsesAssemblyPreload = envBuilder.Parser.UsesAssemblyPreload, AndroidPackageName = AndroidPackageName, diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs new file mode 100644 index 00000000000..a3128403b32 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs @@ -0,0 +1,105 @@ +#nullable enable +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests.Tasks; + +[TestFixture] +public class RuntimePropertiesParserTests : BaseTest +{ + const string RuntimeConfigJson = """ + { + "runtimeOptions": { + "tfm": "net11.0", + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.StartupHookProvider.IsSupported": false, + "Microsoft.Android.Runtime.RuntimeFeature.IsAssignableFromCheck": true + } + } + } + """; + + string directory = ""; + string runtimeConfigPath = ""; + string runtimeConfigDevPath = ""; + + [SetUp] + public void SetUp () + { + directory = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (directory); + runtimeConfigPath = Path.Combine (directory, "App.runtimeconfig.json"); + runtimeConfigDevPath = Path.Combine (directory, "App.runtimeconfig.dev.json"); + File.WriteAllText (runtimeConfigPath, RuntimeConfigJson); + } + + static Dictionary Parse (string runtimeConfigPath, string? runtimeConfigDevPath = null) => + RuntimePropertiesParser.ParseConfig (runtimeConfigPath, runtimeConfigDevPath) ?? + throw new AssertionException ($"Could not parse '{runtimeConfigPath}'."); + + [Test] + public void MissingConfigReturnsNull () + { + Assert.IsNull (RuntimePropertiesParser.ParseConfig (Path.Combine (directory, "does-not-exist.json"))); + } + + [Test] + public void ConfigPropertiesAreParsed () + { + var properties = Parse (runtimeConfigPath); + + Assert.AreEqual ("false", properties ["System.Reflection.Metadata.MetadataUpdater.IsSupported"]); + Assert.AreEqual ("true", properties ["Microsoft.Android.Runtime.RuntimeFeature.IsAssignableFromCheck"]); + } + + [Test] + public void DevConfigPropertiesWin () + { + // This is what the .NET SDK emits for `Debug` builds + File.WriteAllText (runtimeConfigDevPath, """ + { + "runtimeOptions": { + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": true, + "System.StartupHookProvider.IsSupported": true + } + } + } + """); + + var properties = Parse (runtimeConfigPath, runtimeConfigDevPath); + + Assert.AreEqual ("true", properties ["System.Reflection.Metadata.MetadataUpdater.IsSupported"], "Hot Reload should be enabled by the dev file."); + Assert.AreEqual ("true", properties ["System.StartupHookProvider.IsSupported"], "Startup hooks should be enabled by the dev file."); + Assert.AreEqual ("true", properties ["Microsoft.Android.Runtime.RuntimeFeature.IsAssignableFromCheck"], "Properties absent from the dev file should be preserved."); + } + + [Test] + public void DevConfigWithoutConfigPropertiesIsIgnored () + { + // Pre-net6.0 style dev file, which only carries probing paths + File.WriteAllText (runtimeConfigDevPath, """ + { + "runtimeOptions": { + "additionalProbingPaths": [ "/home/user/.nuget/packages" ] + } + } + """); + + var properties = Parse (runtimeConfigPath, runtimeConfigDevPath); + + Assert.AreEqual ("false", properties ["System.Reflection.Metadata.MetadataUpdater.IsSupported"]); + } + + [Test] + public void MissingDevConfigIsIgnored () + { + var properties = Parse (runtimeConfigPath, runtimeConfigDevPath); + + Assert.AreEqual ("false", properties ["System.StartupHookProvider.IsSupported"]); + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs b/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs index bf47af55265..432e5ffd259 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs @@ -7,30 +7,61 @@ namespace Xamarin.Android.Tasks; class RuntimePropertiesParser { - public static Dictionary? ParseConfig (string projectRuntimeConfigFilePath) + /// + /// Reads the `configProperties` from `*.runtimeconfig.json`, layering the ones from the + /// companion `*.runtimeconfig.dev.json` on top when it exists. + /// + /// `hostfxr` performs this merge when the app starts, but .NET for Android does not use + /// `hostfxr`: the properties are baked into the app at build time and handed to + /// `coreclr_initialize()`, so the merge has to happen here instead. The .NET SDK uses the + /// dev file to turn on Hot Reload switches for `Debug` builds. + /// + public static Dictionary? ParseConfig (string projectRuntimeConfigFilePath, string? projectRuntimeConfigDevFilePath = null) { if (String.IsNullOrEmpty (projectRuntimeConfigFilePath) || !File.Exists (projectRuntimeConfigFilePath)) { return null; } - using var fs = File.OpenRead (projectRuntimeConfigFilePath); + var ret = new Dictionary (StringComparer.OrdinalIgnoreCase); + AddConfigProperties (projectRuntimeConfigFilePath, ret, required: true); + + if (!projectRuntimeConfigDevFilePath.IsNullOrEmpty () && File.Exists (projectRuntimeConfigDevFilePath)) { + // Values from the dev file win, matching `hostfxr` + AddConfigProperties (projectRuntimeConfigDevFilePath, ret, required: false); + } + + return ret; + } + + static void AddConfigProperties (string path, Dictionary properties, bool required) + { + using var fs = File.OpenRead (path); var jsonOptions = new JsonDocumentOptions { AllowTrailingCommas = true, // yes, please! CommentHandling = JsonCommentHandling.Skip, }; using JsonDocument config = JsonDocument.Parse (fs, jsonOptions); - JsonElement runtimeOptions = config.RootElement.GetProperty ("runtimeOptions"); - JsonElement properties = runtimeOptions.GetProperty ("configProperties"); - var ret = new Dictionary (StringComparer.OrdinalIgnoreCase); - foreach (JsonProperty prop in properties.EnumerateObject ()) { + + JsonElement configProperties; + if (required) { + JsonElement runtimeOptions = config.RootElement.GetProperty ("runtimeOptions"); + configProperties = runtimeOptions.GetProperty ("configProperties"); + } else { + // The dev file is generated by the .NET SDK and may legitimately have neither key, + // such as when it only contains `additionalProbingPaths`. + if (!config.RootElement.TryGetProperty ("runtimeOptions", out JsonElement runtimeOptions) || + !runtimeOptions.TryGetProperty ("configProperties", out configProperties)) { + return; + } + } + + foreach (JsonProperty prop in configProperties.EnumerateObject ()) { string? value = GetJsonValueAsString (prop.Value); if (value is not null) { - ret[prop.Name] = value; + properties[prop.Name] = value; } } - - return ret; } static string? GetJsonValueAsString (JsonElement element) => diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 1e57801422e..a7584e36eef 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1748,6 +1748,9 @@ because xbuild doesn't support framework reference assemblies. <_GeneratePackageManagerJavaInputs Include="@(_GenerateJavaStubsInputs)" /> <_GeneratePackageManagerJavaInputs Include="$(_XARemapMembersFilePath)" Condition=" '@(_AndroidRemapMembers->Count())' != '0' " /> + + <_GeneratePackageManagerJavaInputs Include="$(ProjectRuntimeConfigFilePath)" Condition="Exists('$(ProjectRuntimeConfigFilePath)')" /> + <_GeneratePackageManagerJavaInputs Include="$(ProjectRuntimeConfigDevFilePath)" Condition="Exists('$(ProjectRuntimeConfigDevFilePath)')" /> @@ -1819,6 +1822,7 @@ because xbuild doesn't support framework reference assemblies. TargetsCLR="$(_AndroidUseCLR)" AndroidRuntime="$(_AndroidRuntime)" ProjectRuntimeConfigFilePath="$(ProjectRuntimeConfigFilePath)" + ProjectRuntimeConfigDevFilePath="$(ProjectRuntimeConfigDevFilePath)" > diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index e339ca33708..7ee5928f1e7 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -1000,6 +1000,57 @@ public void SubscribeToAppDomainUnhandledException ([Values (AndroidRuntime.Core $"Output did not contain {expectedLogcatOutput}!"); } + [Test] + public void RuntimeConfigDevJsonIsApplied () + { + var proj = new XamarinAndroidApplicationProject (packageName: PackageUtils.MakePackageName (AndroidRuntime.CoreCLR)) { + IsRelease = false, + }; + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetRuntimeIdentifiers (new [] {"arm64-v8a", "x86_64"}); + + // The .NET SDK writes these two switches into `*.runtimeconfig.dev.json` for `Debug` builds and + // nowhere else. `hostfxr` layers that file over `*.runtimeconfig.json` at startup, but .NET for + // Android bakes the properties into the app at build time, so seeing them here proves the dev + // file was picked up. Asking for `$(StartupHookSupport)` to be `false` also proves the dev file + // wins over `*.runtimeconfig.json`. + // + // The SDK we currently build against sets `$(GenerateRuntimeConfigDevFile)` to `false` for + // `net6.0`+, so it does not emit the file and leaves `$(ProjectRuntimeConfigDevFilePath)` empty. + // Write the file and point the property at it ourselves. Both of these can be dropped once we + // pick up an SDK containing https://github.com/dotnet/sdk/pull/53715. + const string devFileName = "runtimeconfig.dev.json"; + proj.OtherBuildItems.Add (new BuildItem ("None", devFileName) { + TextContent = () => """ + { + "runtimeOptions": { + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": true, + "System.StartupHookProvider.IsSupported": true + } + } + } + """, + }); + proj.SetProperty ("ProjectRuntimeConfigDevFilePath", $"$(MSBuildProjectDirectory)/{devFileName}"); + proj.SetProperty ("StartupHookSupport", "false"); + + proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", +@" AppContext.TryGetSwitch (""System.StartupHookProvider.IsSupported"", out bool startupHookProvider); + AppContext.TryGetSwitch (""System.Reflection.Metadata.MetadataUpdater.IsSupported"", out bool metadataUpdater); + Console.WriteLine (""# runtimeconfig.dev.json: StartupHookProvider={0}; MetadataUpdater={1}"", + startupHookProvider, metadataUpdater); +"); + builder = CreateApkBuilder (); + Assert.IsTrue (builder.Install (proj), "Install should have succeeded."); + RunProjectAndAssert (proj, builder); + + const string expectedLogcatOutput = "# runtimeconfig.dev.json: StartupHookProvider=True; MetadataUpdater=True"; + Assert.IsTrue ( + MonitorAdbLogcat (CreateLineChecker (expectedLogcatOutput), + logcatFilePath: Path.Combine (Root, builder.ProjectDirectory, "runtimeconfig-logcat.log"), timeout: 60), + $"Output did not contain {expectedLogcatOutput}!"); + } public static Func CreateLineChecker (string expectedLogcatOutput) { From eac4742ffca309a27f13800b2c7c44c8662cdf05 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Jul 2026 09:01:31 -0500 Subject: [PATCH 2/2] Tolerate a `*.runtimeconfig.json` without `configProperties` `AddConfigProperties (..., required: true)` used `GetProperty ("configProperties")`, which throws `KeyNotFoundException` when the file omits the key. A project that sets no feature switches produces exactly that shape, and it would have prevented `*.runtimeconfig.dev.json` from being applied even though it carries the Hot Reload switches. Drop the `required` flag and always use `TryGetProperty()`, treating a missing `runtimeOptions` or `configProperties` in either file as "no properties". This is also what `hostfxr` does. Not reachable today: `Microsoft.Android.Sdk.CoreCLR.targets` adds four `@(RuntimeHostConfigurationOption)` items unconditionally, so a CoreCLR app always has a non-empty `configProperties`. Fix it anyway so the merge does not depend on that. Add `ConfigWithoutConfigPropertiesStillGetsDevProperties` to cover it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b158ad70-1e5e-44cd-a1f2-60353f3bb560 --- .../Tasks/RuntimePropertiesParserTests.cs | 28 +++++++++++++++++++ .../Utilities/RuntimePropertiesParser.cs | 23 ++++++--------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs index a3128403b32..a30ab349da1 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs @@ -102,4 +102,32 @@ public void MissingDevConfigIsIgnored () Assert.AreEqual ("false", properties ["System.StartupHookProvider.IsSupported"]); } + + [Test] + public void ConfigWithoutConfigPropertiesStillGetsDevProperties () + { + // A project that sets no feature switches gets a `*.runtimeconfig.json` without `configProperties` + File.WriteAllText (runtimeConfigPath, """ + { + "runtimeOptions": { + "tfm": "net11.0" + } + } + """); + File.WriteAllText (runtimeConfigDevPath, """ + { + "runtimeOptions": { + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": true, + "System.StartupHookProvider.IsSupported": true + } + } + } + """); + + var properties = Parse (runtimeConfigPath, runtimeConfigDevPath); + + Assert.AreEqual ("true", properties ["System.Reflection.Metadata.MetadataUpdater.IsSupported"], "Hot Reload should be enabled by the dev file."); + Assert.AreEqual ("true", properties ["System.StartupHookProvider.IsSupported"], "Startup hooks should be enabled by the dev file."); + } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs b/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs index 432e5ffd259..4683ad96522 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs @@ -23,17 +23,17 @@ class RuntimePropertiesParser } var ret = new Dictionary (StringComparer.OrdinalIgnoreCase); - AddConfigProperties (projectRuntimeConfigFilePath, ret, required: true); + AddConfigProperties (projectRuntimeConfigFilePath, ret); if (!projectRuntimeConfigDevFilePath.IsNullOrEmpty () && File.Exists (projectRuntimeConfigDevFilePath)) { // Values from the dev file win, matching `hostfxr` - AddConfigProperties (projectRuntimeConfigDevFilePath, ret, required: false); + AddConfigProperties (projectRuntimeConfigDevFilePath, ret); } return ret; } - static void AddConfigProperties (string path, Dictionary properties, bool required) + static void AddConfigProperties (string path, Dictionary properties) { using var fs = File.OpenRead (path); @@ -43,17 +43,12 @@ static void AddConfigProperties (string path, Dictionary propert }; using JsonDocument config = JsonDocument.Parse (fs, jsonOptions); - JsonElement configProperties; - if (required) { - JsonElement runtimeOptions = config.RootElement.GetProperty ("runtimeOptions"); - configProperties = runtimeOptions.GetProperty ("configProperties"); - } else { - // The dev file is generated by the .NET SDK and may legitimately have neither key, - // such as when it only contains `additionalProbingPaths`. - if (!config.RootElement.TryGetProperty ("runtimeOptions", out JsonElement runtimeOptions) || - !runtimeOptions.TryGetProperty ("configProperties", out configProperties)) { - return; - } + // Either file may legitimately omit both keys: the dev file often only carries + // `additionalProbingPaths`, and a project that sets no feature switches produces a + // `*.runtimeconfig.json` without `configProperties`. Treat both as "no properties". + if (!config.RootElement.TryGetProperty ("runtimeOptions", out JsonElement runtimeOptions) || + !runtimeOptions.TryGetProperty ("configProperties", out JsonElement configProperties)) { + return; } foreach (JsonProperty prop in configProperties.EnumerateObject ()) {