How do you see what a Combine pipeline is doing: handleEvents, print, breakpoint and the order of subscription, request, output, completion and cancel?

11.7.5 · L2 · Learning

Predict first

What does the lifecycle log show for a sink that is cancelled?

import Combine

var bag = Set<AnyCancellable>()
let s = PassthroughSubject<Int, Never>()
s.handleEvents(receiveSubscription: { _ in print("subscription") },
               receiveOutput: { print("output", $0) },
               receiveCompletion: { print("completion", $0) },
               receiveCancel: { print("cancel") },
               receiveRequest: { print("request", $0) })
 .sink { _ in }
 .store(in: &bag)
s.send(1)
bag.removeAll()
s.send(2)
print("done")
  • subscription / request unlimited / output 1 / cancel / done
  • subscription / output 1 / output 2 / done
  • output 1 / output 2 / cancel / done

Mental model. handleEvents is a set of clip-on meters on a pipe: you can read the flow at any joint without cutting the pipe.

Combine pipelines are hard to step through, so you instrument them. handleEvents lets you observe each lifecycle event without changing the stream: receiveSubscription, receiveRequest (the demand), receiveOutput, receiveCompletion and receiveCancel. In the check the order is “subscription”, then “request unlimited” (a sink asks for unlimited demand), then each “output”, and when the stored cancellable is removed the pipeline prints “cancel”; the later send(2) prints nothing, because nobody is subscribed. That tells you, for any bug, whether the pipeline was subscribed at all, whether it was cancelled early (often because the AnyCancellable was released, Q310), and what demand it asked for.

Other tools: .print("tag") logs every event to the console with a prefix (quick but noisy); .breakpointOnError() and .breakpoint(receiveOutput:) trap in the debugger on a condition; and .share() or a subject placed deliberately can make a stage inspectable. Put handleEvents between stages to bisect where a value stops, and strip them before shipping (or compile them out with #if DEBUG), because they add overhead and log noise. When a sink never fires, the usual causes are: the cancellable was not stored, the upstream never emits (a subject you forgot to send to), the stream already completed or failed, or receive(on:) is delivering later than you expect.

The log shows subscription, demand, each output, then cancel when the cancellable goes away; later sends are not delivered.

Common traps

  • Leaving print in release builds. It writes to stdout on every event.
  • Blaming the operator when the cancellable was released. A “cancel” event in the log points at lifetime, not logic.

They might follow up with

  • Why “request unlimited”? sink and assign ask for unlimited demand; a custom subscriber can ask for less (Q303).
  • Can I measure time between stages? Yes, record timestamps in handleEvents at each stage.

Related questions

  • 11.7.3 Where should a catch go in a search pipeline, and what happens to the stream if you put it outside the…
  • 11.9.1 Which thread does each stage of a pipeline run on, and what do subscribe(on:) and receive(on:) each move?
  • 11.1.2 What is demand and backpressure in Combine, and how does a custom subscriber control it?
  • 11.5.1 What is AnyCancellable, how long does a subscription live, and how do you manage it in a view model?

← Why does retry sometimes not retry anything, and how do Future and…