返回博客

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 运行时提供了一套回部署 shimswift_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 上下文里被释放。

坑:没有 deinit 的 final class 在 @MainActor 上下文释放会触发回部署 shim 的 libmalloc bug

在 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_ALLOCATEDSIGABRT

症状:整个 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       // 底层依赖最后释放
}
正解:空 deinit 绕过回部署 shim,tearDown 按依赖逆序释放

给每个受影响的 final classdeinit { },同时在 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 的路径,代价几乎为零。