kotlinx.coroutines icon indicating copy to clipboard operation
kotlinx.coroutines copied to clipboard

JaCoCo reports coverage on invalid line="0" when using flatMapLatest() or combine()

Open dzirbel opened this issue 1 year ago • 7 comments

Describe the bug

JaCoCo generates coverage reports for a function invokeSuspend on (invalid) line="0" in code that uses flatMapLatest() or combine(). This seems likely to be related to the convergence of inline functions which accept crossinline and suspend lambdas. I'm not particularly well-versed in Kotlin bytecode, but at a glance I don't see any obvious issues to the generated invokeSuspend function. It may also be that the invokeSuspend function should be marked synthetic to start with, avoiding it appearing in coverage reports at all.

This is a somewhat bizarre issue which may be ultimately the responsibility of the Kotlin compiler or JaCoCo. I'm submitting it here since I can't reproduce it in non-suspend inline functions with crossinline parameters, so my guess is that it's related to the generating of code by coroutines.

Provide a Reproducer

These simple snippets demonstrate the issue:

import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest

fun flatMapLatest() {
    emptyFlow<Unit>().flatMapLatest { emptyFlow<Unit>() }
}

which produces this in the JaCoCo report:

<class name="FlatMapLatestKt$foo$$inlined$flatMapLatest$1" sourcefilename="Merge.kt">
	<method desc="(Ljava/lang/Object;)Ljava/lang/Object;" line="0" name="invokeSuspend">
		<counter covered="0" missed="28" type="INSTRUCTION"/>
		<counter covered="0" missed="2" type="LINE"/>
		<counter covered="0" missed="1" type="COMPLEXITY"/>
		<counter covered="0" missed="1" type="METHOD"/>
	</method>
	<counter covered="0" missed="28" type="INSTRUCTION"/>
	<counter covered="0" missed="2" type="LINE"/>
	<counter covered="0" missed="1" type="COMPLEXITY"/>
	<counter covered="0" missed="1" type="METHOD"/>
	<counter covered="0" missed="1" type="CLASS"/>
</class>

and

import kotlinx.coroutines.flow.combine

fun combine() {
    combine<Unit, Unit>(emptyList()) { }
}

which produces:

<class name="CombineKt$bar$$inlined$combine$1$3" sourcefilename="Zip.kt">
	<method desc="(Ljava/lang/Object;)Ljava/lang/Object;" line="0" name="invokeSuspend">
		<counter covered="0" missed="29" type="INSTRUCTION"/>
		<counter covered="0" missed="2" type="LINE"/>
		<counter covered="0" missed="1" type="COMPLEXITY"/>
		<counter covered="0" missed="1" type="METHOD"/>
	</method>
	<counter covered="0" missed="29" type="INSTRUCTION"/>
	<counter covered="0" missed="2" type="LINE"/>
	<counter covered="0" missed="1" type="COMPLEXITY"/>
	<counter covered="0" missed="1" type="METHOD"/>
	<counter covered="0" missed="1" type="CLASS"/>
</class>

Both can be found in this sample project: https://github.com/dzirbel/compose-playground/tree/jacoco (repurposed for testing Compose, but without any Compose dependencies in this branch) and which can reproduce the issue by running ./gradlew jacocoTestReport and inspecting build/reports/jacoco/test/jacocoTestReport.xml. For reference, these are the contents (with some XML formatting):

Full JaCoCo report
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!DOCTYPE report
  PUBLIC '-//JACOCO//DTD Report 1.1//EN'
  'report.dtd'>
