import Foundation import UIKit import XCTest final class UIVCSubclass: UIViewController { deinit { print("VC subclass deinit") } } final class UIKitAsyncDeallocCrashTests: XCTestCase { func test_uivc_bg_dealloc() { XCTAssert(Thread.isMainThread) let done = self.expectation(description: "done") autoreleasepool { let parent: UIViewController = UIVCSubclass() let child: UIViewController = UIViewController() parent.addChild(child) child.didMove(toParent: parent) // form a strong reference to the parent VC from the main thread var mainThreadVCRef: UIViewController? = parent let queue = DispatchQueue(label: "bg-test") queue.suspend() queue.async { // uncomment `nonRetainedVC` references for further debugging // var nonRetainedVC = NSValue() autoreleasepool { let bgVCRef = mainThreadVCRef // zero the main-thread ref. the only remaining strong reference // to the parent VC should now be `bgVCRef`, scoped to this block mainThreadVCRef = nil // enqueue a weak reference formation to the deallocating // VC that indirectly accesses via the child VC DispatchQueue.main.async { if let v = child.parent { print("v: \(v)") } // loading the weak ref via the objc runtime does not (immediately) crash var strongParentRef: AnyObject? = child.parent withUnsafeMutablePointer(to: &strongParentRef) { unsafeVCPtr in let result = objc_loadWeak( AutoreleasingUnsafeMutablePointer(unsafeVCPtr) ) print("objc_loadWeak: \(result ?? "nil")") // nil /* // writing to the weak variable _will_ crash weak var newWeakVar: AnyObject? withUnsafeMutablePointer(to: &newWeakVar) { newWeakVarPtr in let result = objc_storeWeak( AutoreleasingUnsafeMutablePointer(newWeakVarPtr), unsafeVCPtr.pointee ) } */ } // creating a new weak reference via standard means causes an immediate crash inside `weak_register_no_lock` weak var weakMainThreadParentRef = strongParentRef // 💥 Cannot form weak reference to instance... withExtendedLifetime(weakMainThreadParentRef) {} } // nonRetainedVC = NSValue(nonretainedObject: bgVCRef) // refcount: 4 // is deallocating: false // nonRetainedVC.printDebugInfo() withExtendedLifetime(bgVCRef) {} // parent vc should be marked for deallocation approximately here, but something internal to UIKit asynchronously redirects deallocation to the main thread // set a symbolic break on `_objc_initiateDealloc` to debug } // refcount: 0 // is deallocating: true // nonRetainedVC.printDebugInfo() DispatchQueue.main.async { done.fulfill() } } queue.resume() } self.wait(for: [done], timeout: 1) } } private extension NSValue { func printDebugInfo() { print("approximate refcount: \(refCount)") print("is deallocating: \(isDeallocating)") } var refCount: Int { CFGetRetainCount(nonretainedObjectValue! as AnyObject) } var isDeallocating: Bool { (self.nonretainedObjectValue as? NSObject)? .value(forKey: "_isDeallocating") as? Bool ?? false } }