Skip to content
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

Negative consequences of catchNonTestRelatedExceptions from TestScope to Spring Boot apps #4335

Open
man85 opened this issue Jan 17, 2025 · 22 comments

Comments

@man85
Copy link

man85 commented Jan 17, 2025

There is no ability to change the value of catchNonTestRelatedExceptions, but kdoc says "some tests may want to disable it" @dkhalanskyjb

@man85 man85 added the bug label Jan 17, 2025
@dkhalanskyjb
Copy link
Collaborator

@man85, that's intentional, because we don't publicly support this functionality. It's purely a workaround for some codebases that have their own specialized error handling or a temporary measure for large codebases migrating to a new version of kotlinx-coroutines-test. We may even remove this workaround at a later point without considering it a breaking change.

If you do want to change that value anyway, here's how: #3736 (comment) Could you explain why you want to do that? We are interested in learning the use cases to decide if this needs a proper public API, a hidden API for workarounds, or if it can be removed.

@man85
Copy link
Author

man85 commented Jan 17, 2025

Could you explain why you want to do that?

We have a test class which makes database migrations before each test method via JUnit extension and it does it with a SupervisorJob. In this job happens exception, because of wrong SQL for the first test method (with runTest) and this exception is being processed with invokeOnCompletion, but ExceptionCollector takes this exception after first test method fail and when second test starts (with runTest), UncaughtExceptionsBeforeTest will be thrown.

In this case, such an exception in a job coroutine should not be treated as an error and should not be collected by the ExceptionCollector. That's why I would like to disable this feature of collecting unprocessed errors.

Same case was described here.

@dkhalanskyjb
Copy link
Collaborator

this exception is being processed with invokeOnCompletion

That's not the intended way to process exceptions. If you check your logs, you'll probably see that these failures are spammed as stacktraces. What you need is https://kotlinlang.org/docs/exception-handling.html#coroutineexceptionhandler . With CoroutineExceptionHandler, exceptions will neither be spammed into logs nor fail tests. Also, invokeOnCompletion is a deeply technical, low-level thing, and it's best to avoid using it unless you have some special requirements (for example, invokeOnCompletion is valuable when you're writing your own concurrent data structures or coroutine primitives, for example).

@dkhalanskyjb
Copy link
Collaborator

If you are trying to replace kotlinx-coroutines-test exception handling with this, then this approach wouldn't help you, because if there are several globally installed CoroutineExceptionHandlers, all of them get notified by default. The order is:

  • Try notifying the parent coroutine.
  • If that fails, try a local CoroutineExceptionHandler.
  • If there is no local CoroutineExceptionHandler, notify the global ones.

To answer your question directly, you need to let the service loader machinery know about the exposed service, so you need to add a META-INF file like https://github.com/Kotlin/kotlinx.coroutines/blob/master/ui/kotlinx-coroutines-android/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler

@man85
Copy link
Author

man85 commented Jan 20, 2025

Thanks for the clarification!
I suppose it would be great to have a CoroutineExceptionHandler for each test instance class, but not globally.

@dkhalanskyjb
Copy link
Collaborator

This is how kotlinx-coroutines-test already works, in a way: runTest will report the uncaught exceptions at the end of the test, so if all of your coroutines finish by the time runTest exits, you will never encounter UncaughtExceptionsBeforeTest. UncaughtExceptionsBeforeTest is the last-ditch effort to report the exceptions at least somewhere so that you get alerted about an error even if the error happens after the test has already completed.

@man85
Copy link
Author

man85 commented Jan 20, 2025

If test classes are executed in parallel, is it possible that an exception from one test class will be reported in another?

@man85
Copy link
Author

man85 commented Jan 20, 2025

runTest will report the uncaught exceptions at the end of the test

But runTest cannot track exceptions that occurred before the first test method, because report callbacks for uncaught exceptions are not yet registered with ExceptionCollector.

Try execute:

import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestMethodOrder
import java.util.concurrent.atomic.AtomicInteger