<report name="compose-playground">
	<sessioninfo dump="1696668887764" id="stella-11a0ee1d" start="1696668887356"/>
	<package name="">
		<class name="CombineKt" sourcefilename="Combine.kt">
			<method desc="()V" line="5" name="bar">
				<counter covered="0" missed="6" type="INSTRUCTION"/>
				<counter covered="0" missed="2" type="LINE"/>
				<counter covered="0" missed="1" type="COMPLEXITY"/>
				<counter covered="0" missed="1" type="METHOD"/>
			</method>
			<counter covered="0" missed="6" type="INSTRUCTION"/>
			<counter covered="0" missed="2" type="LINE"/>
			<counter covered="0" missed="1" type="COMPLEXITY"/>
			<counter covered="0" missed="1" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</class>
		<class name="CombineKt$bar$$inlined$combine$1$3" sourcefilename="Zip.kt">
			<method desc="(Ljava/lang/Object;)Ljava/lang/Object;" line="0" name="invokeSuspend">
				<counter covered="0" missed="29" type="INSTRUCTION"/>
				<counter covered="0" missed="2" type="LINE"/>
				<counter covered="0" missed="1" type="COMPLEXITY"/>
				<counter covered="0" missed="1" type="METHOD"/>
			</method>
			<counter covered="0" missed="29" type="INSTRUCTION"/>
			<counter covered="0" missed="2" type="LINE"/>
			<counter covered="0" missed="1" type="COMPLEXITY"/>
			<counter covered="0" missed="1" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</class>
		<class name="FlatMapLatestKt$foo$$inlined$flatMapLatest$1" sourcefilename="Merge.kt">
			<method desc="(Ljava/lang/Object;)Ljava/lang/Object;" line="0" name="invokeSuspend">
				<counter covered="0" missed="28" type="INSTRUCTION"/>
				<counter covered="0" missed="2" type="LINE"/>
				<counter covered="0" missed="1" type="COMPLEXITY"/>
				<counter covered="0" missed="1" type="METHOD"/>
			</method>
			<counter covered="0" missed="28" type="INSTRUCTION"/>
			<counter covered="0" missed="2" type="LINE"/>
			<counter covered="0" missed="1" type="COMPLEXITY"/>
			<counter covered="0" missed="1" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</class>
		<class name="CombineKt$bar$$inlined$combine$1$2" sourcefilename="Zip.kt">
			<method desc="()[Ljava/lang/Object;" line="291" name="invoke">
				<counter covered="0" missed="5" type="INSTRUCTION"/>
				<counter covered="0" missed="1" type="LINE"/>
				<counter covered="0" missed="1" type="COMPLEXITY"/>
				<counter covered="0" missed="1" type="METHOD"/>
			</method>
			<counter covered="0" missed="5" type="INSTRUCTION"/>
			<counter covered="0" missed="1" type="LINE"/>
			<counter covered="0" missed="1" type="COMPLEXITY"/>
			<counter covered="0" missed="1" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</class>
		<class name="CombineKt$bar$$inlined$combine$1" sourcefilename="SafeCollector.common.kt">
			<method desc="([Lkotlinx/coroutines/flow/Flow;)V" line="107" name="&lt;init&gt;">
				<counter covered="0" missed="6" type="INSTRUCTION"/>
				<counter covered="0" missed="1" type="LINE"/>
				<counter covered="0" missed="1" type="COMPLEXITY"/>
				<counter covered="0" missed="1" type="METHOD"/>
			</method>
			<method desc="(Lkotlinx/coroutines/flow/FlowCollector;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;" line="109" name="collect">
				<counter covered="0" missed="8" type="INSTRUCTION"/>
				<counter covered="0" missed="2" type="LINE"/>
				<counter covered="0" missed="1" type="COMPLEXITY"/>
				<counter covered="0" missed="1" type="METHOD"/>
			</method>
			<counter covered="0" missed="14" type="INSTRUCTION"/>
			<counter covered="0" missed="3" type="LINE"/>
			<counter covered="0" missed="2" type="COMPLEXITY"/>
			<counter covered="0" missed="2" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</class>
		<class name="FlatMapLatestKt" sourcefilename="FlatMapLatest.kt">
			<method desc="()V" line="5" name="foo">
				<counter covered="0" missed="5" type="INSTRUCTION"/>
				<counter covered="0" missed="2" type="LINE"/>
				<counter covered="0" missed="1" type="COMPLEXITY"/>
				<counter covered="0" missed="1" type="METHOD"/>
			</method>
			<counter covered="0" missed="5" type="INSTRUCTION"/>
			<counter covered="0" missed="2" type="LINE"/>
			<counter covered="0" missed="1" type="COMPLEXITY"/>
			<counter covered="0" missed="1" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</class>
		<sourcefile name="FlatMapLatest.kt">
			<line cb="0" ci="0" mb="0" mi="4" nr="5"/>
			<line cb="0" ci="0" mb="0" mi="1" nr="6"/>
			<counter covered="0" missed="5" type="INSTRUCTION"/>
			<counter covered="0" missed="2" type="LINE"/>
			<counter covered="0" missed="1" type="COMPLEXITY"/>
			<counter covered="0" missed="1" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</sourcefile>
		<sourcefile name="Zip.kt">
			<line cb="0" ci="0" mb="0" mi="8" nr="0"/>
			<line cb="0" ci="0" mb="0" mi="5" nr="291"/>
			<line cb="0" ci="0" mb="0" mi="21" nr="292"/>
			<counter covered="0" missed="34" type="INSTRUCTION"/>
			<counter covered="0" missed="3" type="LINE"/>
			<counter covered="0" missed="2" type="COMPLEXITY"/>
			<counter covered="0" missed="2" type="METHOD"/>
			<counter covered="0" missed="2" type="CLASS"/>
		</sourcefile>
		<sourcefile name="SafeCollector.common.kt">
			<line cb="0" ci="0" mb="0" mi="3" nr="107"/>
			<line cb="0" ci="0" mb="0" mi="7" nr="109"/>
			<line cb="0" ci="0" mb="0" mi="1" nr="110"/>
			<counter covered="0" missed="14" type="INSTRUCTION"/>
			<counter covered="0" missed="3" type="LINE"/>
			<counter covered="0" missed="2" type="COMPLEXITY"/>
			<counter covered="0" missed="2" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</sourcefile>
		<sourcefile name="Combine.kt">
			<line cb="0" ci="0" mb="0" mi="5" nr="5"/>
			<line cb="0" ci="0" mb="0" mi="1" nr="6"/>
			<counter covered="0" missed="6" type="INSTRUCTION"/>
			<counter covered="0" missed="2" type="LINE"/>
			<counter covered="0" missed="1" type="COMPLEXITY"/>
			<counter covered="0" missed="1" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</sourcefile>
		<sourcefile name="Merge.kt">
			<line cb="0" ci="0" mb="0" mi="7" nr="0"/>
			<line cb="0" ci="0" mb="0" mi="21" nr="193"/>
			<counter covered="0" missed="28" type="INSTRUCTION"/>
			<counter covered="0" missed="2" type="LINE"/>
			<counter covered="0" missed="1" type="COMPLEXITY"/>
			<counter covered="0" missed="1" type="METHOD"/>
			<counter covered="0" missed="1" type="CLASS"/>
		</sourcefile>
		<counter covered="0" missed="87" type="INSTRUCTION"/>
		<counter covered="0" missed="12" type="LINE"/>
		<counter covered="0" missed="7" type="COMPLEXITY"/>
		<counter covered="0" missed="7" type="METHOD"/>
		<counter covered="0" missed="6" type="CLASS"/>
	</package>
	<counter covered="0" missed="87" type="INSTRUCTION"/>
	<counter covered="0" missed="12" type="LINE"/>
	<counter covered="0" missed="7" type="COMPLEXITY"/>
	<counter covered="0" missed="7" type="METHOD"/>
	<counter covered="0" missed="6" type="CLASS"/>
