iosdev

Crash with callback methods in parent-child data sources

Swift Concurrency gotchas will never stop.

Say I have ParentDataSource which holds a strong reference to ChildDataSource, which has a callback method that ParentDataSource implements:

final class ChildDataSource: NSObject {
	var dataRefreshed: () -> Void = {}
}

final class ParentDataSource: NSObject {
	let childDataSource: ChildDataSource

	init(...) {
		...
		childDataSource.dataRefreshed = {
			[unowned self] in

			self.doSomething()
		}
	}

Initial thinking: “so I have a strongly referenced child and whenever it calls me back I [ParentDataSource] will be there to answer”. Seems reasonable, eh?

It can crash when ChildDataSource has a closure which awaits something.

Task(name: "CDS.init") {
	await prepareData(flowIdentifier: fid)
	dataRefreshed()
}

What happens here..?

  1. An unstructured Task { … } is detached from the object graph. The Task’s continuation queue holds the closure; the Task handle is not stored, so no one can cancel it.
  2. Calling an instance method inside the Task’s prepareData() implicitly captures self strongly. As long as the Task is suspended on await, Swift keeps ChildDataSource alive.
  3. ChildDataSource’s parent is owned by its view controller ParentController. When the user taps away from ParentController, its navigation stack drops the UIVC, ParentController deallocates which drops ParentDataSource, which drops its strong reference to ChildDataSource — but ChildDataSource doesn’t die because the pending Task is still holding it.
  4. The Task eventually resumes, fires dataRefreshed() and the closure tries to read unowned ParentDataSourcegonecrash.

The lazy fix is to safe-guard parent from whatever child does:

childDataSource.dataRefreshed = {
	[weak self] in
	guard let self else { return }

	self.doSomething()
}

The “proper” fix would be for ChildDataSource to store the Task handle and cancel it on deinit and check Task.isCancelled after the await before calling dataRefreshed(). That would let the child die with the parent.

final class ChildDataSource: NSObject {
	var dataRefreshed: () -> Void = {}

	deinit {
		initTask?.cancel()
	}

	private var initTask: Task<Void, Never>?

	init(...) {
		...
		initTask = Task(name: "CDS.init") {
			await prepareData(flowIdentifier: fid)

			guard !Task.isCancelled else { return }
			dataRefreshed()
		}
	}

When the owning controller (and the parent DataSource) goes away, ChildDataSource now has no strong holder except the Task. The Task is no longer kept alive by the object graph — but since the Task itself captures self strongly (via prepareData / dataRefreshed), the object still survives until the Task finishes.

What the new code changes is that when ChildDataSource finally deallocates, the deinit cancels the Task; if cancellation beats the prepareData() suspension, the guard !Task.isCancelled short-circuits dataRefreshed() and we bail before touching the (now-gone) parent callback.

Important caveat — this is reasonable defense from crash, not a complete disconnection. Because prepareData() is an instance method and captures self strongly inside the Task, ChildDataSource cannot deallocate until the Task finishes. So deinit will only fire after the Task is already done. The cancel there is effectively a no-op for the init Task itself but it becomes useful if you later add other Tasks that hold weak self — cancelling them from deinit would then actually cut work short.

To make cancel-before-deallocate work for the init Task itself, I’ll need to refactor prepareData() so the Task captures [weak self] and re-enters via self?.…, which can be much larger change than the crash fix warrants. The current state is: the weak self in the parent’s dataRefreshed() closure prevents the crash; this deinit + isCancelled check is safe-guarding against adding more Tasks in the future development.

(I sure hope I understood this correctly thus making this post useful.)