Skip to content

Root [Export] callback delegates to prevent GC on CoreCLR - #10997

Merged
jonathanpeppers merged 5 commits into
mainfrom
copilot/fix-export-delegate-crash
Mar 24, 2026
Merged

Root [Export] callback delegates to prevent GC on CoreCLR#10997
jonathanpeppers merged 5 commits into
mainfrom
copilot/fix-export-delegate-crash

Conversation

Copilot AI commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

[Export] callback delegates created by CreateDynamicCallback() are only referenced by a local JniNativeMethodRegistration[] array passed to RegisterNatives(). Once the array goes out of scope, CoreCLR's GC collects the delegates, causing SIGABRT on first invocation. MonoVM was unaffected because it uses native trampolines rather than managed delegates.

Non-[Export] delegates are safe because generated connector code caches them in static fields (e.g. cb_onCreate). [Export] delegates have no such caching — they're created dynamically via Mono.Android.Export.dll and returned into a local.

Fix

  • Add a static List<Delegate> in AndroidTypeManager to hold strong references to dynamically-created [Export] delegates
  • Root each delegate immediately after CreateDynamicCallback() returns
+ static List<Delegate> prevent_delegate_gc = new List<Delegate> ();

  callback = CreateDynamicCallback (minfo);
+ prevent_delegate_gc.Add (callback);

Growth is bounded by the number of unique [Export] methods in the app (registered once per type).

Test

  • Added ExportedMembersSurviveGarbageCollection device test in MonoAndroidExportTest.cs that verifies [Export] delegates survive GC on all runtimes by:
    1. Creating an object with [Export] methods (triggers delegate registration via RegisterNativeMembers)
    2. Forcing 10 aggressive GC cycles (GC.Collect + GC.WaitForPendingFinalizers)
    3. Invoking the [Export] method through JNI (GetObjectClassGetMethodIDCallVoidMethod) to exercise the Java → native delegate → C# path
    4. Verifying the callback succeeds by checking logcat for the success message

Without the fix, the JNI call at step 3 crashes with SIGABRT on CoreCLR because the delegate backing n_Exported was collected.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

…CoreCLR

Root dynamically-created [Export] callback delegates in a static list
to prevent CoreCLR's GC from collecting them between JNI registration
and first invocation.

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Agent-Logs-Url: https://github.com/dotnet/android/sessions/28ca1465-aa32-4295-9366-82c2a12b6a9b
Copilot AI changed the title [WIP] Fix crash due to unrooted callback delegates after JNI registration Root [Export] callback delegates to prevent GC on CoreCLR Mar 23, 2026
Copilot AI requested a review from simonrozsival March 23, 2026 12:35
@simonrozsival

Copy link
Copy Markdown
Member

@copilot add a new test scenario which will allow us to verify that this fix solves the problem

