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")]),
]
)
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.