Skip to content

Unexpected behaviour of the join() method of CompletableJob #1527

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

Closed
vngantk opened this issue Sep 11, 2019 · 6 comments
Closed

Unexpected behaviour of the join() method of CompletableJob #1527

vngantk opened this issue Sep 11, 2019 · 6 comments
Assignees
Labels
docs KDoc and API reference question

Comments

@vngantk
Copy link

vngantk commented Sep 11, 2019

I happen to find that the behaviour of the join() method of CompletableJob returned by Job() is not what I would expect. Consider the following code:

fun main() = runBlocking {
    val job = Job()
    launch {
        delay(1000)
        job.completeExceptionally(IllegalStateException("Failed"))
    }
    try {
        job.join()
        println("foo")
    } catch (e: Exception) {
        println("bar")
        e.printStackTrace()
    }
}

I would expect the IllegalStateException would be caught, and "bar" and the stack trace would be printed. But, it turns out the "foo" is printed. This doesn't seem right to me because if I replace the use of Job() by CompletableDeferred() as in the following example, calling await() throws the exception.

fun main() = runBlocking {
    val deferred = CompletableDeferred<String>()
    launch {
        delay(1000)
        deferred.completeExceptionally(IllegalStateException("Failed"))
    }
    try {
        deferred.await()
        println("foo")
    } catch (e: Exception) {
        println("bar")
        e.printStackTrace()
    }
}

If join() of Job is considered as the counterpart of await() of Deferred, then their behaviour should be consistent.

If I change my first example to use the job object returned by launch() as in the following example, the behaviour is consistent with what I would expect, i.e. the exception is caught:

fun main() = runBlocking {
    val job = launch {
        delay(1000)
        throw IllegalStateException("Failed")
    }
    try {
        job.join()
        println("foo")
    } catch (e: Exception) {
        println("bar")
        e.printStackTrace()
    }
}

This leads me to the conclusion that the behaviour of the join() method of CompletableJob is incorrectly implemented. It is inconsistent with other similar or equivalent cases.

@fvasco
Copy link
Contributor

fvasco commented Sep 11, 2019

If join() of Job is considered as the counterpart of await() of Deferred

No, the described behavior was documented

If I change my first example to use the job object returned by launch() as in the following example, the behaviour is consistent with what I would expect, i.e. the exception is caught:

IllegalStateException("Failed") is not caught, it is aCancellationException.

Please read carefully the official documentation

@qwwdfsad
Copy link
Collaborator

This behaviour is indeed documented. Throwing an arbitrary exception from the join method is not the sanest option and is not expected by most of the users.

Let's put coroutines aside and fallback to regular Java threads:

val thread = thread {
    Thread.sleep(100)
    throw Exception()
}

thread.join() // 1

You do not expect 1 to throw, do you?

In general, join is designed to wait until the given coroutine (or a hierarchy of coroutines) completes no matter whether it was successfully or exceptionally. await is designed to wait until computed value (or exception instead)

If join() of Job is considered as the counterpart of await() of Deferred

It is not and it's stated in the documentation. Moreover, that's why Deferred is a Job and can be both join-ed and await-ed.

@vngantk
Copy link
Author

vngantk commented Sep 11, 2019

But, why can't we implement the join() of CompletableJob behaves similar to that of the join() of a launch job, i.e. throwing CancellationException. This is more consistent and would allow us to emulate the behaviour of a launch job using CompletableJob. In the current implementation, the coroutine waiting at join() detects nothing, just resumes quietly.

Also, what is the purpose of providing the completeExceptionally(exception: Throwable) method in the CompletableJob interface? It is documented as "This function transitions this job into cancelled state if it was not completed or cancelled yet.". Clearly it means that when completeExceptionally() is called, the state of this CompletableJob becomes cancelled, and this implies that the coroutine waiting at the join() would receive a CancellationException.

@vngantk
Copy link
Author

vngantk commented Sep 11, 2019

I agree that join() should throw CancellationException not IllegalStateException, but it should not be just silent as in the current implementation.

@qwwdfsad
Copy link
Collaborator

qwwdfsad commented Sep 11, 2019

join is already consistent among all implementations (moreover, it's the same code).

i.e. throwing CancellationException.

Note that join throws CancellationException not when the target coroutine is cancelled, but when join's caller is cancelled.
E.g. let's replace completeExceptionally from your example with an actual exception:

val job = GlobalScope.launch {
    delay(1000)
    throw IllegalStateException("Failed")
}

try {
    job.join()
    println("foo")
} catch (e: Exception) {
    println("bar")
    e.printStackTrace()
}

foo is still printed. Because it's responsibility of the coroutine to handle its own exception.
Same as my example with plain thread -- it's the responsibility of the thread to handle its exception, not all its awaiters.
We cannot throw IllegalStateException exception here because it's already handled by its owner. And we cannot throw CancellationException either, because joining coroutine is not cancelled!
Moreover, the following pattern will stop working:

// Declared somewhere in a servlet container
val myServletJob = Job() 


// Socket was timeouted, kill the whole hierarchy and wait for it
myServletJob.completeExceptionally(ConnectionTimeoutException())
myServletJob.join() // Wait

I think we can improve the documentation here and clarify in the documentation that the owner of the job is responsible for handling the exception that he passes to completeExceptionally

Clearly it means that when completeExceptionally() is called, the state of this CompletableJob becomes cancelled, and this implies that the coroutine waiting at the join() would receive a CancellationException.

It does not imply it.
Please take a look at the join documentation:

This suspending function is cancellable and always checks for a cancellation of the invoking coroutine's Job.
If the [Job] of the invoking coroutine is cancelled or completed when this
suspending function is invoked or while it is suspended, this function
throws [CancellationException].

@vngantk
Copy link
Author

vngantk commented Sep 11, 2019

Thanks for your clarification. I think I have misunderstood the join of CompletableJob right at the beginning. I always thought it behaves like CompletableFuture of Java.

@qwwdfsad qwwdfsad reopened this Sep 11, 2019
@qwwdfsad qwwdfsad added the docs KDoc and API reference label Sep 11, 2019
@qwwdfsad qwwdfsad self-assigned this Sep 11, 2019
qwwdfsad added a commit that referenced this issue Sep 13, 2019
qwwdfsad added a commit that referenced this issue Sep 13, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs KDoc and API reference question
Projects
None yet
Development

No branches or pull requests

3 participants