Skip to content

Commit 2edbb5c

Browse files
authored
Do not wrap a null literal during instrumentation (#806)
The backend compiles `x == null` and `x != null` into a direct ifnull/ifnonnull reference check, but only when the literal reaches it as a bare Literal(Constant(null)). Instrumentation wraps every literal in a Block, which defeats that match, so the comparison falls back to Any.equals and `other != null` is emitted as `other.equals(null)`. For a class that overrides equals and dereferences its argument, that recursive call is passed a genuinely null argument and throws a NullPointerException - the failure reported in #680, where the same test passes under mvn verify and throws under mvn scoverage:report. Leave null literals alone. Adds a regression test that loads the class scoverage just instrumented and calls equals through reflection, so it fails with the actual NullPointerException rather than with a changed statement count. Closes #680
1 parent 90f6242 commit 2edbb5c

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

‎plugin/src/main/scala/scoverage/ScoveragePlugin.scala‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,15 @@ class ScoverageInstrumentationComponent(
718718
case l: LabelDef =>
719719
treeCopy.LabelDef(tree, l.name, l.params, transform(l.rhs))
720720

721+
// A null literal has to stay a bare Literal(Constant(null)). The backend compiles
722+
// `x == null` and `x != null` into a direct ifnull/ifnonnull reference check, but only
723+
// when it recognises that exact shape as the argument. Wrapping the literal in a
724+
// Block, as the case below does for every literal, defeats that match and the
725+
// comparison falls back to Any.equals, turning `other != null` into
726+
// `other.equals(null)`. If the type overrides equals and dereferences its argument,
727+
// that recursive call then throws a NullPointerException. See #680.
728+
case l: Literal if l.value.value == null => l
729+
721730
// profile access to a literal for function args todo do we need to do this?
722731
case l: Literal => instrument(l, l)
723732

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package scoverage
2+
3+
import munit.FunSuite
4+
5+
import java.io.File
6+
import java.net.URLClassLoader
7+
8+
/** Runtime regression test for #680.
9+
*
10+
* The reporter's minimal case (github.com/josephlbarnett/testscoverageissue) passes under
11+
* `mvn verify` and throws under `mvn scoverage:report`:
12+
*
13+
* java.lang.NullPointerException
14+
* at com.example.Wrapper.equals(Wrapper.scala:13) // obj.getClass != classOf[Wrapper]
15+
* at com.example.Wrapper.equals(Wrapper.scala:21) // other != null && ...
16+
* at com.example.WrapperTest.testEquals(WrapperTest.scala:11)
17+
*
18+
* The two stacked `equals` frames are the symptom: under instrumentation `other != null` is
19+
* compiled as `other.equals(null)` rather than a reference check, so it recurses into the
20+
* overridden `equals` with a genuinely null argument and dereferences it.
21+
*
22+
* PluginCoverageTest checks statement counts after compilation. This test loads the class
23+
* scoverage just instrumented and calls `equals` through reflection, so it fails with the
24+
* actual NullPointerException rather than with a changed count.
25+
*/
26+
class Issue680RegressionTest extends FunSuite {
27+
28+
// Same shape as the reporter's Wrapper.scala; the names differ only to avoid clashing with
29+
// other snippets compiled into the shared test output directory.
30+
private val snippet =
31+
"""
32+
|package issue680
33+
|class Bean680 { var id: String = _ }
34+
|class Wrapper680 {
35+
| var bean: Bean680 = _
36+
| var code: String = _
37+
| override def equals(obj: Any): Boolean = {
38+
| if (obj.getClass != classOf[Wrapper680]) {
39+
| return false
40+
| } else {
41+
| val other = obj.asInstanceOf[Wrapper680]
42+
| if (
43+
| (other.code != code) ||
44+
| (other.bean == null && bean != null) ||
45+
| (other.bean != null && bean == null) ||
46+
| (other != null && bean != null && other.bean.id != bean.id)
47+
| ) {
48+
| return false
49+
| } else {
50+
| return true
51+
| }
52+
| }
53+
| super.equals(obj)
54+
| }
55+
|}
56+
|""".stripMargin
57+
58+
test(
59+
"instrumented Wrapper680.equals mirrors the #680 repro sequence without NullPointerException"
60+
) {
61+
val compiler = ScoverageCompiler.default
62+
compiler.compileCodeSnippet(snippet)
63+
compiler.assertNoErrors()
64+
65+
val outDir = new File(compiler.settings.outdir.value)
66+
val loader =
67+
new URLClassLoader(Array(outDir.toURI.toURL), getClass.getClassLoader)
68+
val wrapperClass = loader.loadClass("issue680.Wrapper680")
69+
val beanClass = loader.loadClass("issue680.Bean680")
70+
71+
def newWrapper(): AnyRef =
72+
wrapperClass.getDeclaredConstructor().newInstance().asInstanceOf[AnyRef]
73+
def newBean(): AnyRef =
74+
beanClass.getDeclaredConstructor().newInstance().asInstanceOf[AnyRef]
75+
def setBean(w: AnyRef, b: AnyRef): Unit =
76+
wrapperClass.getMethod("bean_$eq", beanClass).invoke(w, b)
77+
def setBeanId(b: AnyRef, id: String): Unit =
78+
beanClass.getMethod("id_$eq", classOf[String]).invoke(b, id)
79+
def equalsCall(a: AnyRef, b: AnyRef): Boolean =
80+
wrapperClass
81+
.getMethod("equals", classOf[Object])
82+
.invoke(a, b)
83+
.asInstanceOf[Boolean]
84+
85+
// Step-by-step replay of WrapperTest.testEquals(). The *very first* call below is exactly
86+
// where the reporter's `mvn scoverage:report` run threw: both `bean` fields are still null
87+
// (Scala default), so evaluation of the `||` chain reaches the last clause and short-circuit
88+
// `&&` evaluates `other != null` first - the miscompiled comparison this test guards against.
89+
val a = newWrapper()
90+
val b = newWrapper()
91+
assert(
92+
equalsCall(a, b),
93+
"two fresh wrappers with null beans should be equal"
94+
)
95+
96+
val beanA = newBean()
97+
setBean(a, beanA)
98+
assert(
99+
!equalsCall(a, b),
100+
"wrapper with a bean should not equal one without a bean"
101+
)
102+
103+
val beanB = newBean()
104+
setBean(b, beanB)
105+
assert(
106+
equalsCall(a, b),
107+
"wrappers with two fresh (equal) beans should be equal again"
108+
)
109+
110+
setBeanId(beanA, "id")
111+
assert(!equalsCall(a, b), "differing bean ids should not be equal")
112+
113+
setBeanId(beanB, "id")
114+
assert(equalsCall(a, b), "matching bean ids should be equal again")
115+
}
116+
}

0 commit comments

Comments
 (0)