Search code examples
swiftreferencerx-swift

Observable.create capture behaviour


Why does this result in an attempted read of an already deallocated reference when doing printOnSelf("onFire")?

After subject!.startEmitter() call, there should be two references to the TimerInvalidator: the first one is from the Controller as let invalidator = TimerInvalidator() and the second one is from the Observable.create closure which captures the local variable invalidator and therefore holds a reference to it.

When Controller gets deallocated, the first reference to the TimerInvalidator should be lost, while the second one is still captured inside the Observable.create closure. But because the Controller has been deallocated, the DisposeBag should also get deallocated at the same time and dispose the created observable sequence. That should deallocate the Observable.create closure and therefore remove the second reference to the TimerInvalidator. That should mean that the TimerInvalidator should also get deallocated and invalidate the timer.

But in reality, the timer fires long after the Controller has been freed, causing an error when accessing self. What am I missing?


Of course it would make much more sense not to access self inside the Observable.create, but to do it inside a .do(onNext:) closure after the create method. Or it could be fixed by invalidating the timer when the sequence is disposed by changing:

return Disposables.create {
    print("Emitter.start.create.dispose")
    invalidator.timer?.invalidate()
}

Or it could be fixed by capturing TimerInvalidator as unowned by changing:

return Observable.create { [unowned self, unowned invalidator] event in
    ...

But I am interested in the reason, why this approach is not fine. And also why those fixes work.


The following code is obviously just a minimal example, that I am running as a package.

main.swift

import Foundation
import RxSwift
import RxCocoa

class TimerInvalidator {
    var timer: Timer?
    
    deinit {
        print("TimerInvalidator.deinit")
        timer?.invalidate()
    }
}

class Controller {
    let db = DisposeBag()
    let invalidator = TimerInvalidator()
    
    let emitter = Emitter()
    
    func startEmitter() {
        print("Controller.startEmitter")
        emitter.createSignal(with: invalidator)
            .emit() // Do stuff
            .disposed(by: db)
    }
    
    deinit {
        print("Controller.deinit")
    }
}
        
class Emitter {
    func printOnSelf(_ string: String) {
        // Represents some operation on self
        print("\(Self.self): " + string)
    }
    
    func createSignal(with invalidator: TimerInvalidator) -> Signal<String> {
        return Observable.create { [unowned self] event in
            printOnSelf("onCreated")
            invalidator.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [unowned self] timer in
                printOnSelf("onFire")
                event.onNext("fire")
                event.onCompleted()
            }
            return Disposables.create {
                print("Emitter.start.create.dispose")
            }
        }
        .asSignal(onErrorJustReturn: "error")
    }
    
    deinit {
       print("Emitter.deinit")
    }
}

var subject: Controller? = Controller()
subject!.startEmitter()

DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
    subject = nil
}

RunLoop.current.run(mode: .default, before: Date() + 10)

Package.swift (is not related directly to the question, just for your convenience)

import PackageDescription

let package = Package(
    name: "StackOverflow",
    products: [
        .executable(name: "StackOverflow", targets: ["StackOverflow"])
    ],
    dependencies: [
        .package(url: "https://github.com/ReactiveX/RxSwift", exact: "6.5.0")
    ],
    targets: [
        .target(
            name: "StackOverflow",
            dependencies: ["RxSwift", .product(name: "RxCocoa", package: "RxSwift")]),
    ]
)

Solution

  • The observable contract guarantees that the dispose() of the subscription will be called when the dispose bag's deinit is called. It makes no guarantees as to exactly when the memory associated with the Observable will be cleaned up. That's up to the underlying iOS VM which, if you look at the memory graph of the TimerInvalidator at the moment your app crashes, is still holding on to the Observable.

    You probably noticed that at the time the app crashed, your TimerInvalidator's deinit had not yet been called. However, if you remove the offending line and let the app continue, the deinit does get called.

    Keep in mind that the Rx system was designed to have determinable behavior no matter what the underlying memory model is. That's why the create function requires that you return a Disposable that correctly cleans up resources. By failing to do that, you are breaking the contract which leaves the behavior of the library undefined.

    You said:

    ...it could be fixed by invalidating the timer when the sequence is disposed...

    In fact, that is the only valid (as in following the contract) way to fix it.