Sins of Carthage: RxSwift Re-Entrancy, Undetected

When I started developing Mac apps eons ago, I only had a crappy Intel Mac Mini that took forever to compile my apps. To make this manageable, I adopted library-based development and prepared UI components as dependencies I could compile once, then link as .frameworks into the app. Carthage was a big help there, automating the process.

In the past couple of years, I’ve moved more and more of my components to expose Swift Package manifests and switch over from Carthage. Only around last Christmas I realized I had all RxSwift-using packages ready, and so I could also manage RxSwift itself as a Swift Package.

This is the first time, I believe ever!, that I’ve built and used RxSwift in DEBUG mode in my app. And it shows, because I get a ton of reentrancy warnings:

⚠️ Reentrancy anomaly was detected.
  > Debugging: To debug this issue you can set a breakpoint in (...)/RxSwift/Rx.swift:96 and observe the call stack.
  > Problem: This behavior is breaking the observable sequence grammar. `next (error | completed)?`
    This behavior breaks the grammar because there is overlapping between sequence events.
    Observable sequence is trying to send an event before sending of previous event has finished.
  > Interpretation: This could mean that there is some kind of unexpected cyclic dependency in your code,
    or that the system is not behaving in the expected way.
  > Remedy: If this is the expected behavior this message can be suppressed by adding `.observe(on:MainScheduler.asyncInstance)`
    or by enqueuing sequence events in some other way.

Yikes. “Reentrancy anomaly” doesn’t sound healthy.

The app runs fine. It ran fine for years. So it’s not a pressing issue.

But these messages spam my log and make it unreadable. And unexpected app behavior could manifest due to this eventually, so they need to be addressed.

The problem, thankfully, seems to be isolated to a Reactive extension I built myself, and I only need to change a dozen places to fix things. It happens because I’ve made the store of my ReSwift-powered app state observable, and the violation stems from one subscription callback not finishing before the next is triggered.

It’s a fundamental design incompatibility with ReSwift: you can subscribe to changes to the app state, and in the callback you are permitted to dispatch new actions immediately. Like this:

struct AppState: ReSwift.State {
    var count = 0
}

struct IncrementCount: ReSwift.Action { ... }
struct DecrementCount: ReSwift.Action { ... }

struct HalveCount: ReSwift.Action {
    func reduce(appState: inout AppState) {
        appState.count = appState.count / 2
    }
}

class LargeCountHalver: ReSwift.StoreSubscriber {
    init() {}
    
    // Subscription callback:
    func newState(state count: Int) {
        if count > 100 {
            store.dispatch(HalveCount())
        }
    }
}

// Elsewhere:
let store = ReSwift.Store(...)
let halvingService = LargeCountHalver()
store.subscribe(halvingService) { $0.select(\.count) }

So whenever the app state’s count property exceeds the value 100, a halving action is sent. This is a contrived example (you would probably implement this in a Middleware instead). But it’s perfectly legal.

Now with RxSwift extension, you can write this “inline” instead:

let store = ReSwift.Store(...)
let disposeBag = ...
store.rx
    .state
    .map(\.count)
    .subscribe(onNext: { count in 
        if count > 100 {
            store.dispatch(HalveCount())
        }
    })
    .disposed(by: disposeBag)

This would dispatch a new action synchronously and trigger a state update before the subscribe callback finishes.

Instead of inline subscribers like this, I actually still have the objects like in the original example – but I use RxSwift to manage subscribing/unsubscribing via the DisposeBag:

store.rx
    .state
    .map(\.count)
    .subscribe(largeCountHalver)
    .disposed(by: disposeBag)

I realize I’ve never shared these extensions. I guess that’s for the better :)

A quick fix proposed by the original RxSwift warning is to dispatch on the main scheduler, but asynchronously. That could indeed break up the nesting and make things run sequentially. But solving a synchronous design’s problem with asynchrony just opens up a Pandoras Box of new, harder to detect, and thus harder to debug problems in the future – ones that would manifest in weird app behavior and UI interactions. So that’s not a real solution.

The real solution is to avoid nesting store action dispatch calls in subscriptions when RxSwift store subscribers are involved. It’s fine for all user-interactive components to fire actions, well, reactively as the user does things. I merely need to remove it from services, and automated background tasks.

Replacing these thin wrappers with ReSwift store subscriptions and managing their lifetime myself where needed is the right course of action.

I would’ve detected this years ago when I introduced these helpers if only I had ever built RxSwift in DEBUG mode. But I didn’t, because I used Carthage.