From 0810f0f8a8410db431bc478537728f38bdb34da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Mon, 5 Oct 2026 17:55:40 +0200 Subject: [PATCH 1/3] feat: deallocation configuration, warning severity and grace period `DeallocationConfiguration` (timeout, severity, grace period) is set per Swift Testing test or suite with traits, and in XCTest with `withDeallocationConfiguration` around a test's code or `invokeTest()`. `timeout` stays a parameter of `expectDeallocation` because it describes the object; the rest is configuration only. An object is checked with the configuration in effect where it was tracked. `.deallocationIssues(.warning)` reports leaks without failing the test, for adopting dealloc tests in an existing project. An object still alive at the timeout is watched for a grace period (3 s); if it goes away then, it's reported as bounded retention, a warning, instead of a leak. Passing checks never wait for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DeallocTests/Diagnostics/LeakReport.swift | 15 +- .../DeallocationConfiguration.swift | 138 ++++++++++++++++++ .../Expectation/DeallocationTracker.swift | 43 +++++- ...pectDeallocation+DependencyInjection.swift | 4 +- .../Expectation/ExpectDeallocation.swift | 4 +- .../Expectation/IssueReporting.swift | 30 +++- .../Expectation/TrackForDeallocation.swift | 1 + .../ConfigurationTests.swift | 104 +++++++++++++ .../ExpectDeallocationTests.swift | 4 +- .../DeallocTestsTests/GracePeriodTests.swift | 61 ++++++++ Tests/DeallocTestsTests/LeakReportTests.swift | 12 ++ .../TrackForDeallocationXCTests.swift | 66 ++++++++- 12 files changed, 466 insertions(+), 16 deletions(-) create mode 100644 Sources/DeallocTests/Expectation/DeallocationConfiguration.swift create mode 100644 Tests/DeallocTestsTests/ConfigurationTests.swift create mode 100644 Tests/DeallocTestsTests/GracePeriodTests.swift diff --git a/Sources/DeallocTests/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift index 2782497..5bee5a0 100644 --- a/Sources/DeallocTests/Diagnostics/LeakReport.swift +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -9,16 +9,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, " diff --git a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift new file mode 100644 index 0000000..4f9c5e5 --- /dev/null +++ b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift @@ -0,0 +1,138 @@ +// +// 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() +} + +/// 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( + _ 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( + _ 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 diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index 50ca678..c609b93 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -5,6 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // + @MainActor final class DeallocationTracker { private struct TrackedObject { @@ -16,26 +17,60 @@ final class DeallocationTracker { @TaskLocal static var current: DeallocationTracker? private var trackedObjects = [TrackedObject]() + private var configuration = DeallocationConfiguration.current func track(_ object: AnyObject, at location: TestSourceLocation) { + if trackedObjects.isEmpty { + configuration = DeallocationConfiguration.current + } + trackedObjects.append( TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location) ) } - func verifyDeallocation(timeout: Duration = .seconds(2)) async { + func verifyDeallocation(timeout: Duration? = nil) async { let objects = trackedObjects trackedObjects.removeAll() + let timeout = timeout ?? configuration.timeout + let gracePeriod = configuration.severity == .error ? configuration.gracePeriod : .zero + let clock = ContinuousClock() + let start = clock.now + _ = await Polling.waitUntil(timeout: timeout) { !objects.contains { $0.object != nil } } + var lateReleases = [(TrackedObject, Duration)]() + var pending = objects.filter { $0.object != nil } + + if !pending.isEmpty, gracePeriod > .zero { + _ = await Polling.waitUntil(timeout: gracePeriod) { + pending.removeAll { trackedObject in + guard trackedObject.object == nil else { + return false + } + lateReleases.append((trackedObject, clock.now - start)) + return true + } + return pending.isEmpty + } + } + guard !Task.isCancelled else { return } - for trackedObject in objects { + for (trackedObject, releasedAfter) in lateReleases { + reportIssue( + LeakReport.lateReleaseMessage(typeName: trackedObject.typeName, releasedAfter: releasedAfter, timeout: timeout), + at: trackedObject.location, + severity: .warning + ) + } + + for trackedObject in pending { guard let object = trackedObject.object else { continue } @@ -44,9 +79,11 @@ final class DeallocationTracker { LeakReport( typeName: trackedObject.typeName, timeout: timeout, + gracePeriod: gracePeriod, hints: LeakHints.hints(for: object) ).message, - at: trackedObject.location + at: trackedObject.location, + severity: configuration.severity ) } } diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift index 5dc2957..1698d5c 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -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( of type: Dependency.Type, resolvedFrom container: AsyncContainer, - timeout: Duration = .seconds(2), + timeout: Duration? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift index 66f748c..2fb31d0 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -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( _ lifecycle: Lifecycle = .none, - timeout: Duration = .seconds(2), + timeout: Duration? = nil, afterRelease: @MainActor () async -> Void = {}, fileID: StaticString = #fileID, filePath: StaticString = #filePath, diff --git a/Sources/DeallocTests/Expectation/IssueReporting.swift b/Sources/DeallocTests/Expectation/IssueReporting.swift index 239664c..948e8d0 100644 --- a/Sources/DeallocTests/Expectation/IssueReporting.swift +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -18,20 +18,29 @@ struct TestSourceLocation: Sendable { let column: UInt } -func reportIssue(_ message: String, at location: TestSourceLocation) { +func reportIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity = .error) { #if canImport(Testing) if Test.current != nil { - recordSwiftTestingIssue(message, at: location) + recordSwiftTestingIssue(message, at: location, severity: severity) return } #endif - XCTFail(message, file: location.filePath, line: location.line) + switch severity { + case .error: + XCTFail(message, file: location.filePath, line: location.line) + case .warning: + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("Reported as a warning", options: options) { + XCTFail(message, file: location.filePath, line: location.line) + } + } } #if canImport(Testing) -private func recordSwiftTestingIssue(_ message: String, at location: TestSourceLocation) { +private func recordSwiftTestingIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity) { let sourceLocation = SourceLocation( fileID: String(describing: location.fileID), filePath: String(describing: location.filePath), @@ -39,7 +48,18 @@ private func recordSwiftTestingIssue(_ message: String, at location: TestSourceL column: Int(location.column) ) - Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + switch severity { + case .error: + Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + case .warning: + #if compiler(>=6.3) + Issue.record(Comment(rawValue: message), severity: .warning, sourceLocation: sourceLocation) + #else + withKnownIssue("Reported as a warning", isIntermittent: true) { + Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + } + #endif + } } #endif diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift index 08df116..e9d706f 100644 --- a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -17,6 +17,7 @@ public extension XCTestCase { /// Checks that the object deallocates when the test ends. /// /// Keep the object in a local variable. A property of the test case lives until the test case is released. + /// The check uses the `DeallocationConfiguration` in effect where this method is called. /// /// ```swift /// func test_viewModel() { diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift new file mode 100644 index 0000000..99ab1c6 --- /dev/null +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -0,0 +1,104 @@ +// +// ConfigurationTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import Testing + +func isLeakReport(within timeout: String) -> (Issue) -> Bool { + { issue in + issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.RetainCycleObject was not deallocated within \(timeout).") } + } +} + +@Suite("Deallocation configuration", .deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) +@MainActor +struct ConfigurationTests { + @Test func suiteTimeoutApplies() async { + let clock = ContinuousClock() + let start = clock.now + + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "100 ms")(issue) + } + + #expect(clock.now - start < .seconds(1), "the default 2 s timeout must not apply") + } + + @Test(.deallocationTimeout(.milliseconds(300))) + func testTraitWinsOverSuiteTrait() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "300 ms")(issue) + } + } + + @Test func explicitTimeoutWinsOverTraits() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(50)) { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "50 ms")(issue) + } + } + + @Test func withDeallocationConfigurationChangesTheTimeout() async { + await withKnownIssue { + await withDeallocationConfiguration({ $0.timeout = .milliseconds(150) }) { + await expectDeallocation { RetainCycleObject() } + } + } matching: { issue in + isLeakReport(within: "150 ms")(issue) + } + } + + @Test(.deallocationIssues(.warning)) + func warningSeverityDoesNotFailTheTest() async { + await expectDeallocation { RetainCycleObject() } + } + + @Test func trackedObjectsUseTheConfiguredTimeout() async throws { + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: #require(Test.current), + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + } + ) + } matching: { issue in + isLeakReport(within: "100 ms")(issue) + } + } + + @Test func trackedObjectsUseTheConfigurationOfANestedTrait() async throws { + let test = try #require(Test.current) + + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + try await DeallocationConfigurationTrait.deallocationTimeout(.milliseconds(130)).provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + } + ) + } + ) + } matching: { issue in + isLeakReport(within: "130 ms")(issue) + } + } +} diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 3fe1eb0..2413eb1 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -224,7 +224,9 @@ struct TrackForDeallocationTests { testCase: Test.Case.current, performing: { await MainActor.run { - _ = trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } } ) diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift new file mode 100644 index 0000000..9a13146 --- /dev/null +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -0,0 +1,61 @@ +// +// GracePeriodTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import Foundation +import Testing + +@MainActor +func makeObjectReleasedAfter(_ delay: Duration) -> PlainObject { + let object = PlainObject() + Task { @MainActor in + try? await Task.sleep(for: delay) + withExtendedLifetime(object) {} + } + return object +} + +@Suite("Grace period") +@MainActor +struct GracePeriodTests { + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(2))) + func lateReleaseIsAWarning() async { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.milliseconds(300))) + func leakIsStillAFailureAfterTheGracePeriod() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + issue.comments.contains { + $0.rawValue.hasPrefix( + "DeallocTestsTests.RetainCycleObject was not deallocated within 100 ms. It was watched for another 300 ms after that." + ) + } + } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) + func zeroGracePeriodTurnsItOff() async { + await withKnownIssue { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } matching: { issue in + issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.PlainObject was not deallocated within 100 ms. Something") } + } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(3)), .deallocationIssues(.warning)) + func warningSeveritySkipsTheGracePeriod() async { + let clock = ContinuousClock() + let start = clock.now + + await expectDeallocation { RetainCycleObject() } + + #expect(clock.now - start < .seconds(1)) + } +} diff --git a/Tests/DeallocTestsTests/LeakReportTests.swift b/Tests/DeallocTestsTests/LeakReportTests.swift index a559c80..520c528 100644 --- a/Tests/DeallocTestsTests/LeakReportTests.swift +++ b/Tests/DeallocTestsTests/LeakReportTests.swift @@ -64,6 +64,18 @@ struct LeakReportTests { #expect(DurationText.describe(duration) == text) } + @Test func lateReleaseMessageExplainsBoundedRetention() { + let message = LeakReport.lateReleaseMessage(typeName: "App.Store", releasedAfter: .milliseconds(3250), timeout: .seconds(2)) + + #expect(message.hasPrefix("App.Store was released after 3.2 sec, later than the 2 sec timeout. That's bounded retention, not a leak")) + } + + @Test func messageMentionsTheGracePeriod() { + let report = LeakReport(typeName: "App.Screen", timeout: .seconds(2), gracePeriod: .seconds(3)) + + #expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. It was watched for another 3 sec after that.")) + } + @Test func hintsNameClosureProperties() { let holder = ClosureHolder() holder.onUpdate = {} diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index 95e91d8..30df3a3 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -9,6 +9,23 @@ import DeallocTests import XCTest final class TrackForDeallocationXCTests: XCTestCase { + override func invokeTest() { + withDeallocationConfiguration({ $0.gracePeriod = .zero }) { + super.invokeTest() + } + } + + @MainActor + func test_invokeTestConfiguration_appliesToTheTest() async { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { issue in + issue.compactDescription.contains("within 100 ms.") && !issue.compactDescription.contains("watched for another") + } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } + } + @MainActor func test_trackedObject_passes() { let object = trackForDeallocation(PlainObject()) @@ -19,7 +36,9 @@ final class TrackForDeallocationXCTests: XCTestCase { func test_trackedLeak_fails() { XCTExpectFailure("RetainCycleObject has a retain cycle") - trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } @MainActor @@ -45,4 +64,49 @@ final class TrackForDeallocationXCTests: XCTestCase { return OwnerObject(viewModel: child) } } + + @MainActor + func test_withDeallocationConfiguration_changesTheTimeout() async { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { $0.compactDescription.contains("within 120 ms") } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + await withDeallocationConfiguration({ $0.timeout = .milliseconds(120) }) { + await expectDeallocation { RetainCycleObject() } + } + } + + @MainActor + func test_trackedObject_usesTheConfigurationItWasTrackedWith() { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { $0.compactDescription.contains("within 130 ms") } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + withDeallocationConfiguration({ + $0.timeout = .milliseconds(130) + $0.gracePeriod = .zero + }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + + @MainActor + func test_warningSeverity_doesNotFailTheTest() async { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.severity = .warning + }) { + await expectDeallocation { RetainCycleObject() } + } + } + + @MainActor + func test_lateRelease_isAWarning() async { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.gracePeriod = .seconds(2) + }) { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + } } From 70e7b6f05058132aed4243bef208668049948a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Mon, 5 Oct 2026 18:15:07 +0200 Subject: [PATCH 2/3] test: deterministic configuration and grace period tests Two tests asserted on wall-clock time and failed on a loaded CI runner (6.2 s and 1.9 s against a 1 s limit). The suite timeout test already proves the 100 ms timeout through the failure message, so its clock check goes. The rule that warnings skip the grace period is now `DeallocationConfiguration.effectiveGracePeriod`, which the tracker uses and the test checks directly. CI also runs the tests with the latest Xcode, so the Swift 6.3 warning path (Issue.record(severity:)) is compiled and tested. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 11 +++++++++++ .../Expectation/DeallocationConfiguration.swift | 6 ++++++ .../Expectation/DeallocationTracker.swift | 2 +- Tests/DeallocTestsTests/ConfigurationTests.swift | 5 ----- Tests/DeallocTestsTests/GracePeriodTests.swift | 15 +++++++-------- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f32c28..7a1c6f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift index 4f9c5e5..bc862fc 100644 --- a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift +++ b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift @@ -32,6 +32,12 @@ public struct DeallocationConfiguration: Sendable { @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 diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index c609b93..a7bfced 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -34,7 +34,7 @@ final class DeallocationTracker { trackedObjects.removeAll() let timeout = timeout ?? configuration.timeout - let gracePeriod = configuration.severity == .error ? configuration.gracePeriod : .zero + let gracePeriod = configuration.effectiveGracePeriod let clock = ContinuousClock() let start = clock.now diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift index 99ab1c6..d51a687 100644 --- a/Tests/DeallocTestsTests/ConfigurationTests.swift +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -18,16 +18,11 @@ func isLeakReport(within timeout: String) -> (Issue) -> Bool { @MainActor struct ConfigurationTests { @Test func suiteTimeoutApplies() async { - let clock = ContinuousClock() - let start = clock.now - await withKnownIssue { await expectDeallocation { RetainCycleObject() } } matching: { issue in isLeakReport(within: "100 ms")(issue) } - - #expect(clock.now - start < .seconds(1), "the default 2 s timeout must not apply") } @Test(.deallocationTimeout(.milliseconds(300))) diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift index 9a13146..5cf9a1d 100644 --- a/Tests/DeallocTestsTests/GracePeriodTests.swift +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -5,7 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // -import DeallocTests +@testable import DeallocTests import Foundation import Testing @@ -49,13 +49,12 @@ struct GracePeriodTests { } } - @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(3)), .deallocationIssues(.warning)) - func warningSeveritySkipsTheGracePeriod() async { - let clock = ContinuousClock() - let start = clock.now + @Test func warningSeveritySkipsTheGracePeriod() { + var configuration = DeallocationConfiguration() + configuration.gracePeriod = .seconds(3) + #expect(configuration.effectiveGracePeriod == .seconds(3)) - await expectDeallocation { RetainCycleObject() } - - #expect(clock.now - start < .seconds(1)) + configuration.severity = .warning + #expect(configuration.effectiveGracePeriod == .zero) } } From b43c82a71b880f023778d62ca970b67f9c9799f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Tue, 6 Oct 2026 10:23:44 +0200 Subject: [PATCH 3/3] fix: per-object configuration and asserted warnings - Each tracked object keeps the configuration in effect where it was tracked (timeout, grace period, severity). The tracker used the first object's configuration for all of them, so objects tracked in a different configuration scope were checked with the wrong values. One polling loop now checks every object against its own deadlines. - The warning tests assert that a warning is emitted with the expected message: in Swift Testing through withKnownIssue matching severity .warning (Swift 6.3+), in XCTest through an XCTestObservation that records expected failures. Before, they only checked that the test didn't fail. - The throwing-test tracking check uses a 100 ms timeout like its neighbours. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Expectation/DeallocationTracker.swift | 87 ++++++++++++------- .../ConfigurationTests.swift | 54 +++++++++++- .../ExpectDeallocationTests.swift | 4 +- .../DeallocTestsTests/GracePeriodTests.swift | 11 ++- .../TrackForDeallocationXCTests.swift | 47 +++++++--- 5 files changed, 156 insertions(+), 47 deletions(-) diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index a7bfced..56ffece 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -12,78 +12,101 @@ final class DeallocationTracker { 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? private var trackedObjects = [TrackedObject]() - private var configuration = DeallocationConfiguration.current func track(_ object: AnyObject, at location: TestSourceLocation) { - if trackedObjects.isEmpty { - configuration = DeallocationConfiguration.current - } - 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? = nil) async { - let objects = trackedObjects + let checks = trackedObjects.map { trackedObject in + Check( + trackedObject: trackedObject, + timeout: timeout ?? trackedObject.configuration.timeout, + gracePeriod: trackedObject.configuration.effectiveGracePeriod + ) + } trackedObjects.removeAll() - let timeout = timeout ?? configuration.timeout - let gracePeriod = configuration.effectiveGracePeriod 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: timeout) { - !objects.contains { $0.object != nil } - } + _ = await Polling.waitUntil(timeout: longestWait) { + let elapsed = clock.now - start - var lateReleases = [(TrackedObject, Duration)]() - var pending = objects.filter { $0.object != nil } - - if !pending.isEmpty, gracePeriod > .zero { - _ = await Polling.waitUntil(timeout: gracePeriod) { - pending.removeAll { trackedObject in - guard trackedObject.object == nil else { - return false + pending.removeAll { check in + if check.trackedObject.object == nil { + if elapsed > check.timeout, check.gracePeriod > .zero { + lateReleases.append((check, elapsed)) } - lateReleases.append((trackedObject, clock.now - start)) return true } - return pending.isEmpty + + 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, releasedAfter) in lateReleases { + for (check, releasedAfter) in lateReleases { reportIssue( - LeakReport.lateReleaseMessage(typeName: trackedObject.typeName, releasedAfter: releasedAfter, timeout: timeout), - at: trackedObject.location, + LeakReport.lateReleaseMessage( + typeName: check.trackedObject.typeName, + releasedAfter: releasedAfter, + timeout: check.timeout + ), + at: check.trackedObject.location, severity: .warning ) } - for trackedObject in pending { - guard let object = trackedObject.object else { + for check in leaks { + guard let object = check.trackedObject.object else { continue } reportIssue( LeakReport( - typeName: trackedObject.typeName, - timeout: timeout, - gracePeriod: gracePeriod, + typeName: check.trackedObject.typeName, + timeout: check.timeout, + gracePeriod: check.gracePeriod, hints: LeakHints.hints(for: object) ).message, - at: trackedObject.location, - severity: configuration.severity + at: check.trackedObject.location, + severity: check.trackedObject.configuration.severity ) } } diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift index d51a687..7ac7f26 100644 --- a/Tests/DeallocTestsTests/ConfigurationTests.swift +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -6,8 +6,22 @@ // import DeallocTests +import Foundation import Testing +final class RecordedComments: @unchecked Sendable { + private let lock = NSLock() + private var comments = [String]() + + func append(_ issue: Issue) { + lock.withLock { comments += issue.comments.map(\.rawValue) } + } + + func contains(_ text: String) -> Bool { + lock.withLock { comments.contains { $0.contains(text) } } + } +} + func isLeakReport(within timeout: String) -> (Issue) -> Bool { { issue in issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.RetainCycleObject was not deallocated within \(timeout).") } @@ -53,8 +67,44 @@ struct ConfigurationTests { } @Test(.deallocationIssues(.warning)) - func warningSeverityDoesNotFailTheTest() async { - await expectDeallocation { RetainCycleObject() } + func warningSeverityRecordsAWarning() async { + #if compiler(>=6.3) + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + issue.severity == .warning && isLeakReport(within: "100 ms")(issue) + } + #else + await expectDeallocation { RetainCycleObject() } + #endif + } + + @Test func eachTrackedObjectUsesItsOwnConfiguration() async throws { + let test = try #require(Test.current) + let recorded = RecordedComments() + + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + withDeallocationConfiguration({ $0.timeout = .milliseconds(150) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + withDeallocationConfiguration({ $0.timeout = .milliseconds(250) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + } + ) + } matching: { issue in + recorded.append(issue) + return isLeakReport(within: "150 ms")(issue) || isLeakReport(within: "250 ms")(issue) + } + + #expect(recorded.contains("within 150 ms")) + #expect(recorded.contains("within 250 ms")) } @Test func trackedObjectsUseTheConfiguredTimeout() async throws { diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 8c3cc27..5c7fdea 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -262,7 +262,9 @@ struct TrackForDeallocationTests { testCase: Test.Case.current, performing: { await MainActor.run { - _ = trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } throw LifecycleError() } diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift index 5cf9a1d..368fa1f 100644 --- a/Tests/DeallocTestsTests/GracePeriodTests.swift +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -24,7 +24,16 @@ func makeObjectReleasedAfter(_ delay: Duration) -> PlainObject { struct GracePeriodTests { @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(2))) func lateReleaseIsAWarning() async { - await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + #if compiler(>=6.3) + await withKnownIssue { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } matching: { issue in + issue.severity == .warning + && issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.PlainObject was released after") } + } + #else + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + #endif } @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.milliseconds(300))) diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index 30df3a3..6a61d4b 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -8,7 +8,24 @@ import DeallocTests import XCTest +final class ExpectedFailureRecorder: NSObject, XCTestObservation, @unchecked Sendable { + private(set) var descriptions = [String]() + + func testCase(_ testCase: XCTestCase, didRecord expectedFailure: XCTExpectedFailure) { + descriptions.append(expectedFailure.issue.compactDescription) + } +} + final class TrackForDeallocationXCTests: XCTestCase { + @MainActor + private func recordingExpectedFailures(_ operation: () async -> Void) async -> [String] { + let recorder = ExpectedFailureRecorder() + XCTestObservationCenter.shared.addTestObserver(recorder) + await operation() + XCTestObservationCenter.shared.removeTestObserver(recorder) + return recorder.descriptions + } + override func invokeTest() { withDeallocationConfiguration({ $0.gracePeriod = .zero }) { super.invokeTest() @@ -91,22 +108,30 @@ final class TrackForDeallocationXCTests: XCTestCase { } @MainActor - func test_warningSeverity_doesNotFailTheTest() async { - await withDeallocationConfiguration({ - $0.timeout = .milliseconds(100) - $0.severity = .warning - }) { - await expectDeallocation { RetainCycleObject() } + func test_warningSeverity_isReportedAsAnExpectedFailure() async { + let warnings = await recordingExpectedFailures { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.severity = .warning + }) { + await expectDeallocation { RetainCycleObject() } + } } + + XCTAssertTrue(warnings.contains { $0.contains("RetainCycleObject was not deallocated within 100 ms") }, "\(warnings)") } @MainActor func test_lateRelease_isAWarning() async { - await withDeallocationConfiguration({ - $0.timeout = .milliseconds(100) - $0.gracePeriod = .seconds(2) - }) { - await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + let warnings = await recordingExpectedFailures { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.gracePeriod = .seconds(2) + }) { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } } + + XCTAssertTrue(warnings.contains { $0.contains("PlainObject was released after") }, "\(warnings)") } }