Verifies [Export] delegates survive GC on CoreCLR by forcing aggressive
GC cycles between delegate registration and JNI invocation, then calling
the method through JNI (Java -> native delegate -> C#) to exercise the
exact path that crashes when the delegate is collected.

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Agent-Logs-Url: https://github.com/dotnet/android/sessions/9c9c2212-3532-4407-b394-a1b28d2a2c62

Copilot AI commented Mar 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot add a new test scenario which will allow us to verify that this fix solves the problem

Added ExportedMembersSurviveGarbageCollection test in 414b356. The test:

  1. Creates a class with [Export] methods (triggers delegate registration via RegisterNativeMembers)
  2. Forces 10 aggressive GC cycles (GC.Collect + GC.WaitForPendingFinalizers) to collect any unrooted delegates
  3. Invokes the [Export] method through JNI (GetObjectClassGetMethodIDCallVoidMethod) which exercises the Java → native delegate → C# path
  4. Verifies the callback succeeds by checking logcat for the success message

Without the fix, the JNI call at step 3 would crash with SIGABRT on CoreCLR because the delegate backing n_Exported was collected. With the fix, the delegate is rooted in prevent_delegate_gc and the call succeeds.

@simonrozsival
simonrozsival marked this pull request as ready for review March 23, 2026 14:32
Copilot AI review requested due to automatic review settings March 23, 2026 14:32

Copilot AI left a comment

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.

Pull request overview

This PR addresses a CoreCLR-specific crash where dynamically-created [Export] JNI callback delegates can be garbage-collected after registration but before first invocation, leading to SIGABRT. It does so by holding strong references to these delegates in the runtime, and adds a device test to exercise the Java → native → managed callback path after forced GC.

Changes:

  • Root dynamically created [Export] delegates in AndroidRuntime.AndroidTypeManager to prevent GC collection on CoreCLR.
  • Add a new MSBuild device integration test that forces aggressive GC and then invokes an [Export] method through JNI to verify stability.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/Mono.Android/Android.Runtime/AndroidRuntime.cs Adds a static delegate root list and stores each dynamically-created [Export] callback delegate.
tests/MSBuildDeviceIntegration/Tests/MonoAndroidExportTest.cs Adds a new device test that forces GC and calls an [Export] method via JNI, validating no crash and expected log output.

Comment on lines +428 to 432
// [Export] callback delegates are created dynamically via DynamicCallbackCodeGenerator and are not
// cached in static fields (unlike non-[Export] connector delegates). Without rooting them here,
// CoreCLR's GC can collect them between JNI registration and first invocation, causing a crash.
static List<Delegate> prevent_delegate_gc = new List<Delegate> ();
static List<JniNativeMethodRegistration> sharedRegistrations = new List<JniNativeMethodRegistration> ();

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

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

prevent_delegate_gc is a shared mutable List<Delegate> which is appended to from RegisterNativeMembers(). List<T> is not thread-safe; RegisterNativeMembers() can be invoked concurrently for different types, and concurrent Add() calls can corrupt the list or throw, leading to app crashes. Consider protecting the collection with a lock, or switching to a thread-safe collection (e.g., ConcurrentBag<Delegate>), and ensure the chosen approach matches expected access patterns.

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +186
proj.AndroidManifest = $@"<?xml version=""1.0"" encoding=""utf-8""?>
<manifest xmlns:android=""http://schemas.android.com/apk/res/android"" android:versionCode=""1"" android:versionName=""1.0"" package=""${proj.PackageName}"">
<uses-sdk android:targetSdkVersion=""{apiLevel}"" />
<application android:label=""${{PROJECT_NAME}}"">
</application >
</manifest>";

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

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

The manifest string uses package="${proj.PackageName}" inside an interpolated string, which will emit a leading $ in the package name (e.g., $com.example...). This produces an invalid manifest package and can cause build/install failures. Use package="{proj.PackageName}" (no $ inside), consistent with other device tests (e.g., DebuggingTest.cs).

Copilot uses AI. Check for mistakes.
@simonrozsival

Copy link
Copy Markdown
Member

@copilot address all review comments

…t package name

- Protect prevent_delegate_gc with a dedicated lock object since
  RegisterNativeMembers can be called concurrently for different types
- Fix manifest package attribute in ExportedMembersSurviveGarbageCollection
  test: remove erroneous $ prefix that produced invalid package name

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Agent-Logs-Url: https://github.com/dotnet/android/sessions/d78cdf0b-7890-4fe2-b5b3-8b10f5d6f0f4

Copilot AI commented Mar 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot address all review comments

Both review comments addressed in abccc78:

  1. Thread safety: Added a dedicated prevent_delegate_gc_lock object and wrapped the Add() call in a lock block, consistent with the existing Monitor-based pattern used for sharedRegistrations in the same file.
  2. Manifest package name: Removed the erroneous $ prefix from package="${proj.PackageName}"package="{proj.PackageName}" in the new test, matching the pattern used in DebuggingTest.cs and InstallAndRunTests.cs.

Comment thread src/Mono.Android/Android.Runtime/AndroidRuntime.cs Outdated
@jonathanpeppers
jonathanpeppers merged commit b0c9bc0 into main Mar 24, 2026
6 checks passed
@jonathanpeppers
jonathanpeppers deleted the copilot/fix-export-delegate-crash branch March 24, 2026 13:53
jonathanpeppers added a commit that referenced this pull request Mar 24, 2026
…reCLR (#10997)

* Fix [Export] callback delegates not rooted after JNI registration on CoreCLR

Root dynamically-created [Export] callback delegates in a static list
to prevent CoreCLR's GC from collecting them between JNI registration
and first invocation.

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Agent-Logs-Url: https://github.com/dotnet/android/sessions/28ca1465-aa32-4295-9366-82c2a12b6a9b

* Add test: ExportedMembersSurviveGarbageCollection

Verifies [Export] delegates survive GC on CoreCLR by forcing aggressive
GC cycles between delegate registration and JNI invocation, then calling
the method through JNI (Java -> native delegate -> C#) to exercise the
exact path that crashes when the delegate is collected.

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com>
jonathanpeppers added a commit that referenced this pull request Mar 24, 2026
…reCLR (#10997)

* Fix [Export] callback delegates not rooted after JNI registration on CoreCLR

Root dynamically-created [Export] callback delegates in a static list
to prevent CoreCLR's GC from collecting them between JNI registration
and first invocation.

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Agent-Logs-Url: https://github.com/dotnet/android/sessions/28ca1465-aa32-4295-9366-82c2a12b6a9b

* Add test: ExportedMembersSurviveGarbageCollection

Verifies [Export] delegates survive GC on CoreCLR by forcing aggressive
GC cycles between delegate registration and JNI invocation, then calling
the method through JNI (Java -> native delegate -> C#) to exercise the
exact path that crashes when the delegate is collected.

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com>
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 24, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants