import Foundation /// Measures how long the main thread goes without answering. /// /// A thread of its own posts a block to the main queue every 5 ms and /// times how long each takes to run. A main thread that is free answers /// within a millisecond or two; one that is blocked answers only when it /// is free again, and the wait is the stall. Several posts can be waiting /// at once, so the longest single wait is the longest stall, to within /// the 5 ms period. /// /// It measures the main *queue*, which is what the main actor runs on and /// what a SwiftUI app's run loop drains: a beachball is this number above /// about a second. final class MainThreadWatchdog: @unchecked Sendable { struct Stall: Sendable { /// Seconds since the watchdog started. var at: Double var seconds: Double } private let lock = NSLock() private var running = false private var longest: Stall? private var stalls: [Stall] = [] private let started = CFAbsoluteTimeGetCurrent() private let period: Double /// Waits at or above this are kept, for the report. private let noteAbove: Double init(period: Double = 0.005, noteAbove: Double = 0.05) { self.period = period self.noteAbove = noteAbove } func start() { lock.lock() running = true lock.unlock() let thread = Thread { [self] in while isRunning { let posted = CFAbsoluteTimeGetCurrent() DispatchQueue.main.async { [self] in let now = CFAbsoluteTimeGetCurrent() record(Stall(at: posted - started, seconds: now - posted)) } Thread.sleep(forTimeInterval: period) } } thread.qualityOfService = .userInteractive thread.start() } private var isRunning: Bool { lock.lock() defer { lock.unlock() } return running } private func record(_ stall: Stall) { lock.lock() defer { lock.unlock() } guard running else { return } if stall.seconds > (longest?.seconds ?? -1) { longest = stall } if stall.seconds >= noteAbove { stalls.append(stall) } } /// Forgets what was measured; the next `longest` is from now. func reset() { lock.lock() longest = nil stalls = [] lock.unlock() } /// Stops posting and gives the longest wait since the last reset, and /// every wait above `noteAbove`. func stop() -> (longest: Double, stalls: [Stall]) { lock.lock() defer { lock.unlock() } running = false return (longest?.seconds ?? 0, stalls) } /// The longest wait so far, without stopping. var longestSoFar: Double { lock.lock() defer { lock.unlock() } return longest?.seconds ?? 0 } }