Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/Mono.Android/Android.App/Activity.cs
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down Expand Up @@ -39,7 +41,43 @@ public void RunOnUiThread (Action action)
{
RunOnUiThread (new Java.Lang.Thread.RunnableImplementor (action));
}
}
}

// The binding generator has no hook for injecting the startup no-GC cleanup into this method.
[SupportedOSPlatform ("android19.0")]
[Register ("reportFullyDrawn", "()V", "GetReportFullyDrawnHandler")]
public virtual unsafe void ReportFullyDrawn ()
Comment on lines +45 to +48

@jonathanpeppers jonathanpeppers Sep 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think this seems odd, do we already bind this? We document it somehow?!?

If we don't bind it, can we figure out why it's not bound already? Android docs here:

Then we can decide if a manual binding is appropriate, or if we need to just fix some binding bug or metadata transform instead.

{
const string id = "reportFullyDrawn.()V";
try {
_members.InstanceMethods.InvokeVirtualVoidMethod (id, this, null);
} finally {
if (!RuntimeFeature.IsMonoRuntime && RuntimeFeature.StartupNoGCRegion) {
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);
}
}
Comment thread
simonrozsival marked this conversation as resolved.

static void __n_ReportFullyDrawn (IntPtr jnienv, IntPtr native__this)
{
var activity = Java.Lang.Object.GetObject<Activity> (jnienv, native__this, JniHandleOwnership.DoNotTransfer);
if (activity == null) {
throw new InvalidOperationException ("Could not obtain the managed Activity instance for reportFullyDrawn.");
}
activity.ReportFullyDrawn ();
}
}
}
7 changes: 7 additions & 0 deletions src/Mono.Android/Android.Runtime/JNIEnvInit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ internal static void InitializeNativeAotRuntime (JniRuntime runtime, JnienvIniti
throw new NotSupportedException ("Internal error: NativeAOT cannot be enabled with MonoVM or CoreCLR.");
}

if (!RuntimeFeature.IsMonoRuntime && RuntimeFeature.StartupNoGCRegion) {
StartupNoGCRegion.Start ();
}
androidRuntime = runtime;
JniRuntime.SetCurrent (runtime);
RegisterTrimmableTypeMapNativeMethodsIfNeeded ();
Expand All @@ -126,6 +129,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.IsMonoRuntime && RuntimeFeature.StartupNoGCRegion) {
StartupNoGCRegion.Start ();
}

IntPtr total_timing_sequence = IntPtr.Zero;
IntPtr partial_timing_sequence = IntPtr.Zero;

Expand Down
84 changes: 84 additions & 0 deletions src/Mono.Android/Android.Runtime/StartupNoGCRegion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System;
using System.Threading;

namespace Android.Runtime;

sealed class StartupNoGCRegion
{
const long Budget = 24 * 1024 * 1024;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work well on Android Arm32?

I would expect TryStartNoGCRegion is always going to fail with 24MB reservation on 32-bit platforms.

// 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 ();

readonly Lock sync = new ();
Timer? fallbackTimer;
State state;

enum State
{
NotStarted,
Active,
Ended,
}

internal static void Start () => instance.StartRegion ();

internal static void End () => instance.Finish ();

void StartRegion ()
{
lock (sync) {
if (state != State.NotStarted) {
return;
}

bool started;
try {
started = GC.TryStartNoGCRegion (Budget, disallowFullBlockingGC: true);
} catch (InvalidOperationException) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may want to catch all exceptions.

TryStartNoGCRegion can also throw ArgumentOutOfRangeException when the Budget is too high for the current GC configuration (like on 32-bit platforms).

state = State.Ended;
return;
}

if (!started) {
state = State.Ended;
return;
}

fallbackTimer = new Timer (
static value => {
if (value is StartupNoGCRegion noGCRegion) {
noGCRegion.Finish ();
}
},
this,
DefaultFallbackTimeout,
Timeout.InfiniteTimeSpan
);
state = State.Active;
}
}

