Retry Closures That Keep the Screen Alive
A paging feed. FeedViewController keeps var pendingRequests: [() -> Void] so failed pages can be retried from a Retry button. Each failure appends { self.load(page: nextPage) }, where nextPage is a var property on the controller. The network wrapper uses URLSession with escaping completions. QA files two bugs. Memory climbs from about 45 MB to 320 MB after ten push/pop cycles, and the controller's deinit never prints. Second: after pages 3, 4, and 5 fail, tapping Retry loads page 5 three times instead of loading pages 3, 4, and 5. The array is only emptied after a load that succeeds for every page. No third-party code is involved; the app targets iOS 17.
1. Draw the retain graph that keeps `FeedViewController` alive and explain why `deinit` never runs.
Read the worked answer
The controller owns pendingRequests as a stored property. Each element is a closure whose body references self.load(page:), and nothing breaks the default strong capture. The graph is:
FeedViewController → pendingRequests array → closure → FeedViewController
A cycle. UIKit drops its reference when the screen is popped, but the controller's retain count stays at least 1 through its own array, so deinit never runs and the controller leaks - together with everything it owns: the loaded page models, decoded images, the array itself, and any tasks still referenced. Ten cycles at roughly 27 MB each matches the reported 45 → 320 MB.
Note what is not to blame: URLSession is not retaining the controller, and escaping is not a bug - escaping merely permits the closure to outlive the call, which is what makes a stored closure able to form the cycle. The fix is a capture list, or better, not storing closures that reach back into the controller at all.
2. Explain the page-5 bug in terms of closure capture, and give the minimal fix. Does adding `[weak self]` fix it?
Read the worked answer
Closures capture variables, not values. nextPage is a stored var on self, so every appended closure captures the same box (via self) and reads nextPage at the moment Retry runs. After all three failures that value is 5, so all three closures load page 5.
Minimal fix - snapshot before capturing:
let page = nextPage
pendingRequests.append { [weak self] in self?.load(page: page) }
or in one line with a capture list: { [weak self, page = nextPage] in … }. Each closure now carries its own value.
[weak self] alone does not fix the page bug. The closures would still all read the same nextPage, so Retry would still load page 5 three times - just without leaking. The two bugs share a line of code but have independent causes, which is why a partial fix leaves a reproducible defect and the team concludes the fix 'didn't work'.
3. Compare `[weak self]` with `guard let self` against `[unowned self]` for these retry closures, and say where the `guard` should sit.
Read the worked answer
[weak self] makes self optional inside the closure:
pendingRequests.append { [weak self] in
guard let self else { return }
self.load(page: page)
}
Put the guard at the top of the closure body. The strong reference it creates then lasts only for that one synchronous call, which is safe. If you instead form a strong self that outlives the call - for example inside a nested escaping callback - you rebuild the original cycle.
[unowned self] is right only when the closure provably cannot outlive self. Here the closure is stored in an array owned by self and can be invoked after the screen is popped, so unowned would trap: a crash replacing a leak. The retry array is textbook weak territory.
Also bound the array. Clear pendingRequests on success, cap its length, and flush it in viewDidDisappear; otherwise a permanently offline user accumulates one closure per retry attempt.