| 1 | import Foundation |
| 2 | import 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) |
| 20 | func hangTestStall() { |
| 21 | let until = CFAbsoluteTimeGetCurrent() + 0.4 |
| 22 | var sink = 0.0 |
| 23 | while CFAbsoluteTimeGetCurrent() < until { sink += sin(sink) + 1 } |
| 24 | _ = sink |
| 25 | } |
| 26 | |
| 27 | enum 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 | } |