Skip to content

Commit

Permalink
Implement UIHangDetector
Browse files Browse the repository at this point in the history
  • Loading branch information
wplong11 committed Jul 18, 2022
1 parent c62c4cb commit 72488cc
Show file tree
Hide file tree
Showing 6 changed files with 325 additions and 0 deletions.
29 changes: 29 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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"]),
]
)
7 changes: 7 additions & 0 deletions Sources/UIHangDetector/Health.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

public enum Health {
case good
case warning
case critical
}
76 changes: 76 additions & 0 deletions Sources/UIHangDetector/HealthChecker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation
import Combine

internal final class HealthChecker {
private let healthSubject = PassthroughSubject<Health, Never>()
private let healthSignalSubject = CurrentValueSubject<Date, Never>(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<Health, Never> {
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 ..<self.warningCriteria:
return .good
case self.warningCriteria ..< self.criticalCriteria:
return .warning
case self.criticalCriteria...:
return .critical
default: fatalError()
}
}
.removeDuplicates()
.subscribe(self.healthSubject)

RunLoop.current.run()
}

func stop() {
self.subscription?.cancel()
self.subscription = nil

self.timerThread?.cancel()
self.timerThread = nil
}

func acceptHealthSignal() {
self.healthSignalSubject.send(Date())
}
}
27 changes: 27 additions & 0 deletions Sources/UIHangDetector/MeasurementExtensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

public typealias Duration = Measurement<UnitDuration>

public extension Double {
func callAsFunction <U: Dimension>(_ units: U) -> Measurement<U> {
Measurement(value: self, unit: units)
}
}

public extension Int {
func callAsFunction <U: Dimension>(_ units: U) -> Measurement<U> {
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))
}
}
46 changes: 46 additions & 0 deletions Sources/UIHangDetector/UIHangDetector.swift
Original file line number Diff line number Diff line change
@@ -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<Health, Never> {
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
}
}
140 changes: 140 additions & 0 deletions Tests/UIHangDetectorTests/UIHangDetectorTests.swift
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable>()
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<AnyCancellable>()
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<AnyCancellable>()
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<AnyCancellable>()
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<AnyCancellable>()
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])
}
}

0 comments on commit 72488cc

Please sign in to comment.