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/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift index 7942d0e..f755f4e 100644 --- a/Sources/DeallocTests/Diagnostics/LeakReport.swift +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -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, " diff --git a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift new file mode 100644 index 0000000..bc862fc --- /dev/null +++ b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift @@ -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( + _ 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..4c66058 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -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? @@ -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 + } } 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 3132281..ca78a62 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 8d05970..1187005 100644 --- a/Sources/DeallocTests/Expectation/IssueReporting.swift +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -18,24 +18,33 @@ struct TestSourceLocation: Sendable { let column: UInt } -func reportIssue(_ message: String, at location: TestSourceLocation) { +func reportIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity = .error) { guard !Task.isCancelled else { return } #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), @@ -43,7 +52,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/ReleaseRecorder.swift b/Sources/DeallocTests/Expectation/ReleaseRecorder.swift new file mode 100644 index 0000000..580eeb7 --- /dev/null +++ b/Sources/DeallocTests/Expectation/ReleaseRecorder.swift @@ -0,0 +1,52 @@ +// +// ReleaseRecorder.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +/// Records when an object was released, independently of when the check gets to look. +/// The checks poll on the main actor, so a busy main actor would otherwise shift the +/// observed release time and turn a leak into a late release. +final class ReleaseRecorder: @unchecked Sendable { + private let lock = NSLock() + private var releasedAt: ContinuousClock.Instant? + + /// When the object was released, or `nil` while it's alive + var releaseInstant: ContinuousClock.Instant? { + lock.withLock { releasedAt } + } + + /// Starts recording the release of `object`. The sentinel is stored as an associated object, + /// so it's released together with the object, on whichever thread releases it. + static func attached(to object: AnyObject) -> ReleaseRecorder { + let recorder = ReleaseRecorder() + let sentinel = Sentinel(recorder: recorder) + objc_setAssociatedObject( + object, + Unmanaged.passUnretained(sentinel).toOpaque(), + sentinel, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return recorder + } + + private func recordRelease() { + let now = ContinuousClock.now + lock.withLock { releasedAt = now } + } + + private final class Sentinel { + let recorder: ReleaseRecorder + + init(recorder: ReleaseRecorder) { + self.recorder = recorder + } + + deinit { + recorder.recordRelease() + } + } +} diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift index 502e421..60edef8 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..7ac7f26 --- /dev/null +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -0,0 +1,149 @@ +// +// ConfigurationTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +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).") } + } +} + +@Suite("Deallocation configuration", .deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) +@MainActor +struct ConfigurationTests { + @Test func suiteTimeoutApplies() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "100 ms")(issue) + } + } + + @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 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 { + 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 25ac7bd..430d40d 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -256,7 +256,9 @@ struct TrackForDeallocationTests { testCase: Test.Case.current, performing: { await MainActor.run { - _ = trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } } ) @@ -274,7 +276,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 new file mode 100644 index 0000000..9ee0ddf --- /dev/null +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -0,0 +1,110 @@ +// +// GracePeriodTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +@testable 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 +} + +private final class ReleasingBox: @unchecked Sendable { + var object: PlainObject? +} + +/// Releases the object on a background queue while the main thread is blocked, +/// so the check can only notice the release once the main thread is free again +@MainActor +func makeObjectReleasedWhileMainThreadIsBlocked(releaseAfter: Duration, blockMainThreadFor: Duration) -> PlainObject { + let object = PlainObject() + let box = ReleasingBox() + box.object = object + + DispatchQueue.main.async { + Thread.sleep(forTimeInterval: blockMainThreadFor / .seconds(1)) + } + DispatchQueue.global().asyncAfter(deadline: .now() + releaseAfter / .seconds(1)) { + box.object = nil + } + + return object +} + +@Suite("Grace period") +@MainActor +struct GracePeriodTests { + // A passing check ends as soon as the object goes away, so the long grace period only + // matters on a loaded runner, where the release can come seconds late + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(60))) + func lateReleaseIsAWarning() async { + #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))) + 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." + ) + } + } + } + + // The release happens after the grace period, but the check only gets to look once the + // main thread is free again, so it has to judge by when the object actually went away + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.milliseconds(200))) + func releaseAfterTheGracePeriodIsALeakEvenWhenNoticedLate() async { + await withKnownIssue { + await expectDeallocation { + makeObjectReleasedWhileMainThreadIsBlocked(releaseAfter: .milliseconds(400), blockMainThreadFor: .milliseconds(700)) + } + } matching: { issue in + issue.comments.contains { + $0.rawValue.hasPrefix( + "DeallocTestsTests.PlainObject was not deallocated within 100 ms. It was watched for another 200 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 func warningSeveritySkipsTheGracePeriod() { + var configuration = DeallocationConfiguration() + configuration.gracePeriod = .seconds(3) + #expect(configuration.effectiveGracePeriod == .seconds(3)) + + configuration.severity = .warning + #expect(configuration.effectiveGracePeriod == .zero) + } +} 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..ae992e0 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -8,7 +8,41 @@ 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() + } + } + + @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 +53,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 +81,58 @@ 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_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 { + let warnings = await recordingExpectedFailures { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + // Long on purpose: a passing check ends when the object goes away, and a loaded runner can release it late + $0.gracePeriod = .seconds(60) + }) { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + } + + XCTAssertTrue(warnings.contains { $0.contains("PlainObject was released after") }, "\(warnings)") + } }