Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
ab572da
Merge branch 'dc/release-4.0/3-tracking-hints-di' into dc/release-4.0…
DanielCech Oct 26, 2026
ad14e4b
fix: judge a release by when it happened, not when the polling notice…
DanielCech Oct 26, 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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ jobs:
- 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 tests (iOS Simulator)
runs-on: macos-15
Expand Down
15 changes: 13 additions & 2 deletions Sources/DeallocTests/Diagnostics/LeakReport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,27 @@
struct LeakReport: Sendable {
let typeName: String
let timeout: Duration
let gracePeriod: Duration
let hints: [String]

init(typeName: String, timeout: Duration, 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 {
let summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))."
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, "
Expand Down
144 changes: 144 additions & 0 deletions Sources/DeallocTests/Expectation/DeallocationConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//
// DeallocationConfiguration.swift
// DeallocTests
//
// Copyright © 2026 STRV. All rights reserved.
//

#if canImport(Testing)
import Testing
#endif

/// How deallocation checks behave by default.
///
/// An object is checked with the configuration in effect where it's tracked. Only the timeout
/// can also be passed to `expectDeallocation`, because it describes the object. Set the
/// configuration for a Swift Testing suite or test with traits such as `.deallocationTimeout(_:)`,
/// or for a block of code with `withDeallocationConfiguration(_:operation:)`.
public struct DeallocationConfiguration: Sendable {
/// How long a check waits for the objects to deallocate
public var timeout: Duration = .seconds(2)
/// How much longer to keep watching objects that are still alive after the timeout.
/// An object released in that time is reported as a warning (bounded retention, not a
/// leak) instead of a failure. `.zero` turns it off. Not used when `severity` is `.warning`,
/// because a leak is then reported as a warning anyway.
public var gracePeriod: Duration = .seconds(3)
/// Whether a leak fails the test or is reported as a warning
public var severity: DeallocationIssueSeverity = .error

public init() {}

/// The configuration that applies to the current task
@TaskLocal public static var current = DeallocationConfiguration()
}

extension DeallocationConfiguration {
var effectiveGracePeriod: Duration {
severity == .error ? gracePeriod : .zero
}
}

/// How a leak is reported
public enum DeallocationIssueSeverity: Sendable {
/// The test fails
case error
/// The test passes and the leak is shown as a warning, e.g. while adopting dealloc tests
case warning
}

/// Runs the operation with a changed deallocation configuration.
///
/// The way to configure checks in XCTest, which has no traits:
///
/// ```swift
/// func test_profileScreen() async {
/// await withDeallocationConfiguration({ $0.timeout = .seconds(5) }) {
/// await expectDeallocation(.present) { makeProfileViewController() }
/// }
/// }
/// ```
///
/// It runs on the caller's actor, so it can be called from `@MainActor` tests.
public func withDeallocationConfiguration<Result>(
_ change: (inout DeallocationConfiguration) -> Void,
isolation: isolated (any Actor)? = #isolation,
operation: () async throws -> Result
) async rethrows -> Result {
var configuration = DeallocationConfiguration.current
change(&configuration)
return try await DeallocationConfiguration.$current.withValue(configuration) {
try await operation()
}
}

/// Runs the operation with a changed deallocation configuration.
///
/// The synchronous variant, e.g. for a whole XCTest test case class:
///
/// ```swift
/// final class LegacyDeallocTests: XCTestCase {
/// override func invokeTest() {
/// withDeallocationConfiguration({ $0.severity = .warning }) {
/// super.invokeTest()
/// }
/// }
/// }
/// ```
public func withDeallocationConfiguration<Result>(
_ change: (inout DeallocationConfiguration) -> Void,
operation: () throws -> Result
) rethrows -> Result {
var configuration = DeallocationConfiguration.current
change(&configuration)
return try DeallocationConfiguration.$current.withValue(configuration) {
try operation()
}
}

#if canImport(Testing)

/// Changes the deallocation configuration for a test, or for every test in a suite.
/// A test's own trait wins over its suite's.
public struct DeallocationConfigurationTrait: TestTrait, SuiteTrait, TestScoping {
let change: @Sendable (inout DeallocationConfiguration) -> Void

public var isRecursive: Bool {
true
}

public func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws {
var configuration = DeallocationConfiguration.current
change(&configuration)
try await DeallocationConfiguration.$current.withValue(configuration) {
try await function()
}
}
}

public extension Trait where Self == DeallocationConfigurationTrait {
/// How long deallocation checks wait for objects to deallocate
///
/// ```swift
/// @Suite(.deallocationTimeout(.seconds(5)))
/// struct ScreenDeallocTests { … }
/// ```
static func deallocationTimeout(_ timeout: Duration) -> Self {
Self { $0.timeout = timeout }
}

/// How much longer to watch objects still alive after the timeout before calling them leaked
static func deallocationGracePeriod(_ gracePeriod: Duration) -> Self {
Self { $0.gracePeriod = gracePeriod }
}

/// Whether leaks fail the test (`.error`, the default) or are reported as warnings
static func deallocationIssues(_ severity: DeallocationIssueSeverity) -> Self {
Self { $0.severity = severity }
}
}

#endif
119 changes: 105 additions & 14 deletions Sources/DeallocTests/Expectation/DeallocationTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,27 @@
// Copyright © 2026 STRV. All rights reserved.
//


@MainActor
final class DeallocationTracker {
private struct TrackedObject {
weak var object: AnyObject?
let typeName: String
let location: TestSourceLocation
let configuration: DeallocationConfiguration
let releaseRecorder: ReleaseRecorder
}

private struct Check {
let trackedObject: TrackedObject
let timeout: Duration
let gracePeriod: Duration
}

private enum Outcome {
case released
case releasedLate(after: Duration)
case leaked
}

@TaskLocal static var current: DeallocationTracker?
Expand All @@ -19,35 +34,111 @@ final class DeallocationTracker {

func track(_ object: AnyObject, at location: TestSourceLocation) {
trackedObjects.append(
TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location)
TrackedObject(
object: object,
typeName: TypeNames.readableName(of: object),
location: location,
configuration: DeallocationConfiguration.current,
releaseRecorder: ReleaseRecorder.attached(to: object)
)
)
}

func verifyDeallocation(timeout: Duration = .seconds(2)) async {
let objects = trackedObjects
func verifyDeallocation(timeout: Duration? = nil) async {
let checks = trackedObjects.map { trackedObject in
Check(
trackedObject: trackedObject,
timeout: timeout ?? trackedObject.configuration.timeout,
gracePeriod: trackedObject.configuration.effectiveGracePeriod
)
}
trackedObjects.removeAll()

_ = await Polling.waitUntil(timeout: timeout) {
!objects.contains { $0.object != nil }
}
let (lateReleases, leaks) = await waitForOutcomes(of: checks)

guard !Task.isCancelled else {
return
}

for trackedObject in objects {
guard let object = trackedObject.object else {
continue
}
for (check, releasedAfter) in lateReleases {
reportIssue(
LeakReport.lateReleaseMessage(
typeName: check.trackedObject.typeName,
releasedAfter: releasedAfter,
timeout: check.timeout
),
at: check.trackedObject.location,
severity: .warning
)
}

for check in leaks {
// An object released after the deadline is still a leak, but there's nothing left to inspect for hints
reportIssue(
LeakReport(
typeName: trackedObject.typeName,
timeout: timeout,
hints: LeakHints.hints(for: object)
typeName: check.trackedObject.typeName,
timeout: check.timeout,
gracePeriod: check.gracePeriod,
hints: check.trackedObject.object.map(LeakHints.hints(for:)) ?? []
).message,
at: trackedObject.location
at: check.trackedObject.location,
severity: check.trackedObject.configuration.severity
)
}
}

private func waitForOutcomes(of checks: [Check]) async -> (lateReleases: [(Check, Duration)], leaks: [Check]) {
let start = ContinuousClock.now
let longestWait = checks.map { $0.timeout + $0.gracePeriod }.max() ?? .zero
var outcomes = [(Check, Outcome)]()
var pending = checks

_ = await Polling.waitUntil(timeout: longestWait) {
pending.removeAll { check in
guard let outcome = Self.outcome(of: check, since: start) else {
return false
}
outcomes.append((check, outcome))
return true
}

return pending.isEmpty
}

outcomes += pending.map { ($0, Self.outcome(of: $0, since: start) ?? .leaked) }

var lateReleases = [(Check, Duration)]()
var leaks = [Check]()
for (check, outcome) in outcomes {
switch outcome {
case .released:
break
case let .releasedLate(releasedAfter):
lateReleases.append((check, releasedAfter))
case .leaked:
leaks.append(check)
}
}
return (lateReleases, leaks)
}

/// Judges a check by when its object was actually released, not by when the polling noticed it,
/// so a busy main actor can't turn a leak into a late release or an on-time release into a late one.
/// Returns `nil` while the object is alive and its deadline hasn't passed.
private static func outcome(of check: Check, since start: ContinuousClock.Instant) -> Outcome? {
let deadline = check.timeout + check.gracePeriod

guard let releaseInstant = check.trackedObject.releaseRecorder.releaseInstant else {
return ContinuousClock.now - start >= deadline ? .leaked : nil
}

let releasedAfter = releaseInstant - start
if releasedAfter <= check.timeout {
return .released
}
if releasedAfter < deadline {
return .releasedLate(after: releasedAfter)
}
return .leaked
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ import DependencyInjection
/// - 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
/// - timeout: How long to wait for the object to deallocate. Defaults to `DeallocationConfiguration.current.timeout`
@MainActor
public func expectDeallocation<Dependency: Sendable>(
of type: Dependency.Type,
resolvedFrom container: AsyncContainer,
timeout: Duration = .seconds(2),
timeout: Duration? = nil,
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
Expand Down
4 changes: 2 additions & 2 deletions Sources/DeallocTests/Expectation/ExpectDeallocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@
///
/// - Parameters:
/// - lifecycle: What happens with the object before it's released, e.g. `.present` for a view controller
/// - timeout: How long to wait for the object to deallocate
/// - timeout: How long to wait for the object to deallocate. Defaults to `DeallocationConfiguration.current.timeout`
/// - 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,
timeout: Duration = .seconds(2),
timeout: Duration? = nil,
afterRelease: @MainActor () async -> Void = {},
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
Expand Down
Loading
Loading