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.