diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a62d4d4..fff48ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,11 @@ jobs: - { name: "without DependencyInjection", flags: "--disable-default-traits" } steps: - uses: actions/checkout@v4 + - name: Check DeallocTestsCore rules + # Core is shared with DeallocWatcher, which apps link: no test frameworks, no classes or actors + run: | + ! grep -rnE "^\s*(@testable )?import (XCTest|Testing)\b" Sources/DeallocTestsCore + ! grep -rnE "^\s*(public |package |internal |private |fileprivate |final |open )*(class|actor) [A-Z]" Sources/DeallocTestsCore - name: Run tests run: swift test ${{ matrix.traits.flags }} diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..6e6c454 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "9f36501074bd40bb7dd10098138fafe7ad6aeb7dc867f8cfcfa8519e927df92d", + "pins" : [ + { + "identity" : "ios-dependency-injection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/strvcom/ios-dependency-injection.git", + "state" : { + "revision" : "bff058aab9f85fd687be3daddb0d38d094b56e7b", + "version" : "2.0.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 176cfeb..f7841ac 100644 --- a/Package.swift +++ b/Package.swift @@ -35,9 +35,15 @@ let package = Package( .package(url: "https://github.com/strvcom/ios-dependency-injection.git", "1.0.4" ..< "3.0.0") ], targets: [ + // Shared by DeallocTests and the upcoming DeallocWatcher: no XCTest, no Swift Testing, + // no classes or actors (it may be linked into both an app and its test bundle) + .target( + name: "DeallocTestsCore" + ), .target( name: "DeallocTests", dependencies: [ + "DeallocTestsCore", .product( name: "DependencyInjection", package: "ios-dependency-injection", @@ -45,6 +51,10 @@ let package = Package( ) ] ), + .testTarget( + name: "DeallocTestsCoreTests", + dependencies: ["DeallocTestsCore"] + ), .testTarget( name: "DeallocTestsTests", dependencies: [ diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index 72952bb..26ff0b5 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -5,6 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // +import DeallocTestsCore import Foundation /// Keeps weak references to objects and checks that all of them deallocate. @@ -29,22 +30,14 @@ final class DeallocationTracker { func track(_ object: AnyObject, at location: TestSourceLocation) { trackedObjects.append( - TrackedObject(object: object, typeName: Self.readableTypeName(of: object), location: location) + TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location) ) } /// Waits until all tracked objects deallocate and reports the ones that didn't within the timeout func verifyDeallocation(timeout: Duration) async { - let clock = ContinuousClock() - let deadline = clock.now + timeout - - // Polling also lets the run loop drain autorelease pools and finish UIKit transitions - while trackedObjects.contains(where: { $0.object != nil }), clock.now < deadline { - do { - try await Task.sleep(for: .milliseconds(10)) - } catch { - break - } + _ = await Polling.waitUntil(timeout: timeout) { [trackedObjects] in + !trackedObjects.contains { $0.object != nil } } for trackedObject in trackedObjects { @@ -53,31 +46,11 @@ final class DeallocationTracker { } reportIssue( - Self.leakMessage(typeName: trackedObject.typeName, timeout: timeout, hints: LeakHints.hints(for: object)), + LeakReport(typeName: trackedObject.typeName, timeout: timeout, hints: LeakHints.hints(for: object)).message, at: trackedObject.location ) } trackedObjects.removeAll() } - - /// Module-qualified type name without the `(unknown context at $…)` part - /// that Swift adds for private and local types - static func readableTypeName(of object: AnyObject) -> String { - String(reflecting: type(of: object)) - .replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression) - } - - static func leakMessage(typeName: String, timeout: Duration, hints: [String] = []) -> String { - let summary = "\(typeName) was not deallocated within \(timeout.formatted(.units(allowed: [.seconds, .milliseconds])))." - - guard !hints.isEmpty else { - return summary + " Something still holds a strong reference to it: look for closures capturing self, " - + "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions." - } - - // Hints only see the object's own properties; the reference can also come from outside - let causes = hints + ["Or something outside still holds it: a parent's list of children, a cache or a singleton"] - return summary + " Possible causes:\n" + causes.map { " • \($0)" }.joined(separator: "\n") - } } diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift index 38b7326..f9e7479 100644 --- a/Sources/DeallocTests/Expectation/Lifecycle.swift +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -5,6 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // +import DeallocTestsCore import Foundation #if canImport(UIKit) @@ -57,27 +58,11 @@ extension Lifecycle { } } -/// Polls the condition until it holds or the timeout elapses. Returns as soon as the -/// condition holds, so the generous default only costs time when UIKit really is stuck, -/// e.g. on a simulator loaded by many tests running in parallel. +/// Waits for a UIKit state. The generous default only costs time when UIKit really is +/// stuck, e.g. on a simulator loaded by many tests running in parallel. @MainActor func waitUntil(timeout: Duration = .seconds(10), _ condition: @MainActor () -> Bool) async -> Bool { - let clock = ContinuousClock() - let deadline = clock.now + timeout - - while !condition() { - guard clock.now < deadline else { - return false - } - - do { - try await Task.sleep(for: .milliseconds(5)) - } catch { - return condition() - } - } - - return true + await Polling.waitUntil(timeout: timeout, interval: .milliseconds(5), condition) } #if canImport(UIKit) diff --git a/Sources/DeallocTests/Expectation/LeakHints.swift b/Sources/DeallocTestsCore/LeakHints.swift similarity index 97% rename from Sources/DeallocTests/Expectation/LeakHints.swift rename to Sources/DeallocTestsCore/LeakHints.swift index 3cdc3cc..bbd8ab5 100644 --- a/Sources/DeallocTests/Expectation/LeakHints.swift +++ b/Sources/DeallocTestsCore/LeakHints.swift @@ -1,6 +1,6 @@ // // LeakHints.swift -// DeallocTests +// DeallocTestsCore // // Copyright © 2026 STRV. All rights reserved. // @@ -11,13 +11,13 @@ import Foundation /// /// `Mirror` can't tell weak properties from strong ones and can't look inside closures, /// so the hints are suggestions, not proof. -enum LeakHints { +package enum LeakHints { /// How deep to look for reference cycles that go through properties static let maximumCycleDepth = 4 /// Upper bound for visited objects, to keep huge object graphs fast static let maximumVisitedObjects = 300 - static func hints(for object: AnyObject) -> [String] { + package static func hints(for object: AnyObject) -> [String] { var hints = [String]() for property in storedProperties(of: object) { diff --git a/Sources/DeallocTestsCore/LeakReport.swift b/Sources/DeallocTestsCore/LeakReport.swift new file mode 100644 index 0000000..bc1bc9b --- /dev/null +++ b/Sources/DeallocTestsCore/LeakReport.swift @@ -0,0 +1,34 @@ +// +// LeakReport.swift +// DeallocTestsCore +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +/// What is known about an object that wasn't deallocated, and how it's described in a failure +package struct LeakReport: Sendable { + package let typeName: String + package let timeout: Duration + package let hints: [String] + + package init(typeName: String, timeout: Duration, hints: [String] = []) { + self.typeName = typeName + self.timeout = timeout + self.hints = hints + } + + package var message: String { + let summary = "\(typeName) was not deallocated within \(timeout.formatted(.units(allowed: [.seconds, .milliseconds])))." + + guard !hints.isEmpty else { + return summary + " Something still holds a strong reference to it: look for closures capturing self, " + + "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions." + } + + // Hints only see the object's own properties; the reference can also come from outside + let causes = hints + ["Or something outside still holds it: a parent's list of children, a cache or a singleton"] + return summary + " Possible causes:\n" + causes.map { " • \($0)" }.joined(separator: "\n") + } +} diff --git a/Sources/DeallocTestsCore/Polling.swift b/Sources/DeallocTestsCore/Polling.swift new file mode 100644 index 0000000..d6aea2c --- /dev/null +++ b/Sources/DeallocTestsCore/Polling.swift @@ -0,0 +1,37 @@ +// +// Polling.swift +// DeallocTestsCore +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +package enum Polling { + /// Polls the condition until it holds or the timeout elapses. Returns as soon as the + /// condition holds. Sleeping between checks lets the run loop drain autorelease pools + /// and finish UIKit transitions. + @MainActor + package static func waitUntil( + timeout: Duration, + interval: Duration = .milliseconds(10), + _ condition: @MainActor () -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + + while !condition() { + guard clock.now < deadline else { + return false + } + + do { + try await Task.sleep(for: interval) + } catch { + return condition() + } + } + + return true + } +} diff --git a/Sources/DeallocTestsCore/TypeNames.swift b/Sources/DeallocTestsCore/TypeNames.swift new file mode 100644 index 0000000..9f2012d --- /dev/null +++ b/Sources/DeallocTestsCore/TypeNames.swift @@ -0,0 +1,21 @@ +// +// TypeNames.swift +// DeallocTestsCore +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +package enum TypeNames { + /// Module-qualified type name without the `(unknown context at $…)` part + /// that Swift adds for private and local types + package static func readableName(of object: AnyObject) -> String { + readableName(of: type(of: object)) + } + + package static func readableName(of type: Any.Type) -> String { + String(reflecting: type) + .replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression) + } +} diff --git a/Tests/DeallocTestsCoreTests/LeakReportTests.swift b/Tests/DeallocTestsCoreTests/LeakReportTests.swift new file mode 100644 index 0000000..507a2d1 --- /dev/null +++ b/Tests/DeallocTestsCoreTests/LeakReportTests.swift @@ -0,0 +1,63 @@ +// +// LeakReportTests.swift +// DeallocTestsCoreTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +@testable import DeallocTestsCore +import Testing + +private final class PrivateObject {} + +final class ClosureHolder { + var onUpdate: (() -> Void)? +} + +@Suite("Core") +struct LeakReportTests { + @Test func readableNameDropsUnknownContext() { + let name = TypeNames.readableName(of: PrivateObject()) + + #expect(name == "DeallocTestsCoreTests.PrivateObject") + } + + @Test func messageWithoutHintsGivesGeneralAdvice() { + let report = LeakReport(typeName: "App.Screen", timeout: .seconds(2)) + + #expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. Something still holds")) + } + + @Test func messageWithHintsListsCausesAndExternalOwners() { + let report = LeakReport(typeName: "App.Screen", timeout: .milliseconds(500), hints: ["`onUpdate` is a closure"]) + + #expect(report.message == """ + App.Screen was not deallocated within 500 ms. Possible causes: + • `onUpdate` is a closure + • Or something outside still holds it: a parent's list of children, a cache or a singleton + """) + } + + @Test func hintsNameClosureProperties() { + let holder = ClosureHolder() + holder.onUpdate = {} + + #expect(LeakHints.hints(for: holder).contains { $0.hasPrefix("`onUpdate` is a closure") }) + } + + @Test @MainActor func pollingReturnsAsSoonAsTheConditionHolds() async { + let clock = ContinuousClock() + let start = clock.now + + let result = await Polling.waitUntil(timeout: .seconds(5)) { true } + + #expect(result) + #expect(clock.now - start < .seconds(1)) + } + + @Test @MainActor func pollingGivesUpAfterTheTimeout() async { + let result = await Polling.waitUntil(timeout: .milliseconds(50)) { false } + + #expect(!result) + } +}