问题描述
#77 修好之后,我在同一台机器上又复现了「Koe 卡死 / 进程存活 / CPU 持续占用」的问题,但这次卡死的位置和 #77 不太一样:之前是卡在 audioEngine.start(),#77 的 PR 把这一步包进了后台队列 + 3s 超时;这次卡在更早的 AVAudioEngine.inputNode / outputFormatForBus: / installTapOnBus: / prepare 一段,仍然运行在主线程上。
结果和 #77 完全一样:Koe 进程不崩,但一直占 CPU,菜单栏点不开,热键触发没有反应,只能强杀重启。
环境
- Koe 版本:
1.0.14 (15)(/Applications/Koe.app/Contents/MacOS/Koe)
- Bundle ID:
nz.owo.koe
- macOS:
26.3 (25D125)
- 芯片:Apple Silicon(arm64)
- 形态:菜单栏应用(
LSUIElement = true)
进程采样
用 sample <pid> 3 抓到主线程 2243/2243 个采样全部卡在同一条调用链,完整路径如下(摘关键几层):
main
└─ -[NSApplication run]
└─ _DPSNextEvent
└─ __CFRunLoopDoTimer
└─ __NSFireTimer
└─ __33-[SPHotkeyMonitor startHoldTimer]_block_invoke
└─ -[SPHotkeyMonitor holdTimerFired]
└─ -[SPAppDelegate hotkeyMonitorDidDetectHoldStart]
└─ -[SPAudioCaptureManager startCaptureWithAudioCallback:] + 180
└─ -[AVAudioEngine inputNode]
└─ AVAudioEngineImpl::UpdateInputNode(bool)
└─ -[AVAudioNode inputFormatForBus:]
└─ AVAudioIONodeImpl::GetInputFormat
└─ AVAudioIOUnit::GetHWFormat
└─ _dispatch_lane_barrier_sync_invoke_and_complete
└─ AVAudioIOUnit_OSX::_GetHWFormat
└─ AVAEHalUtil::GetSubDevices
└─ AudioObjectGetPropertyData_mac_imp
└─ HAL_HardwarePlugIn_ObjectGetPropertyData
└─ HALC_Object_GetPropertyData_DAI32
└─ mach_msg → mach_msg2_trap (100% 样本)
进程运行时间约 12.5 h,ps 显示累计 CPU 时间 225:08,状态 R,RSS ~37MB。也就是说主线程从某个时刻开始就一直卡在 AU 内部 GetHWFormat 对 coreaudiod 的 mach IPC 上。
根因分析
-[SPAudioCaptureManager startCaptureWithAudioCallback:] 当前实现里这几行都在主线程上跑:
self.audioEngine = [[AVAudioEngine alloc] init];
AVAudioInputNode *inputNode = self.audioEngine.inputNode; // ← 主线程在这里被卡死
if (self.pendingDeviceID != kAudioObjectUnknown) {
AudioUnitSetProperty(inputNode.audioUnit, kAudioOutputUnitProperty_CurrentDevice, ...);
}
AVAudioFormat *hardwareFormat = [inputNode outputFormatForBus:0];
...
[inputNode installTapOnBus:0 bufferSize:4096 format:hardwareFormat block:^...];
[self.audioEngine prepare];
上面这几个调用看起来像是单纯读写 Obj-C 属性,但它们内部全部都会 dispatch_sync 进 AVAudioIOUnit 的串行队列,再通过 mach_msg 同步发请求给 coreaudiod。蓝牙耳机路由切换、聚合设备/空间音频子设备枚举、Audio device list changed 等瞬态场景下,这个 reply 会无限延迟(#77 里 HALC_ProxyIOContext::StartAndWaitForState returned error 35 是同一个现象的另一个表现),于是整个主线程被钉死。
#77 的那版修复只把最后一步 [engine startAndReturnError:] 丢到后台 + 3s 超时,前面的 inputNode / installTapOnBus: / prepare 还是在主线程,所以遇到 HAL 同样的毛病时,代码永远走不到已经有超时保护的那一段。
关键证据(和 #77 对比)
| 项 |
#77 卡死位置 |
本次卡死位置 |
| 主线程 frame |
-[AVAudioEngine startAndReturnError:] |
-[AVAudioEngine inputNode] |
| CoreAudio 层 |
HALC_ProxyIOContext::_StartIO / StartAndWaitForState |
AVAudioIOUnit::GetHWFormat → AVAEHalUtil::GetSubDevices |
| 现有保护是否生效 |
是(3s 超时) |
否(还没走到 start) |
| 用户侧表现 |
进程存活 / CPU 30%+ / 无法恢复 |
进程存活 / CPU 30%+ / 无法恢复 |
修复方案
最小侵入的改法:把 startCaptureWithAudioCallback: 里整段接触 CoreAudio 的逻辑(engine 构造、inputNode 查询、AudioUnitSetProperty 切换输入设备、outputFormatForBus:、installTapOnBus:、prepare、startAndReturnError:)都挪进现有的后台 dispatch_async,并复用 kEngineStartTimeoutSec(3s)的那把信号量闸门。主线程等待超时即返回 NO,由 SPAppDelegate.startAudioCaptureWithRetry 已有的 500ms 重试兜底。
额外处理一下「主线程超时但后台后来又成功启动了 engine」的情况:在共享锁里写一个 mainAborted 标志,后台发现 mainAborted == YES 就对迟到启动的 engine 调 stop,避免留下孤儿录音会话。
这样无论 HAL 卡在哪一步,主线程最多被阻塞 3 秒,然后走统一的错误/重试路径,不再出现「进程活着但整 app 假死」。
PR 已经发在 #93,只改 KoeApp/Koe/Audio/SPAudioCaptureManager.m,没有接口级别变动。
其他
如果需要,我可以再补一份完整的 sample 输出(~1800 行)以及复现时的 log show --predicate 'subsystem == "com.apple.coreaudio"' 片段。
问题描述
#77 修好之后,我在同一台机器上又复现了「Koe 卡死 / 进程存活 / CPU 持续占用」的问题,但这次卡死的位置和 #77 不太一样:之前是卡在
audioEngine.start(),#77 的 PR 把这一步包进了后台队列 + 3s 超时;这次卡在更早的AVAudioEngine.inputNode/outputFormatForBus:/installTapOnBus:/prepare一段,仍然运行在主线程上。结果和 #77 完全一样:Koe 进程不崩,但一直占 CPU,菜单栏点不开,热键触发没有反应,只能强杀重启。
环境
1.0.14 (15)(/Applications/Koe.app/Contents/MacOS/Koe)nz.owo.koe26.3 (25D125)LSUIElement = true)进程采样
用
sample <pid> 3抓到主线程 2243/2243 个采样全部卡在同一条调用链,完整路径如下(摘关键几层):进程运行时间约 12.5 h,
ps显示累计 CPU 时间225:08,状态R,RSS ~37MB。也就是说主线程从某个时刻开始就一直卡在 AU 内部GetHWFormat对coreaudiod的 mach IPC 上。根因分析
-[SPAudioCaptureManager startCaptureWithAudioCallback:]当前实现里这几行都在主线程上跑:上面这几个调用看起来像是单纯读写 Obj-C 属性,但它们内部全部都会
dispatch_sync进AVAudioIOUnit的串行队列,再通过 mach_msg 同步发请求给coreaudiod。蓝牙耳机路由切换、聚合设备/空间音频子设备枚举、Audio device list changed等瞬态场景下,这个 reply 会无限延迟(#77 里HALC_ProxyIOContext::StartAndWaitForState returned error 35是同一个现象的另一个表现),于是整个主线程被钉死。#77 的那版修复只把最后一步
[engine startAndReturnError:]丢到后台 + 3s 超时,前面的inputNode/installTapOnBus:/prepare还是在主线程,所以遇到 HAL 同样的毛病时,代码永远走不到已经有超时保护的那一段。关键证据(和 #77 对比)
-[AVAudioEngine startAndReturnError:]-[AVAudioEngine inputNode]HALC_ProxyIOContext::_StartIO/StartAndWaitForStateAVAudioIOUnit::GetHWFormat→AVAEHalUtil::GetSubDevices修复方案
最小侵入的改法:把
startCaptureWithAudioCallback:里整段接触 CoreAudio 的逻辑(engine 构造、inputNode查询、AudioUnitSetProperty切换输入设备、outputFormatForBus:、installTapOnBus:、prepare、startAndReturnError:)都挪进现有的后台dispatch_async,并复用kEngineStartTimeoutSec(3s)的那把信号量闸门。主线程等待超时即返回NO,由SPAppDelegate.startAudioCaptureWithRetry已有的 500ms 重试兜底。额外处理一下「主线程超时但后台后来又成功启动了 engine」的情况:在共享锁里写一个
mainAborted标志,后台发现mainAborted == YES就对迟到启动的 engine 调stop,避免留下孤儿录音会话。这样无论 HAL 卡在哪一步,主线程最多被阻塞 3 秒,然后走统一的错误/重试路径,不再出现「进程活着但整 app 假死」。
PR 已经发在 #93,只改
KoeApp/Koe/Audio/SPAudioCaptureManager.m,没有接口级别变动。其他
如果需要,我可以再补一份完整的
sample输出(~1800 行)以及复现时的log show --predicate 'subsystem == "com.apple.coreaudio"'片段。