From 151ba2165746553ee56f85f148e4e8c0b91f1dfc Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 17:52:11 +0200 Subject: [PATCH 1/5] [dotnet] Restrict R2R composite roots to CoreLib for Debug iOS/tvOS/MacCatalyst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the CoreLib-only R2R composite mechanism into the macios SDK targets as the default behavior for Debug iOS/tvOS/MacCatalyst CoreCLR builds. The two new targets: - _ConfigureCoreLibOnlyCompositeRoots adds System.Private.CoreLib.dll as the sole root of @(PublishReadyToRunCompositeRoots), so every other framework assembly selected by _SelectR2RAssemblies becomes an unrooted composite input. crossgen2 rewrites the unrooted inputs' component R2R header owner pointer to the local .r2r.dylib (no codegen). Gated on Debug + CoreCLR + iOS/tvOS/MacCatalyst + composite-macho R2R + inner build. - _DedupUnrootedReadyToRunPublish works around an asymmetry in Microsoft.NET.CrossGen.targets where _ReadyToRunCompositeUnrootedBuildInput is not removed from ResolvedFileToPublish (rooted inputs are), causing NETSDK1152 'multiple publish output files with the same relative path'. Gated structurally on any user (or this SDK) having set composite roots. Effect on a Debug iOS bundle: one .r2r.dylib in place of Microsoft.NETCore.App.r2r.dylib + per-module BCL dylibs. Smaller bundle, faster cold startup, faster incremental build. The default can be overridden per-project by declaring PublishReadyToRunCompositeRoots statically in the csproj (or any imported props/targets file) — see the build-properties.md docs and the follow-up commit that enforces this opt-out gate. Release builds and macOS/Mono/NativeAOT targets are not affected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/targets/Microsoft.Sdk.R2R.targets | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/dotnet/targets/Microsoft.Sdk.R2R.targets b/dotnet/targets/Microsoft.Sdk.R2R.targets index 5db954f9fd45..485f33c60e5b 100644 --- a/dotnet/targets/Microsoft.Sdk.R2R.targets +++ b/dotnet/targets/Microsoft.Sdk.R2R.targets @@ -22,6 +22,61 @@ + + + + + + + + + + + + + + + + Date: Thu, 28 May 2026 16:54:26 +0200 Subject: [PATCH 2/5] [dotnet] Make CoreLib-only R2R default a true no-op when user sets composite roots Add '@(PublishReadyToRunCompositeRoots->Count())' == '0' to the _ConfigureCoreLibOnlyCompositeRoots target condition so any user-provided set in csproj/imported props takes precedence over the macios default. The previously documented opt-out idiom (Remove="System.Private.CoreLib.dll") was incorrect: at static evaluation the item group is empty, so the Remove is a no-op, and the target subsequently Includes CoreLib regardless. Replace the comment with the working opt-out pattern (static Include of any roots). Verified locally with two builds: * Default Debug build: target fires, restricts roots to CoreLib only. * Build with Directory.Build.props that statically Includes PublishReadyToRunCompositeRoots: target does NOT fire (no 'Restricting' message), user's roots win. _DedupUnrootedReadyToRunPublish still fires because its gate stays decoupled from this policy decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/targets/Microsoft.Sdk.R2R.targets | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/dotnet/targets/Microsoft.Sdk.R2R.targets b/dotnet/targets/Microsoft.Sdk.R2R.targets index 485f33c60e5b..937ceb9b782b 100644 --- a/dotnet/targets/Microsoft.Sdk.R2R.targets +++ b/dotnet/targets/Microsoft.Sdk.R2R.targets @@ -41,12 +41,17 @@ User assemblies stay in PublishReadyToRunExclude via _SelectR2RAssemblies (they have no upstream R2R headers to begin with). - Opt out per-project for the rare app that wants the all-framework-rooted composite - in Debug (e.g. a native app with a very large BCL surface): - + Override per-project by declaring PublishReadyToRunCompositeRoots statically in + the csproj (or an imported props/targets file). Any user-provided set takes + precedence over this default and this target becomes a no-op: + + + + ... whatever roots you need ... + --> From f2caa67c8f40df4574e62e79f1bd09e745d3140a Mon Sep 17 00:00:00 2001 From: David Nguyen <87228593+davidnguyen-tech@users.noreply.github.com> Date: Thu, 28 May 2026 17:04:48 +0200 Subject: [PATCH 3/5] [tests] Add ReadyToRunCompositeTest covering the CoreLib-only Debug default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests covering the new _ConfigureCoreLibOnlyCompositeRoots and _DedupUnrootedReadyToRunPublish targets in dotnet/targets/Microsoft.Sdk.R2R.targets: 1. Debug_DefaultsToCoreLibOnlyCompositeRoot — builds MySimpleApp Debug for iOS / tvOS / MacCatalyst CoreCLR, asserts both targets ran, and verifies the bundle contains exactly one .r2r.framework with no upstream Microsoft.NETCore.App.r2r.framework or per-module *.r2r.dylib files. 2. Debug_UserSetCompositeRoots_OptsOutOfDefault — builds an inline csproj that statically declares PublishReadyToRunCompositeRoots (Include CoreLib + Linq), asserts _ConfigureCoreLibOnlyCompositeRoots did NOT run (the opt-out gate trips) and _DedupUnrootedReadyToRunPublish DID run (decoupled, fires whenever anyone sets composite roots). 3. Release_DoesNotRestrictCompositeRoots — builds MySimpleApp Release for the same three platforms and asserts the CoreLib-only policy target stays inert (the Configuration=Debug gate keeps the full per-module composite in Release). Single-RID test cases throughout, mirroring the RuntimeIdentifiers == '' clause on _SelectR2RAssemblies that the policy target intentionally shadows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../UnitTests/ReadyToRunCompositeTest.cs | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/dotnet/UnitTests/ReadyToRunCompositeTest.cs diff --git a/tests/dotnet/UnitTests/ReadyToRunCompositeTest.cs b/tests/dotnet/UnitTests/ReadyToRunCompositeTest.cs new file mode 100644 index 000000000000..96b0981e0e3b --- /dev/null +++ b/tests/dotnet/UnitTests/ReadyToRunCompositeTest.cs @@ -0,0 +1,155 @@ +#nullable enable + +namespace Xamarin.Tests { + [TestFixture] + public class ReadyToRunCompositeTest : TestBaseClass { + const string EmptyAppManifest = +@" + + + +CFBundleIdentifier +ID + +"; + + const string EmptyMainFile = +@"using System; +using Foundation; + +class MainClass { + static void Main () + { + Console.WriteLine (typeof (NSObject)); + } +} +"; + + // Verify that Debug builds of CoreCLR iOS/tvOS/MacCatalyst apps default to a + // composite R2R image rooted only on System.Private.CoreLib.dll, so the app + // bundle contains exactly one per-app composite framework rather than the + // upstream Microsoft.NETCore.App.r2r.* set or per-module .r2r.framework files. + [Test] + [TestCase (ApplePlatform.iOS, "ios-arm64")] + [TestCase (ApplePlatform.TVOS, "tvos-arm64")] + [TestCase (ApplePlatform.MacCatalyst, "maccatalyst-arm64")] + public void Debug_DefaultsToCoreLibOnlyCompositeRoot (ApplePlatform platform, string runtimeIdentifiers) + { + var project = "MySimpleApp"; + Configuration.IgnoreIfIgnoredPlatform (platform); + Configuration.AssertRuntimeIdentifiersAvailable (platform, runtimeIdentifiers); + + var configuration = "Debug"; + var project_path = GetProjectPath (project, runtimeIdentifiers: runtimeIdentifiers, platform: platform, out var appPath, configuration: configuration); + Clean (project_path); + + var properties = GetDefaultProperties (runtimeIdentifiers); + properties ["Configuration"] = configuration; + properties ["UseMonoRuntime"] = "false"; + + var rv = DotNet.AssertBuild (project_path, properties); + + var allTargets = BinLog.GetAllTargets (rv.BinLogPath); + AssertTargetExecuted (allTargets, "_ConfigureCoreLibOnlyCompositeRoots", "default Debug CoreCLR build"); + AssertTargetExecuted (allTargets, "_DedupUnrootedReadyToRunPublish", "default Debug CoreCLR build"); + + AssertCoreLibOnlyBundleComposition (platform, appPath, project); + } + + // Verify the policy target skips itself when the user has already set + // PublishReadyToRunCompositeRoots via their csproj/Directory.Build.props. + // The dedup target still fires (it is decoupled from the policy decision + // and exists purely to work around the NETSDK1152 asymmetry in + // Microsoft.NET.CrossGen.targets). + [Test] + [TestCase (ApplePlatform.iOS, "ios-arm64")] + [TestCase (ApplePlatform.TVOS, "tvos-arm64")] + [TestCase (ApplePlatform.MacCatalyst, "maccatalyst-arm64")] + public void Debug_UserSetCompositeRoots_OptsOutOfDefault (ApplePlatform platform, string runtimeIdentifiers) + { + Configuration.IgnoreIfIgnoredPlatform (platform); + Configuration.AssertRuntimeIdentifiersAvailable (platform, runtimeIdentifiers); + + var tmpdir = Cache.CreateTemporaryDirectory (); + var csproj = $@" + + {platform.ToFramework ()} + Exe + + + + + +"; + + var project_path = Path.Combine (tmpdir, "TestProject.csproj"); + File.WriteAllText (project_path, csproj); + File.WriteAllText (Path.Combine (tmpdir, "Info.plist"), EmptyAppManifest); + File.WriteAllText (Path.Combine (tmpdir, "Main.cs"), EmptyMainFile); + + var properties = GetDefaultProperties (runtimeIdentifiers); + properties ["Configuration"] = "Debug"; + properties ["UseMonoRuntime"] = "false"; + + var rv = DotNet.AssertBuild (project_path, properties); + + var allTargets = BinLog.GetAllTargets (rv.BinLogPath); + AssertTargetNotExecuted (allTargets, "_ConfigureCoreLibOnlyCompositeRoots", "user-set composite roots opt out"); + AssertTargetExecuted (allTargets, "_DedupUnrootedReadyToRunPublish", "dedup target stays active whenever composite roots are set"); + } + + // Verify Release CoreCLR builds are unchanged: the CoreLib-only policy target + // is gated to Debug and must not fire in Release, where the full per-module + // composite is still the intended behavior. + [Test] + [TestCase (ApplePlatform.iOS, "ios-arm64")] + [TestCase (ApplePlatform.TVOS, "tvos-arm64")] + [TestCase (ApplePlatform.MacCatalyst, "maccatalyst-arm64")] + public void Release_DoesNotRestrictCompositeRoots (ApplePlatform platform, string runtimeIdentifiers) + { + var project = "MySimpleApp"; + Configuration.IgnoreIfIgnoredPlatform (platform); + Configuration.AssertRuntimeIdentifiersAvailable (platform, runtimeIdentifiers); + + var configuration = "Release"; + var project_path = GetProjectPath (project, runtimeIdentifiers: runtimeIdentifiers, platform: platform, out _, configuration: configuration); + Clean (project_path); + + var properties = GetDefaultProperties (runtimeIdentifiers); + properties ["Configuration"] = configuration; + properties ["UseMonoRuntime"] = "false"; + + var rv = DotNet.AssertBuild (project_path, properties); + + var allTargets = BinLog.GetAllTargets (rv.BinLogPath); + AssertTargetNotExecuted (allTargets, "_ConfigureCoreLibOnlyCompositeRoots", "Release CoreCLR build must keep full per-module composite"); + } + + void AssertCoreLibOnlyBundleComposition (ApplePlatform platform, string appPath, string applicationName) + { + Assert.That (appPath, Does.Exist, "App bundle directory"); + + var frameworksDir = Path.Combine (appPath, GetFrameworksRelativePath (platform)); + Assert.That (frameworksDir, Does.Exist, "Frameworks directory"); + + var r2rFrameworks = Directory.GetDirectories (frameworksDir, "*.r2r.framework"); + Assert.That (r2rFrameworks.Length, Is.EqualTo (1), + $"Expected exactly one .r2r.framework (the per-app composite), found:\n {string.Join ("\n ", r2rFrameworks)}"); + + var perAppName = applicationName + ".r2r.framework"; + Assert.That (Path.GetFileName (r2rFrameworks [0]), Is.EqualTo (perAppName), + $"The single .r2r.framework should be the per-app composite ({perAppName})"); + + // The upstream Microsoft.NETCore.App.r2r.* (.framework on iOS/tvOS/MacCatalyst, + // .dylib on macOS) must not be carried into the bundle: every non-CoreLib BCL + // assembly was re-headered to point at the per-app composite instead. + var leakedNetCoreFrameworks = Directory.GetDirectories (frameworksDir, "Microsoft.NETCore.App.r2r*"); + Assert.That (leakedNetCoreFrameworks, Is.Empty, + $"Bundle must not contain upstream Microsoft.NETCore.App.r2r framework(s):\n {string.Join ("\n ", leakedNetCoreFrameworks)}"); + + var leakedR2RDylibs = Directory.GetFiles (appPath, "*.r2r.dylib", SearchOption.AllDirectories); + Assert.That (leakedR2RDylibs, Is.Empty, + $"Bundle must not contain any *.r2r.dylib files on iOS/tvOS/MacCatalyst:\n {string.Join ("\n ", leakedR2RDylibs)}"); + } + } +} From 72e04052e3924d53d5ff93755c7fb1dd250b5d27 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 17:52:12 +0200 Subject: [PATCH 4/5] [docs] Document the Debug CoreLib-only R2R default + opt-out Add a BreakingChanges.md note for the Debug iOS/tvOS/MacCatalyst CoreCLR bundle composition change: the bundle now contains a single per-app .r2r.framework instead of the upstream Microsoft.NETCore.App.r2r.framework plus per-module BCL .r2r.framework bundles. Include the Include-based opt-out recipe for users who need to restore the previous all-rooted composite, and call out that macOS / NativeAOT / Mono / Release builds are unaffected. PublishReadyToRunCompositeRoots itself is defined by the .NET SDK in Microsoft.NET.CrossGen.targets; this commit only documents the macios-side behavior change, not the SDK item group. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/BreakingChanges.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/dotnet/BreakingChanges.md b/dotnet/BreakingChanges.md index f90444eaee7b..a04390f6490b 100644 --- a/dotnet/BreakingChanges.md +++ b/dotnet/BreakingChanges.md @@ -212,3 +212,38 @@ for tvOS apps). The version format has also changed, from "X.Y.Z.W (`branch`: `hash`)" to the semantic versioning we use for .NET: "X.Y.Z-`branch`.Z+sha.`hash`". + +## Debug iOS / tvOS / Mac Catalyst CoreCLR bundle composition + +Debug iOS, tvOS, and Mac Catalyst apps built on CoreCLR now restrict the +composite ReadyToRun (R2R) image to root only on `System.Private.CoreLib.dll`. +Every other R2R-eligible framework assembly is re-headered to point at the +single per-app composite, so the upstream `Microsoft.NETCore.App.r2r.framework` +and per-module BCL `.r2r.framework` bundles are no longer carried into the app +bundle. The bundle now contains a single `.r2r.framework` instead. + +This change reduces Debug bundle size and improves cold startup and incremental +build times, but it changes the on-disk shape of the Debug bundle: + +* `Microsoft.NETCore.App.r2r.framework` is no longer present. +* Per-module BCL R2R frameworks (e.g. `System.Linq.r2r.framework`) are no + longer present. +* A single `.r2r.framework` aggregates the R2R code path. + +Apps that introspect their own Debug bundle structure may need adjustment. +macOS, NativeAOT, Mono, and Release builds are unaffected. + +To opt out and restore the previous behavior (every R2R-eligible assembly is a +root), declare the underlying SDK item group statically in the project: + +```xml + + + + + +``` + +Any user-provided `PublishReadyToRunCompositeRoots` takes precedence over the +macios default. + From 8fefd6a6fe5f9be0da61d41b3a23747987d515c8 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 16:22:22 +0200 Subject: [PATCH 5/5] [tests] Fix Mac Catalyst bundle-composition assertions in ReadyToRunCompositeTest The Debug_DefaultsToCoreLibOnlyCompositeRoot test ran for iOS, tvOS and Mac Catalyst but AssertCoreLibOnlyBundleComposition only understood the iOS/tvOS shape (a .r2r.framework under Frameworks/). Mac Catalyst packages the composite as a .r2r.dylib under Contents/MonoBundle/ instead (see _CreateR2RModuleDylibs and the MacCatalyst-CoreCLR-R2R-size.txt baseline), so the Mac Catalyst case would have failed both the "exactly one .r2r.framework" and the "no *.r2r.dylib anywhere" assertions. Make the helper platform-aware: assert the framework shape for iOS/tvOS and the MonoBundle dylib shape for Mac Catalyst, and in both cases assert the upstream Microsoft.NETCore.App.r2r.* image is not carried into the bundle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../UnitTests/ReadyToRunCompositeTest.cs | 75 +++++++++++++------ 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/tests/dotnet/UnitTests/ReadyToRunCompositeTest.cs b/tests/dotnet/UnitTests/ReadyToRunCompositeTest.cs index 96b0981e0e3b..c182f23a45c9 100644 --- a/tests/dotnet/UnitTests/ReadyToRunCompositeTest.cs +++ b/tests/dotnet/UnitTests/ReadyToRunCompositeTest.cs @@ -129,27 +129,60 @@ void AssertCoreLibOnlyBundleComposition (ApplePlatform platform, string appPath, { Assert.That (appPath, Does.Exist, "App bundle directory"); - var frameworksDir = Path.Combine (appPath, GetFrameworksRelativePath (platform)); - Assert.That (frameworksDir, Does.Exist, "Frameworks directory"); - - var r2rFrameworks = Directory.GetDirectories (frameworksDir, "*.r2r.framework"); - Assert.That (r2rFrameworks.Length, Is.EqualTo (1), - $"Expected exactly one .r2r.framework (the per-app composite), found:\n {string.Join ("\n ", r2rFrameworks)}"); - - var perAppName = applicationName + ".r2r.framework"; - Assert.That (Path.GetFileName (r2rFrameworks [0]), Is.EqualTo (perAppName), - $"The single .r2r.framework should be the per-app composite ({perAppName})"); - - // The upstream Microsoft.NETCore.App.r2r.* (.framework on iOS/tvOS/MacCatalyst, - // .dylib on macOS) must not be carried into the bundle: every non-CoreLib BCL - // assembly was re-headered to point at the per-app composite instead. - var leakedNetCoreFrameworks = Directory.GetDirectories (frameworksDir, "Microsoft.NETCore.App.r2r*"); - Assert.That (leakedNetCoreFrameworks, Is.Empty, - $"Bundle must not contain upstream Microsoft.NETCore.App.r2r framework(s):\n {string.Join ("\n ", leakedNetCoreFrameworks)}"); - - var leakedR2RDylibs = Directory.GetFiles (appPath, "*.r2r.dylib", SearchOption.AllDirectories); - Assert.That (leakedR2RDylibs, Is.Empty, - $"Bundle must not contain any *.r2r.dylib files on iOS/tvOS/MacCatalyst:\n {string.Join ("\n ", leakedR2RDylibs)}"); + // iOS/tvOS package the per-app composite as a .r2r.framework under + // Frameworks/; Mac Catalyst packages it as a .r2r.dylib under + // Contents/MonoBundle/ (see _CreateR2RModuleFrameworks vs + // _CreateR2RModuleDylibs in Microsoft.Sdk.R2R.targets, and the + // -CoreCLR-R2R-size.txt baselines). + switch (platform) { + case ApplePlatform.iOS: + case ApplePlatform.TVOS: + var frameworksDir = Path.Combine (appPath, GetFrameworksRelativePath (platform)); + Assert.That (frameworksDir, Does.Exist, "Frameworks directory"); + + var r2rFrameworks = Directory.GetDirectories (frameworksDir, "*.r2r.framework"); + Assert.That (r2rFrameworks.Length, Is.EqualTo (1), + $"Expected exactly one .r2r.framework (the per-app composite), found:\n {string.Join ("\n ", r2rFrameworks)}"); + + var perAppFramework = applicationName + ".r2r.framework"; + Assert.That (Path.GetFileName (r2rFrameworks [0]), Is.EqualTo (perAppFramework), + $"The single .r2r.framework should be the per-app composite ({perAppFramework})"); + + // The upstream Microsoft.NETCore.App.r2r.framework must not be carried + // into the bundle: every non-CoreLib BCL assembly was re-headered to + // point at the per-app composite instead. + var leakedNetCoreFrameworks = Directory.GetDirectories (frameworksDir, "Microsoft.NETCore.App.r2r*"); + Assert.That (leakedNetCoreFrameworks, Is.Empty, + $"Bundle must not contain upstream Microsoft.NETCore.App.r2r framework(s):\n {string.Join ("\n ", leakedNetCoreFrameworks)}"); + + // On iOS/tvOS the composite ships as a framework whose binary is + // .r2r (no extension), so no *.r2r.dylib should be present. + var leakedR2RDylibs = Directory.GetFiles (appPath, "*.r2r.dylib", SearchOption.AllDirectories); + Assert.That (leakedR2RDylibs, Is.Empty, + $"Bundle must not contain any *.r2r.dylib files on iOS/tvOS:\n {string.Join ("\n ", leakedR2RDylibs)}"); + break; + case ApplePlatform.MacCatalyst: + var monoBundleDir = Path.Combine (appPath, "Contents", "MonoBundle"); + Assert.That (monoBundleDir, Does.Exist, "MonoBundle directory"); + + var r2rDylibs = Directory.GetFiles (monoBundleDir, "*.r2r.dylib"); + Assert.That (r2rDylibs.Length, Is.EqualTo (1), + $"Expected exactly one .r2r.dylib (the per-app composite), found:\n {string.Join ("\n ", r2rDylibs)}"); + + var perAppDylib = applicationName + ".r2r.dylib"; + Assert.That (Path.GetFileName (r2rDylibs [0]), Is.EqualTo (perAppDylib), + $"The single .r2r.dylib should be the per-app composite ({perAppDylib})"); + + // The upstream Microsoft.NETCore.App.r2r.dylib must not be carried into + // the bundle: every non-CoreLib BCL assembly was re-headered to point at + // the per-app composite instead. + var leakedNetCoreDylibs = Directory.GetFiles (monoBundleDir, "Microsoft.NETCore.App.r2r*"); + Assert.That (leakedNetCoreDylibs, Is.Empty, + $"Bundle must not contain upstream Microsoft.NETCore.App.r2r dylib(s):\n {string.Join ("\n ", leakedNetCoreDylibs)}"); + break; + default: + throw new ArgumentOutOfRangeException (nameof (platform), platform, "Unsupported platform for CoreLib-only R2R bundle composition"); + } } } }