How do you end a pipeline: prefix(untilOutputFrom:), cancel and completion, and why does a sink that captures self and is stored on self never deinit?

11.10.1 · L3 · Interview

Predict first

What does prefix(untilOutputFrom:) deliver, and what does a cancelled sink see?

import Combine

var bag = Set<AnyCancellable>()
let stop = PassthroughSubject<Void, Never>()
let values = PassthroughSubject<Int, Never>()
values.prefix(untilOutputFrom: stop)
    .sink(receiveCompletion: { print("completion", $0) }, receiveValue: { print("value", $0) })
    .store(in: &bag)
values.send(1); values.send(2)
stop.send()
values.send(3)
let sub = values.sink { print("second", $0) }
sub.cancel()
values.send(4)
print("done")
  • value 1 / value 2 / completion finished / done
  • value 1 / value 2 / value 3 / second 4 / done
  • value 1 / value 2 / completion finished / second 4 / done

Predict first

Which screen is deallocated when dropped?

import Combine

final class Screen {
    var cancellables = Set<AnyCancellable>()
    var total = 0
    let name: String
    init(_ name: String, _ s: PassthroughSubject<Int, Never>, useWeak: Bool) {
        self.name = name
        if useWeak { s.sink { [weak self] in self?.total += $0 }.store(in: &cancellables) }
        else { s.sink { self.total += $0 }.store(in: &cancellables) }
    }
    deinit { print("deinit", name) }
}
let s = PassthroughSubject<Int, Never>()
var a: Screen? = Screen("strong capture", s, useWeak: false)
a = nil
print("dropped the strong-capture screen")
var b: Screen? = Screen("weak capture", s, useWeak: true)
b = nil
print("dropped the weak-capture screen")
  • Only the weak-capture one: "dropped the strong-capture screen" is not followed by a deinit, then "deinit weak capture" appears
  • Both
  • Neither

Mental model. Prefix-until is a timer that closes the shop when the bell rings. A cycle is a shop and its landlord who each hold the other’s keys, so neither can ever leave.

A pipeline ends in one of three ways: the upstream completes or fails; a downstream operator completes early; or the subscriber cancels. prefix(untilOutputFrom: stop) is a clean “take until” operator: it forwards values until stop emits, then completes (“completion finished”) and cancels its upstream, so the later 3 is not delivered. It is the idiomatic way to tie a pipeline to an event such as a deinit signal or a dismissed screen. cancel() on a subscription stops delivery without a completion: the second subscriber was cancelled and never sees 4, and it receives no completion event either, so do cleanup in receiveCancel if you need it.

Lifetime is where bugs hide. A sink is kept alive by its AnyCancellable, and the common pattern stores that in a Set<AnyCancellable> on the same object. If the sink’s closure uses self strongly, the object holds the cancellable which holds the closure which holds the object: a cycle. The check shows it: “deinit strong capture” never prints, while the screen with [weak self] deinitialises as soon as it is dropped (“deinit weak capture” before the next line). Fix with [weak self] (or assign(to: &$property), which does not retain, Q142). A cancellable not stored is the opposite bug: the subscription is cancelled immediately when it is deallocated, so nothing ever arrives (Q310).

Prefix completes when the stop signal fires, and a cancelled subscriber receives nothing more.

The strong capture plus the stored cancellable form a cycle; [weak self] breaks it.

Common traps

  • Using self strongly in a sink stored on self. The screen never deallocates.
  • Expecting cancel() to send a completion. It sends nothing; observe cancel events if you need to clean up.

They might follow up with

  • What replaces sink + store for a property? assign(to: &$property) ties the lifetime to the property’s owner with no extra cancellable and no cycle.
  • How do I find such a cycle? A log in deinit, and the memory graph debugger (Q159).

Related questions

  • 11.10.2 Why does merging or combining publishers fail to compile with “types Never and E must be equivalent”, and how…
  • 11.7.5 How do you see what a Combine pipeline is doing: handleEvents, print, breakpoint and the order of…
  • 3.3.1 What is a retain cycle, and how do you find and fix one?
  • 3.3.5 How do Combine subscriptions create cycles, and why is assign(to:on:) a trap?
  • 11.5.1 What is AnyCancellable, how long does a subscription live, and how do you manage it in a view model?

Why does merging or combining publishers fail to compile with “types… →