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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ jobs:
- { name: "without DependencyInjection", flags: "--disable-default-traits" }
steps:
- uses: actions/checkout@v4
- name: Check DeallocTestsCore rules
# Core is shared with DeallocWatcher, which apps link: no test frameworks, no classes or actors
run: |
! grep -rnE "^\s*(@testable )?import (XCTest|Testing)\b" Sources/DeallocTestsCore
! grep -rnE "^\s*(public |package |internal |private |fileprivate |final |open )*(class|actor) [A-Z]" Sources/DeallocTestsCore
- name: Run tests
run: swift test ${{ matrix.traits.flags }}

Expand Down
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,26 @@ let package = Package(
.package(url: "https://github.com/strvcom/ios-dependency-injection.git", "1.0.4" ..< "3.0.0")
],
targets: [
// Shared by DeallocTests and the upcoming DeallocWatcher: no XCTest, no Swift Testing,
// no classes or actors (it may be linked into both an app and its test bundle)
.target(
name: "DeallocTestsCore"
),
.target(
name: "DeallocTests",
dependencies: [
"DeallocTestsCore",
.product(
name: "DependencyInjection",
package: "ios-dependency-injection",
condition: .when(traits: ["DependencyInjection"])
)
]
),
.testTarget(
name: "DeallocTestsCoreTests",
dependencies: ["DeallocTestsCore"]
),
.testTarget(
name: "DeallocTestsTests",
dependencies: [
Expand Down
37 changes: 5 additions & 32 deletions Sources/DeallocTests/Expectation/DeallocationTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Copyright © 2026 STRV. All rights reserved.
//

import DeallocTestsCore
import Foundation

/// Keeps weak references to objects and checks that all of them deallocate.
Expand All @@ -29,22 +30,14 @@ final class DeallocationTracker {

func track(_ object: AnyObject, at location: TestSourceLocation) {
trackedObjects.append(
TrackedObject(object: object, typeName: Self.readableTypeName(of: object), location: location)
TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location)
)
}

/// Waits until all tracked objects deallocate and reports the ones that didn't within the timeout
func verifyDeallocation(timeout: Duration) async {
let clock = ContinuousClock()
let deadline = clock.now + timeout

// Polling also lets the run loop drain autorelease pools and finish UIKit transitions
while trackedObjects.contains(where: { $0.object != nil }), clock.now < deadline {
do {
try await Task.sleep(for: .milliseconds(10))
} catch {
break
}
_ = await Polling.waitUntil(timeout: timeout) { [trackedObjects] in
!trackedObjects.contains { $0.object != nil }
}

for trackedObject in trackedObjects {
Expand All @@ -53,31 +46,11 @@ final class DeallocationTracker {
}

reportIssue(
Self.leakMessage(typeName: trackedObject.typeName, timeout: timeout, hints: LeakHints.hints(for: object)),
LeakReport(typeName: trackedObject.typeName, timeout: timeout, hints: LeakHints.hints(for: object)).message,
at: trackedObject.location
)
}

trackedObjects.removeAll()
}

/// Module-qualified type name without the `(unknown context at $…)` part
/// that Swift adds for private and local types
static func readableTypeName(of object: AnyObject) -> String {
String(reflecting: type(of: object))
.replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression)
}

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

// Hints only see the object's own properties; the reference can also come from outside
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")
}
}
23 changes: 4 additions & 19 deletions Sources/DeallocTests/Expectation/Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Copyright © 2026 STRV. All rights reserved.
//

import DeallocTestsCore
import Foundation

#if canImport(UIKit)
Expand Down Expand Up @@ -57,27 +58,11 @@ extension Lifecycle {
}
}

/// Polls the condition until it holds or the timeout elapses. Returns as soon as the
/// condition holds, so the generous default only costs time when UIKit really is stuck,
/// e.g. on a simulator loaded by many tests running in parallel.
/// Waits for a UIKit state. The generous default only costs time when UIKit really is
/// stuck, e.g. on a simulator loaded by many tests running in parallel.
@MainActor
func waitUntil(timeout: Duration = .seconds(10), _ condition: @MainActor () -> Bool) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now + timeout

while !condition() {
guard clock.now < deadline else {
return false
}

do {
try await Task.sleep(for: .milliseconds(5))
} catch {
return condition()
}
}