@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TestA {
    val failureNumber = AtomicInteger(0)

    @BeforeEach
    fun failure(){
        val context = CoroutineName(this.javaClass.name)
        val backgroundScope = CoroutineScope(context + Dispatchers.Unconfined)
        backgroundScope.launch {
            val errorMsg = "Background failure ${failureNumber.incrementAndGet()}"
            println("Throwing $errorMsg")
            throw RuntimeException(errorMsg)
        }
    }

    @Test
    @Order(1)
    fun test1() = runTest {}

    @Test
    @Order(2)
    fun test2() = runTest {}

    @Test
    @Order(3)
    fun test3() = runTest {}
}


Background failure 1 swallowed

@dkhalanskyjb Should I report it as a bug?

@man85
Copy link
Author

man85 commented Jan 21, 2025

If test classes are executed in parallel, is it possible that an exception from one test class will be reported in another?

Yes, it is possible. Steps to reproduce:

  1. Create TestA and TestB in same package.
  2. Specify environment variable in IDE JAVA_TOOL_OPTIONS=-Djunit.jupiter.execution.parallel.enabled=true -Djunit.jupiter.execution.parallel.mode.default=concurrent to run tests in parallel.
  3. Run tests.
  4. TestB fails because of background failure of TestA: Suppressed: java.lang.RuntimeException: Background failure А
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestMethodOrder

@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TestA {

    @BeforeEach
    fun failure() {
        Thread.sleep(5000)
        val context = CoroutineName(this.javaClass.name)
        val backgroundScope = CoroutineScope(context + Dispatchers.Unconfined)
        backgroundScope.launch {
            throw RuntimeException("Background failure А")
        }
        Thread.sleep(20000)
    }

    @Test
    @Order(1)
    fun test1() = runTest {}
}
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestMethodOrder

@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TestB {
    @AfterEach
    fun waitForFailureOccurred() {
        Thread.sleep(10000)
    }

    @Test
    @Order(1)
    fun test1() = runTest {}

    @Test
    @Order(2)
    fun test2() = runTest {}
}

@dkhalanskyjb Should I report it as a bug?

@dkhalanskyjb
Copy link
Collaborator

But runTest cannot track exceptions that occurred before the first test method, because report callbacks for uncaught exceptions are not yet registered with ExceptionCollector.

Yes, that's true. We intentionally chose to do this so that there wouldn't be a memory leak due to storing exceptions for a runTest invocation that never happens, which is possible in codebases that accidentally add a kotlinx-coroutines-test dependency and don't use it.

Should I report it as a bug?

I don't see the point. First, it's impossible for us to fix this short of prohibiting parallel runTest invocations, because the coroutines in your example have no relation to the tests they were launched in. Second, any exception that actually goes to the ExceptionCollector is an anomaly. On Android, in production, any such uncaught exception crashes the whole application. On Kotlin/Native, too. On non-Android Kotlin/JVM, the exception handler of the thread is used, which spams the stacktrace to logs by default. If you receive an exception from ExceptionCollector, this means some of your coroutine scopes have neither a CoroutineExceptionHandler nor a parent coroutine, so with the absence of any proper ways to propagate the exception, the last-ditch-effort exception propagation mechanism is used. The same lack of structure is what prevents runTest from tracking exceptions back to the tests that led to those exceptions. Exceptions originating in coroutines that are children of the TestScope of a runTest don't have to use CoroutineExceptionHandler or the global handlers, the coroutines machinery deals with exception propagation.

@man85
Copy link
Author

man85 commented Jan 21, 2025

any exception that actually goes to the ExceptionCollector is an anomaly

I can't agree with you.
In the following code snippet, exceptions and success execution are normally processed in invokeOnCompletion, but ExceptionCollector catches exceptions.

 val job = SupervisorJob(coroutineContext.job)
 activeMigrationJob = launch(job) { 
     ...
 }.invokeOnCompletion {
      if (it == null) {
          ...
      } else ...
 }

@dkhalanskyjb
Copy link
Collaborator

exceptions and success execution are normally processed

They are not.

As I said in #4335 (comment), invokeOnCompletion is not an exception-handling mechanism, it's a low-level primitive for extremely specialized scenarios. You can quickly verify this by running this code:

        val job = SupervisorJob()
        val scope = CoroutineScope(Dispatchers.Default + job)
        scope.launch {
            throw IllegalStateException("never happens")
        }.invokeOnCompletion {
            if (it == null) {
                println("No exception")
            } else {
                println("Got an exception ($it)")
            }
        }
        Thread.sleep(100)

