withContext breaks coroutine supervision

I have the following exception handler (just as example):

class StatefulCoroutineExceptionHandler(
    private val logging: Boolean
) : CoroutineExceptionHandler {
    val throwables = mutableListOf<Throwable>()

    override val key: CoroutineContext.Key<*> = CoroutineExceptionHandler.Key

    override fun handleException(
        context: CoroutineContext,
        exception: Throwable,
    ) {
        if (logging) {
            log.error("{} - {}", exception.javaClass.canonicalName, exception.message)
        }
        throwables.add(exception)
    }
}

If I do something like this:

val handler = StatefulCoroutineExceptionHandler(logging)
val ans = supervisorScope {
    launch(handler) { ... }
    launch(handler) { ... }
}

everything works as expected. I thought I could try using withContext like so:

val handler = StatefulCoroutineExceptionHandler(logging)
val ans = supervisorScope {
    withContext(handler) {
        launch { ... }
        launch { ... }
    }
}

but not only does that not work, it actually breaks supervision - if I throw an exception from one of the launched jobs, the other one is cancelled. This also seems to be the case even if I ignore the handler completely and simply do something like withContext(CoroutineName("foo")).

Is this expected? Can I never use withContext together with supervisorScope?

Tested with kotlin 2.1.21 BTW.

1 Like

@alexis such behavior is undocumented, but “expected”.

The documentation should be updated as part of Document structured concurrency by dkhalanskyjb · Pull Request #4433 · Kotlin/kotlinx.coroutines · GitHub to cover such cases.

Here’s another report similar to your question: supervisorScope does not handle exceptions from children within withContext as expected · Issue #4385 · Kotlin/kotlinx.coroutines · GitHub

1 Like

supervisorScope doesn’t apply deeply, but only to its direct children. And in this example it has a single child only which is withContext. Inside withContext regular coroutine behavior applies, so a failure of one child cancels another one.

Keep in mind that you can design your own withContext that’d get around this. I do wonder if withContext should be special-cased here so that it mimics its parent, since this behaviour can be rather surprising (especially if your mental model is that withContext simply changes the context, but seemingly it acts more so like coroutineScope)

Yes, that was my mental model, that’s why I thought it could work. I imagine withContext needs its own job to actually wait for its children to finish before restoring the context, which would be very important when changing dispatchers. On the other hand, making supervisorScope transitive means we could call code we didn’t write and modify its behavior, which could end up being even more surprising.

Maybe the best approach would be to add a context parameter to supervisorScope? I’m not sure, I suppose there’s a reason it doesn’t have it.

1 Like

I think you could probably pass the supervisorScope to the withContext. Maybe something like:

supervisorScope {
    withContext(this) {
        ...
    }
}

No idea if that actually works, I’m just guessing. :face_with_tongue:

1 Like

I think this should work:

withContext(SupervisorJob(currentCoroutineContext().job) + …your other context…) {
    launch { … }
    launch { … }
}

Haven’t tested it, but I think it won’t work. In general, withContext(coroutineContext + x) {} is equivalent to withContext(x), so what you’ve done is like supervisorScope { withContext(x) { ... } }, which is the same situation we’re in. Instead, you want something like withContext(x) { supervisorScope { } }