[CoreCLR] Automatically detach current thread from JNI - #10316

Closed
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads
Closed

[CoreCLR] Automatically detach current thread from JNI#10316
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Fixes#10314

In #10198 I added code which attaches current thread to JNI (https://github.com/dotnet/android/pull/10198/files#diff-fc0414b3741163879db7993fd1fb42fa76e5305bb54c0e9478e077b3094e7aa7R42-R46). If this code is called from a thread pool thread, the thread won't be detached from JNI when exitting.

This PR adds the recommended steps to automatically detach the thread from JNI when exitting: https://developer.android.com/training/articles/perf-jni#threads

/cc @grendello@jonathanpeppers

@jonathanpeppersjonathanpeppers left a comment

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.

Is there a way to add a test for this?

Or did MAUI's tests crash on exit? So, we'd need to somehow run an extra test suite?

@jonpryor

Copy link
Copy Markdown
Contributor

What's odd is that I think we have a test for this? Maybe?

From the abort message in #10314:

7-21 15:25:22.624 1610 1610 F DEBUG : Abort message: 'art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[15,tid=4355,Native,Thread*=0x7f3c09434600,peer=0x12c89580,"Thread-234"]'

which suggests that if we:

  1. Create a new Thread
  2. Do some JNI work on that thread, and
  3. Exit the thread

then the assertion should be triggered, no?

We have tests that do that, e.g.:

