Combine: Cancelling Subscriptions Within Sink Closures

I needed to cancel subscriptions (AnyCancellable) made using sink inside the sink closure without causing any leakage. It didn’t turn out to be straightforward. We’ll explore how I solved that here.

For this example, we’ll use a dummy Timer Publisher:

struct SingletonTimerProvider {
    static let timer = 
        Timer.publish(every: 2, on: .main, in: .default).autoconnect()
}

We have a UIViewController that recursively subscribes to the Timer. Every time the subscription’s sink closure gets executed, it creates another subscription. They stop creating more subscriptions when they reach a certain threshold. The starting code looks like this:

final class ViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>()
    private let startingTimeInterval = Date().timeIntervalSince1970
    private let maxThreshold = 10.0

    override func viewDidLoad() {
        super.viewDidLoad()
        subscribeToTimer(threshold: 3.0)
    }

    private func subscribeToTimer(threshold: TimeInterval) {
        SingletonTimerProvider.timer.sink { [weak self] value in
            print("Received timer value: \(value)")
            guard let self = self else {
                return
            }

            print("Subscriptions = \(self.cancellables.count)")

            if value.timeIntervalSince1970 - self.startingTimeInterval
                < min(threshold, self.maxThreshold) {
                print("Recursively subscribing to timer")
                self.subscribeToTimer(threshold: threshold + 3.0)
            }
        }.store(in: &cancellables)
    }
}

The immediately noticeable problem with this is that as the subscriptions reach the threshold, they stop creating child subscriptions, but they are still active. The log looks like this:

Received timer value: 2021-03-14 23:53:37 +0000
Subscriptions = 4
Received timer value: 2021-03-14 23:53:39 +0000
Subscriptions = 4
Recursively subscribing to timer
Received timer value: 2021-03-14 23:53:39 +0000
Subscriptions = 5
Recursively subscribing to timer
Received timer value: 2021-03-14 23:53:39 +0000
Subscriptions = 6
Received timer value: 2021-03-14 23:53:39 +0000
Subscriptions = 6
Received timer value: 2021-03-14 23:53:41 +0000
Subscriptions = 6
Received timer value: 2021-03-14 23:53:41 +0000
Subscriptions = 6

... and so on

The "Receive timer value" logs go on forever, and the number of subscriptions stays at 6. The Timer still executes the closures because we never cancel the subscriptions. To fix this, I tried to manually remove the subscription from the self.cancellables property:

var cancellable: AnyCancellable?
cancellable = SingletonTimerProvider.timer.sink { [weak self] value in
    // ...

    if value.timeIntervalSince1970 - self.startingTimeInterval 
        < min(threshold, self.maxThreshold) {
        // ...
    } else {
        if let cancellable = cancellable {
            self.cancellables.remove(cancellable)
        }
    }
}

cancellables.insert(cancellable!)

This didn’t work out too well. The number of subscriptions reached zero, but the closures still got executed.

Received timer value: 2021-03-15 00:18:55 +0000
Subscriptions = 2
Received timer value: 2021-03-15 00:18:55 +0000
Subscriptions = 1
Received timer value: 2021-03-15 00:18:57 +0000
Subscriptions = 0
Received timer value: 2021-03-15 00:18:57 +0000
Subscriptions = 0
Received timer value: 2021-03-15 00:18:57 +0000
Subscriptions = 0
Received timer value: 2021-03-15 00:18:57 +0000
Subscriptions = 0

... and so on

It turns out that using the cancellable has made the closure strongly capture that variable. And so the subscription still did not get cancelled. I was able to stop capturing cancellable by setting it to nil right after its removal from self.cancellables.

if value.timeIntervalSince1970 - self.startingTimeInterval 
    < min(threshold, self.maxThreshold) {
    // ...
} else {
    if let cancellable = cancellable {
        self.cancellables.remove(cancellable)
    }
    cancellable = nil
}

This worked well. It correctly cancelled the subscriptions. But it has one problem. If the view controller is dismissed manually by the user while the recursive subscriptions are still ongoing, there will be no chance for the cancellable = nil to be called. And thus, the remaining subscriptions will continue to be active. It’s not as harmful as before because the self is captured weakly, and there is a guard. But it’s still a leak that I needed to solve. Here’s what the logs look like now:

Received timer value: 2021-03-15 00:26:10 +0000
Subscriptions = 1
Recursively subscribing to timer
Received timer value: 2021-03-15 00:26:12 +0000
Subscriptions = 2
Received timer value: 2021-03-15 00:26:12 +0000
Subscriptions = 1
Recursively subscribing to timer
Manually closed the view controller!
Received timer value: 2021-03-15 00:26:14 +0000
Received timer value: 2021-03-15 00:26:14 +0000
Received timer value: 2021-03-15 00:26:16 +0000
Received timer value: 2021-03-15 00:26:16 +0000
Received timer value: 2021-03-15 00:26:18 +0000
Received timer value: 2021-03-15 00:26:18 +0000

... and so on

The "Received timer value" logs indicate that the subscriptions were still active if the user manually dismissed the view controller.

To fix this problem, we should avoid capturing the AnyCancellable inside the closure. We still need to retrieve that AnyCancellable so that we can cancel it. To get around that, I used an indirect reference.

  1. Use a UUID as a token that points to the AnyCancellable.
  2. Store the AnyCancellable along with the UUID.
  3. Use the UUID within the closure instead of the AnyCancellable.

The last change looks like this:

private var cancellables = [UUID: AnyCancellable]()

private func subscribeToTimer(threshold: TimeInterval) {
    let token = UUID()
    let cancellable = SingletonTimerProvider.timer.sink { [weak self] value in
        // ...
        if value.timeIntervalSince1970 - self.startingTimeInterval 
            < min(threshold, self.maxThreshold) {
            // ...
        } else {
            self.cancellables.removeValue(forKey: token)
        }
    }

    cancellables[token] = cancellable
}

Now, the closure only references the token, which is just a UUID. Removing the AnyCancellable via self.cancellables.removeValue(forKey: ) permanently cancels the subscription. And the subscriptions immediately get cancelled when the view controller is manually dismissed (deallocated).

You can find the full source code here: AnotherViewController.swift.