197 lines
6.1 KiB
Swift
197 lines
6.1 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
#if DEBUG
|
|
@Suite(.serialized)
|
|
struct MainThreadHangWatchdogTests {
|
|
@MainActor
|
|
@Test
|
|
func `breadcrumb tracks nested activity`() {
|
|
#expect(MainThreadActivityBreadcrumb.current == nil)
|
|
MainThreadActivityBreadcrumb.push("outer")
|
|
MainThreadActivityBreadcrumb.push("inner")
|
|
#expect(MainThreadActivityBreadcrumb.current == "inner")
|
|
MainThreadActivityBreadcrumb.pop()
|
|
#expect(MainThreadActivityBreadcrumb.current == "outer")
|
|
MainThreadActivityBreadcrumb.pop()
|
|
#expect(MainThreadActivityBreadcrumb.current == nil)
|
|
}
|
|
|
|
@Test
|
|
func `watchdog reports a breadcrumb for a delayed main thread response`() throws {
|
|
let watchdog = MainThreadHangWatchdog(
|
|
pingInterval: 0.01,
|
|
hangThreshold: 0.05,
|
|
sampleThreshold: 60,
|
|
sampleCooldown: 3600)
|
|
|
|
let reported = OSAllocatedBox<[(TimeInterval, [String])]>([])
|
|
watchdog.onHangForTesting = { duration, activities in
|
|
reported.append((duration, activities))
|
|
}
|
|
|
|
MainThreadActivityBreadcrumb.push("testStall")
|
|
watchdog.traceHangForTesting(responseDelay: 0.25)
|
|
MainThreadActivityBreadcrumb.pop()
|
|
|
|
let report = try #require(reported.get().first)
|
|
#expect(report.1.contains("testStall"))
|
|
#expect(report.0 >= 0.05)
|
|
}
|
|
|
|
@Test
|
|
func `watchdog polling loop reports a delayed ping response`() {
|
|
let pingScheduled = DispatchSemaphore(value: 0)
|
|
let hangDetected = DispatchSemaphore(value: 0)
|
|
let reported = DispatchSemaphore(value: 0)
|
|
let pendingResponse = OSAllocatedBox<(@Sendable () -> Void)?>(nil)
|
|
let watchdog = MainThreadHangWatchdog(
|
|
pingInterval: 1,
|
|
hangThreshold: 0.02,
|
|
sampleThreshold: 60,
|
|
sampleCooldown: 3600,
|
|
schedulePing: { response in
|
|
pendingResponse.set(response)
|
|
pingScheduled.signal()
|
|
})
|
|
watchdog.onHangForTesting = { _, _ in
|
|
reported.signal()
|
|
}
|
|
watchdog.onHangDetectionForTesting = {
|
|
hangDetected.signal()
|
|
}
|
|
|
|
watchdog.start()
|
|
defer { watchdog.stop() }
|
|
|
|
#expect(pingScheduled.wait(timeout: .now() + 10) == .success)
|
|
#expect(hangDetected.wait(timeout: .now() + 10) == .success)
|
|
pendingResponse.get()?()
|
|
#expect(reported.wait(timeout: .now() + 10) == .success)
|
|
}
|
|
|
|
@Test
|
|
func `sample capture cannot inflate reported hang duration`() throws {
|
|
let sampleRequested = OSAllocatedBox(false)
|
|
let watchdog = MainThreadHangWatchdog(
|
|
pingInterval: 0.01,
|
|
hangThreshold: 0.03,
|
|
sampleThreshold: 0.05,
|
|
sampleCooldown: 3600,
|
|
sampleCaptureOverride: {
|
|
sampleRequested.set(true)
|
|
Thread.sleep(forTimeInterval: 1)
|
|
return "/tmp/codexbar-watchdog-test-sample.txt"
|
|
})
|
|
|
|
let reported = OSAllocatedBox<[(TimeInterval, [String])]>([])
|
|
watchdog.onHangForTesting = { duration, activities in
|
|
reported.append((duration, activities))
|
|
}
|
|
|
|
MainThreadActivityBreadcrumb.push("sampledStall")
|
|
watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true)
|
|
MainThreadActivityBreadcrumb.pop()
|
|
|
|
let report = try #require(reported.get().first)
|
|
#expect(sampleRequested.get())
|
|
#expect(report.1.contains("sampledStall"))
|
|
#expect(report.0 >= 0.03)
|
|
#expect(report.0 < 0.75)
|
|
}
|
|
|
|
@Test
|
|
func `failed sample capture is attempted once per hang`() {
|
|
let attempts = OSAllocatedBox(0)
|
|
let watchdog = MainThreadHangWatchdog(
|
|
pingInterval: 0.01,
|
|
hangThreshold: 0.01,
|
|
sampleThreshold: 0,
|
|
sampleCooldown: 3600,
|
|
sampleCaptureOverride: {
|
|
attempts.withValue { $0 += 1 }
|
|
return nil
|
|
})
|
|
|
|
watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true)
|
|
|
|
#expect(attempts.get() == 1)
|
|
}
|
|
|
|
@Test
|
|
func `missed sample window does not consume cooldown`() {
|
|
let attempts = OSAllocatedBox(0)
|
|
let watchdog = MainThreadHangWatchdog(
|
|
pingInterval: 0.01,
|
|
hangThreshold: 0.01,
|
|
sampleThreshold: 0.02,
|
|
sampleCooldown: 3600,
|
|
sampleCaptureOverride: {
|
|
attempts.withValue { $0 += 1 }
|
|
return nil
|
|
})
|
|
|
|
watchdog.traceHangForTesting(responseDelay: 0.05, responseBeforeTrace: true)
|
|
#expect(attempts.get() == 0)
|
|
|
|
watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true)
|
|
#expect(attempts.get() == 1)
|
|
}
|
|
|
|
@Test
|
|
func `cooldown blocked hang samples when cooldown expires`() {
|
|
let attempts = OSAllocatedBox(0)
|
|
let watchdog = MainThreadHangWatchdog(
|
|
pingInterval: 0.01,
|
|
hangThreshold: 0.01,
|
|
sampleThreshold: 0,
|
|
sampleCooldown: 0.2,
|
|
sampleCaptureOverride: {
|
|
attempts.withValue { $0 += 1 }
|
|
return nil
|
|
})
|
|
|
|
watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true)
|
|
watchdog.traceHangForTesting(responseDelay: 1)
|
|
|
|
#expect(attempts.get() == 2)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private final class OSAllocatedBox<T>: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var value: T
|
|
|
|
init(_ value: T) {
|
|
self.value = value
|
|
}
|
|
|
|
func set(_ newValue: T) {
|
|
self.lock.lock()
|
|
self.value = newValue
|
|
self.lock.unlock()
|
|
}
|
|
|
|
func get() -> T {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
return self.value
|
|
}
|
|
}
|
|
|
|
extension OSAllocatedBox {
|
|
func append<Element>(_ element: Element) where T == [Element] {
|
|
self.lock.lock()
|
|
self.value.append(element)
|
|
self.lock.unlock()
|
|
}
|
|
|
|
func withValue(_ body: (inout T) -> Void) {
|
|
self.lock.lock()
|
|
body(&self.value)
|
|
self.lock.unlock()
|
|
}
|
|
}
|