Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 |
Expand All @@ -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:
Expand All @@ -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.
Expand Down
24 changes: 17 additions & 7 deletions Sources/DeallocTests/Expectation/DeallocationTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
Expand Down Expand Up @@ -46,19 +47,28 @@ 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
)
}

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")
}
}
13 changes: 9 additions & 4 deletions Sources/DeallocTests/Expectation/ExpectDeallocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Object: AnyObject>(
_ lifecycle: Lifecycle<Object> = .none,
Expand Down Expand Up @@ -55,7 +57,10 @@ private func createAndRun<Object: AnyObject>(
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)
}
}
150 changes: 150 additions & 0 deletions Sources/DeallocTests/Expectation/LeakHints.swift
Original file line number Diff line number Diff line change
@@ -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<Task<") {
return "a task. Make sure it's cancelled or captures self weakly"
}

if typeName.contains("Timer") {
return "a timer. A scheduled timer keeps its target until it's invalidated"
}

return nil
}

// MARK: - Cycles

/// Paths through stored properties that lead back to the object
private static func cycles(from root: AnyObject) -> [String] {
let rootIdentifier = ObjectIdentifier(root)
var visited: Set<ObjectIdentifier> = [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: "")
}
}
Loading
Loading