Bug 描述
我在 Mac 上后台运行 Mos 时,偶尔出现以下现象:
- 显示器主动休眠或突然黑屏;
- 唤醒后马上再次黑屏;
- 过一段时间才恢复正常;
- 期间后台的 Mos 进程退出(鼠标滚轮方向恢复 macos 默认)。
现象集中在显示器休眠 / 唤醒阶段出现,并非每次必现,而是偶发。
环境信息
- Mos 版本:4.2.1
- macOS 版本:26.x
- 机型显示器:外接屏 + 原厂屏
- 辅助功能权限:已授予
- 鼠标:普通滚轮鼠标(平滑滚动 / 反向均开启)
说明:已在 4.0.1 及以上复现。因此不是 4.0.0 时代那个"post 路径重复释放"的已知崩溃(那个已被 commit 703fad6 修复)。
根因分析
这一崩溃与已有的两个 issue 类似:
根因代码(Mos/ScrollCore/ScrollPoster.swift):
poster 这个属性:
private var poster: CVDisplayLink? // CoreVideo 管理的 CF 对象
它被两个线程无锁并发访问,而 ScrollPoster 里的 stateLock 只保护滚动状态(current/buffer/delta/filter/ScrollPhase),并不保护 poster:
- 主线程:显示器变化/休眠唤醒时,经
NSApplication.didChangeScreenParametersNotification → recreateDisplayLink() → create(),会 CVDisplayLinkStop(旧 poster) + poster = nil 再建新的;sessionDidResign → disable() → stop() 也会停止它。
- CVDisplayLink 回调线程:滚动手势结束时,
processing() 调用 stop(),内部对 poster 做 CVDisplayLinkStop(poster)。
两条线程对同一个 CF 对象并发"停止 + 释放",破坏其引用计数,最终在 CoreVideo 内部 _CFRelease 时崩溃。
为什么在"显示器黑屏/唤醒"时最易触发:因为那时主线程最可能在 create()/recreateDisplayLink()(didChangeScreenParametersNotification 触发),而回调线程恰好结束一次滚动(stop()),两者在无锁的 poster 上碰撞,竞态窗口闭合。
补充:poster 在 stop() 里是在拿 stateLock 之前就读取的(if let validPoster = poster { CVDisplayLinkStop(validPoster) }),所以这段访问完全不受锁保护。
修改建议
核心思路:让 poster(及 CVDisplayLinkStop)只在主线程被访问。
processing()(CVDisplayLink 回调线程)不要在回调里直接调用 stop(),改为派发到主线程:
DispatchQueue.main.async { [weak self] in
self?.stopIfCurrent(phase, generation: gestureGeneration)
}
- 由于 stop 被延迟到主线程执行,需要防"过期 stop":新增一个滚动手势代次计数(每次
update() 递增),在 stopIfCurrent 里判断代次未变才真正执行,避免一个延迟的 stop 误清用户刚开启的新手势、停掉 link。
- 可选防御:给
poster 加一把独立锁(区别于 stateLock),把所有访问 poster 的路径统一串行化。
Bug 描述
我在 Mac 上后台运行 Mos 时,偶尔出现以下现象:
现象集中在显示器休眠 / 唤醒阶段出现,并非每次必现,而是偶发。
环境信息
根因分析
这一崩溃与已有的两个 issue 类似:
Triggered by Thread: 3 CVDisplayLink,_CFRelease,___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED,abort。同样
Thread 5 CVDisplayLink+SIGABRT。根因代码(
Mos/ScrollCore/ScrollPoster.swift):poster这个属性:它被两个线程无锁并发访问,而
ScrollPoster里的stateLock只保护滚动状态(current/buffer/delta/filter/ScrollPhase),并不保护poster:NSApplication.didChangeScreenParametersNotification→recreateDisplayLink()→create(),会CVDisplayLinkStop(旧 poster)+poster = nil再建新的;sessionDidResign→disable()→stop()也会停止它。processing()调用stop(),内部对poster做CVDisplayLinkStop(poster)。两条线程对同一个 CF 对象并发"停止 + 释放",破坏其引用计数,最终在 CoreVideo 内部
_CFRelease时崩溃。为什么在"显示器黑屏/唤醒"时最易触发:因为那时主线程最可能在
create()/recreateDisplayLink()(didChangeScreenParametersNotification触发),而回调线程恰好结束一次滚动(stop()),两者在无锁的poster上碰撞,竞态窗口闭合。修改建议
核心思路:让
poster(及CVDisplayLinkStop)只在主线程被访问。processing()(CVDisplayLink 回调线程)不要在回调里直接调用stop(),改为派发到主线程:update()递增),在stopIfCurrent里判断代次未变才真正执行,避免一个延迟的 stop 误清用户刚开启的新手势、停掉 link。poster加一把独立锁(区别于stateLock),把所有访问poster的路径统一串行化。