return true
await Polling.waitUntil(timeout: timeout, interval: .milliseconds(5), condition)
}

#if canImport(UIKit)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// LeakHints.swift
// DeallocTests
// DeallocTestsCore
//
// Copyright © 2026 STRV. All rights reserved.
//
Expand All @@ -11,13 +11,13 @@ import Foundation
///
/// `Mirror` can't tell weak properties from strong ones and can't look inside closures,
/// so the hints are suggestions, not proof.
enum LeakHints {
package 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] {
package static func hints(for object: AnyObject) -> [String] {
var hints = [String]()

for property in storedProperties(of: object) {
Expand Down
34 changes: 34 additions & 0 deletions Sources/DeallocTestsCore/LeakReport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// LeakReport.swift
// DeallocTestsCore
//
// Copyright © 2026 STRV. All rights reserved.
//

import Foundation

/// What is known about an object that wasn't deallocated, and how it's described in a failure
package struct LeakReport: Sendable {
package let typeName: String
package let timeout: Duration
package let hints: [String]

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

package var message: 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."
}

// Hints only see the object's own properties; the reference can also come from outside
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")
}
}
37 changes: 37 additions & 0 deletions Sources/DeallocTestsCore/Polling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// Polling.swift
// DeallocTestsCore
//
// Copyright © 2026 STRV. All rights reserved.
//

import Foundation

package enum Polling {
/// Polls the condition until it holds or the timeout elapses. Returns as soon as the
/// condition holds. Sleeping between checks lets the run loop drain autorelease pools
/// and finish UIKit transitions.
@MainActor
package static func waitUntil(
timeout: Duration,
interval: Duration = .milliseconds(10),
_ condition: @MainActor () -> Bool
) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now + timeout

while !condition() {
guard clock.now < deadline else {
return false
}

do {
try await Task.sleep(for: interval)
} catch {
return condition()
}
}

return true
}
}
21 changes: 21 additions & 0 deletions Sources/DeallocTestsCore/TypeNames.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// TypeNames.swift
// DeallocTestsCore
//
// Copyright © 2026 STRV. All rights reserved.
//

import Foundation

package enum TypeNames {
/// Module-qualified type name without the `(unknown context at $…)` part
/// that Swift adds for private and local types
package static func readableName(of object: AnyObject) -> String {
readableName(of: type(of: object))
}

package static func readableName(of type: Any.Type) -> String {
String(reflecting: type)
.replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression)
}
}
63 changes: 63 additions & 0 deletions Tests/DeallocTestsCoreTests/LeakReportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// LeakReportTests.swift
// DeallocTestsCoreTests
//
// Copyright © 2026 STRV. All rights reserved.
//

@testable import DeallocTestsCore
import Testing

private final class PrivateObject {}

final class ClosureHolder {
var onUpdate: (() -> Void)?
}

@Suite("Core")
struct LeakReportTests {
@Test func readableNameDropsUnknownContext() {
let name = TypeNames.readableName(of: PrivateObject())

#expect(name == "DeallocTestsCoreTests.PrivateObject")
}

@Test func messageWithoutHintsGivesGeneralAdvice() {
let report = LeakReport(typeName: "App.Screen", timeout: .seconds(2))

#expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. Something still holds"))
}

@Test func messageWithHintsListsCausesAndExternalOwners() {
let report = LeakReport(typeName: "App.Screen", timeout: .milliseconds(500), hints: ["`onUpdate` is a closure"])

#expect(report.message == """
App.Screen was not deallocated within 500 ms. Possible causes:
• `onUpdate` is a closure
• Or something outside still holds it: a parent's list of children, a cache or a singleton
""")
}

@Test func hintsNameClosureProperties() {
let holder = ClosureHolder()
holder.onUpdate = {}

#expect(LeakHints.hints(for: holder).contains { $0.hasPrefix("`onUpdate` is a closure") })
}

@Test @MainActor func pollingReturnsAsSoonAsTheConditionHolds() async {
let clock = ContinuousClock()
let start = clock.now

let result = await Polling.waitUntil(timeout: .seconds(5)) { true }

#expect(result)
#expect(clock.now - start < .seconds(1))
}

@Test @MainActor func pollingGivesUpAfterTheTimeout() async {
let result = await Polling.waitUntil(timeout: .milliseconds(50)) { false }

#expect(!result)
}
}
Loading