Can I use weak to check for retain cycles?

I've got a class that initializes some blocks and such, and want to ensure I don't have any retain cycles. I'm attempting to write a unit test to verify some simple cases, like this:

func testThat_MyClass_WillRelease() {
    weak var myClass = MyClass()

    XCTAssertNil(myClass)
}

For many cases this seems to work, but I've encountered a class where this doesn't work. When I add some prints around the deinit calls like this:

class MyClass {
    deinit {
        print("MyClass: deinit")
    }
}

func testThat_MyClass_WillRelease() {
   weak var myClass = MyClass()

   print("Checking for nil")
   XCTAssertNil(myClass)
}

What I'm seeing in the output is not in the order I expect:

Checking for nil
MyClass: deinit

So this explains why my test assertion is failing. But I don't understand why this is happening.

I've tried doing a few things to force an out-of-scope call to deinit, but nothing seems to work...

  1. Wrapping in do {} or a function call:
func testThat_MyClass_WillRelease() {
    weak var myClass: MyClass?

    do {
        myClass = MyClass()
    }

    XCTAssertNil(myClass)
}

  1. Wrapping in autoreleasepool {}
func testThat_MyClass_WillRelease() {
    weak var myClass: MyClass?

    autoreleasepool {
        myClass = MyClass()
    }

    XCTAssertNil(myClass)
}

I've tried other more complicated things as well. What am I missing? Is there a better way to write a unit test like this?

Thanks

What I'm seeing in the output is not in the order I expect:

I tested this (with Xcode 15.4) and it didn’t reproduce for me. However, I do have a suggestion for how to investigate: Set a breakpoint on the print(…) call in your deinitialiser and look at the backtrace to see where that last ref is being released.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

1 Like

What if instead of weak reference, use checking references count from Foundation of Ë‹isKnownUniquelyReferenced` from Swift stdlib? That seems to be more obvious (from reader perspective) and determined way to test, since in test case there should be only one reference to the instance.

1 Like

Is that an NSObject subclass?

Thanks all for the comments, here's the full code: GitHub - dannys42/SwiftAsyncSerialQueue: A simple async serial queue for Swift concurrency

The library is intended to offer serial DispatchQueue semantics with Swift Concurrency Tasks (based on discussions in another thread).

I have unit tests that are essentially trying to ensure there are no lingering Tasks. I ended up putting my weak variable in a class called WeakBox, which improved detection a bit.

However, a few of my tests are still failing and I'm not quite sure why. If anyone has any suggestions, here are the problem ones:

testThat_QueuedExecutor_WillRelease() will fail when running repeated tests (e.g. run all tests 100 times).

testThat_UnusedExecutor_HasNoStrongReferences () is commented out because it fails consistently. Assuming I'm using isKnownUniquelyReferenced() correctly, I'm guessing it's probably failing for a similar reason as the previous test that I'm not understanding. For convenience, this is code for this test:



    func testThat_UnusedExecutor_HasNoStrongReferences() async throws {
        var executor = Executor()

        let hasStrongReferences = isKnownUniquelyReferenced(&executor)
        XCTAssertFalse( hasStrongReferences )
    }


That particular test is backwards. isKnownUniquelyReferenced tells you if there’s exactly one outstanding strong reference (which there is, your local variable). It’ll only be false if there are additional references, which in your example seems pretty unlikely (technically it depends on what’s in init() though).

I haven’t looked at the rest of your project.

1 Like

Have you tried a simpler setup that doesn't use weak references:

func test() {
    var c: C? = C()
    c = nil
    print(c)
}

BTW, there are legitimate reasons why deinit might not be firing right away, simple example:

class C {
    deinit {
        print("deinit")
    }
    init() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 123) {
            print(self)
        }
    }
}