[Test]
publicvoidConversionsAndThreadsAndInstanceMappingsOhMy()
{
IntPtrlrefJliArray=JNIEnv.NewObjectArray<int>(new[]{1});
IntPtrgrefJliArray=JNIEnv.NewGlobalRef(lrefJliArray);
JNIEnv.DeleteLocalRef(lrefJliArray);
Java.Lang.Object[]jarray=(Java.Lang.Object[])
JNIEnv.GetArray(grefJliArray,JniHandleOwnership.DoNotTransfer,typeof(Java.Lang.Object));
Exceptionignore_t1=null;
Exceptionignore_t2=null;
vart1=newThread(()=>{
int[]output_array1=newint[1];
for(inti=0;i<2000;++i){
Console.WriteLine("# t1 iter: {0}",i);
try{
JNIEnv.CopyObjectArray(grefJliArray,output_array1);
}catch(Exceptione){
ignore_t1=e;
break;
}
}
});
vart2=newThread(()=>{
for(inti=0;i<2000;++i){
Console.WriteLine("# t2 iter: {0}",i);
try{
JNIEnv.GetArray<int>(jarray);
}catch(Exceptione){
ignore_t2=e;
break;
}
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
for(inti=0;i<jarray.Length;++i){
jarray[i].Dispose();
jarray[i]=null;
}
JNIEnv.DeleteGlobalRef(grefJliArray);
Assert.IsNull(ignore_t1,string.Format("No exception should be thrown [t1]! Got: {0}",ignore_t1));
Assert.IsNull(ignore_t2,string.Format("No exception should be thrown [t2]! Got: {0}",ignore_t2));
}

so why didn't we hit this before?

Are thread pool threads different from "normal" new System.Threading.Thread()?

Do we need to do "different" kinds of JNI calls on the created thread to trigger the assertion? It likely wouldn't be a bad idea to have a test that does:

vart=newThread(()=>{varlist=newJava.Util.ArrayList();list.Add(newJava.Lang.String("a");list.Add(newJava.Lang.Integer(42);});t.Start();t.Join();

and do a bit more than straight JNIEnv calls on the created thread.

The assert message, as-is, does not imply to me that we need to worry about app exit. (And that's ignoring the fact that "app exit" is a very nebulous concept on Android in the first place!)

@grendello

Copy link
Copy Markdown
Contributor

@jonpryor I think the situation here is a bit different, at least from looking at the crash stack trace attached to #10314 (comment)

07-21 14:55:47.473 2121 2121 F DEBUG : #00 pc 0000000000539ff7 /system/lib64/libart.so (art::DumpCheckpoint::Run(art::Thread*)+439)
07-21 14:55:47.473 2121 2121 F DEBUG : #01 pc 000000000053cac6 /system/lib64/libart.so (art::ThreadList::RunCheckpoint(art::Closure*)+278)
07-21 14:55:47.473 2121 2121 F DEBUG : #02 pc 000000000053ddf5 /system/lib64/libart.so (art::ThreadList::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)+357)
07-21 14:55:47.473 2121 2121 F DEBUG : #03 pc 00000000004ffea4 /system/lib64/libart.so (art::Runtime::Abort()+660)
07-21 14:55:47.473 2121 2121 F DEBUG : #04 pc 0000000000178d71 /system/lib64/libart.so (art::LogMessage::~LogMessage()+2865)
07-21 14:55:47.473 2121 2121 F DEBUG : #05 pc 00000000005276ff /system/lib64/libart.so (art::Thread::ThreadExitCallback(void*)+223)
07-21 14:55:47.473 2121 2121 F DEBUG : #06 pc 000000000008590e /system/lib64/libc.so (pthread_key_clean_all()+142)
07-21 14:55:47.473 2121 2121 F DEBUG : #07 pc 000000000008549b /system/lib64/libc.so (pthread_exit+75)
07-21 14:55:47.473 2121 2121 F DEBUG : #08 pc 0000000000084ef6 /system/lib64/libc.so (__pthread_start(void*)+54)
07-21 14:55:47.473 2121 2121 F DEBUG : #09 pc 00000000000296eb /system/lib64/libc.so (__start_thread+11)
07-21 14:55:47.473 2121 2121 F DEBUG : #10 pc 000000000001ce55 /system/lib64/libc.so (__bionic_clone+53)

This is 100% native + ART. @simonrozsival might be onto something with this PR (btw, why was it closed?) the thread mentioned in the abort message (PID 4651) is created to run a test:

07-21 14:55:47.368 4496 4651 D monodroid: [precompiled] p/invoke found
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.ni.dll"...)
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.dll"...)
07-21 14:55:47.383 4496 4651 I DOTNET : [PASS] Charge_State
07-21 14:55:47.393 4496 4651 I DOTNET : [PASS] Charge_Level
07-21 14:55:47.395 4496 4651 I DOTNET : [PASS] Charge_Power
07-21 14:55:47.396 4496 4651 I DOTNET : [PASS] Unsubscribe_BatteryInfoChanged_Does_Not_Crash
07-21 14:55:47.399 4496 4651 I DOTNET : [PASS] App_Is_Not_Lower_Power_mode
07-21 14:55:47.407 4496 4651 W art : Native thread exiting without having called DetachCurrentThread (maybe it's going to use a pthread_key_create destructor?): Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4651 F art : art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4646 F DOTNET : Aborting process.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error. Our test assumes that calling a JNI method actually attaches the thread to JNI, which might not be the case.
In the MonoVM runtime we always attach every thread automatically, this is not necessarily the case with CoreCLR (or it might be a bug in CoreCLR - we don't control how/when it attaches threads)

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

btw, why was it closed?

I was not able to finish the work on the PR and fully test it, so I closed it for the time being. We can definitely revisit the PR.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error.

I don't know if this could be caused by the GC itself (GC has a background thread and the GC bridge has its own separate background thread, both of which are attached to JNI but both should live thoughout app's lifetime). The changes to the ensure_jnienv method affect other pieces of code, not just the GC bridge.

My hypothesis was that when this code is reached on a threadpool thread then the TP thread is attached to JNI and it is likely that this thread will be exitted at some point. This would cause the app to crash because we don't have the automatic detach machanism implemented. I wasn't able to reproduce the crash locally before I left on vacation, so I abandoned this attempt at that time.

@grendello

Copy link
Copy Markdown
Contributor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation? If it is indeed true, then the code crashing on CoreCLR but working on MonoVM would actually be a runtime issue. On MonoVM we get called back whenever any managed thread is created/destroyed, so we can attach/detach accordingly. On CoreCLR we don't have this opportunity, so it is on the runtime to do the right thing.

I used GC bridge as an example of what might occur, it's hard to tell from the stack trace that this is really the case - as you say, it can be anything that creates the thread, attaches it, but then fails to detach. If it's a managed thread, then it will exit only after the workload running it it finishes (I hope that this is the case), so there's still the chance to do the right thing there, a chance that maybe is missed for some reason?

TBH, I would love if CoreCLR called us back the same way MonoVM does on any thread creation/distruction, so that we can control things like JNI/JVM thread attachment/detachment.

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation?

If at some point the ensure_jnienv method is called and the thread isn't attached to JNI yet, it will be attached to JNI. So, if we register the automatic cleanup at this point, it should work just fine and there is no need for us to attach threads to JNI unnecessarily.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Sep 4, 2025
Sign up for freeto 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.

Running device tests for CoreCLR failing on maui on Android API 23

4 participants

@simonrozsival@jonpryor@grendello@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[CoreCLR] Automatically detach current thread from JNI - #10316

Closed
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads
Closed

[CoreCLR] Automatically detach current thread from JNI#10316
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Fixes#10314

In #10198 I added code which attaches current thread to JNI (https://github.com/dotnet/android/pull/10198/files#diff-fc0414b3741163879db7993fd1fb42fa76e5305bb54c0e9478e077b3094e7aa7R42-R46). If this code is called from a thread pool thread, the thread won't be detached from JNI when exitting.

This PR adds the recommended steps to automatically detach the thread from JNI when exitting: https://developer.android.com/training/articles/perf-jni#threads

/cc @grendello@jonathanpeppers

@jonathanpeppersjonathanpeppers left a comment

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.

Is there a way to add a test for this?

Or did MAUI's tests crash on exit? So, we'd need to somehow run an extra test suite?

@jonpryor

Copy link
Copy Markdown
Contributor

What's odd is that I think we have a test for this? Maybe?

From the abort message in #10314:

7-21 15:25:22.624 1610 1610 F DEBUG : Abort message: 'art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[15,tid=4355,Native,Thread*=0x7f3c09434600,peer=0x12c89580,"Thread-234"]'

which suggests that if we:

  1. Create a new Thread
  2. Do some JNI work on that thread, and
  3. Exit the thread

then the assertion should be triggered, no?

We have tests that do that, e.g.:

[Test]
publicvoidConversionsAndThreadsAndInstanceMappingsOhMy()
{
IntPtrlrefJliArray=JNIEnv.NewObjectArray<int>(new[]{1});
IntPtrgrefJliArray=JNIEnv.NewGlobalRef(lrefJliArray);
JNIEnv.DeleteLocalRef(lrefJliArray);
Java.Lang.Object[]jarray=(Java.Lang.Object[])
JNIEnv.GetArray(grefJliArray,JniHandleOwnership.DoNotTransfer,typeof(Java.Lang.Object));
Exceptionignore_t1=null;
Exceptionignore_t2=null;
vart1=newThread(()=>{
int[]output_array1=newint[1];
for(inti=0;i<2000;++i){
Console.WriteLine("# t1 iter: {0}",i);
try{
JNIEnv.CopyObjectArray(grefJliArray,output_array1);
}catch(Exceptione){
ignore_t1=e;
break;
}
}
});
vart2=newThread(()=>{
for(inti=0;i<2000;++i){
Console.WriteLine("# t2 iter: {0}",i);
try{
JNIEnv.GetArray<int>(jarray);
}catch(Exceptione){
ignore_t2=e;
break;
}
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
for(inti=0;i<jarray.Length;++i){
jarray[i].Dispose();
jarray[i]=null;
}
JNIEnv.DeleteGlobalRef(grefJliArray);
Assert.IsNull(ignore_t1,string.Format("No exception should be thrown [t1]! Got: {0}",ignore_t1));
Assert.IsNull(ignore_t2,string.Format("No exception should be thrown [t2]! Got: {0}",ignore_t2));
}

so why didn't we hit this before?

Are thread pool threads different from "normal" new System.Threading.Thread()?

Do we need to do "different" kinds of JNI calls on the created thread to trigger the assertion? It likely wouldn't be a bad idea to have a test that does:

vart=newThread(()=>{varlist=newJava.Util.ArrayList();list.Add(newJava.Lang.String("a");list.Add(newJava.Lang.Integer(42);});t.Start();t.Join();

and do a bit more than straight JNIEnv calls on the created thread.

The assert message, as-is, does not imply to me that we need to worry about app exit. (And that's ignoring the fact that "app exit" is a very nebulous concept on Android in the first place!)

@grendello

Copy link
Copy Markdown
Contributor

@jonpryor I think the situation here is a bit different, at least from looking at the crash stack trace attached to #10314 (comment)

07-21 14:55:47.473 2121 2121 F DEBUG : #00 pc 0000000000539ff7 /system/lib64/libart.so (art::DumpCheckpoint::Run(art::Thread*)+439)
07-21 14:55:47.473 2121 2121 F DEBUG : #01 pc 000000000053cac6 /system/lib64/libart.so (art::ThreadList::RunCheckpoint(art::Closure*)+278)
07-21 14:55:47.473 2121 2121 F DEBUG : #02 pc 000000000053ddf5 /system/lib64/libart.so (art::ThreadList::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)+357)
07-21 14:55:47.473 2121 2121 F DEBUG : #03 pc 00000000004ffea4 /system/lib64/libart.so (art::Runtime::Abort()+660)
07-21 14:55:47.473 2121 2121 F DEBUG : #04 pc 0000000000178d71 /system/lib64/libart.so (art::LogMessage::~LogMessage()+2865)
07-21 14:55:47.473 2121 2121 F DEBUG : #05 pc 00000000005276ff /system/lib64/libart.so (art::Thread::ThreadExitCallback(void*)+223)
07-21 14:55:47.473 2121 2121 F DEBUG : #06 pc 000000000008590e /system/lib64/libc.so (pthread_key_clean_all()+142)
07-21 14:55:47.473 2121 2121 F DEBUG : #07 pc 000000000008549b /system/lib64/libc.so (pthread_exit+75)
07-21 14:55:47.473 2121 2121 F DEBUG : #08 pc 0000000000084ef6 /system/lib64/libc.so (__pthread_start(void*)+54)
07-21 14:55:47.473 2121 2121 F DEBUG : #09 pc 00000000000296eb /system/lib64/libc.so (__start_thread+11)
07-21 14:55:47.473 2121 2121 F DEBUG : #10 pc 000000000001ce55 /system/lib64/libc.so (__bionic_clone+53)

This is 100% native + ART. @simonrozsival might be onto something with this PR (btw, why was it closed?) the thread mentioned in the abort message (PID 4651) is created to run a test:

07-21 14:55:47.368 4496 4651 D monodroid: [precompiled] p/invoke found
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.ni.dll"...)
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.dll"...)
07-21 14:55:47.383 4496 4651 I DOTNET : [PASS] Charge_State
07-21 14:55:47.393 4496 4651 I DOTNET : [PASS] Charge_Level
07-21 14:55:47.395 4496 4651 I DOTNET : [PASS] Charge_Power
07-21 14:55:47.396 4496 4651 I DOTNET : [PASS] Unsubscribe_BatteryInfoChanged_Does_Not_Crash
07-21 14:55:47.399 4496 4651 I DOTNET : [PASS] App_Is_Not_Lower_Power_mode
07-21 14:55:47.407 4496 4651 W art : Native thread exiting without having called DetachCurrentThread (maybe it's going to use a pthread_key_create destructor?): Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4651 F art : art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4646 F DOTNET : Aborting process.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error. Our test assumes that calling a JNI method actually attaches the thread to JNI, which might not be the case.
In the MonoVM runtime we always attach every thread automatically, this is not necessarily the case with CoreCLR (or it might be a bug in CoreCLR - we don't control how/when it attaches threads)

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

btw, why was it closed?

I was not able to finish the work on the PR and fully test it, so I closed it for the time being. We can definitely revisit the PR.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error.

I don't know if this could be caused by the GC itself (GC has a background thread and the GC bridge has its own separate background thread, both of which are attached to JNI but both should live thoughout app's lifetime). The changes to the ensure_jnienv method affect other pieces of code, not just the GC bridge.

My hypothesis was that when this code is reached on a threadpool thread then the TP thread is attached to JNI and it is likely that this thread will be exitted at some point. This would cause the app to crash because we don't have the automatic detach machanism implemented. I wasn't able to reproduce the crash locally before I left on vacation, so I abandoned this attempt at that time.

@grendello

Copy link
Copy Markdown
Contributor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation? If it is indeed true, then the code crashing on CoreCLR but working on MonoVM would actually be a runtime issue. On MonoVM we get called back whenever any managed thread is created/destroyed, so we can attach/detach accordingly. On CoreCLR we don't have this opportunity, so it is on the runtime to do the right thing.

I used GC bridge as an example of what might occur, it's hard to tell from the stack trace that this is really the case - as you say, it can be anything that creates the thread, attaches it, but then fails to detach. If it's a managed thread, then it will exit only after the workload running it it finishes (I hope that this is the case), so there's still the chance to do the right thing there, a chance that maybe is missed for some reason?

TBH, I would love if CoreCLR called us back the same way MonoVM does on any thread creation/distruction, so that we can control things like JNI/JVM thread attachment/detachment.

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation?

If at some point the ensure_jnienv method is called and the thread isn't attached to JNI yet, it will be attached to JNI. So, if we register the automatic cleanup at this point, it should work just fine and there is no need for us to attach threads to JNI unnecessarily.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Sep 4, 2025
Sign up for freeto 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.

Running device tests for CoreCLR failing on maui on Android API 23

4 participants

@simonrozsival@jonpryor@grendello@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[CoreCLR] Automatically detach current thread from JNI - #10316

Closed
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads
Closed

[CoreCLR] Automatically detach current thread from JNI#10316
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Fixes#10314

In #10198 I added code which attaches current thread to JNI (https://github.com/dotnet/android/pull/10198/files#diff-fc0414b3741163879db7993fd1fb42fa76e5305bb54c0e9478e077b3094e7aa7R42-R46). If this code is called from a thread pool thread, the thread won't be detached from JNI when exitting.

This PR adds the recommended steps to automatically detach the thread from JNI when exitting: https://developer.android.com/training/articles/perf-jni#threads

/cc @grendello@jonathanpeppers

@jonathanpeppersjonathanpeppers left a comment

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.

Is there a way to add a test for this?

Or did MAUI's tests crash on exit? So, we'd need to somehow run an extra test suite?

@jonpryor

Copy link
Copy Markdown
Contributor

What's odd is that I think we have a test for this? Maybe?

From the abort message in #10314:

7-21 15:25:22.624 1610 1610 F DEBUG : Abort message: 'art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[15,tid=4355,Native,Thread*=0x7f3c09434600,peer=0x12c89580,"Thread-234"]'

which suggests that if we:

  1. Create a new Thread
  2. Do some JNI work on that thread, and
  3. Exit the thread

then the assertion should be triggered, no?

We have tests that do that, e.g.:

[Test]
publicvoidConversionsAndThreadsAndInstanceMappingsOhMy()
{
IntPtrlrefJliArray=JNIEnv.NewObjectArray<int>(new[]{1});
IntPtrgrefJliArray=JNIEnv.NewGlobalRef(lrefJliArray);
JNIEnv.DeleteLocalRef(lrefJliArray);
Java.Lang.Object[]jarray=(Java.Lang.Object[])
JNIEnv.GetArray(grefJliArray,JniHandleOwnership.DoNotTransfer,typeof(Java.Lang.Object));
Exceptionignore_t1=null;
Exceptionignore_t2=null;
vart1=newThread(()=>{
int[]output_array1=newint[1];
for(inti=0;i<2000;++i){
Console.WriteLine("# t1 iter: {0}",i);
try{
JNIEnv.CopyObjectArray(grefJliArray,output_array1);
}catch(Exceptione){
ignore_t1=e;
break;
}
}
});
vart2=newThread(()=>{
for(inti=0;i<2000;++i){
Console.WriteLine("# t2 iter: {0}",i);
try{
JNIEnv.GetArray<int>(jarray);
}catch(Exceptione){
ignore_t2=e;
break;
}
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
for(inti=0;i<jarray.Length;++i){
jarray[i].Dispose();
jarray[i]=null;
}
JNIEnv.DeleteGlobalRef(grefJliArray);
Assert.IsNull(ignore_t1,string.Format("No exception should be thrown [t1]! Got: {0}",ignore_t1));
Assert.IsNull(ignore_t2,string.Format("No exception should be thrown [t2]! Got: {0}",ignore_t2));
}

so why didn't we hit this before?

Are thread pool threads different from "normal" new System.Threading.Thread()?

Do we need to do "different" kinds of JNI calls on the created thread to trigger the assertion? It likely wouldn't be a bad idea to have a test that does:

vart=newThread(()=>{varlist=newJava.Util.ArrayList();list.Add(newJava.Lang.String("a");list.Add(newJava.Lang.Integer(42);});t.Start();t.Join();

and do a bit more than straight JNIEnv calls on the created thread.

The assert message, as-is, does not imply to me that we need to worry about app exit. (And that's ignoring the fact that "app exit" is a very nebulous concept on Android in the first place!)

@grendello

Copy link
Copy Markdown
Contributor

@jonpryor I think the situation here is a bit different, at least from looking at the crash stack trace attached to #10314 (comment)

07-21 14:55:47.473 2121 2121 F DEBUG : #00 pc 0000000000539ff7 /system/lib64/libart.so (art::DumpCheckpoint::Run(art::Thread*)+439)
07-21 14:55:47.473 2121 2121 F DEBUG : #01 pc 000000000053cac6 /system/lib64/libart.so (art::ThreadList::RunCheckpoint(art::Closure*)+278)
07-21 14:55:47.473 2121 2121 F DEBUG : #02 pc 000000000053ddf5 /system/lib64/libart.so (art::ThreadList::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)+357)
07-21 14:55:47.473 2121 2121 F DEBUG : #03 pc 00000000004ffea4 /system/lib64/libart.so (art::Runtime::Abort()+660)
07-21 14:55:47.473 2121 2121 F DEBUG : #04 pc 0000000000178d71 /system/lib64/libart.so (art::LogMessage::~LogMessage()+2865)
07-21 14:55:47.473 2121 2121 F DEBUG : #05 pc 00000000005276ff /system/lib64/libart.so (art::Thread::ThreadExitCallback(void*)+223)
07-21 14:55:47.473 2121 2121 F DEBUG : #06 pc 000000000008590e /system/lib64/libc.so (pthread_key_clean_all()+142)
07-21 14:55:47.473 2121 2121 F DEBUG : #07 pc 000000000008549b /system/lib64/libc.so (pthread_exit+75)
07-21 14:55:47.473 2121 2121 F DEBUG : #08 pc 0000000000084ef6 /system/lib64/libc.so (__pthread_start(void*)+54)
07-21 14:55:47.473 2121 2121 F DEBUG : #09 pc 00000000000296eb /system/lib64/libc.so (__start_thread+11)
07-21 14:55:47.473 2121 2121 F DEBUG : #10 pc 000000000001ce55 /system/lib64/libc.so (__bionic_clone+53)

This is 100% native + ART. @simonrozsival might be onto something with this PR (btw, why was it closed?) the thread mentioned in the abort message (PID 4651) is created to run a test:

07-21 14:55:47.368 4496 4651 D monodroid: [precompiled] p/invoke found
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.ni.dll"...)
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.dll"...)
07-21 14:55:47.383 4496 4651 I DOTNET : [PASS] Charge_State
07-21 14:55:47.393 4496 4651 I DOTNET : [PASS] Charge_Level
07-21 14:55:47.395 4496 4651 I DOTNET : [PASS] Charge_Power
07-21 14:55:47.396 4496 4651 I DOTNET : [PASS] Unsubscribe_BatteryInfoChanged_Does_Not_Crash
07-21 14:55:47.399 4496 4651 I DOTNET : [PASS] App_Is_Not_Lower_Power_mode
07-21 14:55:47.407 4496 4651 W art : Native thread exiting without having called DetachCurrentThread (maybe it's going to use a pthread_key_create destructor?): Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4651 F art : art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4646 F DOTNET : Aborting process.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error. Our test assumes that calling a JNI method actually attaches the thread to JNI, which might not be the case.
In the MonoVM runtime we always attach every thread automatically, this is not necessarily the case with CoreCLR (or it might be a bug in CoreCLR - we don't control how/when it attaches threads)

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

btw, why was it closed?

I was not able to finish the work on the PR and fully test it, so I closed it for the time being. We can definitely revisit the PR.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error.

I don't know if this could be caused by the GC itself (GC has a background thread and the GC bridge has its own separate background thread, both of which are attached to JNI but both should live thoughout app's lifetime). The changes to the ensure_jnienv method affect other pieces of code, not just the GC bridge.

My hypothesis was that when this code is reached on a threadpool thread then the TP thread is attached to JNI and it is likely that this thread will be exitted at some point. This would cause the app to crash because we don't have the automatic detach machanism implemented. I wasn't able to reproduce the crash locally before I left on vacation, so I abandoned this attempt at that time.

@grendello

Copy link
Copy Markdown
Contributor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation? If it is indeed true, then the code crashing on CoreCLR but working on MonoVM would actually be a runtime issue. On MonoVM we get called back whenever any managed thread is created/destroyed, so we can attach/detach accordingly. On CoreCLR we don't have this opportunity, so it is on the runtime to do the right thing.

I used GC bridge as an example of what might occur, it's hard to tell from the stack trace that this is really the case - as you say, it can be anything that creates the thread, attaches it, but then fails to detach. If it's a managed thread, then it will exit only after the workload running it it finishes (I hope that this is the case), so there's still the chance to do the right thing there, a chance that maybe is missed for some reason?

TBH, I would love if CoreCLR called us back the same way MonoVM does on any thread creation/distruction, so that we can control things like JNI/JVM thread attachment/detachment.

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation?

If at some point the ensure_jnienv method is called and the thread isn't attached to JNI yet, it will be attached to JNI. So, if we register the automatic cleanup at this point, it should work just fine and there is no need for us to attach threads to JNI unnecessarily.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Sep 4, 2025
Sign up for freeto 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.

Running device tests for CoreCLR failing on maui on Android API 23

4 participants

@simonrozsival@jonpryor@grendello@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[CoreCLR] Automatically detach current thread from JNI - #10316

Closed
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads
Closed

[CoreCLR] Automatically detach current thread from JNI#10316
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Fixes#10314

In #10198 I added code which attaches current thread to JNI (https://github.com/dotnet/android/pull/10198/files#diff-fc0414b3741163879db7993fd1fb42fa76e5305bb54c0e9478e077b3094e7aa7R42-R46). If this code is called from a thread pool thread, the thread won't be detached from JNI when exitting.

This PR adds the recommended steps to automatically detach the thread from JNI when exitting: https://developer.android.com/training/articles/perf-jni#threads

/cc @grendello@jonathanpeppers

@jonathanpeppersjonathanpeppers left a comment

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.

Is there a way to add a test for this?

Or did MAUI's tests crash on exit? So, we'd need to somehow run an extra test suite?

@jonpryor

Copy link
Copy Markdown
Contributor

What's odd is that I think we have a test for this? Maybe?

From the abort message in #10314:

7-21 15:25:22.624 1610 1610 F DEBUG : Abort message: 'art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[15,tid=4355,Native,Thread*=0x7f3c09434600,peer=0x12c89580,"Thread-234"]'

which suggests that if we:

  1. Create a new Thread
  2. Do some JNI work on that thread, and
  3. Exit the thread

then the assertion should be triggered, no?

We have tests that do that, e.g.:

[Test]
publicvoidConversionsAndThreadsAndInstanceMappingsOhMy()
{
IntPtrlrefJliArray=JNIEnv.NewObjectArray<int>(new[]{1});
IntPtrgrefJliArray=JNIEnv.NewGlobalRef(lrefJliArray);
JNIEnv.DeleteLocalRef(lrefJliArray);
Java.Lang.Object[]jarray=(Java.Lang.Object[])
JNIEnv.GetArray(grefJliArray,JniHandleOwnership.DoNotTransfer,typeof(Java.Lang.Object));
Exceptionignore_t1=null;
Exceptionignore_t2=null;
vart1=newThread(()=>{
int[]output_array1=newint[1];
for(inti=0;i<2000;++i){
Console.WriteLine("# t1 iter: {0}",i);
try{
JNIEnv.CopyObjectArray(grefJliArray,output_array1);
}catch(Exceptione){
ignore_t1=e;
break;
}
}
});
vart2=newThread(()=>{
for(inti=0;i<2000;++i){
Console.WriteLine("# t2 iter: {0}",i);
try{
JNIEnv.GetArray<int>(jarray);
}catch(Exceptione){
ignore_t2=e;
break;
}
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
for(inti=0;i<jarray.Length;++i){
jarray[i].Dispose();
jarray[i]=null;
}
JNIEnv.DeleteGlobalRef(grefJliArray);
Assert.IsNull(ignore_t1,string.Format("No exception should be thrown [t1]! Got: {0}",ignore_t1));
Assert.IsNull(ignore_t2,string.Format("No exception should be thrown [t2]! Got: {0}",ignore_t2));
}

so why didn't we hit this before?

Are thread pool threads different from "normal" new System.Threading.Thread()?

Do we need to do "different" kinds of JNI calls on the created thread to trigger the assertion? It likely wouldn't be a bad idea to have a test that does:

vart=newThread(()=>{varlist=newJava.Util.ArrayList();list.Add(newJava.Lang.String("a");list.Add(newJava.Lang.Integer(42);});t.Start();t.Join();

and do a bit more than straight JNIEnv calls on the created thread.

The assert message, as-is, does not imply to me that we need to worry about app exit. (And that's ignoring the fact that "app exit" is a very nebulous concept on Android in the first place!)

@grendello

Copy link
Copy Markdown
Contributor

@jonpryor I think the situation here is a bit different, at least from looking at the crash stack trace attached to #10314 (comment)

07-21 14:55:47.473 2121 2121 F DEBUG : #00 pc 0000000000539ff7 /system/lib64/libart.so (art::DumpCheckpoint::Run(art::Thread*)+439)
07-21 14:55:47.473 2121 2121 F DEBUG : #01 pc 000000000053cac6 /system/lib64/libart.so (art::ThreadList::RunCheckpoint(art::Closure*)+278)
07-21 14:55:47.473 2121 2121 F DEBUG : #02 pc 000000000053ddf5 /system/lib64/libart.so (art::ThreadList::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)+357)
07-21 14:55:47.473 2121 2121 F DEBUG : #03 pc 00000000004ffea4 /system/lib64/libart.so (art::Runtime::Abort()+660)
07-21 14:55:47.473 2121 2121 F DEBUG : #04 pc 0000000000178d71 /system/lib64/libart.so (art::LogMessage::~LogMessage()+2865)
07-21 14:55:47.473 2121 2121 F DEBUG : #05 pc 00000000005276ff /system/lib64/libart.so (art::Thread::ThreadExitCallback(void*)+223)
07-21 14:55:47.473 2121 2121 F DEBUG : #06 pc 000000000008590e /system/lib64/libc.so (pthread_key_clean_all()+142)
07-21 14:55:47.473 2121 2121 F DEBUG : #07 pc 000000000008549b /system/lib64/libc.so (pthread_exit+75)
07-21 14:55:47.473 2121 2121 F DEBUG : #08 pc 0000000000084ef6 /system/lib64/libc.so (__pthread_start(void*)+54)
07-21 14:55:47.473 2121 2121 F DEBUG : #09 pc 00000000000296eb /system/lib64/libc.so (__start_thread+11)
07-21 14:55:47.473 2121 2121 F DEBUG : #10 pc 000000000001ce55 /system/lib64/libc.so (__bionic_clone+53)

This is 100% native + ART. @simonrozsival might be onto something with this PR (btw, why was it closed?) the thread mentioned in the abort message (PID 4651) is created to run a test:

07-21 14:55:47.368 4496 4651 D monodroid: [precompiled] p/invoke found
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.ni.dll"...)
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.dll"...)
07-21 14:55:47.383 4496 4651 I DOTNET : [PASS] Charge_State
07-21 14:55:47.393 4496 4651 I DOTNET : [PASS] Charge_Level
07-21 14:55:47.395 4496 4651 I DOTNET : [PASS] Charge_Power
07-21 14:55:47.396 4496 4651 I DOTNET : [PASS] Unsubscribe_BatteryInfoChanged_Does_Not_Crash
07-21 14:55:47.399 4496 4651 I DOTNET : [PASS] App_Is_Not_Lower_Power_mode
07-21 14:55:47.407 4496 4651 W art : Native thread exiting without having called DetachCurrentThread (maybe it's going to use a pthread_key_create destructor?): Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4651 F art : art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4646 F DOTNET : Aborting process.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error. Our test assumes that calling a JNI method actually attaches the thread to JNI, which might not be the case.
In the MonoVM runtime we always attach every thread automatically, this is not necessarily the case with CoreCLR (or it might be a bug in CoreCLR - we don't control how/when it attaches threads)

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

btw, why was it closed?

I was not able to finish the work on the PR and fully test it, so I closed it for the time being. We can definitely revisit the PR.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error.

I don't know if this could be caused by the GC itself (GC has a background thread and the GC bridge has its own separate background thread, both of which are attached to JNI but both should live thoughout app's lifetime). The changes to the ensure_jnienv method affect other pieces of code, not just the GC bridge.

My hypothesis was that when this code is reached on a threadpool thread then the TP thread is attached to JNI and it is likely that this thread will be exitted at some point. This would cause the app to crash because we don't have the automatic detach machanism implemented. I wasn't able to reproduce the crash locally before I left on vacation, so I abandoned this attempt at that time.

@grendello

Copy link
Copy Markdown
Contributor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation? If it is indeed true, then the code crashing on CoreCLR but working on MonoVM would actually be a runtime issue. On MonoVM we get called back whenever any managed thread is created/destroyed, so we can attach/detach accordingly. On CoreCLR we don't have this opportunity, so it is on the runtime to do the right thing.

I used GC bridge as an example of what might occur, it's hard to tell from the stack trace that this is really the case - as you say, it can be anything that creates the thread, attaches it, but then fails to detach. If it's a managed thread, then it will exit only after the workload running it it finishes (I hope that this is the case), so there's still the chance to do the right thing there, a chance that maybe is missed for some reason?

TBH, I would love if CoreCLR called us back the same way MonoVM does on any thread creation/distruction, so that we can control things like JNI/JVM thread attachment/detachment.

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation?

If at some point the ensure_jnienv method is called and the thread isn't attached to JNI yet, it will be attached to JNI. So, if we register the automatic cleanup at this point, it should work just fine and there is no need for us to attach threads to JNI unnecessarily.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Sep 4, 2025
Sign up for freeto 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.

Running device tests for CoreCLR failing on maui on Android API 23

4 participants

@simonrozsival@jonpryor@grendello@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[CoreCLR] Automatically detach current thread from JNI - #10316

Closed
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads
Closed

[CoreCLR] Automatically detach current thread from JNI#10316
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Fixes#10314

In #10198 I added code which attaches current thread to JNI (https://github.com/dotnet/android/pull/10198/files#diff-fc0414b3741163879db7993fd1fb42fa76e5305bb54c0e9478e077b3094e7aa7R42-R46). If this code is called from a thread pool thread, the thread won't be detached from JNI when exitting.

This PR adds the recommended steps to automatically detach the thread from JNI when exitting: https://developer.android.com/training/articles/perf-jni#threads

/cc @grendello@jonathanpeppers

@jonathanpeppersjonathanpeppers left a comment

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.

Is there a way to add a test for this?

Or did MAUI's tests crash on exit? So, we'd need to somehow run an extra test suite?

@jonpryor

Copy link
Copy Markdown
Contributor

What's odd is that I think we have a test for this? Maybe?

From the abort message in #10314:

7-21 15:25:22.624 1610 1610 F DEBUG : Abort message: 'art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[15,tid=4355,Native,Thread*=0x7f3c09434600,peer=0x12c89580,"Thread-234"]'

which suggests that if we:

  1. Create a new Thread
  2. Do some JNI work on that thread, and
  3. Exit the thread

then the assertion should be triggered, no?

We have tests that do that, e.g.:

[Test]
publicvoidConversionsAndThreadsAndInstanceMappingsOhMy()
{
IntPtrlrefJliArray=JNIEnv.NewObjectArray<int>(new[]{1});
IntPtrgrefJliArray=JNIEnv.NewGlobalRef(lrefJliArray);
JNIEnv.DeleteLocalRef(lrefJliArray);
Java.Lang.Object[]jarray=(Java.Lang.Object[])
JNIEnv.GetArray(grefJliArray,JniHandleOwnership.DoNotTransfer,typeof(Java.Lang.Object));
Exceptionignore_t1=null;
Exceptionignore_t2=null;
vart1=newThread(()=>{
int[]output_array1=newint[1];
for(inti=0;i<2000;++i){
Console.WriteLine("# t1 iter: {0}",i);
try{
JNIEnv.CopyObjectArray(grefJliArray,output_array1);
}catch(Exceptione){
ignore_t1=e;
break;
}
}
});
vart2=newThread(()=>{
for(inti=0;i<2000;++i){
Console.WriteLine("# t2 iter: {0}",i);
try{
JNIEnv.GetArray<int>(jarray);
}catch(Exceptione){
ignore_t2=e;
break;
}
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
for(inti=0;i<jarray.Length;++i){
jarray[i].Dispose();
jarray[i]=null;
}
JNIEnv.DeleteGlobalRef(grefJliArray);
Assert.IsNull(ignore_t1,string.Format("No exception should be thrown [t1]! Got: {0}",ignore_t1));
Assert.IsNull(ignore_t2,string.Format("No exception should be thrown [t2]! Got: {0}",ignore_t2));
}

so why didn't we hit this before?

Are thread pool threads different from "normal" new System.Threading.Thread()?

Do we need to do "different" kinds of JNI calls on the created thread to trigger the assertion? It likely wouldn't be a bad idea to have a test that does:

vart=newThread(()=>{varlist=newJava.Util.ArrayList();list.Add(newJava.Lang.String("a");list.Add(newJava.Lang.Integer(42);});t.Start();t.Join();

and do a bit more than straight JNIEnv calls on the created thread.

The assert message, as-is, does not imply to me that we need to worry about app exit. (And that's ignoring the fact that "app exit" is a very nebulous concept on Android in the first place!)

@grendello

Copy link
Copy Markdown
Contributor

@jonpryor I think the situation here is a bit different, at least from looking at the crash stack trace attached to #10314 (comment)

07-21 14:55:47.473 2121 2121 F DEBUG : #00 pc 0000000000539ff7 /system/lib64/libart.so (art::DumpCheckpoint::Run(art::Thread*)+439)
07-21 14:55:47.473 2121 2121 F DEBUG : #01 pc 000000000053cac6 /system/lib64/libart.so (art::ThreadList::RunCheckpoint(art::Closure*)+278)
07-21 14:55:47.473 2121 2121 F DEBUG : #02 pc 000000000053ddf5 /system/lib64/libart.so (art::ThreadList::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)+357)
07-21 14:55:47.473 2121 2121 F DEBUG : #03 pc 00000000004ffea4 /system/lib64/libart.so (art::Runtime::Abort()+660)
07-21 14:55:47.473 2121 2121 F DEBUG : #04 pc 0000000000178d71 /system/lib64/libart.so (art::LogMessage::~LogMessage()+2865)
07-21 14:55:47.473 2121 2121 F DEBUG : #05 pc 00000000005276ff /system/lib64/libart.so (art::Thread::ThreadExitCallback(void*)+223)
07-21 14:55:47.473 2121 2121 F DEBUG : #06 pc 000000000008590e /system/lib64/libc.so (pthread_key_clean_all()+142)
07-21 14:55:47.473 2121 2121 F DEBUG : #07 pc 000000000008549b /system/lib64/libc.so (pthread_exit+75)
07-21 14:55:47.473 2121 2121 F DEBUG : #08 pc 0000000000084ef6 /system/lib64/libc.so (__pthread_start(void*)+54)
07-21 14:55:47.473 2121 2121 F DEBUG : #09 pc 00000000000296eb /system/lib64/libc.so (__start_thread+11)
07-21 14:55:47.473 2121 2121 F DEBUG : #10 pc 000000000001ce55 /system/lib64/libc.so (__bionic_clone+53)

This is 100% native + ART. @simonrozsival might be onto something with this PR (btw, why was it closed?) the thread mentioned in the abort message (PID 4651) is created to run a test:

07-21 14:55:47.368 4496 4651 D monodroid: [precompiled] p/invoke found
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.ni.dll"...)
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.dll"...)
07-21 14:55:47.383 4496 4651 I DOTNET : [PASS] Charge_State
07-21 14:55:47.393 4496 4651 I DOTNET : [PASS] Charge_Level
07-21 14:55:47.395 4496 4651 I DOTNET : [PASS] Charge_Power
07-21 14:55:47.396 4496 4651 I DOTNET : [PASS] Unsubscribe_BatteryInfoChanged_Does_Not_Crash
07-21 14:55:47.399 4496 4651 I DOTNET : [PASS] App_Is_Not_Lower_Power_mode
07-21 14:55:47.407 4496 4651 W art : Native thread exiting without having called DetachCurrentThread (maybe it's going to use a pthread_key_create destructor?): Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4651 F art : art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4646 F DOTNET : Aborting process.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error. Our test assumes that calling a JNI method actually attaches the thread to JNI, which might not be the case.
In the MonoVM runtime we always attach every thread automatically, this is not necessarily the case with CoreCLR (or it might be a bug in CoreCLR - we don't control how/when it attaches threads)

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

btw, why was it closed?

I was not able to finish the work on the PR and fully test it, so I closed it for the time being. We can definitely revisit the PR.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error.

I don't know if this could be caused by the GC itself (GC has a background thread and the GC bridge has its own separate background thread, both of which are attached to JNI but both should live thoughout app's lifetime). The changes to the ensure_jnienv method affect other pieces of code, not just the GC bridge.

My hypothesis was that when this code is reached on a threadpool thread then the TP thread is attached to JNI and it is likely that this thread will be exitted at some point. This would cause the app to crash because we don't have the automatic detach machanism implemented. I wasn't able to reproduce the crash locally before I left on vacation, so I abandoned this attempt at that time.

@grendello

Copy link
Copy Markdown
Contributor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation? If it is indeed true, then the code crashing on CoreCLR but working on MonoVM would actually be a runtime issue. On MonoVM we get called back whenever any managed thread is created/destroyed, so we can attach/detach accordingly. On CoreCLR we don't have this opportunity, so it is on the runtime to do the right thing.

I used GC bridge as an example of what might occur, it's hard to tell from the stack trace that this is really the case - as you say, it can be anything that creates the thread, attaches it, but then fails to detach. If it's a managed thread, then it will exit only after the workload running it it finishes (I hope that this is the case), so there's still the chance to do the right thing there, a chance that maybe is missed for some reason?

TBH, I would love if CoreCLR called us back the same way MonoVM does on any thread creation/distruction, so that we can control things like JNI/JVM thread attachment/detachment.

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation?

If at some point the ensure_jnienv method is called and the thread isn't attached to JNI yet, it will be attached to JNI. So, if we register the automatic cleanup at this point, it should work just fine and there is no need for us to attach threads to JNI unnecessarily.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Sep 4, 2025
Sign up for freeto 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.

Running device tests for CoreCLR failing on maui on Android API 23

4 participants

@simonrozsival@jonpryor@grendello@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[CoreCLR] Automatically detach current thread from JNI - #10316

Closed
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads
Closed

[CoreCLR] Automatically detach current thread from JNI#10316
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Fixes#10314

In #10198 I added code which attaches current thread to JNI (https://github.com/dotnet/android/pull/10198/files#diff-fc0414b3741163879db7993fd1fb42fa76e5305bb54c0e9478e077b3094e7aa7R42-R46). If this code is called from a thread pool thread, the thread won't be detached from JNI when exitting.

This PR adds the recommended steps to automatically detach the thread from JNI when exitting: https://developer.android.com/training/articles/perf-jni#threads

/cc @grendello@jonathanpeppers

@jonathanpeppersjonathanpeppers left a comment

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.

Is there a way to add a test for this?

Or did MAUI's tests crash on exit? So, we'd need to somehow run an extra test suite?

@jonpryor

Copy link
Copy Markdown
Contributor

What's odd is that I think we have a test for this? Maybe?

From the abort message in #10314:

7-21 15:25:22.624 1610 1610 F DEBUG : Abort message: 'art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[15,tid=4355,Native,Thread*=0x7f3c09434600,peer=0x12c89580,"Thread-234"]'

which suggests that if we:

  1. Create a new Thread
  2. Do some JNI work on that thread, and
  3. Exit the thread

then the assertion should be triggered, no?

We have tests that do that, e.g.:

[Test]
publicvoidConversionsAndThreadsAndInstanceMappingsOhMy()
{
IntPtrlrefJliArray=JNIEnv.NewObjectArray<int>(new[]{1});
IntPtrgrefJliArray=JNIEnv.NewGlobalRef(lrefJliArray);
JNIEnv.DeleteLocalRef(lrefJliArray);
Java.Lang.Object[]jarray=(Java.Lang.Object[])
JNIEnv.GetArray(grefJliArray,JniHandleOwnership.DoNotTransfer,typeof(Java.Lang.Object));
Exceptionignore_t1=null;
Exceptionignore_t2=null;
vart1=newThread(()=>{
int[]output_array1=newint[1];
for(inti=0;i<2000;++i){
Console.WriteLine("# t1 iter: {0}",i);
try{
JNIEnv.CopyObjectArray(grefJliArray,output_array1);
}catch(Exceptione){
ignore_t1=e;
break;
}
}
});
vart2=newThread(()=>{
for(inti=0;i<2000;++i){
Console.WriteLine("# t2 iter: {0}",i);
try{
JNIEnv.GetArray<int>(jarray);
}catch(Exceptione){
ignore_t2=e;
break;
}
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
for(inti=0;i<jarray.Length;++i){
jarray[i].Dispose();
jarray[i]=null;
}
JNIEnv.DeleteGlobalRef(grefJliArray);
Assert.IsNull(ignore_t1,string.Format("No exception should be thrown [t1]! Got: {0}",ignore_t1));
Assert.IsNull(ignore_t2,string.Format("No exception should be thrown [t2]! Got: {0}",ignore_t2));
}

so why didn't we hit this before?

Are thread pool threads different from "normal" new System.Threading.Thread()?

Do we need to do "different" kinds of JNI calls on the created thread to trigger the assertion? It likely wouldn't be a bad idea to have a test that does:

vart=newThread(()=>{varlist=newJava.Util.ArrayList();list.Add(newJava.Lang.String("a");list.Add(newJava.Lang.Integer(42);});t.Start();t.Join();

and do a bit more than straight JNIEnv calls on the created thread.

The assert message, as-is, does not imply to me that we need to worry about app exit. (And that's ignoring the fact that "app exit" is a very nebulous concept on Android in the first place!)

@grendello

Copy link
Copy Markdown
Contributor

@jonpryor I think the situation here is a bit different, at least from looking at the crash stack trace attached to #10314 (comment)

07-21 14:55:47.473 2121 2121 F DEBUG : #00 pc 0000000000539ff7 /system/lib64/libart.so (art::DumpCheckpoint::Run(art::Thread*)+439)
07-21 14:55:47.473 2121 2121 F DEBUG : #01 pc 000000000053cac6 /system/lib64/libart.so (art::ThreadList::RunCheckpoint(art::Closure*)+278)
07-21 14:55:47.473 2121 2121 F DEBUG : #02 pc 000000000053ddf5 /system/lib64/libart.so (art::ThreadList::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)+357)
07-21 14:55:47.473 2121 2121 F DEBUG : #03 pc 00000000004ffea4 /system/lib64/libart.so (art::Runtime::Abort()+660)
07-21 14:55:47.473 2121 2121 F DEBUG : #04 pc 0000000000178d71 /system/lib64/libart.so (art::LogMessage::~LogMessage()+2865)
07-21 14:55:47.473 2121 2121 F DEBUG : #05 pc 00000000005276ff /system/lib64/libart.so (art::Thread::ThreadExitCallback(void*)+223)
07-21 14:55:47.473 2121 2121 F DEBUG : #06 pc 000000000008590e /system/lib64/libc.so (pthread_key_clean_all()+142)
07-21 14:55:47.473 2121 2121 F DEBUG : #07 pc 000000000008549b /system/lib64/libc.so (pthread_exit+75)
07-21 14:55:47.473 2121 2121 F DEBUG : #08 pc 0000000000084ef6 /system/lib64/libc.so (__pthread_start(void*)+54)
07-21 14:55:47.473 2121 2121 F DEBUG : #09 pc 00000000000296eb /system/lib64/libc.so (__start_thread+11)
07-21 14:55:47.473 2121 2121 F DEBUG : #10 pc 000000000001ce55 /system/lib64/libc.so (__bionic_clone+53)

This is 100% native + ART. @simonrozsival might be onto something with this PR (btw, why was it closed?) the thread mentioned in the abort message (PID 4651) is created to run a test:

07-21 14:55:47.368 4496 4651 D monodroid: [precompiled] p/invoke found
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.ni.dll"...)
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.dll"...)
07-21 14:55:47.383 4496 4651 I DOTNET : [PASS] Charge_State
07-21 14:55:47.393 4496 4651 I DOTNET : [PASS] Charge_Level
07-21 14:55:47.395 4496 4651 I DOTNET : [PASS] Charge_Power
07-21 14:55:47.396 4496 4651 I DOTNET : [PASS] Unsubscribe_BatteryInfoChanged_Does_Not_Crash
07-21 14:55:47.399 4496 4651 I DOTNET : [PASS] App_Is_Not_Lower_Power_mode
07-21 14:55:47.407 4496 4651 W art : Native thread exiting without having called DetachCurrentThread (maybe it's going to use a pthread_key_create destructor?): Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4651 F art : art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4646 F DOTNET : Aborting process.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error. Our test assumes that calling a JNI method actually attaches the thread to JNI, which might not be the case.
In the MonoVM runtime we always attach every thread automatically, this is not necessarily the case with CoreCLR (or it might be a bug in CoreCLR - we don't control how/when it attaches threads)

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

btw, why was it closed?

I was not able to finish the work on the PR and fully test it, so I closed it for the time being. We can definitely revisit the PR.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error.

I don't know if this could be caused by the GC itself (GC has a background thread and the GC bridge has its own separate background thread, both of which are attached to JNI but both should live thoughout app's lifetime). The changes to the ensure_jnienv method affect other pieces of code, not just the GC bridge.

My hypothesis was that when this code is reached on a threadpool thread then the TP thread is attached to JNI and it is likely that this thread will be exitted at some point. This would cause the app to crash because we don't have the automatic detach machanism implemented. I wasn't able to reproduce the crash locally before I left on vacation, so I abandoned this attempt at that time.

@grendello

Copy link
Copy Markdown
Contributor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation? If it is indeed true, then the code crashing on CoreCLR but working on MonoVM would actually be a runtime issue. On MonoVM we get called back whenever any managed thread is created/destroyed, so we can attach/detach accordingly. On CoreCLR we don't have this opportunity, so it is on the runtime to do the right thing.

I used GC bridge as an example of what might occur, it's hard to tell from the stack trace that this is really the case - as you say, it can be anything that creates the thread, attaches it, but then fails to detach. If it's a managed thread, then it will exit only after the workload running it it finishes (I hope that this is the case), so there's still the chance to do the right thing there, a chance that maybe is missed for some reason?

TBH, I would love if CoreCLR called us back the same way MonoVM does on any thread creation/distruction, so that we can control things like JNI/JVM thread attachment/detachment.

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation?

If at some point the ensure_jnienv method is called and the thread isn't attached to JNI yet, it will be attached to JNI. So, if we register the automatic cleanup at this point, it should work just fine and there is no need for us to attach threads to JNI unnecessarily.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Sep 4, 2025
Sign up for freeto 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.

Running device tests for CoreCLR failing on maui on Android API 23

4 participants

@simonrozsival@jonpryor@grendello@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[CoreCLR] Automatically detach current thread from JNI - #10316

Closed
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads
Closed

[CoreCLR] Automatically detach current thread from JNI#10316
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Fixes#10314

In #10198 I added code which attaches current thread to JNI (https://github.com/dotnet/android/pull/10198/files#diff-fc0414b3741163879db7993fd1fb42fa76e5305bb54c0e9478e077b3094e7aa7R42-R46). If this code is called from a thread pool thread, the thread won't be detached from JNI when exitting.

This PR adds the recommended steps to automatically detach the thread from JNI when exitting: https://developer.android.com/training/articles/perf-jni#threads

/cc @grendello@jonathanpeppers

@jonathanpeppersjonathanpeppers left a comment

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.

Is there a way to add a test for this?

Or did MAUI's tests crash on exit? So, we'd need to somehow run an extra test suite?

@jonpryor

Copy link
Copy Markdown
Contributor

What's odd is that I think we have a test for this? Maybe?

From the abort message in #10314:

7-21 15:25:22.624 1610 1610 F DEBUG : Abort message: 'art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[15,tid=4355,Native,Thread*=0x7f3c09434600,peer=0x12c89580,"Thread-234"]'

which suggests that if we:

  1. Create a new Thread
  2. Do some JNI work on that thread, and
  3. Exit the thread

then the assertion should be triggered, no?

We have tests that do that, e.g.:

[Test]
publicvoidConversionsAndThreadsAndInstanceMappingsOhMy()
{
IntPtrlrefJliArray=JNIEnv.NewObjectArray<int>(new[]{1});
IntPtrgrefJliArray=JNIEnv.NewGlobalRef(lrefJliArray);
JNIEnv.DeleteLocalRef(lrefJliArray);
Java.Lang.Object[]jarray=(Java.Lang.Object[])
JNIEnv.GetArray(grefJliArray,JniHandleOwnership.DoNotTransfer,typeof(Java.Lang.Object));
Exceptionignore_t1=null;
Exceptionignore_t2=null;
vart1=newThread(()=>{
int[]output_array1=newint[1];
for(inti=0;i<2000;++i){
Console.WriteLine("# t1 iter: {0}",i);
try{
JNIEnv.CopyObjectArray(grefJliArray,output_array1);
}catch(Exceptione){
ignore_t1=e;
break;
}
}
});
vart2=newThread(()=>{
for(inti=0;i<2000;++i){
Console.WriteLine("# t2 iter: {0}",i);
try{
JNIEnv.GetArray<int>(jarray);
}catch(Exceptione){
ignore_t2=e;
break;
}
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
for(inti=0;i<jarray.Length;++i){
jarray[i].Dispose();
jarray[i]=null;
}
JNIEnv.DeleteGlobalRef(grefJliArray);
Assert.IsNull(ignore_t1,string.Format("No exception should be thrown [t1]! Got: {0}",ignore_t1));
Assert.IsNull(ignore_t2,string.Format("No exception should be thrown [t2]! Got: {0}",ignore_t2));
}

so why didn't we hit this before?

Are thread pool threads different from "normal" new System.Threading.Thread()?

Do we need to do "different" kinds of JNI calls on the created thread to trigger the assertion? It likely wouldn't be a bad idea to have a test that does:

vart=newThread(()=>{varlist=newJava.Util.ArrayList();list.Add(newJava.Lang.String("a");list.Add(newJava.Lang.Integer(42);});t.Start();t.Join();

and do a bit more than straight JNIEnv calls on the created thread.

The assert message, as-is, does not imply to me that we need to worry about app exit. (And that's ignoring the fact that "app exit" is a very nebulous concept on Android in the first place!)

@grendello

Copy link
Copy Markdown
Contributor

@jonpryor I think the situation here is a bit different, at least from looking at the crash stack trace attached to #10314 (comment)

07-21 14:55:47.473 2121 2121 F DEBUG : #00 pc 0000000000539ff7 /system/lib64/libart.so (art::DumpCheckpoint::Run(art::Thread*)+439)
07-21 14:55:47.473 2121 2121 F DEBUG : #01 pc 000000000053cac6 /system/lib64/libart.so (art::ThreadList::RunCheckpoint(art::Closure*)+278)
07-21 14:55:47.473 2121 2121 F DEBUG : #02 pc 000000000053ddf5 /system/lib64/libart.so (art::ThreadList::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)+357)
07-21 14:55:47.473 2121 2121 F DEBUG : #03 pc 00000000004ffea4 /system/lib64/libart.so (art::Runtime::Abort()+660)
07-21 14:55:47.473 2121 2121 F DEBUG : #04 pc 0000000000178d71 /system/lib64/libart.so (art::LogMessage::~LogMessage()+2865)
07-21 14:55:47.473 2121 2121 F DEBUG : #05 pc 00000000005276ff /system/lib64/libart.so (art::Thread::ThreadExitCallback(void*)+223)
07-21 14:55:47.473 2121 2121 F DEBUG : #06 pc 000000000008590e /system/lib64/libc.so (pthread_key_clean_all()+142)
07-21 14:55:47.473 2121 2121 F DEBUG : #07 pc 000000000008549b /system/lib64/libc.so (pthread_exit+75)
07-21 14:55:47.473 2121 2121 F DEBUG : #08 pc 0000000000084ef6 /system/lib64/libc.so (__pthread_start(void*)+54)
07-21 14:55:47.473 2121 2121 F DEBUG : #09 pc 00000000000296eb /system/lib64/libc.so (__start_thread+11)
07-21 14:55:47.473 2121 2121 F DEBUG : #10 pc 000000000001ce55 /system/lib64/libc.so (__bionic_clone+53)

This is 100% native + ART. @simonrozsival might be onto something with this PR (btw, why was it closed?) the thread mentioned in the abort message (PID 4651) is created to run a test:

07-21 14:55:47.368 4496 4651 D monodroid: [precompiled] p/invoke found
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.ni.dll"...)
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.dll"...)
07-21 14:55:47.383 4496 4651 I DOTNET : [PASS] Charge_State
07-21 14:55:47.393 4496 4651 I DOTNET : [PASS] Charge_Level
07-21 14:55:47.395 4496 4651 I DOTNET : [PASS] Charge_Power
07-21 14:55:47.396 4496 4651 I DOTNET : [PASS] Unsubscribe_BatteryInfoChanged_Does_Not_Crash
07-21 14:55:47.399 4496 4651 I DOTNET : [PASS] App_Is_Not_Lower_Power_mode
07-21 14:55:47.407 4496 4651 W art : Native thread exiting without having called DetachCurrentThread (maybe it's going to use a pthread_key_create destructor?): Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4651 F art : art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4646 F DOTNET : Aborting process.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error. Our test assumes that calling a JNI method actually attaches the thread to JNI, which might not be the case.
In the MonoVM runtime we always attach every thread automatically, this is not necessarily the case with CoreCLR (or it might be a bug in CoreCLR - we don't control how/when it attaches threads)

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

btw, why was it closed?

I was not able to finish the work on the PR and fully test it, so I closed it for the time being. We can definitely revisit the PR.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error.

I don't know if this could be caused by the GC itself (GC has a background thread and the GC bridge has its own separate background thread, both of which are attached to JNI but both should live thoughout app's lifetime). The changes to the ensure_jnienv method affect other pieces of code, not just the GC bridge.

My hypothesis was that when this code is reached on a threadpool thread then the TP thread is attached to JNI and it is likely that this thread will be exitted at some point. This would cause the app to crash because we don't have the automatic detach machanism implemented. I wasn't able to reproduce the crash locally before I left on vacation, so I abandoned this attempt at that time.

@grendello

Copy link
Copy Markdown
Contributor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation? If it is indeed true, then the code crashing on CoreCLR but working on MonoVM would actually be a runtime issue. On MonoVM we get called back whenever any managed thread is created/destroyed, so we can attach/detach accordingly. On CoreCLR we don't have this opportunity, so it is on the runtime to do the right thing.

I used GC bridge as an example of what might occur, it's hard to tell from the stack trace that this is really the case - as you say, it can be anything that creates the thread, attaches it, but then fails to detach. If it's a managed thread, then it will exit only after the workload running it it finishes (I hope that this is the case), so there's still the chance to do the right thing there, a chance that maybe is missed for some reason?

TBH, I would love if CoreCLR called us back the same way MonoVM does on any thread creation/distruction, so that we can control things like JNI/JVM thread attachment/detachment.

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation?

If at some point the ensure_jnienv method is called and the thread isn't attached to JNI yet, it will be attached to JNI. So, if we register the automatic cleanup at this point, it should work just fine and there is no need for us to attach threads to JNI unnecessarily.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Sep 4, 2025
Sign up for freeto 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.

Running device tests for CoreCLR failing on maui on Android API 23

4 participants

@simonrozsival@jonpryor@grendello@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[CoreCLR] Automatically detach current thread from JNI - #10316

Closed
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads
Closed

[CoreCLR] Automatically detach current thread from JNI#10316
simonrozsival wants to merge 3 commits into
mainfrom
dev/srozsival/detach-threads

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Fixes#10314

In #10198 I added code which attaches current thread to JNI (https://github.com/dotnet/android/pull/10198/files#diff-fc0414b3741163879db7993fd1fb42fa76e5305bb54c0e9478e077b3094e7aa7R42-R46). If this code is called from a thread pool thread, the thread won't be detached from JNI when exitting.

This PR adds the recommended steps to automatically detach the thread from JNI when exitting: https://developer.android.com/training/articles/perf-jni#threads

/cc @grendello@jonathanpeppers

@jonathanpeppersjonathanpeppers left a comment

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.

Is there a way to add a test for this?

Or did MAUI's tests crash on exit? So, we'd need to somehow run an extra test suite?

@jonpryor

Copy link
Copy Markdown
Contributor

What's odd is that I think we have a test for this? Maybe?

From the abort message in #10314:

7-21 15:25:22.624 1610 1610 F DEBUG : Abort message: 'art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[15,tid=4355,Native,Thread*=0x7f3c09434600,peer=0x12c89580,"Thread-234"]'

which suggests that if we:

  1. Create a new Thread
  2. Do some JNI work on that thread, and
  3. Exit the thread

then the assertion should be triggered, no?

We have tests that do that, e.g.:

[Test]
publicvoidConversionsAndThreadsAndInstanceMappingsOhMy()
{
IntPtrlrefJliArray=JNIEnv.NewObjectArray<int>(new[]{1});
IntPtrgrefJliArray=JNIEnv.NewGlobalRef(lrefJliArray);
JNIEnv.DeleteLocalRef(lrefJliArray);
Java.Lang.Object[]jarray=(Java.Lang.Object[])
JNIEnv.GetArray(grefJliArray,JniHandleOwnership.DoNotTransfer,typeof(Java.Lang.Object));
Exceptionignore_t1=null;
Exceptionignore_t2=null;
vart1=newThread(()=>{
int[]output_array1=newint[1];
for(inti=0;i<2000;++i){
Console.WriteLine("# t1 iter: {0}",i);
try{
JNIEnv.CopyObjectArray(grefJliArray,output_array1);
}catch(Exceptione){
ignore_t1=e;
break;
}
}
});
vart2=newThread(()=>{
for(inti=0;i<2000;++i){
Console.WriteLine("# t2 iter: {0}",i);
try{
JNIEnv.GetArray<int>(jarray);
}catch(Exceptione){
ignore_t2=e;
break;
}
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
for(inti=0;i<jarray.Length;++i){
jarray[i].Dispose();
jarray[i]=null;
}
JNIEnv.DeleteGlobalRef(grefJliArray);
Assert.IsNull(ignore_t1,string.Format("No exception should be thrown [t1]! Got: {0}",ignore_t1));
Assert.IsNull(ignore_t2,string.Format("No exception should be thrown [t2]! Got: {0}",ignore_t2));
}

so why didn't we hit this before?

Are thread pool threads different from "normal" new System.Threading.Thread()?

Do we need to do "different" kinds of JNI calls on the created thread to trigger the assertion? It likely wouldn't be a bad idea to have a test that does:

vart=newThread(()=>{varlist=newJava.Util.ArrayList();list.Add(newJava.Lang.String("a");list.Add(newJava.Lang.Integer(42);});t.Start();t.Join();

and do a bit more than straight JNIEnv calls on the created thread.

The assert message, as-is, does not imply to me that we need to worry about app exit. (And that's ignoring the fact that "app exit" is a very nebulous concept on Android in the first place!)

@grendello

Copy link
Copy Markdown
Contributor

@jonpryor I think the situation here is a bit different, at least from looking at the crash stack trace attached to #10314 (comment)

07-21 14:55:47.473 2121 2121 F DEBUG : #00 pc 0000000000539ff7 /system/lib64/libart.so (art::DumpCheckpoint::Run(art::Thread*)+439)
07-21 14:55:47.473 2121 2121 F DEBUG : #01 pc 000000000053cac6 /system/lib64/libart.so (art::ThreadList::RunCheckpoint(art::Closure*)+278)
07-21 14:55:47.473 2121 2121 F DEBUG : #02 pc 000000000053ddf5 /system/lib64/libart.so (art::ThreadList::Dump(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)+357)
07-21 14:55:47.473 2121 2121 F DEBUG : #03 pc 00000000004ffea4 /system/lib64/libart.so (art::Runtime::Abort()+660)
07-21 14:55:47.473 2121 2121 F DEBUG : #04 pc 0000000000178d71 /system/lib64/libart.so (art::LogMessage::~LogMessage()+2865)
07-21 14:55:47.473 2121 2121 F DEBUG : #05 pc 00000000005276ff /system/lib64/libart.so (art::Thread::ThreadExitCallback(void*)+223)
07-21 14:55:47.473 2121 2121 F DEBUG : #06 pc 000000000008590e /system/lib64/libc.so (pthread_key_clean_all()+142)
07-21 14:55:47.473 2121 2121 F DEBUG : #07 pc 000000000008549b /system/lib64/libc.so (pthread_exit+75)
07-21 14:55:47.473 2121 2121 F DEBUG : #08 pc 0000000000084ef6 /system/lib64/libc.so (__pthread_start(void*)+54)
07-21 14:55:47.473 2121 2121 F DEBUG : #09 pc 00000000000296eb /system/lib64/libc.so (__start_thread+11)
07-21 14:55:47.473 2121 2121 F DEBUG : #10 pc 000000000001ce55 /system/lib64/libc.so (__bionic_clone+53)

This is 100% native + ART. @simonrozsival might be onto something with this PR (btw, why was it closed?) the thread mentioned in the abort message (PID 4651) is created to run a test:

07-21 14:55:47.368 4496 4651 D monodroid: [precompiled] p/invoke found
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.ni.dll"...)
07-21 14:55:47.375 4496 4651 D monodroid: clr_external_assembly_probe ("xunit.assert.dll"...)
07-21 14:55:47.383 4496 4651 I DOTNET : [PASS] Charge_State
07-21 14:55:47.393 4496 4651 I DOTNET : [PASS] Charge_Level
07-21 14:55:47.395 4496 4651 I DOTNET : [PASS] Charge_Power
07-21 14:55:47.396 4496 4651 I DOTNET : [PASS] Unsubscribe_BatteryInfoChanged_Does_Not_Crash
07-21 14:55:47.399 4496 4651 I DOTNET : [PASS] App_Is_Not_Lower_Power_mode
07-21 14:55:47.407 4496 4651 W art : Native thread exiting without having called DetachCurrentThread (maybe it's going to use a pthread_key_create destructor?): Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4651 F art : art/runtime/thread.cc:1223] Native thread exited without calling DetachCurrentThread: Thread[17,tid=4651,Native,Thread*=0x7fc955cf4a00,peer=0x12c5a3a0,"Thread-212"]
07-21 14:55:47.408 4496 4646 F DOTNET : Aborting process.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error. Our test assumes that calling a JNI method actually attaches the thread to JNI, which might not be the case.
In the MonoVM runtime we always attach every thread automatically, this is not necessarily the case with CoreCLR (or it might be a bug in CoreCLR - we don't control how/when it attaches threads)

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

btw, why was it closed?

I was not able to finish the work on the PR and fully test it, so I closed it for the time being. We can definitely revisit the PR.

If the GC bridge kicks in while the test is running and on the same thread that crashes, this would explain the error.

I don't know if this could be caused by the GC itself (GC has a background thread and the GC bridge has its own separate background thread, both of which are attached to JNI but both should live thoughout app's lifetime). The changes to the ensure_jnienv method affect other pieces of code, not just the GC bridge.

My hypothesis was that when this code is reached on a threadpool thread then the TP thread is attached to JNI and it is likely that this thread will be exitted at some point. This would cause the app to crash because we don't have the automatic detach machanism implemented. I wasn't able to reproduce the crash locally before I left on vacation, so I abandoned this attempt at that time.

@grendello

Copy link
Copy Markdown
Contributor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation? If it is indeed true, then the code crashing on CoreCLR but working on MonoVM would actually be a runtime issue. On MonoVM we get called back whenever any managed thread is created/destroyed, so we can attach/detach accordingly. On CoreCLR we don't have this opportunity, so it is on the runtime to do the right thing.

I used GC bridge as an example of what might occur, it's hard to tell from the stack trace that this is really the case - as you say, it can be anything that creates the thread, attaches it, but then fails to detach. If it's a managed thread, then it will exit only after the workload running it it finishes (I hope that this is the case), so there's still the chance to do the right thing there, a chance that maybe is missed for some reason?

TBH, I would love if CoreCLR called us back the same way MonoVM does on any thread creation/distruction, so that we can control things like JNI/JVM thread attachment/detachment.

@simonrozsival

Copy link
Copy Markdown
MemberAuthor

@simonrozsival does threadpool actually attach threads to JNI? If I remember correctly, both MonoVM and CoreCLR use the same managed threadpool implementation?

If at some point the ensure_jnienv method is called and the thread isn't attached to JNI yet, it will be attached to JNI. So, if we register the automatic cleanup at this point, it should work just fine and there is no need for us to attach threads to JNI unnecessarily.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Sep 4, 2025
Sign up for freeto 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.

Running device tests for CoreCLR failing on maui on Android API 23

4 participants

@simonrozsival@jonpryor@grendello@jonathanpeppers