Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3a4b5f2
feat: expectDeallocation with UIKit, AppKit and SwiftUI lifecycles
DanielCech Oct 5, 2026
63683fe
feat: trackForDeallocation, leak hints and Dependency Injection support
DanielCech Oct 5, 2026
0810f0f
feat: deallocation configuration, warning severity and grace period
DanielCech Oct 5, 2026
e2f7750
style: no doc comment on the internal LeakReport
DanielCech Oct 5, 2026
c94c80a
Merge part 2 (review fixes) into part 3
DanielCech Oct 5, 2026
3245673
style: no doc comment on the internal LeakHints
DanielCech Oct 5, 2026
2fa7649
Merge part 3 (review fixes) into part 4
DanielCech Oct 5, 2026
70e7b6f
test: deterministic configuration and grace period tests
DanielCech Oct 5, 2026
b6ceb74
fix: review feedback on expectDeallocation and lifecycles
DanielCech Oct 6, 2026
0e3172f
Merge part 2 (review fixes) into part 3
DanielCech Oct 6, 2026
2bb0c92
fix: .checksDeallocation also checks tracked objects when the test th…
DanielCech Oct 6, 2026
85ff3d0
Merge part 3 (review fixes) into part 4
DanielCech Oct 6, 2026
b43c82a
fix: per-object configuration and asserted warnings
DanielCech Oct 6, 2026
7597c00
fix: compile for visionOS, which has no UIScreen
DanielCech Oct 15, 2026
65fb076
Merge part 2 (visionOS fix) into part 3
DanielCech Oct 15, 2026
73ce9c9
Merge part 3 (visionOS fix) into part 4
DanielCech Oct 15, 2026
7368b5f
test: late-release tests don't depend on a fast runner
DanielCech Oct 15, 2026
b7dd638
fix: report a CancellationError thrown while the test is still running
DanielCech Oct 21, 2026
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
29 changes: 22 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ concurrency:

jobs:
package:
name: Package build (macOS, ${{ matrix.traits.name }})
name: Package tests (macOS, ${{ matrix.traits.name }})
runs-on: macos-15
strategy:
matrix:
Expand All @@ -20,16 +20,31 @@ jobs:
- { name: "without DependencyInjection", flags: "--disable-default-traits" }
steps:
- uses: actions/checkout@v4
- name: Build
run: swift build ${{ matrix.traits.flags }}
- name: Run tests
run: swift test ${{ matrix.traits.flags }}

package-latest-xcode:
name: Package tests (macOS, latest Xcode)
# Warning severity uses Issue.record(severity:) from Swift 6.3; macos-15 only builds the fallback
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- name: Toolchain
run: swift --version
- name: Run tests
run: swift test

package-ios:
name: Package build (iOS Simulator)
name: Package tests (iOS Simulator)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Build
- name: Run tests
run: |
xcodebuild build \
# Use whichever iPhone simulator the runner's Xcode provides
UDID=$(xcrun simctl list devices available -j \
| jq -r '[.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | startswith("iPhone"))][0].udid')
echo "Simulator: $UDID"
xcodebuild test \
-scheme DeallocTests \
-destination 'generic/platform=iOS Simulator'
-destination "id=$UDID"
13 changes: 12 additions & 1 deletion 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 @@ -42,6 +42,17 @@ let package = Package(
)
]
),
.testTarget(
name: "DeallocTestsTests",
dependencies: [
"DeallocTests",
.product(
name: "DependencyInjection",
package: "ios-dependency-injection",
condition: .when(traits: ["DependencyInjection"])
)
]
),
],
swiftLanguageModes: [.v6]
)
20 changes: 20 additions & 0 deletions Sources/DeallocTests/Diagnostics/DurationText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// DurationText.swift
// DeallocTests
//
// Copyright © 2026 STRV. All rights reserved.
//

enum DurationText {
static func describe(_ duration: Duration) -> String {
let (seconds, attoseconds) = duration.components
let milliseconds = seconds * 1000 + attoseconds / 1_000_000_000_000_000

guard milliseconds >= 1000 else {
return "\(milliseconds) ms"
}

let tenths = milliseconds / 100
return tenths % 10 == 0 ? "\(tenths / 10) sec" : "\(tenths / 10).\(tenths % 10) sec"
}
}
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
}
}
41 changes: 41 additions & 0 deletions Sources/DeallocTests/Diagnostics/LeakReport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// LeakReport.swift
// DeallocTests
//
// Copyright © 2026 STRV. All rights reserved.
//

struct LeakReport: Sendable {
let typeName: String
let timeout: Duration
let gracePeriod: Duration
let hints: [String]

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

static func lateReleaseMessage(typeName: String, releasedAfter: Duration, timeout: Duration) -> String {
"\(typeName) was released after \(DurationText.describe(releasedAfter)), later than the \(DurationText.describe(timeout)) timeout. "
+ "That's bounded retention, not a leak: something kept it alive for a while, e.g. a task, "
+ "an animation or a delayed callback. If that's expected, raise the timeout."
}

var message: String {
var summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))."
if gracePeriod > .zero {
summary += " It was watched for another \(DurationText.describe(gracePeriod)) after that."
}

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")
}
}
19 changes: 19 additions & 0 deletions Sources/DeallocTests/Diagnostics/TypeNames.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// TypeNames.swift
// DeallocTests
//
// Copyright © 2026 STRV. All rights reserved.
//

import Foundation

enum TypeNames {
static func readableName(of object: AnyObject) -> String {
readableName(of: type(of: object))
}

static func readableName(of type: Any.Type) -> String {
String(reflecting: type)
.replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression)
}
}
Loading