1import Foundation
2import Darwin
3
4/// Main-thread hang watchdog. A background thread pings the main queue every
5/// 20ms; when the pong stops coming back for >100ms, the main thread is
6/// stalled — exactly the "microhang" that makes playback video stutter while
7/// audio (decoded off-main by CoreAudio) keeps going. While the stall lasts,
8/// the watchdog suspends the main thread for microseconds at a time, walks its
9/// frame pointers, and symbolicates — so the log names the culprit, not just
10/// the duration. Lines go to ~/Library/Logs/Sequencer.log via SeqLog:
11///
12/// [hang] main thread 0.34s during playback (rate 1.0) — 3 samples,
13/// top: ViewerGridView.update() ← CA::Transaction::commit ← …
14///
15/// Cost when healthy: one trivial main-queue block per 20ms. Disable with
16/// SEQ_NOHANGWATCH=1.
17/// SEQ_HANGTEST target: a recognizable frame that should appear in the
18/// sampled stack. Spins (not sleeps) so the pc sits in our own code.
19@inline(never)
20func hangTestStall() {
21 let until = CFAbsoluteTimeGetCurrent() + 0.4
22 var sink = 0.0
23 while CFAbsoluteTimeGetCurrent() < until { sink += sin(sink) + 1 }
24 _ = sink
25}
26
27enum HangMonitor {
28 /// Written from PlaybackController.setRate (main), read by the watchdog
29 /// thread. Benign race — it only annotates log lines.
30 nonisolated(unsafe) static var playbackRate: Double = 0
31
32 private nonisolated(unsafe) static var mainThread: thread_t = 0
33 private nonisolated(unsafe) static var lastPong = CFAbsoluteTimeGetCurrent()
34 private nonisolated(unsafe) static var pingInFlight = false
35 private static let lock = NSLock()
36 private static let threshold = 0.1 // report stalls longer than this
37
38 /// Call once from the main thread at startup.
39 static func start() {
40 guard ProcessInfo.processInfo.environment["SEQ_NOHANGWATCH"] == nil else { return }
41 mainThread = pthread_mach_thread_np(pthread_self())
42 let t = Thread { watch() }
43 t.name = "sequencer.hangwatch"
44 t.qualityOfService = .userInitiated
45 t.start()
46 }
47
48 private static func watch() {
49 while true {
50 usleep(20_000)
51 lock.lock()
52 let age = CFAbsoluteTimeGetCurrent() - lastPong
53 let busy = pingInFlight
54 if !busy {
55 pingInFlight = true
56 lock.unlock()
57 DispatchQueue.main.async {
58 lock.lock()
59 lastPong = CFAbsoluteTimeGetCurrent()
60 pingInFlight = false
61 lock.unlock()
62 }
63 } else {
64 lock.unlock()
65 }
66 if busy, age > threshold { observeStall(begunAge: age) }
67 }
68 }
69
70 /// Main thread has been unresponsive for `begunAge` already. Sample its
71 /// stack periodically until it recovers, then log one line.
72 private static func observeStall(begunAge: Double) {
73 let start = CFAbsoluteTimeGetCurrent() - begunAge
74 var samples: [[String]] = []
75 while true {
76 if samples.count < 5 {
77 let frames = sampleMainStack()
78 if !frames.isEmpty { samples.append(frames) }
79 }
80 usleep(100_000)
81 lock.lock()
82 let stillStalled = pingInFlight && lastPong < start
83 lock.unlock()
84 if !stillStalled { break }
85 }
86 // Wait for the pong to actually land so the duration is honest.
87 var duration = CFAbsoluteTimeGetCurrent() - start
88 for _ in 0..<200 { // give the queued pong up to 2s to run
89 lock.lock(); let pong = lastPong; lock.unlock()
90 if pong >= start { duration = pong - start; break }
91 usleep(10_000)
92 }
93 guard duration > threshold else { return }
94 let rate = playbackRate
95 let during = rate != 0 ? String(format: " during playback (rate %.1f)", rate) : ""
96 let top = samples.first?.prefix(8).joined(separator: " ← ") ?? "no stack (sampling failed)"
97 SeqLog.log("[hang] main thread %.2fs%@ — %d sample%@, top: %@",
98 duration, during, samples.count, samples.count == 1 ? "" : "s", top)
99 for extra in samples.dropFirst() where extra.first != samples.first?.first {
100 SeqLog.log("[hang] also seen: %@", extra.prefix(5).joined(separator: " ← "))
101 }
102 }
103
104 // MARK: stack sampling (arm64 frame-pointer walk)
105
106 /// Fixed buffers, touched only by the watchdog thread. They exist so the
107 /// suspend window below performs ZERO allocations: if the main thread is
108 /// suspended while holding the malloc lock, any malloc here deadlocks the
109 /// whole app (watchdog waits on the lock, suspended main can never release
110 /// it, thread_resume never runs). This happened — main frozen mid free()
111 /// in drawRuler, watchdog frozen in Array.append → permanent freeze.
112 private static let maxFrames = 50
113 private nonisolated(unsafe) static var pcBuf = [UInt64](repeating: 0, count: maxFrames)
114 private nonisolated(unsafe) static var pairBuf = [UInt64](repeating: 0, count: 2)
115
116 private static func sampleMainStack() -> [String] {
117 guard mainThread != 0, thread_suspend(mainThread) == KERN_SUCCESS else { return [] }
118 // ---- suspend window: no allocation, no locks, no ObjC/Swift runtime
119 // calls that might take either. Only mach syscalls and raw stores. ----
120 var n = 0
121 var state = arm_thread_state64_t()
122 var count = mach_msg_type_number_t(MemoryLayout<arm_thread_state64_t>.size
123 / MemoryLayout<natural_t>.size)
124 let kr = withUnsafeMutablePointer(to: &state) { ptr in
125 ptr.withMemoryRebound(to: natural_t.self, capacity: Int(count)) {
126 thread_get_state(mainThread, ARM_THREAD_STATE64, $0, &count)
127 }
128 }
129 if kr == KERN_SUCCESS {
130 pcBuf[n] = arm64PC(state); n += 1
131 let lr = arm64LR(state)
132 var fp = arm64FP(state)
133 if lr != 0 { pcBuf[n] = lr; n += 1 }
134 // Frame layout: [fp] = caller fp, [fp+8] = return address.
135 while n < maxFrames {
136 guard fp != 0, fp & 0x7 == 0 else { break }
137 var outSize: mach_vm_size_t = 16
138 let r = pairBuf.withUnsafeMutableBytes { buf in
139 mach_vm_read_overwrite(mach_task_self_, mach_vm_address_t(fp), 16,
140 mach_vm_address_t(UInt(bitPattern: buf.baseAddress)),
141 &outSize)
142 }
143 guard r == KERN_SUCCESS, pairBuf[1] != 0 else { break }
144 pcBuf[n] = pairBuf[1]; n += 1
145 guard pairBuf[0] > fp else { break } // stacks grow down; fp chain grows up
146 fp = pairBuf[0]
147 }
148 }
149 thread_resume(mainThread)
150 // ---- end suspend window; symbolication may allocate freely. ----
151 return (0..<n).compactMap { symbolicate(pcBuf[$0]) }
152 }
153
154 private static func arm64PC(_ s: arm_thread_state64_t) -> UInt64 { s.__pc }
155 private static func arm64LR(_ s: arm_thread_state64_t) -> UInt64 {
156 s.__lr & 0x0000_7FFF_FFFF_FFFF // strip ptrauth bits
157 }
158 private static func arm64FP(_ s: arm_thread_state64_t) -> UInt64 { s.__fp }
159
160 private typealias DemangleFn = @convention(c) (
161 UnsafePointer<CChar>?, Int, UnsafeMutablePointer<CChar>?,
162 UnsafeMutablePointer<Int>?, UInt32) -> UnsafeMutablePointer<CChar>?
163 private static let demangleFn: DemangleFn? = {
164 guard let sym = dlsym(dlopen(nil, RTLD_NOW), "swift_demangle") else { return nil }
165 return unsafeBitCast(sym, to: DemangleFn.self)
166 }()
167
168 private static func symbolicate(_ pc: UInt64) -> String? {
169 let stripped = pc & 0x0000_7FFF_FFFF_FFFF
170 var info = Dl_info()
171 guard dladdr(UnsafeRawPointer(bitPattern: UInt(stripped)), &info) != 0 else { return nil }
172 var name: String
173 if let sname = info.dli_sname {
174 name = String(cString: sname)
175 if name.hasPrefix("$s") || name.hasPrefix("_$s"), let fn = demangleFn,
176 let d = fn(name, name.utf8.count, nil, nil, 0) {
177 name = String(cString: d)
178 free(d)
179 // Demangled Swift names are long; keep the signature-free head.
180 if let paren = name.firstIndex(of: "(") { name = String(name[..<paren]) + "()" }
181 }
182 } else if let fname = info.dli_fname {
183 name = (String(cString: fname) as NSString).lastPathComponent
184 + String(format: "+0x%llx", stripped - UInt64(UInt(bitPattern: info.dli_fbase)))
185 } else {
186 return nil
187 }
188 return name
189 }
190}