Skip to content
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
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
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 testThrows() = runTest {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd expect a more descriptive name, like testHandlingUncaughtExceptionsInNodeJs. Maybe even a documentation comment explaining what exactly we are checking here. Given how much JS code there is in this file, it's not trivial to figure out.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I feel like test name is too short to include a very specific descriptive name, so I've only added a comment. Do you want me to rename the test name anyway into the next best thing? (I assume with the goal of having a better name should this test fail.)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, having a rough idea of what failed when you see the list of failed tests after introducing a change is the point.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's just that all the info is in the class name + path (for platform), and naming this accurately would be duplicating those. Had we had a dozen tests, we wouldn't be naming them so verbosely. I've done it anyways for now.

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.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Minor: an extra space.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, double space before comment was intentional. I think I got it from Python.

I wish the IDE reformat action would point this out, too!

Fixed to single space.

Copy link
Collaborator

Choose a reason for hiding this comment

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

No big deal, this is just not the style adopted in our library.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can we enforce it in automated checks?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm open to this (and preventing merging if the style is incorrect), but only if this check doesn't make the CI fail for intermediate commits. It would be annoying to push code just to test something, without ensuring the code is pretty, only to be met with a useless style check complaint. That said, it makes sense to have this check block merging.

Copy link
Contributor

Choose a reason for hiding this comment

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

Note that instead of making such a check part of the regular CI pipeline, or a pre-commit hook, it could also be implemented as a GH Action, (I'm not 100% sure about this part, but) that'll report all problems right into the PR.

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,33 @@
package kotlinx.coroutines

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

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

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

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