</report>

Context

This issue has caused errors in Codecov's processing of JaCoCo reports in my project; see https://github.com/codecov/feedback/issues/72. I can't say for sure whether line="0" is a valid JaCoCo report entry, but if it breaks their report parsing it may cause issues with others as well.

I only began seeing this issue recently when upgrading my version of Compose Multiplatform (from 1.4.3 to 1.5.0), which may have bumped the version of Kotlin or Coroutines being pulled into my project. However, I've reproduced it now on versions dating back for a fair while; it appears to be present in every configuration I've tested:

  • Kotlin 1.9.10, Coroutines 1.7.3, JaCoCo 0.8.10
  • Kotlin 1.9.10, Coroutines 1.6.4, JaCoCo 0.8.10
  • Kotlin 1.9.10, Coroutines 1.5.2, JaCoCo 0.8.10
  • Kotlin 1.8.22, Coroutines 1.5.2, JaCoCo 0.8.10
  • Kotlin 1.8.22, Coroutines 1.4.3, JaCoCo 0.8.10
  • Kotlin 1.8.22, Coroutines 1.4.3, JaCoCo 0.8.9

dzirbel avatar Oct 07 '23 09:10 dzirbel

Update: after further testing, I can no longer reproduce the issue when using JaCoCo 0.8.8 (but can with 0.8.9 and 0.8.10). I'll investigate more tomorrow, but if that finding holds then it's likely to be an issue specific to JaCoCo rather than coroutines; apologies for the noise if so.