void Finish ()
{
Timer? timer;
lock (sync) {
if (state != State.Active) {
return;
}

state = State.Ended;
timer = fallbackTimer;
fallbackTimer = null;
}

timer?.Dispose ();

try {
GC.EndNoGCRegion ();
Comment thread
simonrozsival marked this conversation as resolved.
} catch (InvalidOperationException) {
// The runtime already left the region because its budget was exhausted
// or a collection was induced.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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; } =
Expand Down
1 change: 1 addition & 0 deletions src/Mono.Android/Mono.Android.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@
<Compile Include="Android.Runtime\RuntimeConstants.cs" />
<Compile Include="Android.Runtime\ResourceIdManager.cs" />
<Compile Include="Android.Runtime\RuntimeNativeMethods.cs" />
<Compile Include="Android.Runtime\StartupNoGCRegion.cs" />
<Compile Include="Android.Runtime\StringDefAttribute.cs" />
<Compile Include="Android.Runtime\TimingLogger.cs" />
<Compile Include="Android.Runtime\TypeManager.cs" />
Expand Down
1 change: 1 addition & 0 deletions src/Mono.Android/metadata
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@
<attr api-since="11" path="/api/package[@name='android.animation']/interface[@name='TypeEvaluator']/method[@name='evaluate']/parameter[@name='endValue']" name="type">java.lang.Object</attr>
<attr path="/api/package[@name='android.accessibilityservice']" name="managedName">Android.AccessibilityServices</attr>
<attr api-since="11" path="/api/package[@name='android.app']/class[@name='ActionBar.LayoutParams']/field[@name='gravity']" name="type">Android.Views.GravityFlags</attr>
<remove-node path="/api/package[@name='android.app']/class[@name='Activity']/method[@name='reportFullyDrawn']" />
<attr path="/api/package[@name='android.app']/class[@name='Dialog']/method[@name='setOnKeyListener']" name="eventName">KeyPress</attr>
<attr path="/api/package[@name='android.app']/interface[@name='DatePickerDialog.OnDateSetListener']/method[@name='onDateSet']/parameter[@name='view']" name="sender">true</attr>
<attr path="/api/package[@name='android.app']/interface[@name='TimePickerDialog.OnTimeSetListener']/method[@name='onTimeSet']/parameter[@name='view']" name="sender">true</attr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ See: https://github.com/dotnet/runtime/blob/b13715b6984889a709ba29ea8a1961db469f
Value="$(_AndroidIsAssignableFromCheck)"
Trim="true"
/>
<!-- Private escape hatch for the startup no-GC optimization, which is enabled by default on CoreCLR and NativeAOT. -->
<RuntimeHostConfigurationOption Include="Microsoft.Android.Runtime.RuntimeFeature.StartupNoGCRegion"
Condition="'$(_AndroidEnableStartupNoGCRegion)' != ''"
Value="$(_AndroidEnableStartupNoGCRegion)"
Trim="true"
/>
<RuntimeHostConfigurationOption Include="Microsoft.Android.Runtime.RuntimeFeature.ObjectReferenceLogging"
Condition="'$(_AndroidEnableObjectReferenceLogging)' != ''"
Value="$(_AndroidEnableObjectReferenceLogging)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,35 @@ void CheckAssembly (string assemblyPath, string projectDir)
}
}

[Test]
public void StartupNoGCRegionFeatureSwitch ([Values (true, false, null)] bool? enabled)
{
const AndroidRuntime runtime = AndroidRuntime.CoreCLR;
if (IgnoreUnsupportedConfiguration (runtime, release: true)) {
return;
}

var proj = new XamarinAndroidApplicationProject { IsRelease = true };
proj.SetRuntime (runtime);
// Keep the completion path reachable so it cannot accidentally retain the disabled helper.
proj.MainActivity = proj.DefaultMainActivity.Replace (
"base.OnCreate (bundle);",
"base.OnCreate (bundle);\nReportFullyDrawn ();");
if (enabled.HasValue) {
proj.SetProperty ("_AndroidEnableStartupNoGCRegion", enabled.Value.ToString ());
}

using var b = CreateApkBuilder ();
Assert.IsTrue (b.Build (proj), "Build should have succeeded.");
using var assembly = AssemblyDefinition.ReadAssembly (BuildTest.GetLinkedPath (b, true, "Mono.Android.dll"));
var type = assembly.MainModule.GetType ("Android.Runtime.StartupNoGCRegion");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 ⚠️ Testing — This only verifies whether the helper type survives trimming; it never exercises the new process-wide lifecycle. A regression that stops ReportFullyDrawn() or the timeout from ending the region, calls EndNoGCRegion() after a failed start, or loses the one-shot/concurrent guarantee would still pass. Please add focused runtime/device coverage for successful start/end, start failure, repeated/concurrent completion, and the fallback path (the earlier lifecycle test seam covered these cases).

Rule: Test edge cases

if (enabled != false) {
Assert.IsNotNull (type, "StartupNoGCRegion should be retained when enabled or unspecified.");
} else {
Assert.IsNull (type, "StartupNoGCRegion should be trimmed away completely when disabled.");
}
}

[Test]
public void AndroidUseNegotiateAuthentication ([Values (true, false, null)] bool? useNegotiateAuthentication, [Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT)] AndroidRuntime runtime)
{
Expand Down
Loading