Skip to content

Conversation

@poseidon-mysugr
Copy link
Contributor

When the Kotlin 1.6.30 compiler encounters a Unit - returning suspend fun that forwards to calling another Unit - returning suspend fun as its last statement, and doesn't have other calls to suspend funs, then it generates a ternary operator that can often not be covered in testing:

suspend fun a() {}
suspend fun b() {
  a() // last statement is another suspend fun returning `Unit`
}

The Bytecode generated for b() is:

L0
  LINENUMBER 3 L0
  ALOAD 0
  INVOKESTATIC my/package/Class.a (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
  DUP
  INVOKESTATIC kotlin/[...]/IntrinsicsKt.getCOROUTINE_SUSPENDED ()Ljava/lang/Object;
  IF_ACMPNE L1
  ARETURN
L1
  POP
L2
  LINENUMBER 4 L2
  GETSTATIC kotlin/Unit.INSTANCE : Lkotlin/Unit;
  ARETURN
L3

which decompiles to Java as follows

public static final Object b(@NotNull Continuation $completion) {
  Object var10000 = a($completion);
  return var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED() ? var10000 : Unit.INSTANCE;
}

The ternary operator that can be seen in the decompiled Java code can often not be covered; so the whole ternary operator should be excluded from coverage.

This PR addresses this issue by searching for calls to IntrinsicsKt.getCOROUTINE_SUSPENDED(), followed by the instructions IF_ACMPNE L1, ARETURN, L1, POP with L1 being the Label the IF_ACMPNE instructions jumps to. It then ignores all four instructions (IF_ACMPNE to POP, inclusively).
It furthermore ignores the return Unit.INSTANCE part, being the GETSTATIC and the following ARETURN instruction, because returning Unit can not be asserted in Kotlin anyway.
Note: the return Unit.INSTANCE part may or may not follow directly after the POP like in the given example; there may be a GOTO instruction to jump to it, as can be seen in the tests.

@poseidon-mysugr poseidon-mysugr force-pushed the feature/PUMA-240 branch 3 times, most recently from b08f3d5 to 1a33912 Compare February 11, 2020 12:58
@poseidon-mysugr
Copy link
Contributor Author

I updated the PR from master and reworked the fix based on Evgeny Mandrikov's fix for the same problem - the only thing I found missing is that the return Unit.INSTANCE part should also be ignored.

@Godin
Copy link
Member

Godin commented Feb 13, 2020

@poseidon-mysugr

return Unit.INSTANCE part should also be ignored

It corresponds to non-suspended exit from a function, so either will be executed or function infinitely suspends. In both cases coverage is consistent with non-suspending functions, while won't after your change:

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import kotlin.concurrent.thread

object KotlinCoroutineTarget {

    private suspend fun a1() {
        b1()
    } // assertNotCovered()

    private suspend fun b1() {
        while (true) yield()
    }

    private fun a1_without_suspend() {
        b1_without_suspend()
    } // assertNotCovered()

    private fun b1_without_suspend() {
        while (true) {}
    }

    private suspend fun a2() {
        b2()
    } // assertFullyCovered()

    private suspend fun b2() {
    }

    private fun a2_without_suspend() {
        b2_without_suspend()
    } // assertFullyCovered()

    private fun b2_without_suspend() {
    }

    @JvmStatic
    fun main(args: Array<String>) {
        GlobalScope.launch { a1() }
        thread { a1_without_suspend() }
        GlobalScope.launch { a2() }
        thread { a2_without_suspend() }
        Thread.sleep(2000L)
    }

}

@Godin Godin added the feedback pending Further information is requested label Feb 13, 2020
@poseidon-mysugr
Copy link
Contributor Author

@Godin You're right about indefinitely suspending functions, but there is one case you've missed: The return Unit instructions are never executed if the suspend fun that is being called always suspends, but not indefinitely. For instance, if I modify your example in a very simple way:

    private suspend fun b2() {
        yield()
    }

then the return of a2 is not fully covered anymore, and cannot be, as the actual return of b2 will always be IntrinsicsKt.getCOROUTINE_SUSPENDED(), and never Unit.INSTANCE.

In some instances it's possible to write tests that trigger both paths, but if there's no control over the suspend fun that is being called (like in the example above), this may in fact be an impossible-to-reach branch, and we have a number of such examples in our code base.

I'm not sure how to filter this case while maintaining consistent behaviour with non-suspending functions, filtering the return altogether is the only thing I could come up with... Do you have a better idea?

@Godin Godin self-assigned this Mar 31, 2020
@Godin Godin added this to Filtering Apr 8, 2025
@github-project-automation github-project-automation bot moved this to Awaiting triage in Filtering Apr 8, 2025
@Godin Godin moved this from Awaiting triage to To Do in Filtering Apr 8, 2025
@Godin Godin removed the feedback pending Further information is requested label Apr 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: To Do

Development

Successfully merging this pull request may close these issues.

2 participants