From 348dfbe34308d22d381ec67ee4b660dfaf5f8fa9 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Tue, 28 Jul 2026 16:29:22 +0200 Subject: [PATCH 1/3] [msbuild] Merge runtimeconfig.dev.json into the runtime configuration. We bake the runtime configuration directly into the app and hand it to the runtime, bypassing hostfxr. hostfxr is what would normally merge the companion '*.runtimeconfig.dev.json' file (where the .NET SDK writes Debug-only switches such as Hot Reload's System.StartupHookProvider.IsSupported and System.Reflection.Metadata.MetadataUpdater.IsSupported) into the main runtime configuration, so those switches never reached the runtime. Add a new MergeRuntimeConfigFiles MSBuild task that overlays the dev file's 'runtimeOptions.configProperties' on top of the main file's (dev wins, matching hostfxr), and feed the merged file to the existing RuntimeConfigParserTask. Fixes https://github.com/dotnet/macios/issues/26330 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/targets/Xamarin.Shared.Sdk.targets | 24 +++- .../MSBStrings.resx | 5 + .../Tasks/MergeRuntimeConfigFiles.cs | 72 +++++++++++ .../MergeRuntimeConfigFilesTaskTest.cs | 114 ++++++++++++++++++ 4 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs create mode 100644 tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs diff --git a/dotnet/targets/Xamarin.Shared.Sdk.targets b/dotnet/targets/Xamarin.Shared.Sdk.targets index 92a833c8baae..056654f3f880 100644 --- a/dotnet/targets/Xamarin.Shared.Sdk.targets +++ b/dotnet/targets/Xamarin.Shared.Sdk.targets @@ -26,6 +26,7 @@ + @@ -1593,10 +1594,13 @@ + + <_MergedRuntimeConfigFilePath>$(DeviceSpecificIntermediateOutputPath)runtimeconfig.merged.json + <_RuntimeConfigReservedProperties Include="APP_PATHS" /> @@ -1608,9 +1612,25 @@ <_RuntimeConfigReservedProperties Include="SYSTEM_CORELIB_DIRECTORY" /> <_RuntimeConfigReservedProperties Include="STARTUP_HOOKS" /> - + + + + + + + + diff --git a/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx b/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx index 808b49311e39..7d9e5518907a 100644 --- a/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx +++ b/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx @@ -1705,4 +1705,9 @@ Codesign failed with 'errSecInternalComponent'. This usually means the keychain is locked, which is common when building over SSH. Unlock the keychain first, for example by running 'security unlock-keychain ~/Library/Keychains/login.keychain-db'. Shown when codesign fails with the 'errSecInternalComponent' error, which typically indicates a locked keychain. + + The runtime configuration file '{0}' is not a valid JSON object. + Shown when the main runtime configuration file (*.runtimeconfig.json) does not contain a valid JSON object at its root. +{0} - The path to the runtime configuration file. + diff --git a/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs b/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs new file mode 100644 index 000000000000..6d62ee82ae09 --- /dev/null +++ b/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; + +using Microsoft.Build.Framework; + +#nullable enable + +namespace Xamarin.MacDev.Tasks { + // This task merges the 'runtimeOptions.configProperties' from a '*.runtimeconfig.dev.json' file on top of + // the ones from the main '*.runtimeconfig.json' file, writing the merged result to an output file. + // + // We do this because we hand the runtime configuration directly to the runtime (bypassing hostfxr), and + // hostfxr is what would normally merge the dev file (which contains Debug-only switches such as Hot Reload) + // into the main runtime configuration. The dev values win, matching hostfxr's behavior. + public class MergeRuntimeConfigFiles : XamarinTask { + [Required] + public string RuntimeConfigFile { get; set; } = ""; + + public string? RuntimeConfigDevFile { get; set; } + + [Required] + public string OutputFile { get; set; } = ""; + + static readonly JsonDocumentOptions documentOptions = new JsonDocumentOptions { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + public override bool Execute () + { + var mainNode = JsonNode.Parse (File.ReadAllText (RuntimeConfigFile), documentOptions: documentOptions); + if (mainNode is not JsonObject mainObject) { + Log.LogError (MSBStrings.E7185 /* The runtime configuration file '{0}' is not a valid JSON object. */, RuntimeConfigFile); + return false; + } + + if (!string.IsNullOrEmpty (RuntimeConfigDevFile) && File.Exists (RuntimeConfigDevFile)) { + var devNode = JsonNode.Parse (File.ReadAllText (RuntimeConfigDevFile!), documentOptions: documentOptions); + var devConfigProperties = (devNode as JsonObject)? ["runtimeOptions"]? ["configProperties"] as JsonObject; + if (devConfigProperties is not null && devConfigProperties.Count > 0) { + var runtimeOptions = mainObject ["runtimeOptions"] as JsonObject; + if (runtimeOptions is null) { + runtimeOptions = new JsonObject (); + mainObject ["runtimeOptions"] = runtimeOptions; + } + + var configProperties = runtimeOptions ["configProperties"] as JsonObject; + if (configProperties is null) { + configProperties = new JsonObject (); + runtimeOptions ["configProperties"] = configProperties; + } + + foreach (var property in devConfigProperties) { + configProperties [property.Key] = property.Value is null ? null : JsonNode.Parse (property.Value.ToJsonString ()); + } + } + } + + var outputDirectory = Path.GetDirectoryName (OutputFile); + if (!string.IsNullOrEmpty (outputDirectory)) + Directory.CreateDirectory (outputDirectory!); + + File.WriteAllText (OutputFile, mainObject.ToJsonString (new JsonSerializerOptions { WriteIndented = true })); + + return !Log.HasLoggedErrors; + } + } +} diff --git a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs new file mode 100644 index 000000000000..a907366633d2 --- /dev/null +++ b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; + +using NUnit.Framework; + +#nullable enable + +namespace Xamarin.MacDev.Tasks { + + // Note: we can't use System.Text.Json types here to inspect the output, because the + // Xamarin.MacDev.Tasks assembly under test is ILMerged and also exposes System.Text.Json, + // which causes ambiguous type references. We assert on the raw JSON text instead. + [TestFixture] + public class MergeRuntimeConfigFilesTaskTest : TestBase { + + string RunMerge (string mainJson, string? devJson) + { + var tmp = Cache.CreateTemporaryDirectory (); + var mainFile = Path.Combine (tmp, "app.runtimeconfig.json"); + File.WriteAllText (mainFile, mainJson); + + string? devFile = null; + if (devJson is not null) { + devFile = Path.Combine (tmp, "app.runtimeconfig.dev.json"); + File.WriteAllText (devFile, devJson); + } + + var outputFile = Path.Combine (tmp, "obj", "runtimeconfig.merged.json"); + + var task = CreateTask (); + task.RuntimeConfigFile = mainFile; + task.RuntimeConfigDevFile = devFile; + task.OutputFile = outputFile; + + ExecuteTask (task); + + Assert.That (outputFile, Does.Exist, "output file created"); + return File.ReadAllText (outputFile); + } + + [Test] + public void MergesDevPropertiesOntoMain () + { + var mainJson = @"{ + ""runtimeOptions"": { + ""tfm"": ""net10.0"", + ""configProperties"": { + ""OnlyInMain"": true, + ""InBoth"": ""main-value"" + } + } +}"; + var devJson = @"{ + ""runtimeOptions"": { + ""configProperties"": { + ""OnlyInDev"": ""dev-only"", + ""InBoth"": ""dev-value"" + } + } +}"; + + var merged = RunMerge (mainJson, devJson); + + // (a) properties only in main are preserved + Assert.That (merged, Does.Contain ("\"OnlyInMain\": true"), "OnlyInMain preserved"); + // (b) properties only in dev are added + Assert.That (merged, Does.Contain ("\"OnlyInDev\": \"dev-only\""), "OnlyInDev added"); + // (c) properties in both are taken from dev (dev wins) + Assert.That (merged, Does.Contain ("\"InBoth\": \"dev-value\""), "dev wins for InBoth"); + Assert.That (merged, Does.Not.Contain ("main-value"), "main value for InBoth is gone"); + } + + [Test] + public void NoDevFile () + { + var mainJson = @"{ + ""runtimeOptions"": { + ""configProperties"": { + ""OnlyInMain"": true + } + } +}"; + + var merged = RunMerge (mainJson, null); + + Assert.That (merged, Does.Contain ("\"OnlyInMain\": true"), "OnlyInMain preserved"); + } + + [Test] + public void DevFileWithoutConfigProperties () + { + var mainJson = @"{ + ""runtimeOptions"": { + ""configProperties"": { + ""OnlyInMain"": true + } + } +}"; + var devJson = @"{ + ""runtimeOptions"": { + ""additionalProbingPaths"": [ ""/some/path"" ] + } +}"; + + var merged = RunMerge (mainJson, devJson); + + Assert.That (merged, Does.Contain ("\"OnlyInMain\": true"), "OnlyInMain preserved"); + // The dev file had no configProperties, so nothing from it should have leaked in. + Assert.That (merged, Does.Not.Contain ("additionalProbingPaths"), "dev-only non-configProperties content is not merged"); + } + } +} From f35ef87c86ef1b17bfecef23bcf188f4739619d9 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Tue, 28 Jul 2026 16:52:19 +0200 Subject: [PATCH 2/3] [msbuild] Address review: drop null-forgiving operator, guard parsing, DeepClone. Address Copilot review feedback on MergeRuntimeConfigFiles: - Remove the banned null-forgiving (!) operator; use explicit null/length checks (netstandard2.0's string.IsNullOrEmpty isn't null-annotated). - Catch read/parse failures and report them as an MSBuild error (E7186) instead of letting the task crash. - Clone dev values with JsonNode.DeepClone () instead of serialize+parse. - Add a regression test for the invalid-JSON error path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../MSBStrings.resx | 6 ++++ .../Tasks/MergeRuntimeConfigFiles.cs | 33 +++++++++++++------ .../MergeRuntimeConfigFilesTaskTest.cs | 15 +++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx b/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx index 7d9e5518907a..a635570bc69b 100644 --- a/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx +++ b/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx @@ -1710,4 +1710,10 @@ Shown when the main runtime configuration file (*.runtimeconfig.json) does not contain a valid JSON object at its root. {0} - The path to the runtime configuration file. + + Could not read the runtime configuration file '{0}': {1} + Shown when a runtime configuration file (*.runtimeconfig.json or *.runtimeconfig.dev.json) can't be read or parsed. +{0} - The path to the runtime configuration file. +{1} - The error message. + diff --git a/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs b/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs index 6d62ee82ae09..9cf704652388 100644 --- a/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs +++ b/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.IO; using System.Text.Json; using System.Text.Json.Nodes; @@ -30,16 +31,29 @@ public class MergeRuntimeConfigFiles : XamarinTask { CommentHandling = JsonCommentHandling.Skip, }; + JsonNode? TryParse (string path) + { + try { + return JsonNode.Parse (File.ReadAllText (path), documentOptions: documentOptions); + } catch (Exception e) { + Log.LogError (MSBStrings.E7186 /* Could not read the runtime configuration file '{0}': {1} */, path, e.Message); + return null; + } + } + public override bool Execute () { - var mainNode = JsonNode.Parse (File.ReadAllText (RuntimeConfigFile), documentOptions: documentOptions); - if (mainNode is not JsonObject mainObject) { - Log.LogError (MSBStrings.E7185 /* The runtime configuration file '{0}' is not a valid JSON object. */, RuntimeConfigFile); + if (TryParse (RuntimeConfigFile) is not JsonObject mainObject) { + if (!Log.HasLoggedErrors) + Log.LogError (MSBStrings.E7185 /* The runtime configuration file '{0}' is not a valid JSON object. */, RuntimeConfigFile); return false; } - if (!string.IsNullOrEmpty (RuntimeConfigDevFile) && File.Exists (RuntimeConfigDevFile)) { - var devNode = JsonNode.Parse (File.ReadAllText (RuntimeConfigDevFile!), documentOptions: documentOptions); + var devFile = RuntimeConfigDevFile; + if (devFile is not null && devFile.Length > 0 && File.Exists (devFile)) { + var devNode = TryParse (devFile); + if (devNode is null) + return false; // TryParse already logged an error. var devConfigProperties = (devNode as JsonObject)? ["runtimeOptions"]? ["configProperties"] as JsonObject; if (devConfigProperties is not null && devConfigProperties.Count > 0) { var runtimeOptions = mainObject ["runtimeOptions"] as JsonObject; @@ -54,15 +68,14 @@ public override bool Execute () runtimeOptions ["configProperties"] = configProperties; } - foreach (var property in devConfigProperties) { - configProperties [property.Key] = property.Value is null ? null : JsonNode.Parse (property.Value.ToJsonString ()); - } + foreach (var property in devConfigProperties) + configProperties [property.Key] = property.Value?.DeepClone (); } } var outputDirectory = Path.GetDirectoryName (OutputFile); - if (!string.IsNullOrEmpty (outputDirectory)) - Directory.CreateDirectory (outputDirectory!); + if (outputDirectory is not null && outputDirectory.Length > 0) + Directory.CreateDirectory (outputDirectory); File.WriteAllText (OutputFile, mainObject.ToJsonString (new JsonSerializerOptions { WriteIndented = true })); diff --git a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs index a907366633d2..ded7dc3c5584 100644 --- a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs +++ b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs @@ -110,5 +110,20 @@ public void DevFileWithoutConfigProperties () // The dev file had no configProperties, so nothing from it should have leaked in. Assert.That (merged, Does.Not.Contain ("additionalProbingPaths"), "dev-only non-configProperties content is not merged"); } + + [Test] + public void InvalidMainJsonLogsError () + { + var tmp = Cache.CreateTemporaryDirectory (); + var mainFile = Path.Combine (tmp, "app.runtimeconfig.json"); + File.WriteAllText (mainFile, "this is not json {"); + + var task = CreateTask (); + task.RuntimeConfigFile = mainFile; + task.OutputFile = Path.Combine (tmp, "obj", "runtimeconfig.merged.json"); + + // The task must report an MSBuild error rather than throwing. + ExecuteTask (task, 1); + } } } From eb03d7b1905dcbdacf1c1faa22abb26d401ae8bb Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Tue, 28 Jul 2026 18:38:20 +0200 Subject: [PATCH 3/3] [msbuild] Address review: use Stream overloads for reading/writing json. Per review feedback, avoid materializing the whole runtimeconfig file as a string: parse directly from a FileStream (JsonNode.Parse (Stream)) and write the merged result through a Utf8JsonWriter over a FileStream instead of File.ReadAllText / File.WriteAllText. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs b/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs index 9cf704652388..8e0b7b66d4a5 100644 --- a/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs +++ b/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs @@ -34,7 +34,8 @@ public class MergeRuntimeConfigFiles : XamarinTask { JsonNode? TryParse (string path) { try { - return JsonNode.Parse (File.ReadAllText (path), documentOptions: documentOptions); + using var stream = File.OpenRead (path); + return JsonNode.Parse (stream, documentOptions: documentOptions); } catch (Exception e) { Log.LogError (MSBStrings.E7186 /* Could not read the runtime configuration file '{0}': {1} */, path, e.Message); return null; @@ -77,7 +78,9 @@ public override bool Execute () if (outputDirectory is not null && outputDirectory.Length > 0) Directory.CreateDirectory (outputDirectory); - File.WriteAllText (OutputFile, mainObject.ToJsonString (new JsonSerializerOptions { WriteIndented = true })); + using (var stream = File.Create (OutputFile)) + using (var writer = new Utf8JsonWriter (stream, new JsonWriterOptions { Indented = true })) + mainObject.WriteTo (writer); return !Log.HasLoggedErrors; }