I have a super weird case, somewhat related to Kot...
# kotest
w
I have a super weird case, somewhat related to Kotest but I’m not sure really. Let’s say I have a class
Foo
and a factory which always returns the same value as long as the previous one is still referenced:
Copy code
class Foo

class FooFactory {

    var fooRef: WeakReference<Foo>? = null
    
    fun get() = fooRef?.get() ?: Foo().also { fooRef = WeakReference(it) }
}
So far so good. Now I have spec which should assert that the objects are in memory as expected. This overall might be tricky because GC is not very deterministic, but it’s stable enough:
Copy code
class TestSpec : DescribeSpec() {

    private val fooFactory = FooFactory()

    init {
        describe("foo") {
            var foo: Foo? = fooFactory.get()
            // println(foo)

            describe("bar") {
                gc()
                fooFactory.fooRef?.get() shouldNot beNull()

                foo = null
                gc()
                fooFactory.fooRef?.get() should beNull()

                it("should do something") {
                    println("Done")
                }
            }
        }
    }

    override fun isolationMode() = IsolationMode.InstancePerLeaf
}
And now the tricky part: if the
println(foo)
is commented out, the test works. But if it’s not, then the test fails at the
should beNull()
line. Apparently something is holding it. I checked in IJ’s memory tool and it looks like it’s just a variable being kept, but I have no clue why and how this would happen. Also I expect that with
InstancePerLeaf
this entire thing is executed just once anyway, as there’s only one leaf. Any ideas?
Entire file for reproducing
Btw this doesn’t happen in a simple JUnit test. Perhaps it has something to do with coroutines, but I’m at loss as to what exactly would be causing this 😕
s
Does that test work standalone ?
Can I just run it
w
Yeah you should be able to copy the entire file pasted in the thread and run it
s
Great, will do so. What about if you have single instance mode
does it work then
w
Actually all isolation modes behave exactly the same, crash with
println
and don’t crash without it. I originally kept
InstancePerLeaf
because it was relevant in couple issues I had before, but not this time
s
Ok, that rules out some things then
which version? 4.4.0 ?
w
Yep my first guess was that perhaps Kotest keeps some coroutines which hold onto some references. It’s 4.3.1 but let me check on 4.4.0
s
fails for me on 4.4
same 1
and just to recap, why do you expect this value to be null ?
Copy code
var foo: Foo? = fooFactory.get()
That is always going to be a strong reference
w
Yes, and the first time I assert that the weak reference holds the value still
And then I do
foo = null
, so the only (and last) strong reference to Foo is gone
s
Ok yes
w
I then do gc and expect that the weak reference has been cleared. Which happens only if there’s no
println
before 😕
s
My first guess would be the GC just isn't worried about claiming the reference
A weak reference isn't necessarily going to be gc'ed, just it makes it eligible for collection
w
Sure. My though process: I wrote
gc()
such that when it returns, GC should’ve already ran. And there’s no reason (although I haven’t checked the spec) for JVM to keep objects without strong references when GC happens
s
My thought was that GC would only get GC things that it needed to
w
And in any case, this is 100% reproducible, why would
println(foo)
change the gc behavior?
s
perhaps the JVM realizes that foo has been accessed recently
and therefore keeps it around
w
Ok, then there’s one more thing: if I move
foo = null
to before the
describe("bar")
, then the instance gets back to being GCed every time:
^this test passes every time
s
oh that fails for me
ok this works
Copy code
init {
   describe("foo") {
      var foo: Foo? = fooFactory.get()
      println(foo)
      foo = null
      describe("bar") {
         gc()
         fooFactory.fooRef?.get() shouldBe null
         it("should do something") {
            println("Done")
         }
      }
   }
}
so a lambda is just an instance of some interface, like Function1 or whatever right
By having foo in two lambdas, it must have to hoist it somewhere to share the reference
That could possibly block the gc'ing of it
seems like it, this works:
Copy code
init {
   describe("foo") {
      var foo: Foo? = fooFactory.get()
      println(foo)
      describe("bar") {
         foo = null
         gc()
         it("should do something") {
            println("Done")
         }
      }
   }
   describe("foo2") {
      fooFactory.fooRef?.get() shouldBe null
   }
}
w
🧠 why didn’t I think about decompiling it to java
Thank you! It was driving me crazy
Those pesky coroutines 😄
This happens when I do the print
s
yep there we go
w
And this if I don’t
Perfect, many thanks 🙂 Maybe I’ll transform it into pure coroutines code and ask if this is expected. Probably yes, but I suppose it might be unexpected for some (like me). Thanks again, I’ll sleep much better tonight 😄
👍🏻 1
l
@wasyl I think this is a good question to make a canonical answer to at StackOverflow
You can ask and answer your own question, and this corner case might hit someone in the future, and it won't be documented anywhere
👍🏻 1
w
@LeoColman You mean in the context of Kotest or coroutines in general?
l
Kotest, coroutines, Kotlin maybe