Skip to content

Commit 6f0fb66

Browse files
authored
Propagate exception to wasmJs and JS in propagateExceptionFinalResort (#4472)
Fixes #4451
1 parent bdf86fb commit 6f0fb66

File tree

7 files changed

+140
-16
lines changed

7 files changed

+140
-16
lines changed

kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineConte
6767
* with the corresponding exception when the handler is called. Normally, the handler is used to
6868
* log the exception, show some kind of error message, terminate, and/or restart the application.
6969
*
70-
* If you need to handle exception in a specific part of the code, it is recommended to use `try`/`catch` around
70+
* If you need to handle the exception in a specific part of the code, it is recommended to use `try`/`catch` around
7171
* the corresponding code inside your coroutine. This way you can prevent completion of the coroutine
7272
* with the exception (exception is now _caught_), retry the operation, and/or take other arbitrary actions:
7373
*
@@ -83,14 +83,15 @@ public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineConte
8383
*
8484
* ### Uncaught exceptions with no handler
8585
*
86-
* When no handler is installed, exception are handled in the following way:
87-
* - If exception is [CancellationException], it is ignored, as these exceptions are used to cancel coroutines.
86+
* When no handler is installed, an exception is handled in the following way:
87+
* - If the exception is [CancellationException], it is ignored, as these exceptions are used to cancel coroutines.
8888
* - Otherwise, if there is a [Job] in the context, then [Job.cancel] is invoked.
8989
* - Otherwise, as a last resort, the exception is processed in a platform-specific manner:
9090
* - On JVM, all instances of [CoroutineExceptionHandler] found via [ServiceLoader], as well as
9191
* the current thread's [Thread.uncaughtExceptionHandler], are invoked.
9292
* - On Native, the whole application crashes with the exception.
93-
* - On JS, the exception is logged via the Console API.
93+
* - On JS and Wasm JS, the exception is propagated into the JavaScript runtime's event loop
94+
* and is processed in a platform-specific way determined by the platform itself.
9495
*
9596
* [CoroutineExceptionHandler] can be invoked from an arbitrary thread.
9697
*/
@@ -102,7 +103,7 @@ public interface CoroutineExceptionHandler : CoroutineContext.Element {
102103

103104
/**
104105
* Handles uncaught [exception] in the given [context]. It is invoked
105-
* if coroutine has an uncaught exception.
106+
* if the coroutine has an uncaught exception.
106107
*/
107108
public fun handleException(context: CoroutineContext, exception: Throwable)
108109
}

kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.common.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ internal expect val platformExceptionHandlers: Collection<CoroutineExceptionHand
1515
internal expect fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler)
1616

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
package kotlinx.coroutines.internal
22

3-
import kotlinx.coroutines.*
3+
import kotlin.js.unsafeCast
44

