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
9 changes: 7 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ let package = Package(
traits: [
.trait(
name: "DependencyInjection",
description: "Integration with STRV Dependency Injection: the AsyncContainer in DeallocTester"
description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:)"
),
.default(enabledTraits: ["DependencyInjection"]),
],
Expand All @@ -45,7 +45,12 @@ let package = Package(
.testTarget(
name: "DeallocTestsTests",
dependencies: [
"DeallocTests"
"DeallocTests",
.product(
name: "DependencyInjection",
package: "ios-dependency-injection",
condition: .when(traits: ["DependencyInjection"])
)
]
),
],
Expand Down
165 changes: 165 additions & 0 deletions Sources/DeallocTests/Diagnostics/LeakHints.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//
// LeakHints.swift
// DeallocTests
//
// Copyright © 2026 STRV. All rights reserved.
//

import Foundation

enum LeakHints {
static let maximumCycleDepth = 4
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? {
guard let value = unwrapped(value), !isEmptyCollection(value) else {
return nil
}

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<") {
return "a task. Make sure it's cancelled or captures self weakly"
}

if value is Timer {
return "a timer. A scheduled timer keeps its target until it's invalidated"
}

return nil
}

private static func unwrapped(_ value: Any) -> Any? {
let mirror = Mirror(reflecting: value)

guard mirror.displayStyle == .optional else {
return value
}

return mirror.children.first.flatMap { unwrapped($0.value) }
}

private static func isEmptyCollection(_ value: Any) -> Bool {
let mirror = Mirror(reflecting: value)

switch mirror.displayStyle {
case .collection, .set, .dictionary:
return mirror.children.isEmpty
default:
return false
}
}

// MARK: - Cycles

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 {
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
}

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, !label.hasPrefix("_$") else {
continue
}
properties.append(Property(label: cleaned(label), value: child.value))
}

mirror = currentMirror.superclassMirror
}

return properties
}

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) }
}

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 {
let label = label.replacingOccurrences(of: "$__lazy_storage_$_", with: "")
return label.hasPrefix("_") ? String(label.dropFirst()) : label
}
}
19 changes: 16 additions & 3 deletions Sources/DeallocTests/Diagnostics/LeakReport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,23 @@
struct LeakReport: Sendable {
let typeName: String
let timeout: Duration
let hints: [String]

init(typeName: String, timeout: Duration, hints: [String] = []) {
self.typeName = typeName
self.timeout = timeout
self.hints = hints
}

var message: String {
"\(typeName) was not deallocated within \(DurationText.describe(timeout))."
+ " 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."
let summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))."

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."
}

let causes = hints + ["Or something outside still holds it: a parent's list of children, a cache or a singleton"]
return summary + " Possible causes:\n" + causes.map { " • \($0)" }.joined(separator: "\n")
}
}
16 changes: 13 additions & 3 deletions Sources/DeallocTests/Expectation/DeallocationTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ final class DeallocationTracker {
let location: TestSourceLocation
}

@TaskLocal static var current: DeallocationTracker?

private var trackedObjects = [TrackedObject]()

func track(_ object: AnyObject, at location: TestSourceLocation) {
Expand All @@ -21,7 +23,7 @@ final class DeallocationTracker {
)
}

func verifyDeallocation(timeout: Duration) async {
func verifyDeallocation(timeout: Duration = .seconds(2)) async {
let objects = trackedObjects
trackedObjects.removeAll()

Expand All @@ -33,9 +35,17 @@ final class DeallocationTracker {
return
}

for trackedObject in objects where trackedObject.object != nil {
for trackedObject in objects {
guard let object = trackedObject.object else {
continue
}

reportIssue(
LeakReport(typeName: trackedObject.typeName, timeout: timeout).message,
LeakReport(
typeName: trackedObject.typeName,
timeout: timeout,
hints: LeakHints.hints(for: object)
).message,
at: trackedObject.location
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// ExpectDeallocation+DependencyInjection.swift
// DeallocTests
//
// Copyright © 2026 STRV. All rights reserved.
//

#if DependencyInjection

import DependencyInjection

/// Resolves a dependency, releases it together with the container's shared instances
/// and checks that it deallocates.
///
/// ```swift
/// @Test func apiManagerDoesNotLeak() async {
/// let container = AsyncContainer()
/// await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() }
///
/// await expectDeallocation(of: APIManaging.self, resolvedFrom: container)
/// }
/// ```
///
/// - Parameters:
/// - type: Registered type to resolve. The resolved instance must be a class instance.
/// - container: Container with the registration
/// - timeout: How long to wait for the object to deallocate
@MainActor
public func expectDeallocation<Dependency: Sendable>(
of type: Dependency.Type,
resolvedFrom container: AsyncContainer,
timeout: Duration = .seconds(2),
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
column: UInt = #column
) async {
let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column)
let tracker = DeallocationTracker()

guard await resolveAndTrack(type, from: container, tracker: tracker, location: location) else {
return
}

await container.releaseSharedInstances()
await tracker.verifyDeallocation(timeout: timeout)
}

@MainActor
private func resolveAndTrack<Dependency: Sendable>(
_ type: Dependency.Type,
from container: AsyncContainer,
tracker: DeallocationTracker,
location: TestSourceLocation
) async -> Bool {
let dependency = await container.resolve(type: type)

guard Mirror(reflecting: dependency).displayStyle == .class else {
let concreteType = Swift.type(of: dependency as Any)
reportIssue(
"\(concreteType) resolved for \(type) is a value type, so it can't leak. Check the class instances it holds instead.",
at: location
)
return false
}

tracker.track(dependency as AnyObject, at: location)
return true
}

#endif
12 changes: 8 additions & 4 deletions Sources/DeallocTests/Expectation/ExpectDeallocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

/// 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 @@ -22,6 +23,7 @@
/// - 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 @@ -53,7 +55,9 @@ 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)
try await DeallocationTracker.$current.withValue(tracker) {
let object = try await makeObject()
tracker.track(object, at: location)
return await lifecycle.run(object, location)
}
}
Loading
Loading