This code finishes without errors, but in the console, we see:

Exception in thread "DefaultDispatcher-worker-1 @coroutine#1" java.lang.IllegalStateException: never happens
	at kotlinx.coroutines.C$failure$1.invokeSuspend(DispatcherKeyTest.kt:59)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:829)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [CoroutineId(1), "coroutine#1":StandaloneCoroutine{Cancelling}@1d9cabc0, Dispatchers.Default]
Got an exception (java.lang.IllegalStateException: never happens)

On Native, the equivalent code crashes (replace Thread.sleep(100) with platform.posix.sleep(1.toUInt())):

Execution failed for task ':kotlinx-coroutines-core:linuxX64Test'.
> Test running process exited unexpectedly.
  Current test: failure
  Process output:
       at 0   test.kexe                           0xb64d0d           kfun:kotlin.Throwable#<init>(kotlin.String?){} + 93 
      at 1   test.kexe                           0xb5ffa9           kfun:kotlin.Exception#<init>(kotlin.String?){} + 89 
      at 2   test.kexe                           0xb600e9           kfun:kotlin.RuntimeException#<init>(kotlin.String?){} + 89 
      at 3   test.kexe                           0xb603f9           kfun:kotlin.IllegalStateException#<init>(kotlin.String?){} + 89 
      at 4   test.kexe                           0xb34138           kfun:kotlinx.coroutines.C.failure$lambda$0#internal + 152 
      at 5   test.kexe                           0xb3437d           kfun:kotlinx.coroutines.C.$failure$lambda$0$FUNCTION_REFERENCE$9.invoke#internal + 109 
      at 6   test.kexe                           0xc75b78           kfun:kotlin.Function2#invoke(1:0;1:1){}1:2-trampoline + 88 
      at 7   test.kexe                           0xb6eeb6           kfun:kotlin.coroutines.intrinsics.object-4.invokeSuspend#internal + 1158 
      at 8   test.kexe                           0xc754f3           kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#invokeSuspend(kotlin.Result<kotlin.Any?>){}kotlin.Any?-trampoline + 51 
      at 9   test.kexe                           0xb69c13           kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#resumeWith(kotlin.Result<kotlin.Any?>){} + 963 
      at 10  test.kexe                           0xc75599           kfun:kotlin.coroutines.Continuation#resumeWith(kotlin.Result<1:0>){}-trampoline + 73 
      at 11  test.kexe                           0x493209           kfun:kotlinx.coroutines.DispatchedTask#run(){} + 2969 
      at 12  test.kexe                           0xb3c5f0           kfun:kotlinx.coroutines.Runnable#run(){}-trampoline + 64 
      at 13  test.kexe                           0x4c44f2           kfun:kotlinx.coroutines.MultiWorkerDispatcher.$workerRunLoop$lambda$2COROUTINE$0.invokeSuspend#internal + 2066 
      at 14  test.kexe                           0x4c4a42           kfun:kotlinx.coroutines.MultiWorkerDispatcher.workerRunLoop$lambda$2#internal + 322 
      at 15  test.kexe                           0x4c4d93           kfun:kotlinx.coroutines.MultiWorkerDispatcher.$workerRunLoop$lambda$2$FUNCTION_REFERENCE$4.invoke#internal + 131 
      at 16  test.kexe                           0xc75b78           kfun:kotlin.Function2#invoke(1:0;1:1){}1:2-trampoline + 88 
      at 17  test.kexe                           0xb6eeb6           kfun:kotlin.coroutines.intrinsics.object-4.invokeSuspend#internal + 1158 
      at 18  test.kexe                           0xc754f3           kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#invokeSuspend(kotlin.Result<kotlin.Any?>){}kotlin.Any?-trampoline + 51 
      at 19  test.kexe                           0xb69c13           kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#resumeWith(kotlin.Result<kotlin.Any?>){} + 963 
      at 20  test.kexe                           0xc75599           kfun:kotlin.coroutines.Continuation#resumeWith(kotlin.Result<1:0>){}-trampoline + 73 
      at 21  test.kexe                           0x493209           kfun:kotlinx.coroutines.DispatchedTask#run(){} + 2969 
      at 22  test.kexe                           0xb3c5f0           kfun:kotlinx.coroutines.Runnable#run(){}-trampoline + 64 
      at 23  test.kexe                           0x3c3a8b           kfun:kotlinx.coroutines.EventLoopImplBase#processNextEvent(){}kotlin.Long + 219 
      at 24  test.kexe                           0xb3c403           kfun:kotlinx.coroutines.EventLoop#processNextEvent(){}kotlin.Long-trampoline + 35 
      at 25  test.kexe                           0x4bbf67           kfun:kotlinx.coroutines.BlockingCoroutine.joinBlocking#internal + 647 
      at 26  test.kexe                           0x4bb75f           kfun:kotlinx.coroutines#runBlocking(kotlin.coroutines.CoroutineContext;kotlin.coroutines.SuspendFunction1<kotlinx.coroutines.CoroutineScope,0:0>){0§<kotlin.Any?>}0:0 + 1919 
      at 27  test.kexe                           0x4bb984           kfun:kotlinx.coroutines#runBlocking$default(kotlin.coroutines.CoroutineContext?;kotlin.coroutines.SuspendFunction1<kotlinx.coroutines.CoroutineScope,0:0>;kotlin.Int){0§<kotlin.Any?>}0:0 + 260 
      at 28  test.kexe                           0x4c1ccb           kfun:kotlinx.coroutines.MultiWorkerDispatcher.workerRunLoop#internal + 187 
      at 29  test.kexe                           0x4c384b           kfun:kotlinx.coroutines.MultiWorkerDispatcher.<init>$lambda$1$lambda$0#internal + 59 
      at 30  test.kexe                           0x4c4f8f           kfun:kotlinx.coroutines.MultiWorkerDispatcher.$<init>$lambda$1$lambda$0$FUNCTION_REFERENCE$6.invoke#internal + 63 
      at 31  test.kexe                           0x4c504f           kfun:kotlinx.coroutines.MultiWorkerDispatcher.$<init>$lambda$1$lambda$0$FUNCTION_REFERENCE$6.$<bridge-DNN>invoke(){}#internal + 63 
      at 32  test.kexe                           0xc73718           kfun:kotlin.Function0#invoke(){}1:0-trampoline + 72 
      at 33  test.kexe                           0xb74fe9           WorkerLaunchpad + 105 
      at 34  test.kexe                           0xca4edb           _ZN6Worker19processQueueElementEb + 3403 
      at 35  test.kexe                           0xca40c1           _ZN12_GLOBAL__N_113workerRoutineEPv + 177 
      at 36  libc.so.6                           0x7f61f8e2cd01     0x0 + 140058764168449 
      at 37  libc.so.6                           0x7f61f8eac3ab     0x0 + 140058764690347 
      Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@f7cc3340, DefaultDispatcher@f72225c8]
          at 0   test.kexe                           0xb64d0d           kfun:kotlin.Throwable#<init>(kotlin.String?){} + 93 
          at 1   test.kexe                           0xb5ffa9           kfun:kotlin.Exception#<init>(kotlin.String?){} + 89 
          at 2   test.kexe                           0xb600e9           kfun:kotlin.RuntimeException#<init>(kotlin.String?){} + 89 
          at 3   test.kexe                           0x4c64c7           kfun:kotlinx.coroutines.internal.DiagnosticCoroutineContextException#<init>(kotlin.coroutines.CoroutineContext){} + 167 
          at 4   test.kexe                           0x48e3ca           kfun:kotlinx.coroutines.internal#handleUncaughtCoroutineException(kotlin.coroutines.CoroutineContext;kotlin.Throwable){} + 874 
          at 5   test.kexe                           0x3bee20           kfun:kotlinx.coroutines#handleCoroutineException(kotlin.coroutines.CoroutineContext;kotlin.Throwable){} + 944 
          at 6   test.kexe                           0x3b0ddf           kfun:kotlinx.coroutines.StandaloneCoroutine.handleJobException#internal + 159 
          at 7   test.kexe                           0xb3d3bb           kfun:kotlinx.coroutines.JobSupport#handleJobException(kotlin.Throwable){}kotlin.Boolean-trampoline + 43 
          at 8   test.kexe                           0x3cb28e           kfun:kotlinx.coroutines.JobSupport.finalizeFinishingState#internal + 1582 
          at 9   test.kexe                           0x3d57c6           kfun:kotlinx.coroutines.JobSupport.tryMakeCompletingSlowPath#internal + 3062 
          at 10  test.kexe                           0x3d4b89           kfun:kotlinx.coroutines.JobSupport.tryMakeCompleting#internal + 697 
          at 11  test.kexe                           0x3d4670           kfun:kotlinx.coroutines.JobSupport#makeCompletingOnce(kotlin.Any?){}kotlin.Any? + 672 
          at 12  test.kexe                           0x3abc64           kfun:kotlinx.coroutines.AbstractCoroutine#resumeWith(kotlin.Result<1:0>){} + 228 
          at 13  test.kexe                           0xc75599           kfun:kotlin.coroutines.Continuation#resumeWith(kotlin.Result<1:0>){}-trampoline + 73 
          at 14  test.kexe                           0xb69efc           kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#resumeWith(kotlin.Result<kotlin.Any?>){} + 1708 
          ... and 28 more common stack frames skipped
  Uncaught Kotlin exception: 