5-
internal actual fun propagateExceptionFinalResort(exception: Throwable) {
6-
// log exception
7-
console.error(exception.toString())
8-
}
5+
internal actual external interface JsAny
6+
7+
internal actual fun Throwable.toJsException(): JsAny = this.unsafeCast<JsAny>()
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package kotlinx.coroutines
2+
3+
import kotlinx.coroutines.testing.*
4+
import kotlin.js.*
5+
import kotlin.test.*
6+
7+
class PropagateExceptionFinalResortTest : TestBase() {
8+
@BeforeTest
9+
private fun removeListeners() {
10+
// Remove a Node.js's internal listener, which prints the exception to stdout.
11+
js("""
12+
globalThis.originalListeners = process.listeners('uncaughtException');
13+
process.removeAllListeners('uncaughtException');
14+
""")
15+
}
16+
17+
@AfterTest
18+
private fun restoreListeners() {
19+
js("""
20+
if (globalThis.originalListeners) {
21+
process.removeAllListeners('uncaughtException');
22+
globalThis.originalListeners.forEach(function(listener) {
23+
process.on('uncaughtException', listener);
24+
});
25+
}
26+
""")
27+
}
28+
29+
/*
30+
* Test that `propagateExceptionFinalResort` re-throws the exception on JS.
31+
*
32+
* It is checked by setting up an exception handler within JS.
33+
*/
34+
@Test
35+
fun testPropagateExceptionFinalResortReThrowsOnNodeJS() = runTest {
36+
js("""
37+
globalThis.exceptionCaught = false;
38+
process.on('uncaughtException', function(e) {
39+
globalThis.exceptionCaught = true;
40+
});
41+
""")
42+
val job = GlobalScope.launch {
43+
throw IllegalStateException("My ISE")
44+
}
45+
job.join()
46+
delay(1) // Let the exception be re-thrown and handled.
47+
val exceptionCaught = js("globalThis.exceptionCaught") as Boolean
48+
assertTrue(exceptionCaught)
49+
}
50+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package kotlinx.coroutines.internal
2+
3+
import kotlinx.coroutines.*
4+
5+
internal expect interface JsAny
6+
7+
internal expect fun Throwable.toJsException(): JsAny
8+
9+
/*
10+
* Schedule an exception to be thrown inside JS or Wasm/JS event loop,
11+
* rather than in the current execution branch.
12+
*/
13+
internal fun throwAsync(e: JsAny): Unit = js("setTimeout(function () { throw e }, 0)")
14+
15+
internal actual fun propagateExceptionFinalResort(exception: Throwable) {
16+
throwAsync(exception.toJsException())
17+
}
Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
package kotlinx.coroutines.internal
22

3-
import kotlinx.coroutines.*
3+
internal actual typealias JsAny = kotlin.js.JsAny
44

5-
internal actual fun propagateExceptionFinalResort(exception: Throwable) {
6-
// log exception
7-
console.error(exception.toString())
8-
}
5+
internal actual fun Throwable.toJsException(): JsAny =
6+
toJsError(message, this::class.simpleName, stackTraceToString())
7+
8+
internal fun toJsError(message: String?, className: String?, stack: String?): JsAny {
9+
js("""
10+
const error = new Error();
11+
error.message = message;
12+
error.name = className;
13+
error.stack = stack;
14+
return error;
15+
""")
16+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package kotlinx.coroutines
2+
3+
import kotlinx.coroutines.testing.TestBase
4+
import kotlin.test.*
5+
6+
class PropagateExceptionFinalResortTest : TestBase() {
7+
@BeforeTest
8+
private fun addUncaughtExceptionHandler() {
9+
addUncaughtExceptionHandlerHelper()
10+
}
11+
12+
@AfterTest
13+
private fun removeHandler() {
14+
removeHandlerHelper()
15+
}
16+
17+
/*
18+
* Test that `propagateExceptionFinalResort` re-throws the exception on Wasm/JS.
19+
*
20+
* It is checked by setting up an exception handler within Wasm/JS.
21+
*/
22+
@Test
23+
fun testPropagateExceptionFinalResortReThrowsOnWasmJS() = runTest {
24+
val job = GlobalScope.launch {
25+
throw IllegalStateException("My ISE")
26+
}
27+
job.join()
28+
delay(1) // Let the exception be re-thrown and handled.
29+
assertTrue(exceptionCaught())
30+
}
31+
}
32+
33+
private fun addUncaughtExceptionHandlerHelper() {
34+
js("""
35+
globalThis.exceptionCaught = false;
36+
globalThis.exceptionHandler = function(e) {
37+
globalThis.exceptionCaught = true;
38+
};
39+
process.on('uncaughtException', globalThis.exceptionHandler);
40+
""")
41+
}
42+
43+
private fun removeHandlerHelper() {
44+
js("""
45+
process.removeListener('uncaughtException', globalThis.exceptionHandler);
46+
""")
47+
}
48+
49+
private fun exceptionCaught(): Boolean = js("globalThis.exceptionCaught")

0 commit comments

Comments
 (0)