From 78974e91b431a8d21d2b0803378cc6b75c405265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Sat, 3 Oct 2026 21:07:57 +0200 Subject: [PATCH] refactor: shared DeallocTestsCore target Moves the framework-independent parts out of DeallocTests: readable type names, leak hints, the leak report formatting and polling. Core imports neither XCTest nor Swift Testing and has no classes, so the upcoming DeallocWatcher product can share it with apps. CI checks both rules. No behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 ++ Package.resolved | 15 +++++ Package.swift | 10 +++ .../Expectation/DeallocationTracker.swift | 37 ++--------- .../DeallocTests/Expectation/Lifecycle.swift | 23 ++----- .../LeakHints.swift | 6 +- Sources/DeallocTestsCore/LeakReport.swift | 34 ++++++++++ Sources/DeallocTestsCore/Polling.swift | 37 +++++++++++ Sources/DeallocTestsCore/TypeNames.swift | 21 +++++++ .../LeakReportTests.swift | 63 +++++++++++++++++++ 10 files changed, 197 insertions(+), 54 deletions(-) create mode 100644 Package.resolved rename Sources/{DeallocTests/Expectation => DeallocTestsCore}/LeakHints.swift (97%) create mode 100644 Sources/DeallocTestsCore/LeakReport.swift create mode 100644 Sources/DeallocTestsCore/Polling.swift create mode 100644 Sources/DeallocTestsCore/TypeNames.swift create mode 100644 Tests/DeallocTestsCoreTests/LeakReportTests.swift 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) + } +}