XCTest 全班 abrt 崩溃:一个空 deinit 解决 Swift 回部署运行时的 libmalloc 双释放
为一个 iOS 项目补测试覆盖时,测试套件突然全军覆没——同一个 XCTestCase 子类里所有测试方法同时失败,耗时清一色 0.000s,Xcode 报 Test crashed with signal abrt。最诡异的是:被测代码在生产环境跑得好好的,完全没有手写的并发逻辑,测试也写得中规中矩。
现象
打开 ~/Library/Logs/DiagnosticReports/ 找到崩溃报告,栈顶是:
___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED
swift::TaskLocal::StopLookupScope::~StopLookupScope()
swift_task_deinitOnExecutorImpl
swift_task_deinitOnExecutorMainActorBackDeploy
KeychainStore.__deallocating_deinit
出问题的 KeychainStore 是个毫无亮点的 final class,没有 @MainActor 标注,也没有显式 deinit。为什么它的析构会经过 BackDeploy 主 actor 的 hop?
根因
Swift 5.10 引入了"在特定 executor 上执行 deinit"的能力,让带有 actor 隔离的类可以在正确的 executor 上析构。为了让这个特性在旧版 macOS 上也能跑,Swift 运行时提供了一套回部署 shim:swift_task_deinitOnExecutorMainActorBackDeploy。
问题在于:当一个没有任何 actor 标注的普通 final class 被 @MainActor 隔离的代码释放时,这个 shim 会尝试把析构 hop 到主 actor 上执行。shim 在 macOS 14/15 + Xcode 16 + Swift 5.10 mode 的组合下存在一个 libmalloc 级别的 bug,导致析构链路出现非法指针释放,进而触发 abrt。
而现代 XCTest(macOS 14 起)的 tearDownWithError 默认跑在 @MainActor——这正好踩中触发条件:在 @MainActor-isolated tearDown 里 nil 掉服务引用,链式释放拉起了有 bug 的 shim。生产代码不崩,是因为生产环境中相同的类通常在背景线程或非 @MainActor 上下文里被释放。
在 Xcode 16 / macOS 14+ / Swift 5.10 mode 下,Swift 运行时对被 @MainActor-isolated 代码释放的普通 final class(无 actor 标注、无显式 deinit)会走 swift_task_deinitOnExecutorMainActorBackDeploy 这条回部署路径。该路径存在内存管理 bug,触发 ___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED → SIGABRT。
症状:整个 XCTestCase 所有测试同时 crash,每个测试 0.000s,与测试内容无关。
正解
主修:给受影响的类加空 deinit {}
final class KeychainStore {
init(service: String) { ... }
// 显式 deinit 让编译器生成具体的 __deallocating_deinit,
// 绕过 swift_task_deinitOnExecutorMainActorBackDeploy 回部署 shim。
deinit { }
// ... 其他方法 ...
}
final class NetworkService {
init(keychain: KeychainStore, session: URLSession = .shared) { ... }
deinit { } // 链上每一个 final class 都要加
}为什么有效:显式 deinit 迫使编译器为该类生成一个具体的 __deallocating_deinit 符号,Swift 运行时会走这条已编译好的路径,而非触发有 bug 的回部署 shim。空 deinit 对 ARC 行为、性能、正确性完全无影响——它只是一个阻止 shim 介入的占位符。
注意:需要给析构链上的每一个受影响 final class 都加,不只是最深层的那个。
附加:tearDown 顺序 + URLSession 显式失效
如果服务持有带 mock URLProtocol 的自定义 URLSession,还需要在 tearDown 里正确排序并显式失效 session,否则 URLProtocol 的 handler 闭包会持续持有对测试类的引用,在特定顺序下维持崩溃链:
override func tearDownWithError() throws {
MockURLProtocol.handler = nil
session?.finishTasksAndInvalidate() // 必须先断开 URLProtocol handler 引用
service = nil // 持有者先释放
session = nil
keychain = nil // 底层依赖最后释放
}给每个受影响的 final class 加 deinit { },同时在 tearDown 里:① 先清 mock handler;② finishTasksAndInvalidate() URLSession;③ 按依赖逆序 nil 引用(持有者先、被持有者后)。
快速定位法
遇到 XCTest 整班 0.000s crash,先看崩溃报告栈:
# 最新崩溃报告
ls -t ~/Library/Logs/DiagnosticReports/*.ips | head -3栈里出现 swift_task_deinitOnExecutorMainActorBackDeploy → 立刻检查 tearDown 里 nil 掉的每个 final class,补加空 deinit。
一句话外卖
Swift 回部署 shim 的 bug 是运行时问题,不是你的代码问题——但一个空的
deinit { }就能让编译器绕过那条有 bug 的路径,代价几乎为零。