URLSession invalidateAndCancel() doesn't release delegate as documented

This test doesn't pass, and i'm wondering why. It looks like the url session isn't releasing its delegate after invalidateAndCancel() is called, contrarely to what the documentation says. I've tried adding some dispatch.async in case this would be due to some event loop side effects to no success.


import XCTest

final class URLSessionTests: XCTestCase {

    @objc
    class DelegateObject: NSObject, URLSessionDelegate {
        var onDidBecomeInvalid: (() -> Void)?
        lazy var urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
        func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
            onDidBecomeInvalid?()
        }
    }

    func testURLSessionRelease() throws {
        weak var del: DelegateObject?

        let didBecomeInvalidExp = expectation(description: "did become invalid")
        func instanciateDelegateObjectAndInvalidateSession() {
            let t = DelegateObject()
            t.onDidBecomeInvalid = {
                didBecomeInvalidExp.fulfill()
            }
            del = t
            t.urlSession.invalidateAndCancel()
        }
        instanciateDelegateObjectAndInvalidateSession()
        wait(for: [didBecomeInvalidExp], timeout: 3)
        XCTAssertNil(del)
    }


}

It looks like the url session isn't releasing its delegate after invalidateAndCancel() is called, 

Just at first glance, it looks like DelegateObject still has a strong reference held to it until at least the end of testURLSessionRelease. My question here is what scenario are you trying to test with this code? If you are trying to test functionality of a delegate that exists out in an class in your app, then you can also set your XCTestCase class as a delegate of your URLSession code, but you will need to setup a testing property on the app object that manages this code to divert your delegate class to be pointed at your XCTestCase class instead.

I'm trying to reproduce a scenario found in my app where a component used as a URLSession delegate is retained after its hierarchie has been released.

This scenario is a problem in the case of unit tests, where instances of the component remain active because of that retain cycle across unit tests.

Another problematic scenario is the instanciation of the component used as a delegate in Notification Service Extension. It looks like the NSE is running out of memory after some time (and as such is getting violently killed), and we've noticed the component wasn't released across notification handling because of that last strong reference.

For additional information, in the real application testing code the situation is as followed :

the URLSession.delegate is indeed set to nil after the "invalidateAndCancel" call, yet when looking at the memory object graph at the end of the test, an instance of __NSCFURLSessionDelegateWrapper class still holds a strong reference to it via a "_delegateWrapper" property of the urlsession.

This delegatewrapper however doesn't seem to have anything holding strong references to it.

yet when looking at the memory object graph at the end of the test, an instance of __NSCFURLSessionDelegateWrapper class still holds a strong reference to it via a "_delegateWrapper" property of the urlsession. This delegatewrapper however doesn't seem to have anything holding strong references to it.

After the XCTestCase class is done using the _delegateWrapper wrapper though does the strong reference get released? Or better yet, when tearDownWithError is called does the strong reference get released?

After the XCTestCase class is done using the _delegateWrapper wrapper though does the strong reference get released? Or better yet, when tearDownWithError is called does the strong reference get released?

I'm not following you.. The TestCase class doesn't hold a reference to anything. Only the URLSession does, and it is instantiated by an object who should be released once the function inside the test goes out of scope.

But to answer your question, i've setup a breakpoint in "tearDownWithError" and had a look at the memory graph, which still shows the DelegateObject as been retained by the "_wrapperDelegate" property of a __NSCFURLSessionDelegateWrapper instance.

I’m not sure what’s going on with your specific test but, in general, this works as expected. Consider this:

import Foundation

class Canary: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
        print("canary did invalidate")
    }
    deinit {
        print("canary carked it")
    }
}

func main() {
    let session = URLSession(configuration: .default, delegate: Canary(), delegateQueue: nil)
    print("will invalidate")
    session.invalidateAndCancel()
    print("did invalidate")
    dispatchMain()
}

main()

On macOS 12.6 it prints this:

will invalidate
did invalidate
canary did invalidate
canary carked it

My best guess is that the delegate has somehow ended up in the autorelease pool, which means it won’t be released until testURLSessionRelease() returns. If you’re unfamiliar with that concept, see Objective-C Memory Management for Swift Programmers.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

I definitely hints of objects in autoreleasepool in the memory graph, however it looks to me that the object stuck in an autorelease pool is the __NSCFURLSessionDelegateWrapper instance, which i don't have any access to. I tried wrapping instanciateDelegateObjectAndInvalidateSession() function in an autorelease {} block, without any success.

What exactly are you trying to test here? The code you posted looks like it’s trying to test URLSession itself, but that’s not really the point of a unit test (well, it’d be the point of a CFNetwork unit test, but you’re not working on CFNetwork).

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

I'm trying to understand the exact behavior of the URLSession objet regarding the release of its delegate. It's a quite important detail, that isn't totally explained by the documentation (it only states that the delegate will be released after the session is explicitely invalidated, but doesn't say when).

it only states that the delegate will be released after the session is explicitely invalidated, but doesn't say when

That’s because the exact when is not something we care to nail down. When you invalidate a session, NSURLSession will call -URLSession:didBecomeInvalidWithError: and then, at some point after that, release its last reference to the delegate. It doesn’t guarantee:

  • Exactly when the delegate will be released [1]

  • The context in which that release will be made

  • Whether autorelease pools are involved

If you rely on any of that, you’re relying on the implementation, not the API.

Which brings me to a general point: I recommend against using -dealloc for resource management. If your delegate is managing a ‘heavy’ resource, like a file handle, clean that up in -URLSession:didBecomeInvalidWithError:, not in -dealloc. This makes your -dealloc lightweight, and so the exact point at which it’s call isn’t relevant.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Although you can assume that it’ll happen reasonably promptly.

If your delegate is managing a ‘heavy’ resource, like a file handle, clean that up in -URLSession:didBecomeInvalidWithError:

This only solves one part of the problem. The other is the following one :

My delegate object is actually a component in a component hierarchy. I'm writing a unit test making sure that the hierarchy doesn't contain any retain cycle. This code works as follow :

  • instantiate the root object in a local scope,
  • iterate over all the sub-objects and create weak pointers to them in the parent scope
  • exit the local scope
  • test that all the weak pointers have been set to nil.

Without any guarantee over the time at which the URLSession will release its delegate, there's nothing i can wait upon to make sure no retain cycle exists (because the URLSession itself creates that retain cycle).

Now i got your point, and i think my solution will be to use an intermediate struct as a URLSessionDelegate, containing nothing but a weak pointer to the component, and simply forwarding all the calls to it. (and actually breaking the strong retain created by the URLSession). It will look hacky, but without more commitment on that side of the URLSession api, there isn't a lot more i can do.

URLSession invalidateAndCancel() doesn't release delegate as documented
 
 
Q