dzirbel avatar Oct 07 '23 09:10 dzirbel

Update: after further testing, I can no longer reproduce the issue when using JaCoCo 0.8.8 (but can with 0.8.9 and 0.8.10). I'll investigate more tomorrow, but if that finding holds then it's likely to be an issue specific to JaCoCo rather than coroutines; apologies for the noise if so.

The conclusion from this thread is that JaCoCo 0.8.9+ more accurately reflects incorrectly emitted line numbers of zero from the Kotlin compiler, so previous versions were masking this issue but it's ultimately the responsibility of the Kotlin compiler to avoid marking code at line 0. Given the issue is specifically for invokeSuspend, I suspect it is the responsibility of Coroutines for this case.

dzirbel avatar Oct 08 '23 21:10 dzirbel

@dzirbel Thanks for your effort! Do you know if there is already a ticket opened on Jetbrain's Kotlin bug tracker about this problem?

JuBan1 avatar Nov 29 '23 18:11 JuBan1

@dzirbel Thanks for your effort! Do you know if there is already a ticket opened on Jetbrain's Kotlin bug tracker about this problem?

No, not as far as I know.

dzirbel avatar Dec 01 '23 06:12 dzirbel

incorrectly emitted line numbers of zero from the Kotlin compiler

Please explain why zero is an incorrect number. Kotlin can't emit the actual line number because, for synthetic functions, there isn't one, and looking at the spec for the corresponding part of the class file format (https://docs.oracle.com/javase/specs/jvms/se15/html/jvms-4.html#jvms-4.7.12), I don't see any mentions of zero being prohibited somehow. All it says is,

The value of the line_number item gives the corresponding line number in the original source file.

I don't understand why Kotlin should change its behavior to work around JaCoCo not supporting the full range of possible values in the given fields.

dkhalanskyjb avatar Feb 09 '24 13:02 dkhalanskyjb

to work around JaCoCo

I think the general problem affects other things too. For example, the line numbers of Exception stack traces are also off in certain scenarios.

Kotlin can't emit the actual line number because, for synthetic functions, there isn't one

I'd argue that you can identify the line number in the original source file. It would make sense of line_number to point to the line where the synthetic function originates from. Isn't this how it works for most other language features, too? Such as the synthetic accessors of vals?

I understand it's not an easy thing to solve, but solving it instead of saying "not my problem" would actually be an improvement.

JuBan1 avatar Feb 09 '24 19:02 JuBan1

Let me rephrase this. There are three options:

  1. Kotlin emits informative line numbers in all cases.
  2. Kotlin continues emitting 0.
  3. Kotlin emits some other fictitious line numbers.

Option 1 would be the best, of course, and there are many discussions and requests asking for this, with a thorough analysis of how difficult this is and the inherent limitations. Just Google "Kotlin line numbers," and you'll see many results. No one is trying to sweep it under the rug as "not our problem," as you phrased it.

Yet even if option 1 eventually comes, it's still not going to be a fast process. If Kotlin really is non-compliant and must never emit 0 as a line number, we have to take option 3 today as a temporary measure. So, the question is, must we? Why? If we are fully compliant, but another tool isn't, why should we change the code to accommodate another tool's bug?

dkhalanskyjb avatar Feb 12 '24 09:02 dkhalanskyjb

Pushing it towards the compiler: https://youtrack.jetbrains.com/issue/KT-69283/Incorrect-synthetic-line-numbers-when-inlining-suspend-funs

qwwdfsad avatar Jun 20 '24 12:06 qwwdfsad