A stored completion handler keeps its view controller alive — trace the ownership and fix the leak
A view controller starts work through a shared singleton and passes a completion handler. The handler is stored but never invoked, and the controller's deinit never prints — the controller leaks.
Constraints:
Downloader.sharedis a process-wide singleton that is never deallocated.- Keep the completion-handler API; do not delete the singleton.
final class Downloader {
static let shared = Downloader()
private var onDone: (() -> Void)?
func start(_ completion: @escaping () -> Void) {
onDone = completion
}
}
final class ProfileVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
Downloader.shared.start { self.render() }
}
func render() {}
deinit { print("ProfileVC gone") }
}
Trace the ownership and fix the leak.
The immortal singleton Downloader.shared stores onDone, which strongly captures the controller. Since start never calls or clears it, the closure pins the controller and deinit never runs. Fix it with [weak self] plus onDone = nil after use.
- ✗Blaming a background thread rather than the strong capture kept by the singleton
- ✗Thinking the view controller retains the singleton instead of the reverse
- ✗Believing
@escapingitself, not the never-cleared stored closure, causes the leak
- →Why does clearing
onDoneafter invoking it matter even with[weak self]? - →When would
[unowned self]be wrong for a handler that may outlive the controller?
The thread is not the culprit — the ownership chain is. Downloader.shared is a singleton that lives for the whole process. It stores onDone, and the closure strongly captures ProfileVC through self.render(). Because start puts the closure into onDone but never calls or nils it, the singleton retains the closure indefinitely, and through it the controller. ProfileVC's retain count never reaches zero, so deinit never prints.
func start(_ completion: @escaping () -> Void) {
onDone = completion
// …do the work, then:
onDone?()
onDone = nil // release the stored closure
}
// call site:
Downloader.shared.start { [weak self] in self?.render() }
[weak self] drops the closure's strong reference to the controller, and onDone = nil after invoking releases the closure itself inside the singleton. Either one breaks the retention, but doing both is correct: the weak capture guards against outliving, and clearing keeps the singleton from hoarding dead closures.