React Native 一处诡异 crash: RCTFont SIGABRT

目前在做的一个项目迁移到 React Native 已经一年多了,也意味着踩了一年的坑,感觉光填上各种奇怪的坑都会让自己的水平提升不少。最近解决了一个占比接近 10% 的崩溃,在这里记录一下。

屏幕快照

崩溃的方法是
+[RCTFont updateFont:withFamily:size:weight:style:variant:scaleMultiplier:],在这个位置会抛出 mutex lock failed: Invalid argument 的异常。

查了下崩溃栈,大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Thread 24 Crashed:
0 libsystem_kernel.dylib __pthread_kill + 8
1 libsystem_c.dylib abort + 140
2 libc++abi.dylib __cxa_bad_cast + 0
3 libc++abi.dylib default_terminate_handler() + 280
4 libobjc.A.dylib _objc_terminate() + 140
5 libc++abi.dylib std::__terminate(void (*)()) + 16
6 libc++abi.dylib __cxxabiv1::exception_cleanup_func(_Unwind_Reason_Code, _Unwind_Exception*) + 0
7 libc++.1.dylib std::__1::__throw_system_error(int, char const*) + 88
8 bee +[RCTFont updateFont:withFamily:size:weight:style:variant:scaleMultiplier:] + 780
9 bee -[RCTShadowText _attributedStringWithFontFamily:fontSize:fontWeight:fontStyle:letterSpacing:useBackgroundColor:foregroundColor:backgroundColor:opacity:] + 580
10 bee -[RCTShadowText attributedString] + 192
11 bee -[RCTShadowText recomputeText] + 28
12 bee -[RCTTextManager uiBlockToAmendWithShadowViewRegistry:] + 612
13 bee -[RCTComponentData uiBlockToAmendWithShadowViewRegistry:] + 96
14 bee -[RCTUIManager _layoutAndMount] + 220
15 bee __36-[RCTBatchedBridge batchDidComplete]_block_invoke + 52
16 libdispatch.dylib _dispatch_call_block_and_release + 24
17 libdispatch.dylib _dispatch_client_callout + 16
18 libdispatch.dylib _dispatch_queue_serial_drain + 928
19 libdispatch.dylib _dispatch_queue_invoke + 884
20 libdispatch.dylib _dispatch_root_queue_drain + 540
21 libdispatch.dylib _dispatch_worker_thread3 + 124
22 libsystem_pthread.dylib _pthread_wqthread + 1096
23 libsystem_pthread.dylib start_wqthread + 4

从崩溃栈上可以看出来是 RN 库 RCTFont 模块出的问题,除此之外再也找不到其他信息,放 google 搜了一圈,只能找到别人提的同样的问题 – RCTFont SIGABRT crashApp crashes for “mutex lock failed: Invalid argument”,却没人提出解决方法,看来只能自己解了。

首先把可执行文件拉到 Hopper 里,定位到崩溃处,看一下对应的汇编指令:

屏幕快照 2017-08-01 下午3.03.38

可以得知应用在 RCTFont 内部使用 std::mutex 加锁的时候抛出了异常,对应于 RCTFont.mm 第 103 行

1
2
3
4
5
6
7
{
std::lock_guard<std::mutex> lock(fontCacheMutex); ///< 在这里挂掉了
if (!fontCache) {
fontCache = [NSCache new];
}
font = [fontCache objectForKey:cacheKey];
}

对比收集到的各种崩溃样本,可以总结出以下几处共同点:

  • 主线程都有 handleApplicationDeactivationWithScene+[_UIAlertManager hideAlertsForTermination]exit 的调用;
  • crash 都发生在后台;
  • 都在 mutex::lock 的时候抛出了异常。

handleApplicationDeactivationWithScene 等方法下个断点,发现只有在用户手动 kill 掉 app 时这些方法才会被调用,猜测这个时候系统可能正在做一些清理工作,这时候如果有其他线程调用了 mutex::lock 可能就会导致异常。

假设上面的猜测为真,那么解决问题的关键是让应用进程结束时不调用 mutex::lock 方法。查看 React Native 源码,crash 处的代码只被一个方法调用,即上面提到的 +[RCTFont updateFont:withFamily:size:weight:style:variant:scaleMultiplier:]