So, you are not normally processing exceptions.

This code does process exceptions:

        val job = SupervisorJob()
        val scope = CoroutineScope(Dispatchers.Default + job + CoroutineExceptionHandler {
            _, throwable -> println("Caught an exception ($throwable)")
        })
        scope.launch {
            throw IllegalStateException("never happens")
        }

It won't crash.

This is fine as well:

        val job = SupervisorJob()
        val scope = CoroutineScope(Dispatchers.Default + job)
        scope.launch(CoroutineExceptionHandler {
            _, throwable -> println("Caught an exception ($throwable)")
        }) {
            throw IllegalStateException("never happens")
        }

@man85
Copy link
Author

man85 commented Jan 28, 2025

This code finishes without errors ...

This means that uncaught exceptions are normal when a coroutine is started from a non-suspended function.

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.junit.jupiter.api.Test

class ExceptionInsideCoroutineTest {
    @Test
    fun exceptionInsideSupervisorJob() {
        val job = SupervisorJob()
        val scope = CoroutineScope(Dispatchers.Default + job)
        scope.launch {
            throw IllegalStateException("never happens")
        }.invokeOnCompletion {
            if (it == null) {
                println("No exception")
            } else {
                println("Got an exception ($it)")
            }
        }
        Thread.sleep(1000)
    }
}

