Notification Center Detachment
ID |
swift.notification_center_detachment |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
Swift |
Tags |
ios, memory-leak, reliability, suspicious-code |
Description
Reports a class (or actor) that calls
NotificationCenter.default.addObserver(…) in one of its methods
but never calls removeObserver(…) anywhere on the type. The
observer registration is held by the notification center for the
lifetime of the process and outlives the instance it was registered
against. The next posted notification dispatches on the freed object —
a hard crash on the selector-based API, and a leak that pins
captured state on the block-based API.
class Crashy { // FLAW
init() {
NotificationCenter.default.addObserver(
self, selector: #selector(handle),
name: .foo, object: nil)
}
// no removeObserver, no deinit
}
class Safe { // OK
init() {
NotificationCenter.default.addObserver(
self, selector: #selector(handle),
name: .foo, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
Rationale
Modern Foundation releases auto-clean observers registered with the
selector-based addObserver(_:selector:name:object:) API when the
observer object is deallocated, but the contract is not stable across
deployment targets and breaks completely for the block-based
addObserver(forName:object:queue:using:) API: the returned token
holds the registration, and the block keeps a strong reference to
whatever it captured. A type that subscribes without ever cancelling
leaves dangling state at best, and crashes on the next post at worst.
The rule scans each class / actor body for any postfix chain rooted
at NotificationCenter ending in addObserver(…). If the type has
no balancing removeObserver(…) call anywhere in its body, it
fires once on the type declaration. The match is intentionally lenient
(any removeObserver silences the rule, regardless of which observer
or center it targets) because a precise deinit-only check is not
reliable from the public AST and produces noise on types that wrap
the observer lifecycle in helper methods. Structs, enums, and
protocols are skipped — they cannot define a deinit, and observer
lifetimes on them are usually managed elsewhere.
Remediation
Add a deinit that calls removeObserver on the same center, or
hold the registration as an NSObjectProtocol token and call
removeObserver(token) when the type is torn down:
class Safe {
private var token: NSObjectProtocol?
init() {
token = NotificationCenter.default.addObserver(
forName: .foo, object: nil, queue: .main) { [weak self] note in
self?.handle(note)
}
}
deinit {
if let token = token {
NotificationCenter.default.removeObserver(token)
}
}
}
For SwiftUI / Combine code, prefer
NotificationCenter.default.publisher(for:) and a Set<AnyCancellable>
that the view model owns — the cancellable detaches the observer
automatically when the view model is released.