From 2ca6f46b02438128857bff36d6b0340733d1427b Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 16 Sep 2025 13:52:00 +0200 Subject: [PATCH] Ignore exceptions thrown from Thread.UncaughtExceptionHandler Fixes #4516 Background ---------- Whenever an *uncaught* exception happens in a coroutine, it gets reported to the `CoroutineExceptionHandler`. See . However, if it's not installed, a platform-specific handler is used. On the JVM, this means invoking the thread's `UncaughtExceptionHandler`, which logs the exception to the console by default, but can be configured to do other things (for example, on Android, it will crash the application). Problem ------- User-specified `UncaughtExceptionHandler` instances are allowed to throw exceptions. Java's documentation says so (https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.UncaughtExceptionHandler.html): > Any exception thrown by this method will be ignored by the Java Virtual Machine. This means a user is allowed to write a throwing `UncaughtExceptionHandler`, and the caller has to deal with it. In our implementation, however, we are simply invoking the exception handler as a plain function, and if that function throws an exception, we allow this exception to propagate to the coroutine machinery, causing it to fail. Solution -------- To comply with the contract defined for `UncaughtExceptionHandler`, we also ignore the exceptions thrown from there. --- .../internal/CoroutineExceptionHandlerImpl.kt | 12 +++- .../CoroutineExceptionHandlerJvmTest.kt | 66 ++++++++++++------- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImpl.kt index 2d048ac71a..960c064309 100644 --- a/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImpl.kt @@ -28,7 +28,17 @@ internal actual fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExce internal actual fun propagateExceptionFinalResort(exception: Throwable) { // use the thread's handler val currentThread = Thread.currentThread() - currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception) + val exceptionHandler = currentThread.uncaughtExceptionHandler + try { + exceptionHandler.uncaughtException(currentThread, exception) + } catch (_: Throwable) { + /* Do nothing. + * From https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.UncaughtExceptionHandler.html : + * > Any exception thrown by this method will be ignored by the Java Virtual Machine. + * + * This means the authors of the thread exception handlers have the right to throw exceptions indiscriminately. + * We have no further channels for propagating the fatal exception, so we give up. */ + } } // This implementation doesn't store a stacktrace, which is good because a stacktrace doesn't make sense for this. diff --git a/kotlinx-coroutines-core/jvm/test/exceptions/CoroutineExceptionHandlerJvmTest.kt b/kotlinx-coroutines-core/jvm/test/exceptions/CoroutineExceptionHandlerJvmTest.kt index 2e3519902c..8433581be2 100644 --- a/kotlinx-coroutines-core/jvm/test/exceptions/CoroutineExceptionHandlerJvmTest.kt +++ b/kotlinx-coroutines-core/jvm/test/exceptions/CoroutineExceptionHandlerJvmTest.kt @@ -8,28 +8,15 @@ import kotlin.test.* class CoroutineExceptionHandlerJvmTest : TestBase() { - private val exceptionHandler = Thread.getDefaultUncaughtExceptionHandler() - private lateinit var caughtException: Throwable - - @Before - fun setUp() { - Thread.setDefaultUncaughtExceptionHandler({ _, e -> caughtException = e }) - } - - @After - fun tearDown() { - Thread.setDefaultUncaughtExceptionHandler(exceptionHandler) - } - @Test fun testFailingHandler() = runBlocking { expect(1) - val job = GlobalScope.launch(CoroutineExceptionHandler { _, _ -> throw AssertionError() }) { - expect(2) - throw TestException() + val caughtException = catchingUncaughtException { + GlobalScope.launch(CoroutineExceptionHandler { _, _ -> throw AssertionError() }) { + expect(2) + throw TestException() + }.join() } - - job.join() assertIs(caughtException) assertIs(caughtException.cause) assertIs(caughtException.suppressed[0]) @@ -40,12 +27,47 @@ class CoroutineExceptionHandlerJvmTest : TestBase() { @Test fun testLastDitchHandlerContainsContextualInformation() = runBlocking { expect(1) - GlobalScope.launch(CoroutineName("last-ditch")) { - expect(2) - throw TestException() - }.join() + val caughtException = catchingUncaughtException { + GlobalScope.launch(CoroutineName("last-ditch")) { + expect(2) + throw TestException() + }.join() + } assertIs(caughtException) assertContains(caughtException.suppressed[0].toString(), "last-ditch") finish(3) } + + @Test + fun testFailingUncaughtExceptionHandler() = runBlocking { + expect(1) + withUncaughtExceptionHandler({ _, e -> + expect(3) + throw TestException("uncaught") + }) { + launch(Job()) { + expect(2) + throw TestException("to be reported") + }.join() + } + finish(4) + } +} + +private inline fun catchingUncaughtException(action: () -> Unit): Throwable? { + var caughtException: Throwable? = null + withUncaughtExceptionHandler({ _, e -> caughtException = e }) { + action() + } + return caughtException +} + +private inline fun withUncaughtExceptionHandler(handler: Thread.UncaughtExceptionHandler, action: () -> T): T { + val exceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(handler) + try { + return action() + } finally { + Thread.setDefaultUncaughtExceptionHandler(exceptionHandler) + } }