Skip to content

Propagate exception to wasm-js and js in propagateExceptionFinalResort #4472

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 28, 2025
11 changes: 6 additions & 5 deletions kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineConte
* with the corresponding exception when the handler is called. Normally, the handler is used to
* log the exception, show some kind of error message, terminate, and/or restart the application.
*
* If you need to handle exception in a specific part of the code, it is recommended to use `try`/`catch` around
* If you need to handle the exception in a specific part of the code, it is recommended to use `try`/`catch` around
* the corresponding code inside your coroutine. This way you can prevent completion of the coroutine
* with the exception (exception is now _caught_), retry the operation, and/or take other arbitrary actions:
*
Expand All @@ -83,14 +83,15 @@ public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineConte
*
* ### Uncaught exceptions with no handler
*
* When no handler is installed, exception are handled in the following way:
* - If exception is [CancellationException], it is ignored, as these exceptions are used to cancel coroutines.
* When no handler is installed, an exception is handled in the following way:
* - If the exception is [CancellationException], it is ignored, as these exceptions are used to cancel coroutines.
* - Otherwise, if there is a [Job] in the context, then [Job.cancel] is invoked.
* - Otherwise, as a last resort, the exception is processed in a platform-specific manner:
* - On JVM, all instances of [CoroutineExceptionHandler] found via [ServiceLoader], as well as
* the current thread's [Thread.uncaughtExceptionHandler], are invoked.
* - On Native, the whole application crashes with the exception.
* - On JS, the exception is logged via the Console API.
* - On JS and Wasm JS, the exception is propagated into the JavaScript runtime's event loop
* and is processed in a platform-specific way determined by the platform itself.
*
* [CoroutineExceptionHandler] can be invoked from an arbitrary thread.
*/
Expand All @@ -102,7 +103,7 @@ public interface CoroutineExceptionHandler : CoroutineContext.Element {

/**
* Handles uncaught [exception] in the given [context]. It is invoked
* if coroutine has an uncaught exception.
* if the coroutine has an uncaught exception.
*/
public fun handleException(context: CoroutineContext, exception: Throwable)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal expect val platformExceptionHandlers: Collection<CoroutineExceptionHand
internal expect fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler)

/**
* The platform-dependent global exception handler, used so that the exception is logged at least *somewhere*.
* The platform-dependent global exception handler, used so that the exception is processed at least *somewhere*.
*/
internal expect fun propagateExceptionFinalResort(exception: Throwable)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package kotlinx.coroutines.internal

import kotlinx.coroutines.*
import kotlin.js.unsafeCast

internal actual fun propagateExceptionFinalResort(exception: Throwable) {
// log exception
console.error(exception.toString())
}
internal actual external interface JsAny

internal actual fun Throwable.toJsException(): JsAny = this.unsafeCast<JsAny>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package kotlinx.coroutines

import kotlinx.coroutines.testing.*
import kotlin.js.*
import kotlin.test.*

class PropagateExceptionFinalResortTest : TestBase() {
@BeforeTest
private fun removeListeners() {
// Remove a Node.js's internal listener, which prints the exception to stdout.
js("""
globalThis.originalListeners = process.listeners('uncaughtException');
process.removeAllListeners('uncaughtException');
""")
}

@AfterTest
private fun restoreListeners() {
js("""
if (globalThis.originalListeners) {
process.removeAllListeners('uncaughtException');
globalThis.originalListeners.forEach(function(listener) {
process.on('uncaughtException', listener);
});
}
""")
}

/*
* Test that `propagateExceptionFinalResort` re-throws the exception on JS.
*
* It is checked by setting up an exception handler within JS.
*/
@Test
fun testPropagateExceptionFinalResortReThrowsOnNodeJS() = runTest {
js("""
globalThis.exceptionCaught = false;
process.on('uncaughtException', function(e) {
globalThis.exceptionCaught = true;
});
""")
val job = GlobalScope.launch {
throw IllegalStateException("My ISE")
}
job.join()
delay(1) // Let the exception be re-thrown and handled.
val exceptionCaught = js("globalThis.exceptionCaught") as Boolean
assertTrue(exceptionCaught)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package kotlinx.coroutines.internal

import kotlinx.coroutines.*

internal expect interface JsAny

internal expect fun Throwable.toJsException(): JsAny

/*
* Schedule an exception to be thrown inside JS or Wasm/JS event loop,
* rather than in the current execution branch.
*/
internal fun throwAsync(e: JsAny): Unit = js("setTimeout(function () { throw e }, 0)")

internal actual fun propagateExceptionFinalResort(exception: Throwable) {
throwAsync(exception.toJsException())
Copy link
Member

Choose a reason for hiding this comment

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

@murfel @dkhalanskyjb can we throw an exception right from here? May it break anything inside kx-coroutines?

In my demo project, throwAsync was mainly needed to escape kx-coroutines handling and emulate the unhandled exception behavior while using CoroutineExceptionHandler.

Copy link
Collaborator

Choose a reason for hiding this comment

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

No, this method is not allowed to fail. It's called deep inside the coroutine internals, and if it throws anything, things will break.

Copy link
Member

Choose a reason for hiding this comment

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

From kotlin 2.2.20-Beta2, in kotlin code, you can just throw a kotlin exception, and everything else will be done by kotlin runtime/compiler, including unwrapping JsExecption.

throw e

But this way, users using old toolchain will get a WebAssembly.Exception.

We can try kotlin version at runtime and use a fallback for older versions, but should we?

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

We can replace this with just throw e when kotlinx.coroutines upgrades to Kotlin 2.2.20. Would that work?

}
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package kotlinx.coroutines.internal

import kotlinx.coroutines.*
internal actual typealias JsAny = kotlin.js.JsAny

internal actual fun propagateExceptionFinalResort(exception: Throwable) {
// log exception
console.error(exception.toString())
}
internal actual fun Throwable.toJsException(): JsAny =
toJsError(message, this::class.simpleName, stackTraceToString())

internal fun toJsError(message: String?, className: String?, stack: String?): JsAny {
js("""
const error = new Error();
error.message = message;
error.name = className;
error.stack = stack;
return error;
""")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package kotlinx.coroutines

import kotlinx.coroutines.testing.TestBase
import kotlin.test.*

class PropagateExceptionFinalResortTest : TestBase() {
@BeforeTest
private fun addUncaughtExceptionHandler() {
addUncaughtExceptionHandlerHelper()
}

@AfterTest
private fun removeHandler() {
removeHandlerHelper()
}

/*
* Test that `propagateExceptionFinalResort` re-throws the exception on Wasm/JS.
*
* It is checked by setting up an exception handler within Wasm/JS.
*/
@Test
fun testPropagateExceptionFinalResortReThrowsOnWasmJS() = runTest {
val job = GlobalScope.launch {
throw IllegalStateException("My ISE")
}
job.join()
delay(1) // Let the exception be re-thrown and handled.
assertTrue(exceptionCaught())
}
}

private fun addUncaughtExceptionHandlerHelper() {
js("""
globalThis.exceptionCaught = false;
globalThis.exceptionHandler = function(e) {
globalThis.exceptionCaught = true;
};
process.on('uncaughtException', globalThis.exceptionHandler);
""")
}

private fun removeHandlerHelper() {
js("""
process.removeListener('uncaughtException', globalThis.exceptionHandler);
""")
}

private fun exceptionCaught(): Boolean = js("globalThis.exceptionCaught")