diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8ed3e33 --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.6 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "UIHangDetector", + platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v6)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "UIHangDetector", + targets: ["UIHangDetector"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "UIHangDetector", + dependencies: []), + .testTarget( + name: "UIHangDetectorTests", + dependencies: ["UIHangDetector"]), + ] +) diff --git a/Sources/UIHangDetector/Health.swift b/Sources/UIHangDetector/Health.swift new file mode 100644 index 0000000..66b9079 --- /dev/null +++ b/Sources/UIHangDetector/Health.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum Health { + case good + case warning + case critical +} diff --git a/Sources/UIHangDetector/HealthChecker.swift b/Sources/UIHangDetector/HealthChecker.swift new file mode 100644 index 0000000..afcd390 --- /dev/null +++ b/Sources/UIHangDetector/HealthChecker.swift @@ -0,0 +1,76 @@ +import Foundation +import Combine + +internal final class HealthChecker { + private let healthSubject = PassthroughSubject() + private let healthSignalSubject = CurrentValueSubject(Date.distantPast) + + private var timerThread: Thread? + private var subscription: AnyCancellable? + + private let warningCriteria: TimeInterval + private let criticalCriteria: TimeInterval + private let healthSignalCheckInterval: TimeInterval + + var healthStream: AnyPublisher { + get { + return self.healthSubject.eraseToAnyPublisher() + } + } + + init( + warningCriteria: Duration, + criticalCriteria: Duration, + healthSignalCheckInterval: Duration + ) { + self.warningCriteria = warningCriteria.converted(to: .seconds).value + self.criticalCriteria = criticalCriteria.converted(to: .seconds).value + self.healthSignalCheckInterval = healthSignalCheckInterval.converted(to: .seconds).value + } + + func start() { + guard self.timerThread == nil else { return } + guard self.subscription == nil else { return } + + let timerThread = Thread(block: self.startImpl) + timerThread.name = "HealthChecker" + self.timerThread = timerThread + self.timerThread?.start() + } + + private func startImpl() { + self.subscription = Timer.publish(every: self.healthSignalCheckInterval, on: RunLoop.current, in: .common) + .autoconnect() + .combineLatest(self.healthSignalSubject.receive(on: RunLoop.current)) + .compactMap { (now: Date, lastSignal: Date) -> TimeInterval in + now.timeIntervalSince(lastSignal) + } + .map { (timeDiff: TimeInterval) -> Health in + switch (timeDiff) { + case .. + +public extension Double { + func callAsFunction (_ units: U) -> Measurement { + Measurement(value: self, unit: units) + } +} + +public extension Int { + func callAsFunction (_ units: U) -> Measurement { + Measurement(value: Double(self), unit: units) + } +} + +public extension Thread { + class func sleep(forDuration duration: Duration) { + Self.sleep(forTimeInterval: duration.converted(to: .seconds).value) + } +} + +public extension Task where Success == Never, Failure == Never { + static func sleep(forDuration duration: Duration) async throws { + try await Self.sleep(nanoseconds: UInt64(duration.converted(to: .nanoseconds).value)) + } +} diff --git a/Sources/UIHangDetector/UIHangDetector.swift b/Sources/UIHangDetector/UIHangDetector.swift new file mode 100644 index 0000000..76aaf35 --- /dev/null +++ b/Sources/UIHangDetector/UIHangDetector.swift @@ -0,0 +1,46 @@ +import Foundation +import Combine + +public final class UIHangDetector { + private let healthSignalInterval: TimeInterval + private let healthChecker: HealthChecker + private var timer: Timer? + + public var healthStream: AnyPublisher { + get { + return self.healthChecker.healthStream + } + } + + public init( + warningCriteria: Duration, + criticalCriteria: Duration, + healthSignalInterval: Duration = 0.5(.seconds), + healthSignalCheckInterval: Duration = 0.1(.seconds) + ) { + self.healthSignalInterval = healthSignalInterval.converted(to: .seconds).value + self.healthChecker = HealthChecker( + warningCriteria: warningCriteria, + criticalCriteria: criticalCriteria, + healthSignalCheckInterval: healthSignalCheckInterval + ) + } + + public func start() { + self.healthChecker.start() + + DispatchQueue.main.async { + self.healthChecker.acceptHealthSignal() + self.timer = Timer.scheduledTimer(withTimeInterval: self.healthSignalInterval, repeats: true) { _ in + self.healthChecker.acceptHealthSignal() + } + } + } + + public func stop() { + self.healthChecker.stop() + + self.timer?.invalidate() + self.timer = nil + } +} diff --git a/Tests/UIHangDetectorTests/UIHangDetectorTests.swift b/Tests/UIHangDetectorTests/UIHangDetectorTests.swift new file mode 100644 index 0000000..f8f9f05 --- /dev/null +++ b/Tests/UIHangDetectorTests/UIHangDetectorTests.swift @@ -0,0 +1,140 @@ +import XCTest +import Combine +@testable import UIHangDetector + +final class UIHangDetectorTests: XCTestCase { + func test_sut_should_detect_warning_state() async throws { + // Arrange + let sut = UIHangDetector( + warningCriteria: 500(.milliseconds), + criticalCriteria: 1(.seconds), + healthSignalInterval: 500(.milliseconds), + healthSignalCheckInterval: 100(.milliseconds) + ) + + var history: [Health] = [] + var cancellableStorage = Set() + sut.healthStream + .sink { history.append($0) } + .store(in: &cancellableStorage) + + // Act + sut.start() + DispatchQueue.main.async { + Thread.sleep(forDuration: 700(.milliseconds)) + } + + // Arrange + try await Task.sleep(forDuration: 600(.milliseconds)) + XCTAssertTrue(history.dropFirst() == [.warning]) + } + + func test_sut_should_detect_critical_state() async throws { + // Arrange + let sut = UIHangDetector( + warningCriteria: 500(.milliseconds), + criticalCriteria: 1(.seconds), + healthSignalInterval: 500(.milliseconds), + healthSignalCheckInterval: 100(.milliseconds) + ) + + var history: [Health] = [] + var cancellableStorage = Set() + sut.healthStream + .sink { history.append($0) } + .store(in: &cancellableStorage) + + // Act + sut.start() + DispatchQueue.main.async { + Thread.sleep(forDuration: 1.3(.seconds)) + } + + // Arrange + try await Task.sleep(forDuration: 1.1(.seconds)) + XCTAssertTrue(history.dropFirst(2) == [.critical]) + } + + func test_sut_should_detect_state_recovered_from_warning() async throws { + // Arrange + let sut = UIHangDetector( + warningCriteria: 500(.milliseconds), + criticalCriteria: 1(.seconds), + healthSignalInterval: 500(.milliseconds), + healthSignalCheckInterval: 100(.milliseconds) + ) + + var history: [Health] = [] + var cancellableStorage = Set() + sut.healthStream + .sink { history.append($0) } + .store(in: &cancellableStorage) + + // Act + sut.start() + DispatchQueue.main.async { + Thread.sleep(forDuration: 600(.milliseconds)) + } + + // Arrange + try await Task.sleep(forDuration: 800(.milliseconds)) + XCTAssertTrue(history == [.good, .warning, .good]) + } + + func test_sut_should_detect_state_recovered_from_critical() async throws { + // Arrange + let sut = UIHangDetector( + warningCriteria: 500(.milliseconds), + criticalCriteria: 1(.seconds), + healthSignalInterval: 500(.milliseconds), + healthSignalCheckInterval: 100(.milliseconds) + ) + + var history: [Health] = [] + var cancellableStorage = Set() + sut.healthStream + .sink { history.append($0) } + .store(in: &cancellableStorage) + + // Act + sut.start() + DispatchQueue.main.async { + Thread.sleep(forDuration: 1.1(.seconds)) + } + + // Arrange + try await Task.sleep(forDuration: 1.3(.seconds)) + XCTAssertTrue(history == [.good, .warning, .critical, .good]) + } + + func test_sut_should_handle_first_health_signal_correctly() async throws { + // Arrange + let sut = UIHangDetector( + warningCriteria: 500(.milliseconds), + criticalCriteria: 1(.seconds), + healthSignalInterval: 500(.milliseconds), + healthSignalCheckInterval: 100(.milliseconds) + ) + + var history: [Health] = [] + var cancellableStorage = Set() + sut.healthStream + .sink { history.append($0) } + .store(in: &cancellableStorage) + + // Act + DispatchQueue.main.async { + DispatchQueue.global().sync { + sut.start() + } + + DispatchQueue.main.async { + Thread.sleep(forDuration: 1.1(.seconds)) + } + } + + // Arrange + try await Task.sleep(forDuration: 1.2(.seconds)) + XCTAssertTrue(history == [.good, .warning, .critical, .good]) + } +}