diff --git a/CHANGELOG.md b/CHANGELOG.md index 1942f63..d8f9e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 3.3.0 + +### Added +- Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties. +- `.hosting { … }` lifecycle that shows a SwiftUI view built from the object in a test window (UIKit and AppKit), so `onAppear` and `.task` run. +- `trackForDeallocation(_:)` inside an `expectDeallocation` closure checks the object together with the tested one, in Swift Testing and XCTest. + ## 3.2.0 ### Added diff --git a/README.md b/README.md index 28a713d..3d1850c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ import PackageDescription let package = Package( name: "HelloDeallocTests", dependencies: [ - .package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "3.2.0")) + .package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "3.3.0")) ], targets: [ .testTarget( @@ -93,12 +93,16 @@ struct LeakTests { } ``` -The same calls work inside an `XCTestCase`. A leak fails with: +The same calls work inside an `XCTestCase`. A leak fails at the line of the test, and the message points at the likely cause: ``` -LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. Something still holds a strong reference to it: look for closures capturing self, delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions. +LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. Possible causes: + • `onUpdate` is a closure. Make sure it captures self weakly + • `self.viewModel.owner` refers back to the object. That's a retain cycle unless one of the references is weak ``` +The hints come from the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers, and reference cycles through properties. Reflection can't tell weak properties from strong ones or look inside closures, so treat them as suggestions. + Many leaks only appear once a screen loads or appears, so pick the lifecycle that exercises the object: | Lifecycle | What happens before release | @@ -107,6 +111,7 @@ Many leaks only appear once a screen loads or appears, so pick the lifecycle tha | `.loadView` | The view controller loads its view (`viewDidLoad`). UIKit and AppKit. | | `.present`, `.present(style:interaction:)` | The view controller is presented in a test window, then dismissed | | `.push`, `.push(interaction:)` | The view controller is pushed onto a navigation controller in a test window, then popped | +| `.hosting { object in SomeView(model: object) }` | A SwiftUI view built from the object is shown in a test window, then removed. `onAppear` and `.task` run. | | `.custom { object in … }` | Your code runs with the object, e.g. calls the methods you suspect of leaking | `interaction` runs while the controller is on screen: @@ -120,6 +125,23 @@ await expectDeallocation(.present(interaction: { controller in } ``` +SwiftUI views are values, so check the object behind them, typically the view model: + +```swift +await expectDeallocation(.hosting { ProfileView(viewModel: $0) }) { + ProfileViewModel(api: MockAPI()) +} +``` + +To also check objects the tested one owns, wrap them in `trackForDeallocation` inside the closure. They must deallocate together with it: + +```swift +await expectDeallocation(.present) { + let viewModel = trackForDeallocation(ProfileViewModel(api: MockAPI())) + return ProfileViewController(viewModel: viewModel) +} +``` + Other parameters: - `timeout` sets how long to wait for the deallocation (2 seconds by default). The check passes as soon as the object is gone. diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index 57dbbc8..044838b 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -17,7 +17,8 @@ final class DeallocationTracker { let location: TestSourceLocation } - /// Tracker installed by the `.checksDeallocation` Swift Testing trait + /// Tracker that `trackForDeallocation(_:)` adds objects to. + /// Installed by `expectDeallocation` and the `.checksDeallocation` Swift Testing trait. @TaskLocal static var current: DeallocationTracker? private var trackedObjects = [TrackedObject]() @@ -46,9 +47,13 @@ final class DeallocationTracker { } } - for trackedObject in trackedObjects where trackedObject.object != nil { + for trackedObject in trackedObjects { + guard let object = trackedObject.object else { + continue + } + reportIssue( - Self.leakMessage(typeName: trackedObject.typeName, timeout: timeout), + Self.leakMessage(typeName: trackedObject.typeName, timeout: timeout, hints: LeakHints.hints(for: object)), at: trackedObject.location ) } @@ -56,9 +61,14 @@ final class DeallocationTracker { trackedObjects.removeAll() } - static func leakMessage(typeName: String, timeout: Duration) -> String { - "\(typeName) was not deallocated within \(timeout.formatted(.units(allowed: [.seconds, .milliseconds]))). " - + "Something still holds a strong reference to it: look for closures capturing self, " - + "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions." + static func leakMessage(typeName: String, timeout: Duration, hints: [String] = []) -> String { + let summary = "\(typeName) was not deallocated within \(timeout.formatted(.units(allowed: [.seconds, .milliseconds])))." + + guard !hints.isEmpty else { + return summary + " Something still holds a strong reference to it: look for closures capturing self, " + + "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions." + } + + return summary + " Possible causes:\n" + hints.map { " • \($0)" }.joined(separator: "\n") } } diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift index 7f659a7..112c170 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -9,7 +9,8 @@ import Foundation /// Creates an object, runs its lifecycle, releases it and checks that it deallocates. /// -/// Works in Swift Testing and XCTest. A leak is reported at the line that calls this function. +/// Works in Swift Testing and XCTest. A leak is reported at the line that calls this function, +/// with hints about properties that commonly cause leaks. /// /// ```swift /// @Test func secondScreenDoesNotLeak() async { @@ -24,6 +25,7 @@ import Foundation /// - timeout: How long to wait for the object to deallocate /// - afterRelease: Runs after the object is released and before the check, e.g. to release cached instances /// - makeObject: Creates the tested object. Don't keep any other reference to it. +/// Objects passed to `trackForDeallocation(_:)` inside it are checked too. @MainActor public func expectDeallocation( _ lifecycle: Lifecycle = .none, @@ -55,7 +57,10 @@ private func createAndRun( tracker: DeallocationTracker, location: TestSourceLocation ) async rethrows -> Bool { - let object = try await makeObject() - tracker.track(object, at: location) - return await lifecycle.run(object, location) + // `trackForDeallocation(_:)` called from the factory or the lifecycle adds objects to this check + try await DeallocationTracker.$current.withValue(tracker) { + let object = try await makeObject() + tracker.track(object, at: location) + return await lifecycle.run(object, location) + } } diff --git a/Sources/DeallocTests/Expectation/LeakHints.swift b/Sources/DeallocTests/Expectation/LeakHints.swift new file mode 100644 index 0000000..f6345a3 --- /dev/null +++ b/Sources/DeallocTests/Expectation/LeakHints.swift @@ -0,0 +1,150 @@ +// +// LeakHints.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +/// Looks at the stored properties of a leaked object and points at the usual suspects. +/// +/// `Mirror` can't tell weak properties from strong ones and can't look inside closures, +/// so the hints are suggestions, not proof. +enum LeakHints { + /// How deep to look for reference cycles that go through properties + static let maximumCycleDepth = 4 + /// Upper bound for visited objects, to keep huge object graphs fast + static let maximumVisitedObjects = 300 + + static func hints(for object: AnyObject) -> [String] { + var hints = [String]() + + for property in storedProperties(of: object) { + if let kind = suspiciousKind(of: property.value) { + hints.append("`\(property.label)` is \(kind)") + } + } + + hints += cycles(from: object).map { path in + "`\(path)` refers back to the object. That's a retain cycle unless one of the references is weak" + } + + return hints + } + + // MARK: - Suspicious properties + + private static func suspiciousKind(of value: Any) -> String? { + let typeName = String(describing: type(of: value)) + + if typeName.contains("->") { + return "a closure. Make sure it captures self weakly" + } + + if typeName.contains("AnyCancellable") { + return "a Combine subscription. Make sure its sink captures self weakly" + } + + if typeName.hasPrefix("Task<") || typeName.hasPrefix("Optional [String] { + let rootIdentifier = ObjectIdentifier(root) + var visited: Set = [rootIdentifier] + var queue: [(object: AnyObject, path: String, depth: Int)] = [(root, "self", 0)] + var found = [String]() + + while !queue.isEmpty, visited.count < maximumVisitedObjects { + let (object, path, depth) = queue.removeFirst() + + guard depth < maximumCycleDepth else { + continue + } + + for property in storedProperties(of: object) { + for child in referencedObjects(in: property.value) { + let childPath = "\(path).\(property.label)" + let childIdentifier = ObjectIdentifier(child) + + if childIdentifier == rootIdentifier, depth > 0 { + found.append(childPath) + } else if isUserDefined(type(of: child)), visited.insert(childIdentifier).inserted { + queue.append((child, childPath, depth + 1)) + } + } + } + } + + return found + } + + // MARK: - Reflection + + private struct Property { + let label: String + let value: Any + } + + /// Stored properties of the object's own class and its user-defined superclasses + private static func storedProperties(of object: AnyObject) -> [Property] { + var properties = [Property]() + var mirror: Mirror? = Mirror(reflecting: object) + + while let currentMirror = mirror { + if let subjectType = currentMirror.subjectType as? AnyClass, !isUserDefined(subjectType) { + break + } + + for child in currentMirror.children { + guard let label = child.label else { + continue + } + properties.append(Property(label: cleaned(label), value: child.value)) + } + + mirror = currentMirror.superclassMirror + } + + return properties + } + + /// Class instances stored directly or inside optionals, collections, tuples and structs + private static func referencedObjects(in value: Any, depth: Int = 0) -> [AnyObject] { + let mirror = Mirror(reflecting: value) + + if mirror.displayStyle == .class { + return [value as AnyObject] + } + + guard depth < 3 else { + return [] + } + + return mirror.children.prefix(50).flatMap { referencedObjects(in: $0.value, depth: depth + 1) } + } + + /// Skips Apple framework classes like `UIView`, so hints point at the app's own code + private static func isUserDefined(_ objectClass: AnyClass) -> Bool { + guard let bundleIdentifier = Bundle(for: objectClass).bundleIdentifier else { + return true + } + return !bundleIdentifier.hasPrefix("com.apple.") + } + + private static func cleaned(_ label: String) -> String { + // Lazy properties are stored as `$__lazy_storage_$_name` + label.replacingOccurrences(of: "$__lazy_storage_$_", with: "") + } +} diff --git a/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift new file mode 100644 index 0000000..0dc4a0a --- /dev/null +++ b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift @@ -0,0 +1,121 @@ +// +// Lifecycle+SwiftUI.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if canImport(SwiftUI) + +import SwiftUI + +public extension Lifecycle { + /// Shows a SwiftUI view built from the object, typically its view model, in a test window, then removes it. + /// + /// `onAppear` and `.task` run while the view is shown. When it's removed, SwiftUI cancels its tasks. + /// + /// ```swift + /// await expectDeallocation(.hosting { ProfileView(viewModel: $0) }) { + /// ProfileViewModel() + /// } + /// ``` + /// + /// - Parameters: + /// - interaction: Runs while the view is on screen + /// - content: Builds the view from the object + static func hosting( + interaction: Interaction? = nil, + @ViewBuilder _ content: @escaping @MainActor (Object) -> Content + ) -> Self { + Self { object, location in + guard let host = await SwiftUIHost(rootView: content(object), location: location) else { + return false + } + + // Lets SwiftUI call `onAppear` and start `.task` modifiers + await settle() + await perform(interaction, with: object, at: location) + + host.remove() + + // Lets SwiftUI call `onDisappear` and cancel tasks + await settle() + return true + } + } +} + +@MainActor +private func settle() async { + try? await Task.sleep(for: .milliseconds(50)) +} + +#if canImport(UIKit) + +/// Shows a hosting controller as a child of an empty controller in a test window +@MainActor +private final class SwiftUIHost { + private let window: TestWindow + private let hostController: HostViewController + private let hostingController: UIHostingController + + init?(rootView: Content, location: TestSourceLocation) async { + hostController = HostViewController() + window = TestWindow(rootViewController: hostController) + hostingController = UIHostingController(rootView: rootView) + + guard await window.waitUntilVisible(hostController, at: location) else { + window.close() + return nil + } + + hostController.addChild(hostingController) + hostingController.view.frame = hostController.view.bounds + hostController.view.addSubview(hostingController.view) + hostingController.didMove(toParent: hostController) + + guard await waitUntil({ [hostingController] in hostingController.viewIfLoaded?.window != nil }) else { + reportIssue("The SwiftUI view could not be shown", at: location) + remove() + return nil + } + } + + func remove() { + hostingController.willMove(toParent: nil) + hostingController.view.removeFromSuperview() + hostingController.removeFromParent() + window.close() + } +} + +#elseif canImport(AppKit) + +/// Shows a hosting controller in a test window +@MainActor +private final class SwiftUIHost { + private let window: NSWindow + + init?(rootView: Content, location: TestSourceLocation) async { + let hostingController = NSHostingController(rootView: rootView) + window = NSWindow(contentViewController: hostingController) + window.isReleasedWhenClosed = false + window.orderFront(nil) + + guard await waitUntil({ [window] in hostingController.view.window === window && window.isVisible }) else { + reportIssue("The SwiftUI view could not be shown", at: location) + remove() + return nil + } + } + + func remove() { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } +} + +#endif + +#endif diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift index ea403d5..8b34a7d 100644 --- a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -34,8 +34,16 @@ public extension XCTestCase { line: UInt = #line, column: UInt = #column ) -> Object { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + + // Inside `expectDeallocation`, the object joins its check + if let tracker = DeallocationTracker.current { + tracker.track(object, at: location) + return object + } + let tracker = DeallocationTracker() - tracker.track(object, at: TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column)) + tracker.track(object, at: location) addTeardownBlock { @MainActor in await tracker.verifyDeallocation(timeout: timeout) @@ -47,7 +55,8 @@ public extension XCTestCase { // MARK: - Swift Testing -/// Checks that the object deallocates when the test ends. Requires the `.checksDeallocation` trait. +/// Checks that the object deallocates when the test ends. Requires the `.checksDeallocation` trait, +/// or a call inside `expectDeallocation`, which then checks the object together with the tested one. /// /// ```swift /// @Test(.checksDeallocation) func viewModel() async { @@ -68,8 +77,8 @@ public func trackForDeallocation( guard let tracker = DeallocationTracker.current else { reportIssue( - "trackForDeallocation(_:) needs the .checksDeallocation trait on the test or its suite. " - + "In XCTest, call it on the test case.", + "trackForDeallocation(_:) needs the .checksDeallocation trait on the test or its suite, " + + "or a call inside expectDeallocation. In XCTest, call it on the test case.", at: location ) return object diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift new file mode 100644 index 0000000..31b0512 --- /dev/null +++ b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift @@ -0,0 +1,207 @@ +// +// LeakHintsAndSwiftUITests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Combine +import DeallocTests +import SwiftUI +import Testing + +// MARK: - Fixtures + +final class ClosureLeak { + var onUpdate: (() -> Void)? + + init() { + onUpdate = { _ = self } + } +} + +final class CycleParent { + var child: CycleChild? + + init() { + child = CycleChild(parent: self) + } +} + +final class CycleChild { + let parent: CycleParent + + init(parent: CycleParent) { + self.parent = parent + } +} + +final class SubscriptionLeak { + let updates = PassthroughSubject() + var cancellables = Set() + var value = 0 + + init() { + // The subscription stays alive and its sink captures self strongly + updates.sink { self.value = $0 }.store(in: &cancellables) + } +} + +final class OwnerObject { + let viewModel: PlainObject + + init(viewModel: PlainObject) { + self.viewModel = viewModel + } +} + +/// Starts an endless task on appear and never cancels it +@MainActor +final class TaskLeakModel { + var task: Task? + var ticks = 0 + + func start() { + task = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + self.ticks += 1 + } + } + } +} + +/// Runs work in `.task`, which SwiftUI cancels when the view goes away +@MainActor +final class TaskModifierModel { + var ticks = 0 + var appeared = false + + func run() async { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + ticks += 1 + } + } +} + +struct TaskLeakView: View { + let model: TaskLeakModel + + var body: some View { + Text("Leaking").onAppear { model.start() } + } +} + +struct TaskModifierView: View { + let model: TaskModifierModel + + var body: some View { + Text("Clean") + .onAppear { model.appeared = true } + .task { await model.run() } + } +} + +func isLeakReport(of typeName: String, mentioning hint: String) -> (Issue) -> Bool { + { issue in + isLeakReport(of: typeName)(issue) && issue.comments.contains { $0.rawValue.contains(hint) } + } +} + +// MARK: - Leak hints + +@Suite("Leak hints") +@MainActor +struct LeakHintsTests { + @Test func closurePropertyIsNamed() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ClosureLeak() } + } matching: { issue in + isLeakReport(of: "ClosureLeak", mentioning: "`onUpdate` is a closure")(issue) + } + } + + @Test func propertyCycleIsShown() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { CycleParent() } + } matching: { issue in + isLeakReport(of: "CycleParent", mentioning: "`self.child.parent` refers back to the object")(issue) + } + } + + @Test func subscriptionIsNamed() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { SubscriptionLeak() } + } matching: { issue in + isLeakReport(of: "SubscriptionLeak", mentioning: "`cancellables` is a Combine subscription")(issue) + } + } + + @Test func leakWithoutSuspectsGetsGenericMessage() async { + let cache = Cache() + + await withKnownIssue { + await expectDeallocation(.custom { cache.objects.append($0) }, timeout: .milliseconds(100)) { PlainObject() } + } matching: { issue in + isLeakReport(of: "PlainObject", mentioning: "Something still holds a strong reference")(issue) + } + } +} + +// MARK: - Tracking objects inside expectDeallocation + +@Suite("trackForDeallocation inside expectDeallocation") +@MainActor +struct NestedTrackingTests { + @Test func trackedChildPasses() async { + await expectDeallocation { + OwnerObject(viewModel: trackForDeallocation(PlainObject())) + } + } + + @Test func leakedChildIsReported() async { + let cache = Cache() + + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { + let viewModel = trackForDeallocation(PlainObject()) + cache.objects.append(viewModel) + return OwnerObject(viewModel: viewModel) + } + } matching: { issue in + isLeakReport(of: "PlainObject")(issue) + } + } +} + +// MARK: - SwiftUI + +@Suite("SwiftUI hosting", .serialized) +@MainActor +struct SwiftUIHostingTests { + @Test func taskModifierIsCancelledWithView() async { + var appeared = false + + await expectDeallocation(.hosting { TaskModifierView(model: $0) }) { + let model = TaskModifierModel() + return model + } + + await expectDeallocation(.hosting(interaction: { appeared = $0.appeared }) { TaskModifierView(model: $0) }) { + TaskModifierModel() + } + + #expect(appeared) + } + + @Test func taskStartedOnAppearLeaks() async { + await withKnownIssue { + await expectDeallocation(.hosting { TaskLeakView(model: $0) }, timeout: .milliseconds(200)) { + TaskLeakModel() + } + } matching: { issue in + isLeakReport(of: "TaskLeakModel", mentioning: "`task` is a task")(issue) + } + } +} diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index f73dbc0..dfa8b67 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -33,4 +33,16 @@ final class TrackForDeallocationXCTests: XCTestCase { await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } } + + @MainActor + func test_trackedChildInsideExpectDeallocation_isCheckedWithIt() async { + XCTExpectFailure("The child is kept alive by the cache") + let cache = Cache() + + await expectDeallocation(timeout: .milliseconds(100)) { + let child = trackForDeallocation(PlainObject()) + cache.objects.append(child) + return OwnerObject(viewModel: child) + } + } }