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
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
80 changes: 70 additions & 10 deletions Sources/DeallocTests/Expectation/DeallocationTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@
// 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
}

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

@TaskLocal static var current: DeallocationTracker?
Expand All @@ -19,34 +27,86 @@ 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
)
)
}

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 clock = ContinuousClock()
let start = clock.now
let longestWait = checks.map { $0.timeout + $0.gracePeriod }.max() ?? .zero
var pending = checks
var lateReleases = [(Check, Duration)]()
var leaks = [Check]()

_ = await Polling.waitUntil(timeout: longestWait) {
let elapsed = clock.now - start

pending.removeAll { check in
if check.trackedObject.object == nil {
if elapsed > check.timeout, check.gracePeriod > .zero {
lateReleases.append((check, elapsed))
}
return true
}

if elapsed >= check.timeout + check.gracePeriod {
leaks.append(check)
return true
}

return false
}

return pending.isEmpty
}

leaks += pending

guard !Task.isCancelled else {
return
}

for trackedObject in objects {
guard let object = trackedObject.object else {
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 {
guard let object = check.trackedObject.object else {
continue
}

reportIssue(
LeakReport(
typeName: trackedObject.typeName,
timeout: timeout,
typeName: check.trackedObject.typeName,
timeout: check.timeout,
gracePeriod: check.gracePeriod,
hints: LeakHints.hints(for: object)
).message,
at: trackedObject.location
at: check.trackedObject.location,
severity: check.trackedObject.configuration.severity
)
}
}
Expand Down
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