... but in the console, we see:

@dkhalanskyjb You should not care about the console. This situation is like you do some work in daemon thread, but if this daemon thread fails, the app should not fail.

On Native, the equivalent code crashes (replace Thread.sleep(100) with platform.posix.sleep(1.toUInt()))

Native is not a common case

@dkhalanskyjb
Copy link
Collaborator

Native is not a common case

Android also crashes the whole application if there are uncaught exceptions. If something can be called the common case for Kotlin, it's Android.

You should not care about the console

On the contrary, you should. If you are handling coroutine exceptions properly, then the only exceptions in your logs are exceptions you don't expect and don't properly handle, which usually indicates a problem in your code. It's very convenient when the console helps you debug your code.

This means that uncaught exceptions are normal

The guiding principle for exception handling in Kotlin is https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07, which says that no, they aren't. Because of that, runTest treats uncaught exceptions as problems. This is fully intentional. Exceptions in Kotlin programs aren't expected, and we try to ensure that the programmer gets notified about every exception.

Also, there are no reasons to use invokeOnCompletion, which was never intended for processing uncaught exceptions, instead of CoroutineExceptionHandler, which was created for this exact purpose. Please take a look at the documentation for invokeOnCompletion: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/invoke-on-completion.html

Implementation of CompletionHandler must be fast, non-blocking, and thread-safe.

Many typical cases of exception handling don't have these properties, which is why invokeOnCompletion is unsuitable for that.

@man85
Copy link
Author

man85 commented Jan 28, 2025

Android also crashes the whole application if there are uncaught exceptions

@dkhalanskyjb Can you give an example of such crash in JVM?

