From 3edd00a676f737216a5941ae340eeffb89865e3d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 13:10:10 +0200 Subject: [PATCH 1/9] [CoreCLR] Use no-GC region during Android startup Start a bounded 24 MiB no-GC region during CoreCLR initialization and end it after Activity.ReportFullyDrawn. Preserve the generated binding contract manually and use a timeout plus collection tracking for safe fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Android.App/Activity.cs | 41 +++- .../Android.Runtime/JNIEnvInit.cs | 4 + .../Android.Runtime/StartupNoGCRegion.cs | 129 +++++++++++ src/Mono.Android/Mono.Android.csproj | 1 + src/Mono.Android/metadata | 1 + .../Android.Runtime/StartupNoGCRegionTest.cs | 207 ++++++++++++++++++ .../Mono.Android.NET-Tests.csproj | 1 + 7 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs create mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/StartupNoGCRegionTest.cs diff --git a/src/Mono.Android/Android.App/Activity.cs b/src/Mono.Android/Android.App/Activity.cs index 4fd2a108c7f..e0b19344095 100644 --- a/src/Mono.Android/Android.App/Activity.cs +++ b/src/Mono.Android/Android.App/Activity.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Runtime.Versioning; using Android.Runtime; +using RuntimeFeature = Microsoft.Android.Runtime.RuntimeFeature; namespace Android.App { @@ -39,7 +41,42 @@ public void RunOnUiThread (Action action) { RunOnUiThread (new Java.Lang.Thread.RunnableImplementor (action)); } - } -} + [SupportedOSPlatform ("android19.0")] + [Register ("reportFullyDrawn", "()V", "GetReportFullyDrawnHandler")] + public virtual unsafe void ReportFullyDrawn () + { + const string id = "reportFullyDrawn.()V"; + try { + _members.InstanceMethods.InvokeVirtualVoidMethod (id, this, null); + } finally { + if (RuntimeFeature.IsCoreClrRuntime) { + StartupNoGCRegion.End (); + } + } + } + + static Delegate? cb_reportFullyDrawn_ReportFullyDrawn_V; + + static Delegate GetReportFullyDrawnHandler () + { + return cb_reportFullyDrawn_ReportFullyDrawn_V ??= new _JniMarshal_PP_V (n_ReportFullyDrawn); + } + + static void n_ReportFullyDrawn (IntPtr jnienv, IntPtr native__this) + { + unsafe { + Java.Interop.JniMarshal.SafeInvokeAction (jnienv, native__this, &__n_ReportFullyDrawn); + } + } + static void __n_ReportFullyDrawn (IntPtr jnienv, IntPtr native__this) + { + var activity = Java.Lang.Object.GetObject (jnienv, native__this, JniHandleOwnership.DoNotTransfer); + if (activity == null) { + throw new InvalidOperationException ("Could not obtain the managed Activity instance for reportFullyDrawn."); + } + activity.ReportFullyDrawn (); + } + } +} diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs index 9e6c6fa599d..d6ffce936be 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs @@ -126,6 +126,10 @@ internal static unsafe void Initialize (JnienvInitializeArgs* args) throw new NotSupportedException ("Internal error: exactly one of RuntimeFeature.IsMonoRuntime or RuntimeFeature.IsCoreClrRuntime must be enabled."); } + if (RuntimeFeature.IsCoreClrRuntime) { + StartupNoGCRegion.Start (); + } + IntPtr total_timing_sequence = IntPtr.Zero; IntPtr partial_timing_sequence = IntPtr.Zero; diff --git a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs new file mode 100644 index 00000000000..b67a3e759d6 --- /dev/null +++ b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; + +namespace Android.Runtime; + +sealed class StartupNoGCRegion +{ + const long Budget = 24 * 1024 * 1024; + // Bound the process-wide region when an app never reports that startup is fully drawn. + static readonly TimeSpan DefaultFallbackTimeout = TimeSpan.FromSeconds (10); + static readonly StartupNoGCRegion instance = new ( + static (totalSize, disallowFullBlockingGC) => GC.TryStartNoGCRegion (totalSize, disallowFullBlockingGC), + GC.EndNoGCRegion, + GC.CollectionCount, + DefaultFallbackTimeout + ); + + readonly object sync = new (); + readonly Func tryStartNoGCRegion; + readonly Action endNoGCRegion; + readonly Func collectionCount; + readonly TimeSpan fallbackTimeout; + Timer? fallbackTimer; + State state; + int gen0CollectionCount; + int gen1CollectionCount; + int gen2CollectionCount; + + enum State + { + NotStarted, + Active, + Ended, + } + + internal StartupNoGCRegion ( + Func tryStartNoGCRegion, + Action endNoGCRegion, + Func collectionCount, + TimeSpan fallbackTimeout + ) + { + ArgumentNullException.ThrowIfNull (tryStartNoGCRegion); + ArgumentNullException.ThrowIfNull (endNoGCRegion); + ArgumentNullException.ThrowIfNull (collectionCount); + + this.tryStartNoGCRegion = tryStartNoGCRegion; + this.endNoGCRegion = endNoGCRegion; + this.collectionCount = collectionCount; + this.fallbackTimeout = fallbackTimeout; + } + + internal static void Start () => instance.Start (isCoreClrRuntime: true); + + internal static void End () => instance.Finish (); + + internal void Start (bool isCoreClrRuntime) + { + if (!isCoreClrRuntime) { + return; + } + + lock (sync) { + if (state != State.NotStarted) { + return; + } + + bool started; + try { + started = tryStartNoGCRegion (Budget, true); + } catch (InvalidOperationException) { + state = State.Ended; + return; + } + + if (!started) { + state = State.Ended; + return; + } + + gen0CollectionCount = collectionCount (0); + gen1CollectionCount = collectionCount (1); + gen2CollectionCount = collectionCount (2); + fallbackTimer = new Timer ( + static value => { + if (value is StartupNoGCRegion noGCRegion) { + noGCRegion.Finish (); + } + }, + this, + fallbackTimeout, + Timeout.InfiniteTimeSpan + ); + state = State.Active; + } + } + + internal void Finish () + { + Timer? timer; + bool collectionOccurred; + lock (sync) { + if (state != State.Active) { + return; + } + + state = State.Ended; + timer = fallbackTimer; + fallbackTimer = null; + collectionOccurred = + collectionCount (0) != gen0CollectionCount || + collectionCount (1) != gen1CollectionCount || + collectionCount (2) != gen2CollectionCount; + } + + timer?.Dispose (); + + if (collectionOccurred) { + return; + } + + try { + endNoGCRegion (); + } catch (InvalidOperationException) { + // The runtime already left the region because its budget was exhausted + // or a collection was induced. + } + } +} diff --git a/src/Mono.Android/Mono.Android.csproj b/src/Mono.Android/Mono.Android.csproj index a8b5764cad1..6f67d76a438 100644 --- a/src/Mono.Android/Mono.Android.csproj +++ b/src/Mono.Android/Mono.Android.csproj @@ -269,6 +269,7 @@ + diff --git a/src/Mono.Android/metadata b/src/Mono.Android/metadata index f6ba8846fe1..895ed3a6c21 100644 --- a/src/Mono.Android/metadata +++ b/src/Mono.Android/metadata @@ -158,6 +158,7 @@ java.lang.Object Android.AccessibilityServices Android.Views.GravityFlags + KeyPress true true diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/StartupNoGCRegionTest.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/StartupNoGCRegionTest.cs new file mode 100644 index 00000000000..d3d21525bfb --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/StartupNoGCRegionTest.cs @@ -0,0 +1,207 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +using Android.App; +using Android.Runtime; + +using NUnit.Framework; + +namespace Xamarin.Android.RuntimeTests; + +[TestFixture] +public class StartupNoGCRegionTest +{ + static readonly TimeSpan LongFallbackTimeout = TimeSpan.FromMinutes (1); + + [Test] + public void ReportFullyDrawnPreservesRegistration () + { + var method = typeof (Activity).GetMethod (nameof (Activity.ReportFullyDrawn)); + if (method == null) { + Assert.Fail ("Could not find Activity.ReportFullyDrawn."); + return; + } + + var register = method.GetCustomAttribute (); + if (register == null) { + Assert.Fail ("Activity.ReportFullyDrawn does not have RegisterAttribute."); + return; + } + + Assert.IsTrue (method.IsPublic); + Assert.IsTrue (method.IsVirtual); + Assert.AreEqual ("reportFullyDrawn", register.Name); + Assert.AreEqual ("()V", register.Signature); + Assert.AreEqual ("GetReportFullyDrawnHandler", register.Connector); + } + + [Test] + public void OnlyStartsForCoreClr () + { + int startCount = 0; + var noGCRegion = Create ( + (_, _) => { + startCount++; + return true; + } + ); + + noGCRegion.Start (isCoreClrRuntime: false); + noGCRegion.Finish (); + + Assert.AreEqual (0, startCount); + } + + [Test] + public void StartFailureDoesNotEndRegion () + { + int endCount = 0; + var noGCRegion = Create ( + (_, _) => false, + () => endCount++ + ); + + noGCRegion.Start (isCoreClrRuntime: true); + noGCRegion.Finish (); + + Assert.AreEqual (0, endCount); + } + + [Test] + public void ExistingNoGCRegionDoesNotEndRegion () + { + int endCount = 0; + var noGCRegion = Create ( + (_, _) => throw new InvalidOperationException (), + () => endCount++ + ); + + Assert.DoesNotThrow (() => noGCRegion.Start (isCoreClrRuntime: true)); + noGCRegion.Finish (); + + Assert.AreEqual (0, endCount); + } + + [Test] + public void StartUsesExpectedBudget () + { + long requestedBudget = 0; + bool disallowFullBlockingGC = false; + var noGCRegion = Create ( + (budget, disallowBlockingGC) => { + requestedBudget = budget; + disallowFullBlockingGC = disallowBlockingGC; + return true; + } + ); + + noGCRegion.Start (isCoreClrRuntime: true); + noGCRegion.Finish (); + + Assert.AreEqual (24 * 1024 * 1024, requestedBudget); + Assert.IsTrue (disallowFullBlockingGC); + } + + [Test] + public void RepeatedReportFullyDrawnEndsRegionOnce () + { + int endCount = 0; + var noGCRegion = Create ( + (_, _) => true, + () => endCount++ + ); + + noGCRegion.Start (isCoreClrRuntime: true); + noGCRegion.Finish (); + noGCRegion.Finish (); + + Assert.AreEqual (1, endCount); + } + + [Test] + public void ConcurrentReportFullyDrawnEndsRegionOnce () + { + int endCount = 0; + var noGCRegion = Create ( + (_, _) => true, + () => Interlocked.Increment (ref endCount) + ); + + noGCRegion.Start (isCoreClrRuntime: true); + Parallel.For (0, 16, _ => noGCRegion.Finish ()); + + Assert.AreEqual (1, endCount); + } + + [Test] + public void RegionExhaustionIsHandledOnce () + { + int endCount = 0; + var noGCRegion = Create ( + (_, _) => true, + () => { + endCount++; + throw new InvalidOperationException (); + } + ); + + noGCRegion.Start (isCoreClrRuntime: true); + + Assert.DoesNotThrow (noGCRegion.Finish); + Assert.DoesNotThrow (noGCRegion.Finish); + Assert.AreEqual (1, endCount); + } + + [Test] + public void ReplacementRegionIsNotEndedAfterCollection () + { + int collectionCount = 0; + int endCount = 0; + var noGCRegion = new StartupNoGCRegion ( + (_, _) => true, + () => endCount++, + _ => collectionCount, + LongFallbackTimeout + ); + + noGCRegion.Start (isCoreClrRuntime: true); + collectionCount++; + noGCRegion.Finish (); + + Assert.AreEqual (0, endCount); + } + + [Test] + public void FallbackEndsRegionOnce () + { + using var ended = new ManualResetEventSlim (); + int endCount = 0; + var noGCRegion = new StartupNoGCRegion ( + (_, _) => true, + () => { + Interlocked.Increment (ref endCount); + ended.Set (); + }, + _ => 0, + TimeSpan.Zero + ); + + noGCRegion.Start (isCoreClrRuntime: true); + + Assert.IsTrue (ended.Wait (TimeSpan.FromSeconds (5))); + noGCRegion.Finish (); + Assert.AreEqual (1, endCount); + } + + static StartupNoGCRegion Create ( + Func tryStartNoGCRegion, + Action? endNoGCRegion = null + ) => new ( + tryStartNoGCRegion, + endNoGCRegion ?? (() => { }), + _ => 0, + LongFallbackTimeout + ); +} diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj index 8c817a19434..cb39a4d665c 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj @@ -132,6 +132,7 @@ + From be35259a6c1994398acf75788071fd99c493c7fa Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 14:32:34 +0200 Subject: [PATCH 2/9] [CoreCLR] Add startup no-GC opt-out Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../building-apps/build-properties.md | 11 ++++++++++ .../Android.Runtime/JNIEnvInit.cs | 4 +--- .../Android.Runtime/StartupNoGCRegion.cs | 12 +++++++--- .../RuntimeFeature.cs | 5 +++++ .../Microsoft.Android.Sdk.CoreCLR.targets | 5 +++++ .../Android.Runtime/StartupNoGCRegionTest.cs | 22 ++++++++++++++++++- 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Documentation/docs-mobile/building-apps/build-properties.md b/Documentation/docs-mobile/building-apps/build-properties.md index 63ce915429e..8ec17657da1 100644 --- a/Documentation/docs-mobile/building-apps/build-properties.md +++ b/Documentation/docs-mobile/building-apps/build-properties.md @@ -529,6 +529,17 @@ will be used. This property is `False` by default. +## AndroidEnableStartupNoGCRegion + +The `$(AndroidEnableStartupNoGCRegion)` property controls whether CoreCLR Android +applications request a 24 MiB no-GC region during runtime-managed application +startup. The region ends when `Activity.ReportFullyDrawn()` is first called or +after a bounded fallback timeout. The default value is `True` for CoreCLR +applications. + +Set this property to `False` when application startup code, startup hooks, or +libraries manage no-GC regions directly through `GC.TryStartNoGCRegion()`. + ## AndroidErrorOnCustomJavaObject A boolean property that diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs index d6ffce936be..61cc6e1b768 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs @@ -126,9 +126,7 @@ internal static unsafe void Initialize (JnienvInitializeArgs* args) throw new NotSupportedException ("Internal error: exactly one of RuntimeFeature.IsMonoRuntime or RuntimeFeature.IsCoreClrRuntime must be enabled."); } - if (RuntimeFeature.IsCoreClrRuntime) { - StartupNoGCRegion.Start (); - } + StartupNoGCRegion.Start (); IntPtr total_timing_sequence = IntPtr.Zero; IntPtr partial_timing_sequence = IntPtr.Zero; diff --git a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs index b67a3e759d6..e8af19d5050 100644 --- a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs +++ b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs @@ -50,13 +50,16 @@ TimeSpan fallbackTimeout this.fallbackTimeout = fallbackTimeout; } - internal static void Start () => instance.Start (isCoreClrRuntime: true); + internal static void Start () => instance.Start ( + Microsoft.Android.Runtime.RuntimeFeature.IsCoreClrRuntime, + Microsoft.Android.Runtime.RuntimeFeature.StartupNoGCRegion + ); internal static void End () => instance.Finish (); - internal void Start (bool isCoreClrRuntime) + internal void Start (bool isCoreClrRuntime, bool isEnabled = true) { - if (!isCoreClrRuntime) { + if (!isCoreClrRuntime || !isEnabled) { return; } @@ -107,6 +110,9 @@ internal void Finish () state = State.Ended; timer = fallbackTimer; fallbackTimer = null; + // CoreCLR does not provide an ownership token for the process-wide no-GC region. + // Avoid ending a replacement region when ownership loss is already observable. + // Apps which manage their own no-GC regions must disable this startup feature. collectionOccurred = collectionCount (0) != gen0CollectionCount || collectionCount (1) != gen1CollectionCount || diff --git a/src/Mono.Android/Microsoft.Android.Runtime/RuntimeFeature.cs b/src/Mono.Android/Microsoft.Android.Runtime/RuntimeFeature.cs index 718f0b6f656..7585bc29a30 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/RuntimeFeature.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/RuntimeFeature.cs @@ -9,6 +9,7 @@ static class RuntimeFeature const bool IsCoreClrRuntimeEnabledByDefault = false; const bool IsNativeAotRuntimeEnabledByDefault = false; const bool IsAssignableFromCheckEnabledByDefault = true; + const bool StartupNoGCRegionEnabledByDefault = true; const bool StartupHookSupportEnabledByDefault = true; const bool TrimmableTypeMapEnabledByDefault = false; const bool ObjectReferenceLoggingEnabledByDefault = false; @@ -33,6 +34,10 @@ static class RuntimeFeature internal static bool IsAssignableFromCheck { get; } = AppContext.TryGetSwitch ($"{FeatureSwitchPrefix}{nameof (IsAssignableFromCheck)}", out bool isEnabled) ? isEnabled : IsAssignableFromCheckEnabledByDefault; + [FeatureSwitchDefinition ($"{FeatureSwitchPrefix}{nameof (StartupNoGCRegion)}")] + internal static bool StartupNoGCRegion { get; } = + AppContext.TryGetSwitch ($"{FeatureSwitchPrefix}{nameof (StartupNoGCRegion)}", out bool isEnabled) ? isEnabled : StartupNoGCRegionEnabledByDefault; + [FeatureSwitchDefinition (StartupHookProviderSwitch)] [FeatureGuard (typeof (RequiresUnreferencedCodeAttribute))] internal static bool StartupHookSupport { get; } = diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets index 9d687057a26..b43ebbcf143 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets @@ -12,6 +12,7 @@ This file contains the CoreCLR-specific MSBuild logic for .NET for Android. <_AndroidRuntimePackRuntime>CoreCLR + true @@ -35,6 +36,10 @@ This file contains the CoreCLR-specific MSBuild logic for .NET for Android. Value="true" Trim="true" /> + { + startCount++; + return true; + }, + () => endCount++ + ); + + noGCRegion.Start (isCoreClrRuntime: true, isEnabled: false); + noGCRegion.Finish (); + + Assert.AreEqual (0, startCount); + Assert.AreEqual (0, endCount); + } + [Test] public void StartFailureDoesNotEndRegion () { @@ -155,7 +175,7 @@ public void RegionExhaustionIsHandledOnce () } [Test] - public void ReplacementRegionIsNotEndedAfterCollection () + public void ObservedOwnershipLossDoesNotEndReplacementRegion () { int collectionCount = 0; int endCount = 0; From a0ee8610256ed5ece5f2fb8503b9ff45495c01c9 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 15:40:20 +0200 Subject: [PATCH 3/9] Use startup no-GC region with NativeAOT Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../building-apps/build-properties.md | 11 - src/Mono.Android/Android.App/Activity.cs | 5 +- .../Android.Runtime/JNIEnvInit.cs | 5 +- .../Android.Runtime/StartupNoGCRegion.cs | 61 +---- .../RuntimeFeature.cs | 5 - .../Microsoft.Android.Sdk.CoreCLR.targets | 5 - .../Android.Runtime/StartupNoGCRegionTest.cs | 227 ------------------ .../Mono.Android.NET-Tests.csproj | 1 - 8 files changed, 12 insertions(+), 308 deletions(-) delete mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/StartupNoGCRegionTest.cs diff --git a/Documentation/docs-mobile/building-apps/build-properties.md b/Documentation/docs-mobile/building-apps/build-properties.md index 8ec17657da1..63ce915429e 100644 --- a/Documentation/docs-mobile/building-apps/build-properties.md +++ b/Documentation/docs-mobile/building-apps/build-properties.md @@ -529,17 +529,6 @@ will be used. This property is `False` by default. -## AndroidEnableStartupNoGCRegion - -The `$(AndroidEnableStartupNoGCRegion)` property controls whether CoreCLR Android -applications request a 24 MiB no-GC region during runtime-managed application -startup. The region ends when `Activity.ReportFullyDrawn()` is first called or -after a bounded fallback timeout. The default value is `True` for CoreCLR -applications. - -Set this property to `False` when application startup code, startup hooks, or -libraries manage no-GC regions directly through `GC.TryStartNoGCRegion()`. - ## AndroidErrorOnCustomJavaObject A boolean property that diff --git a/src/Mono.Android/Android.App/Activity.cs b/src/Mono.Android/Android.App/Activity.cs index e0b19344095..4ec9a7f8bea 100644 --- a/src/Mono.Android/Android.App/Activity.cs +++ b/src/Mono.Android/Android.App/Activity.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.Versioning; using Android.Runtime; -using RuntimeFeature = Microsoft.Android.Runtime.RuntimeFeature; namespace Android.App { @@ -50,9 +49,7 @@ public virtual unsafe void ReportFullyDrawn () try { _members.InstanceMethods.InvokeVirtualVoidMethod (id, this, null); } finally { - if (RuntimeFeature.IsCoreClrRuntime) { - StartupNoGCRegion.End (); - } + StartupNoGCRegion.End (); } } diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs index 61cc6e1b768..2b10de41ea2 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs @@ -109,6 +109,7 @@ internal static void InitializeNativeAotRuntime (JniRuntime runtime, JnienvIniti throw new NotSupportedException ("Internal error: NativeAOT cannot be enabled with MonoVM or CoreCLR."); } + StartupNoGCRegion.Start (); androidRuntime = runtime; JniRuntime.SetCurrent (runtime); RegisterTrimmableTypeMapNativeMethodsIfNeeded (); @@ -126,7 +127,9 @@ internal static unsafe void Initialize (JnienvInitializeArgs* args) throw new NotSupportedException ("Internal error: exactly one of RuntimeFeature.IsMonoRuntime or RuntimeFeature.IsCoreClrRuntime must be enabled."); } - StartupNoGCRegion.Start (); + if (RuntimeFeature.IsCoreClrRuntime) { + StartupNoGCRegion.Start (); + } IntPtr total_timing_sequence = IntPtr.Zero; IntPtr partial_timing_sequence = IntPtr.Zero; diff --git a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs index e8af19d5050..a9d0b28abb0 100644 --- a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs +++ b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs @@ -8,23 +8,11 @@ sealed class StartupNoGCRegion const long Budget = 24 * 1024 * 1024; // Bound the process-wide region when an app never reports that startup is fully drawn. static readonly TimeSpan DefaultFallbackTimeout = TimeSpan.FromSeconds (10); - static readonly StartupNoGCRegion instance = new ( - static (totalSize, disallowFullBlockingGC) => GC.TryStartNoGCRegion (totalSize, disallowFullBlockingGC), - GC.EndNoGCRegion, - GC.CollectionCount, - DefaultFallbackTimeout - ); + static readonly StartupNoGCRegion instance = new (); readonly object sync = new (); - readonly Func tryStartNoGCRegion; - readonly Action endNoGCRegion; - readonly Func collectionCount; - readonly TimeSpan fallbackTimeout; Timer? fallbackTimer; State state; - int gen0CollectionCount; - int gen1CollectionCount; - int gen2CollectionCount; enum State { @@ -33,33 +21,13 @@ enum State Ended, } - internal StartupNoGCRegion ( - Func tryStartNoGCRegion, - Action endNoGCRegion, - Func collectionCount, - TimeSpan fallbackTimeout - ) - { - ArgumentNullException.ThrowIfNull (tryStartNoGCRegion); - ArgumentNullException.ThrowIfNull (endNoGCRegion); - ArgumentNullException.ThrowIfNull (collectionCount); - - this.tryStartNoGCRegion = tryStartNoGCRegion; - this.endNoGCRegion = endNoGCRegion; - this.collectionCount = collectionCount; - this.fallbackTimeout = fallbackTimeout; - } - - internal static void Start () => instance.Start ( - Microsoft.Android.Runtime.RuntimeFeature.IsCoreClrRuntime, - Microsoft.Android.Runtime.RuntimeFeature.StartupNoGCRegion - ); + internal static void Start () => instance.StartRegion (); internal static void End () => instance.Finish (); - internal void Start (bool isCoreClrRuntime, bool isEnabled = true) + void StartRegion () { - if (!isCoreClrRuntime || !isEnabled) { + if (Microsoft.Android.Runtime.RuntimeFeature.IsMonoRuntime) { return; } @@ -70,7 +38,7 @@ internal void Start (bool isCoreClrRuntime, bool isEnabled = true) bool started; try { - started = tryStartNoGCRegion (Budget, true); + started = GC.TryStartNoGCRegion (Budget, true); } catch (InvalidOperationException) { state = State.Ended; return; @@ -81,9 +49,6 @@ internal void Start (bool isCoreClrRuntime, bool isEnabled = true) return; } - gen0CollectionCount = collectionCount (0); - gen1CollectionCount = collectionCount (1); - gen2CollectionCount = collectionCount (2); fallbackTimer = new Timer ( static value => { if (value is StartupNoGCRegion noGCRegion) { @@ -91,7 +56,7 @@ internal void Start (bool isCoreClrRuntime, bool isEnabled = true) } }, this, - fallbackTimeout, + DefaultFallbackTimeout, Timeout.InfiniteTimeSpan ); state = State.Active; @@ -101,7 +66,6 @@ internal void Start (bool isCoreClrRuntime, bool isEnabled = true) internal void Finish () { Timer? timer; - bool collectionOccurred; lock (sync) { if (state != State.Active) { return; @@ -110,23 +74,12 @@ internal void Finish () state = State.Ended; timer = fallbackTimer; fallbackTimer = null; - // CoreCLR does not provide an ownership token for the process-wide no-GC region. - // Avoid ending a replacement region when ownership loss is already observable. - // Apps which manage their own no-GC regions must disable this startup feature. - collectionOccurred = - collectionCount (0) != gen0CollectionCount || - collectionCount (1) != gen1CollectionCount || - collectionCount (2) != gen2CollectionCount; } timer?.Dispose (); - if (collectionOccurred) { - return; - } - try { - endNoGCRegion (); + GC.EndNoGCRegion (); } catch (InvalidOperationException) { // The runtime already left the region because its budget was exhausted // or a collection was induced. diff --git a/src/Mono.Android/Microsoft.Android.Runtime/RuntimeFeature.cs b/src/Mono.Android/Microsoft.Android.Runtime/RuntimeFeature.cs index 7585bc29a30..718f0b6f656 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/RuntimeFeature.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/RuntimeFeature.cs @@ -9,7 +9,6 @@ static class RuntimeFeature const bool IsCoreClrRuntimeEnabledByDefault = false; const bool IsNativeAotRuntimeEnabledByDefault = false; const bool IsAssignableFromCheckEnabledByDefault = true; - const bool StartupNoGCRegionEnabledByDefault = true; const bool StartupHookSupportEnabledByDefault = true; const bool TrimmableTypeMapEnabledByDefault = false; const bool ObjectReferenceLoggingEnabledByDefault = false; @@ -34,10 +33,6 @@ static class RuntimeFeature internal static bool IsAssignableFromCheck { get; } = AppContext.TryGetSwitch ($"{FeatureSwitchPrefix}{nameof (IsAssignableFromCheck)}", out bool isEnabled) ? isEnabled : IsAssignableFromCheckEnabledByDefault; - [FeatureSwitchDefinition ($"{FeatureSwitchPrefix}{nameof (StartupNoGCRegion)}")] - internal static bool StartupNoGCRegion { get; } = - AppContext.TryGetSwitch ($"{FeatureSwitchPrefix}{nameof (StartupNoGCRegion)}", out bool isEnabled) ? isEnabled : StartupNoGCRegionEnabledByDefault; - [FeatureSwitchDefinition (StartupHookProviderSwitch)] [FeatureGuard (typeof (RequiresUnreferencedCodeAttribute))] internal static bool StartupHookSupport { get; } = diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets index b43ebbcf143..9d687057a26 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets @@ -12,7 +12,6 @@ This file contains the CoreCLR-specific MSBuild logic for .NET for Android. <_AndroidRuntimePackRuntime>CoreCLR - true @@ -36,10 +35,6 @@ This file contains the CoreCLR-specific MSBuild logic for .NET for Android. Value="true" Trim="true" /> - (); - if (register == null) { - Assert.Fail ("Activity.ReportFullyDrawn does not have RegisterAttribute."); - return; - } - - Assert.IsTrue (method.IsPublic); - Assert.IsTrue (method.IsVirtual); - Assert.AreEqual ("reportFullyDrawn", register.Name); - Assert.AreEqual ("()V", register.Signature); - Assert.AreEqual ("GetReportFullyDrawnHandler", register.Connector); - } - - [Test] - public void OnlyStartsForCoreClr () - { - int startCount = 0; - var noGCRegion = Create ( - (_, _) => { - startCount++; - return true; - } - ); - - noGCRegion.Start (isCoreClrRuntime: false); - noGCRegion.Finish (); - - Assert.AreEqual (0, startCount); - } - - [Test] - public void DisabledRegionDoesNotAffectApplicationRegion () - { - int startCount = 0; - int endCount = 0; - var noGCRegion = Create ( - (_, _) => { - startCount++; - return true; - }, - () => endCount++ - ); - - noGCRegion.Start (isCoreClrRuntime: true, isEnabled: false); - noGCRegion.Finish (); - - Assert.AreEqual (0, startCount); - Assert.AreEqual (0, endCount); - } - - [Test] - public void StartFailureDoesNotEndRegion () - { - int endCount = 0; - var noGCRegion = Create ( - (_, _) => false, - () => endCount++ - ); - - noGCRegion.Start (isCoreClrRuntime: true); - noGCRegion.Finish (); - - Assert.AreEqual (0, endCount); - } - - [Test] - public void ExistingNoGCRegionDoesNotEndRegion () - { - int endCount = 0; - var noGCRegion = Create ( - (_, _) => throw new InvalidOperationException (), - () => endCount++ - ); - - Assert.DoesNotThrow (() => noGCRegion.Start (isCoreClrRuntime: true)); - noGCRegion.Finish (); - - Assert.AreEqual (0, endCount); - } - - [Test] - public void StartUsesExpectedBudget () - { - long requestedBudget = 0; - bool disallowFullBlockingGC = false; - var noGCRegion = Create ( - (budget, disallowBlockingGC) => { - requestedBudget = budget; - disallowFullBlockingGC = disallowBlockingGC; - return true; - } - ); - - noGCRegion.Start (isCoreClrRuntime: true); - noGCRegion.Finish (); - - Assert.AreEqual (24 * 1024 * 1024, requestedBudget); - Assert.IsTrue (disallowFullBlockingGC); - } - - [Test] - public void RepeatedReportFullyDrawnEndsRegionOnce () - { - int endCount = 0; - var noGCRegion = Create ( - (_, _) => true, - () => endCount++ - ); - - noGCRegion.Start (isCoreClrRuntime: true); - noGCRegion.Finish (); - noGCRegion.Finish (); - - Assert.AreEqual (1, endCount); - } - - [Test] - public void ConcurrentReportFullyDrawnEndsRegionOnce () - { - int endCount = 0; - var noGCRegion = Create ( - (_, _) => true, - () => Interlocked.Increment (ref endCount) - ); - - noGCRegion.Start (isCoreClrRuntime: true); - Parallel.For (0, 16, _ => noGCRegion.Finish ()); - - Assert.AreEqual (1, endCount); - } - - [Test] - public void RegionExhaustionIsHandledOnce () - { - int endCount = 0; - var noGCRegion = Create ( - (_, _) => true, - () => { - endCount++; - throw new InvalidOperationException (); - } - ); - - noGCRegion.Start (isCoreClrRuntime: true); - - Assert.DoesNotThrow (noGCRegion.Finish); - Assert.DoesNotThrow (noGCRegion.Finish); - Assert.AreEqual (1, endCount); - } - - [Test] - public void ObservedOwnershipLossDoesNotEndReplacementRegion () - { - int collectionCount = 0; - int endCount = 0; - var noGCRegion = new StartupNoGCRegion ( - (_, _) => true, - () => endCount++, - _ => collectionCount, - LongFallbackTimeout - ); - - noGCRegion.Start (isCoreClrRuntime: true); - collectionCount++; - noGCRegion.Finish (); - - Assert.AreEqual (0, endCount); - } - - [Test] - public void FallbackEndsRegionOnce () - { - using var ended = new ManualResetEventSlim (); - int endCount = 0; - var noGCRegion = new StartupNoGCRegion ( - (_, _) => true, - () => { - Interlocked.Increment (ref endCount); - ended.Set (); - }, - _ => 0, - TimeSpan.Zero - ); - - noGCRegion.Start (isCoreClrRuntime: true); - - Assert.IsTrue (ended.Wait (TimeSpan.FromSeconds (5))); - noGCRegion.Finish (); - Assert.AreEqual (1, endCount); - } - - static StartupNoGCRegion Create ( - Func tryStartNoGCRegion, - Action? endNoGCRegion = null - ) => new ( - tryStartNoGCRegion, - endNoGCRegion ?? (() => { }), - _ => 0, - LongFallbackTimeout - ); -} diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj index cb39a4d665c..8c817a19434 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj @@ -132,7 +132,6 @@ - From 149aa6e7c35a5d9705b456d0f9e76e4fd44afa24 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 15:43:30 +0200 Subject: [PATCH 4/9] Use System.Threading.Lock for startup GC state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs index a9d0b28abb0..6fa616c5ff3 100644 --- a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs +++ b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs @@ -10,7 +10,7 @@ sealed class StartupNoGCRegion static readonly TimeSpan DefaultFallbackTimeout = TimeSpan.FromSeconds (10); static readonly StartupNoGCRegion instance = new (); - readonly object sync = new (); + readonly Lock sync = new (); Timer? fallbackTimer; State state; From 5b09e5453e953ba376942b6463b93dc2ca4abf71 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 15:44:47 +0200 Subject: [PATCH 5/9] Name startup no-GC option argument Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs index 6fa616c5ff3..17dd50689e4 100644 --- a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs +++ b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs @@ -38,7 +38,7 @@ void StartRegion () bool started; try { - started = GC.TryStartNoGCRegion (Budget, true); + started = GC.TryStartNoGCRegion (Budget, disallowFullBlockingGC: true); } catch (InvalidOperationException) { state = State.Ended; return; From 5c345353f0e3cb631c9fa006a7e01119aa454311 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 15:45:59 +0200 Subject: [PATCH 6/9] Centralize startup no-GC runtime gating Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Android.Runtime/JNIEnvInit.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs index 2b10de41ea2..e2226dc7d51 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs @@ -127,9 +127,7 @@ internal static unsafe void Initialize (JnienvInitializeArgs* args) throw new NotSupportedException ("Internal error: exactly one of RuntimeFeature.IsMonoRuntime or RuntimeFeature.IsCoreClrRuntime must be enabled."); } - if (RuntimeFeature.IsCoreClrRuntime) { - StartupNoGCRegion.Start (); - } + StartupNoGCRegion.Start (); IntPtr total_timing_sequence = IntPtr.Zero; IntPtr partial_timing_sequence = IntPtr.Zero; From 5d71d432c5e07dff06f88df0253a23d8297ac948 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 15:49:34 +0200 Subject: [PATCH 7/9] Use unmanaged Activity fully-drawn callback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Android.App/Activity.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Mono.Android/Android.App/Activity.cs b/src/Mono.Android/Android.App/Activity.cs index 4ec9a7f8bea..00a01f849f7 100644 --- a/src/Mono.Android/Android.App/Activity.cs +++ b/src/Mono.Android/Android.App/Activity.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; using System.Runtime.Versioning; using Android.Runtime; @@ -53,14 +54,23 @@ public virtual unsafe void ReportFullyDrawn () } } - static Delegate? cb_reportFullyDrawn_ReportFullyDrawn_V; - static Delegate GetReportFullyDrawnHandler () { - return cb_reportFullyDrawn_ReportFullyDrawn_V ??= new _JniMarshal_PP_V (n_ReportFullyDrawn); + return new _JniMarshal_PP_V (n_ReportFullyDrawn_Dynamic); } + [UnmanagedCallersOnly] static void n_ReportFullyDrawn (IntPtr jnienv, IntPtr native__this) + { + InvokeReportFullyDrawn (jnienv, native__this); + } + + static void n_ReportFullyDrawn_Dynamic (IntPtr jnienv, IntPtr native__this) + { + InvokeReportFullyDrawn (jnienv, native__this); + } + + static void InvokeReportFullyDrawn (IntPtr jnienv, IntPtr native__this) { unsafe { Java.Interop.JniMarshal.SafeInvokeAction (jnienv, native__this, &__n_ReportFullyDrawn); From 904163a87995e6b8e332c51e8884c73cc3d11444 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 15:55:58 +0200 Subject: [PATCH 8/9] Restore standard Activity JNI callback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Android.App/Activity.cs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/Mono.Android/Android.App/Activity.cs b/src/Mono.Android/Android.App/Activity.cs index 00a01f849f7..4ec9a7f8bea 100644 --- a/src/Mono.Android/Android.App/Activity.cs +++ b/src/Mono.Android/Android.App/Activity.cs @@ -1,6 +1,5 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; using System.Runtime.Versioning; using Android.Runtime; @@ -54,23 +53,14 @@ public virtual unsafe void ReportFullyDrawn () } } + static Delegate? cb_reportFullyDrawn_ReportFullyDrawn_V; + static Delegate GetReportFullyDrawnHandler () { - return new _JniMarshal_PP_V (n_ReportFullyDrawn_Dynamic); + return cb_reportFullyDrawn_ReportFullyDrawn_V ??= new _JniMarshal_PP_V (n_ReportFullyDrawn); } - [UnmanagedCallersOnly] static void n_ReportFullyDrawn (IntPtr jnienv, IntPtr native__this) - { - InvokeReportFullyDrawn (jnienv, native__this); - } - - static void n_ReportFullyDrawn_Dynamic (IntPtr jnienv, IntPtr native__this) - { - InvokeReportFullyDrawn (jnienv, native__this); - } - - static void InvokeReportFullyDrawn (IntPtr jnienv, IntPtr native__this) { unsafe { Java.Interop.JniMarshal.SafeInvokeAction (jnienv, native__this, &__n_ReportFullyDrawn); From ab4b43912572e322aa3cbe8ef2cc2bc9389e94e6 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 14 Sep 2026 16:01:58 +0200 Subject: [PATCH 9/9] Keep startup no-GC completion private Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs index 17dd50689e4..cfa0b506e77 100644 --- a/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs +++ b/src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs @@ -63,7 +63,7 @@ void StartRegion () } } - internal void Finish () + void Finish () { Timer? timer; lock (sync) {