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?
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
selfstrongly in a sink stored onself. 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 + storefor 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… →