@dkhalanskyjb
Copy link
Collaborator

dkhalanskyjb commented Jan 29, 2025 via email

@man85
Copy link
Author

man85 commented Jan 29, 2025

Android is JVM

@dkhalanskyjb Android is a special case. Could you give an example of Spring Boot application with such crash?

@dkhalanskyjb
Copy link
Collaborator

Nope, I think a Spring Boot application wouldn't crash because of incorrect handling of uncaught exceptions in coroutines.

@man85
Copy link
Author

man85 commented Jan 29, 2025

Nope, I think a Spring Boot application wouldn't crash because of incorrect handling of uncaught exceptions in coroutines.

As a summary:

  1. CatchNonTestRelatedExceptions mechanism brings benefits to Android.
  2. CatchNonTestRelatedExceptions mechanism brings negative consequences to Spring Boot app, considering unhandled errors as the cause of application failure. Because such exceptions do not lead to Spring Boot application failure, kotlinx-coroutine-test should not threat them as a test failure. It should be provided a normal way without reflection to disable this feature. Moreover, this feature leads to induced errors in another test classes, what confuses the developer when analyzing test failures.

@man85 man85 changed the title There is no ability to change value of catchNonTestRelatedExceptions variable in TestScope Negative consequences of catchNonTestRelatedExceptions from TestScope to Spring Boot apps Jan 29, 2025
@dkhalanskyjb
Copy link
Collaborator

Because such exceptions do not lead to Spring Boot application failure, kotlinx-coroutine-test should not threat them as a test failure.

This part of your summary is incorrect. Tests very often fail when production would keep working, but produce the wrong result or have undesirable aspects of behavior. That's what test assertions are for. In your scenario, the test failure warns you about the undesirable behavior of invoking the default thread exception handler.

It should be provided a normal way without reflection to disable this feature.

If we see a compelling use case for that, sure, we're ready to expose this functionality. The code examples you've shown in this thread are all incorrectly handling coroutine failures, and fixing that gets rid of the issue, cleans up your logs, improves the performance in cases when there are many failing coroutines, makes your code more idiomatic, and helps you avoid the error-prone invokeOnCompletion API.

@man85
Copy link
Author

man85 commented Jan 31, 2025

@dkhalanskyjb , what is the correct way to do some logic asynchronously after a coroutine job has successfully completed?

@dkhalanskyjb
Copy link
Collaborator

There are several options, depending on what behavior you want. Here are a couple of examples, but if you clarify your use case (what should happen in the cleanup when your job is cancelled, fails with an error, or succeeds; which thread/dispatcher should run the logic after the completion of the coroutine; also, whether you control the code running in the coroutine, whether you have access to the scope in which that coroutine is launched, and whether you control if it's launch or async), I'll describe a suitable solution.

The easiest and most idiomatic solution is to use try/catch/finally if you're in control of the code in your coroutine:

val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
  try {
    // what you want to do
  } catch (e: IllegalArgumentException) { // or any other exception you're expecting
    // process the exceptions
  } finally {
    // cleanup resources
  }
}

Alternatively, you can replace launch with async, and then .await() will rethrow the exception with which the block finished:

val scope = CoroutineScope(Dispatchers.Default)
val anotherScope = CoroutineScope(Dispatchers.Default)
val deferred = scope.async { /* some code */ }
anotherScope.launch {
  try {
    deferred.await()
    // your normal non-throwing cleanup code
  } catch (e: Throwable) {
    // your cleanup code for the error scenario
  }
}

In the example above, I chose anotherScope instead of scope because if some code fails, it will cancel scope as well if it has a default job (non-supervisor).

Or another scenario:

val scope = CoroutineScope(Dispatchers.Default)
val job = scope.launch(CoroutineExceptionHandler { ctx, throwable -> /* process the exception */ }) {
  /* some code */
}
scope.launch { // will not run at all if `scope` fails before this code block is entered; use CoroutineStart.ATOMIC to change this
  job.join() // await completion, or fail with a `CancellationException` if `scope` gets cancelled
  // cleanup for normal completion
}

I could go on, because there are many different use cases that require different threading and error propagation behavior, but in general, the approach is: launch a new coroutine that waits for your coroutine to complete, and there, process the result as needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants