Combine debugging using operators in Swift

Combine debugging can be hard with long stack traces which don’t help at all. Asynchronous callbacks follow up on each other rapidly and easily make it hard to find the root cause of an issue. It’s next to the big learning curve one of the most common reasons to not go for reactive programming in projects.

Combine Debugging can be hard with long stack traces

Combine Debugging can be hard with long stack traces

Luckily enough there are a few tips and tricks to improve the debugging experience in Xcode. Apple even introduces specific debugging operators for you to use!

Just getting started with Combine? You might want to first take a look at Getting started with the Combine framework in Swift or my Combine Playground.

Debugging using the handleEvents operator

The handleEvents operator allows you to catch any events on a publisher. It will perform the specified closures whenever the related event occurs.

let subject = PassthroughSubject<String, Never>()
let subscription = subject.handleEvents(receiveSubscription: { (subscription) in
    print("Receive subscription")
}, receiveOutput: { output in
    print("Received output: \(output)")
}, receiveCompletion: { _ in
    print("Receive completion")
}, receiveCancel: {
    print("Receive cancel")
}, receiveRequest: { demand in
    print("Receive request: \(demand)")
}).sink { _ in }

subject.send("Hello!")
subscription.cancel()

// Prints out:
// Receive request: unlimited
// Receive subscription
// Received output: Hello!
// Receive cancel

It allows you to define closures for:

  • Receiving a new subscription. The closure parameter points to the new subscription.
  • New received values. The closure parameter contains the value.
  • The completion event including the type of completion: failure or success. Will not be called when the subscription is canceled.
  • The cancelation event which will not be called if the subscription is already completed.
  • Receiving a request for more elements. The closure includes the demand value which will either be unlimited or a defined maximum number of items.

All closures are optional and therefore allow you to listen to only one of them if needed.

In the above code example prints are being used to help to debug. Obviously, you can also use breakpoints inside the closure to use LLDB to debug even deeper.

Debugging a Combine stream using prints

The print operator logs messages for all publishing events and helps to get more insights into changes of a stream.

let printSubscription = subject.print("Print example").sink { _ in }

subject.send("Hello!")
printSubscription.cancel()

// Prints out:
// Print example: receive subscription: (PassthroughSubject)
// Print example: request unlimited
// Print example: receive value: (Hello!)
// Print example: receive cancel

As you can see, it almost prints out the same as our previous code example using the handle events operator. The print operator, however, is a lot easier to use and allows you to pass in a prefix like “Print example” as we used here.

It’s perfect when you quickly want to print out some more information but it does not allow you to debug using breakpoints.

Using the breakpointOnError operator

The breakpointOnError operator does exactly what you expect: trigger a breakpoint when an error occurs inside a publisher. The operator raises a SIGTRAP signal which will stop the process in the debugger. Do note that it might not stop in the right thread and position in the stack trace. Therefore, you might need to navigate a bit to find the stream that failed.

The breakpointOnError stops in the wrong thread

The breakpointOnError stops in the wrong thread

If you’re done debugging you can simply press continue and your process will continue as normal.

Triggering a Combine debugging breakpoint based on state

The breakpoint operator allows you to trigger the same signal as the breakpointOnError operator based on a specific state. It comes with optional closures to trigger a breakpoint when a new subscription, a new value or a completion event is received.

URLSession.shared.dataTaskPublisher(for: url)
                    .map { $0.data }
                    .decode(type: SearchResponse.self, decoder: self.decoder)
                    .map { $0.items }
                    .breakpoint(receiveOutput: { (items) -> Bool in
                        // Only trigger a breakpoint when the items count is 1
                        return items.count == 1
                    })
                    .catch { error -> Just<[Repo]> in
                        print("Decoding failed with error: \(error)")
                        return Just([])
                    }

A breakpoint will only be triggered if one of those closures is implemented and returns true. Therefore, an empty breakpoint() operator will do nothing more than passing through the values.

Up until Xcode 11 beta 3, this operator does nothing more than triggering a breakpoint. The stack trace you get provides useless information and does not even allow you to navigate to the causing operator. If anything changes in this behavior, this blog post will be updated.

Swift Playground

Everything described in this blog post is added as a separated page to my Swift Combine Playground which you can find here.

Conclusion

After the big learning curve that comes with Reactive Programming, debugging is stated as one of the many reasons to not go with a framework like Combine. Hopefully, this blog post takes away the debugging troubles you might foresee. However, it’s still a lot harder to debug reactive code compared to non-reactive code.

Therefore, try to keep your implementations to the minimum and only use reactive programming solutions if they really make sense.

Thanks!