屏幕快照 2017-08-09 上午11.45.36

这个方法的调用者有多个,但最终都走到了 React Native 模块的各个属性的设置方法里,在 React Native 线程里,由 RCTBatchedBridgeRCTJSCExecutor 驱动

屏幕快照 2017-08-23 下午12.25.22

注意到 js 每次调用时都会检查 _valid 属性,所以只需要在进程结束时把 _valid 置为 false,crash 处的代码就不会被执行。RCTBatchedBridge 和它的包装类 RCTBridge 刚好暴露出了 invalidate 方法,可以把 _valid 置为 false:

1
2
3
4
5
6
7
8
9
10
11
12
// -[RCTBridge invalidate]
- (void)invalidate
{
RCTBridge *batchedBridge = self.batchedBridge;
self.batchedBridge = nil;

if (batchedBridge) {
RCTExecuteOnMainQueue(^{
[batchedBridge invalidate];
});
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// -[RCTBatchedBridge invalidate]
- (void)invalidate
{
if (!_valid) {
return;
}

_loading = NO;
_valid = NO;

// Invalidate modules
for (RCTModuleData *moduleData in _moduleDataByID) {
id<RCTBridgeModule> instance = moduleData.instance;
[instance invalidate];
[moduleData invalidate];
}
}

用户杀掉 app 时,系统会调用 App Delegate 的 applicationWillTerminate 方法,所以我们需要在这里调用一下 -[RCTBridge invalidate],使 RCTBridge 失效,这样就不会再触发导致 crash 的代码了。

但问题是,-[RCTBridge invalidate] 方法是异步的,applicationWillTerminate 一返回马上就进入 exit 函数,这时候程序还来不及干掉 RCTBridge,crash 处的代码还是会执行。所以这里还需要借用 runloop 让 applicationWillTerminate 卡一会儿,直到 RCTBridge 完全停止:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)applicationWillTerminate:(UIApplication *)application {
RCTBridge *batchedBridge = [self.bridge valueForKey:@"batchedBridge"];
[self.bridge invalidate];

NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
NSArray<NSRunLoopMode> *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(runLoop.getCFRunLoop));
while (batchedBridge.moduleClasses) {
for (NSRunLoopMode mode in allModes) {
[runLoop runMode:mode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
}
}
}

这里用到了几处 trick:

  • 在这里阻塞 applicationWillTerminate 要用 runloop,而不是简单的 sleep,原因是 invalidate 方法内部向主线程分发了一些事要做(见 RCTBatchedBridge.mm 源码),需要主线程有处理事件的能力;
  • invalidate 方法最后一步是置空 RCTBatchedBridgemoduleClasses 属性,所以可以通过它是否为空来确定 RCTBridge 完全停止的时机。 batchedBridge 是私有属性所以需要 kvc 来拿到。

加入工程发版之后,这个 crash 就消失了,撒花。

屏幕快照 2017-08-23 下午1.05.44


One more thing

为什么主线程调用了 exit 之后,其他线程调用 mutex::lock 方法时会抛异常?

1
2
3
4
5
6
7
8
9
10
11
12
static NSCache *fontCache;
static std::mutex fontCacheMutex;

NSString *cacheKey = [NSString stringWithFormat:@"%.1f/%.2f", size, weight];
UIFont *font;
{
std::lock_guard<std::mutex> lock(fontCacheMutex);
if (!fontCache) {
fontCache = [NSCache new];
}
font = [fontCache objectForKey:cacheKey];
}

回过来看崩溃位置代码,第 2 行声明了一个局部变量 fontCacheMutex,通过汇编指令可以看出,它在创建的时候通过 __cxa_atexit 方法注册了一个销毁函数:

DX-20170823@2x

主线程调用 exit 方法时,会通过 __cxa_finalize 逐个调用之前注册的销毁函数(参考 atexit.c 源码),这个静态变量 fontCacheMutex 随之销毁,之后再调用这个销毁过的 mutex 对象的方法自然会 crash 了。