10.
Debugging
Written by Florent Pillet
Understanding the event flow in asynchronous code has always been a challenge. It is particularly the case in the context of Combine, as chains of operators in a publisher may not immediately emit events. For example, operators like throttle(for:scheduler:latest:) will not emit all events they receive, so you need to understand what’s going on. Combine provides a few operators to help with debugging your reactive flows. Knowing them will help you troubleshoot puzzling situations.
Printing events
The print(_:to:) operator is the first one you should use when you’re unsure whether anything is going through your publishers. It’s a passthrough publisher which prints a lot of information about what’s happening.
Even with simple cases like this one:
let subscription = (1...3).publisher
.print("publisher")
.sink { _ in }
The output is very detailed:
publisher: receive subscription: (1...3)
publisher: request unlimited
publisher: receive value: (1)
publisher: receive value: (2)
publisher: receive value: (3)
publisher: receive finished
Here you see that the print(_:to:) operators shows a lot of information, as it:
- Prints when it receives a subscription and shows the description of its upstream publisher.
- Prints the subscriber‘s demand requests so you can see how many items are being requested.
- Prints every value the upstream publisher emits.
- Finally, prints the completion event.
There is an additional parameter that takes a TextOutputStream object. You can use this to redirect strings to print to a logger. You can also add information to the log, like the current date and time, etc. The possibilities are endless!
For example, you can create a simple logger that displays the time interval between each string so you can get a sense of how fast your publisher emits values:
class TimeLogger: TextOutputStream {
private var previous = Date()
private let formatter = NumberFormatter()
init() {
formatter.maximumFractionDigits = 5
formatter.minimumFractionDigits = 5
}
func write(_ string: String) {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let now = Date()
print("+\(formatter.string(for: now.timeIntervalSince(previous))!)s: \(string)")
previous = now
}
}
It’s very simple to use in your code:
let subscription = (1...3).publisher
.print("publisher", to: TimeLogger())
.sink { _ in }
And the result displays the time between each printed line:
+0.00111s: publisher: receive subscription: (1...3)
+0.03485s: publisher: request unlimited
+0.00035s: publisher: receive value: (1)
+0.00025s: publisher: receive value: (2)
+0.00027s: publisher: receive value: (3)
+0.00024s: publisher: receive finished
As mentioned above, the possibilities are quite endless here.
Acting on events — performing side effects
Besides printing out information, it is often useful to perform actions upon specific events. We call this performing side effects, as actions you take “on the side” don’t directly impact further publishers down the stream, but can have an effect like modifying an external variable.
The handleEvents(receiveSubscription:receiveOutput:receiveCompletion:receiveCancel:receiveRequest:) (wow, what a signature!) lets you intercept any and all events in the lifecycle of a publisher and then take action at each step.
Imagine you‘re tracking an issue where a publisher must perform a network request, then emit some data. When you run it, it never receives any data. What’s happening? Is the request really working? Do you even listen to what comes back?
Consider this code:
let request = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://www.raywenderlich.com/")!)
request
.sink(receiveCompletion: { completion in
print("Sink received completion: \(completion)")
}) { (data, _) in
print("Sink received data: \(data)")
}
You run it and never see anything print. Can you see the issue by looking at the code?
If not, use handleEvents to track what‘s happening. You can insert this operator between the publisher and sink:
.handleEvents(receiveSubscription: { _ in
print("Network request will start")
}, receiveOutput: { _ in
print("Network request data received")
}, receiveCancel: {
print("Network request cancelled")
})
Then, run the code again. This time you see some debugging output:
Network request will start
Network request cancelled
There! You found it: You forgot to keep the Cancellable around. So, the subscription starts but gets canceled immediately. Now, you can fix your code by retaining the Cancellable:
let subscription = request
.handleEvents...
Then, running your code again, you‘ll now see it behaving correctly:
Network request will start
Network request data received
Sink received data: 153253 bytes
Sink received completion: finished
Using the debugger as a last resort
The last resort operator is one you pull in situations where you really need to introspect things at certain times in the debugger, because nothing else helped you figure out what’s wrong.
The first simple operator is breakpointOnError(). As the name suggests, when you use this operator, if any of the upstream publishers emits an error, Xcode will break in the debugger to let you look at the stack and, hopefully, find why and where your publisher errors out.
A more complete variant is breakpoint(receiveSubscription:receiveOutput:receiveCompletion:). It allows you to intercept various events and decide on a case-by-case basis whether you want to pause the debugger.
For example, you could break only if certain values pass through the publisher:
.breakpoint(receiveOutput: { value in
return value > 10 && value < 15
})
Assuming the upstream publisher emits integer values, but values 11 to 14 should never happen, you can configure breakpoint to break only in this case and let you investigate! You can also conditionally break subscription and completion times, but cannot intercept cancelations like the handleEvents operator.
Note: None of the breakpoint publishers will work in playgrounds. You will see an error stating that execution was interrupted, but it won‘t drop into the debugger.
Key points
- Track the lifecycle of a publisher with the
printoperator, - Create your own
TextOutputStreamto customize the output strings, - Use the
handleEventsoperator to intercept lifecycle events and perform actions, - Use the
breakpointOnErrorandbreakpointoperators to break on specific events.
Where to go from here?
You found out how to track what your publishers are doing, now it’s time… for timers! Move on to the next chapter to learn how to trigger events at regular intervals with Combine.