Tracking Down a Crash in iOS

March 27, 2014

One of the iOS black arts is hunting down the cause of a crash report. At the best of times they’re vague and indicative of the final point of failure; rarely do they ever identify the actual root cause in any enlightening way.

So let’s take a look at a stack trace from one of our crash logs:

Incident Identifier: 6FBDC34D-419A-4935-A779-79695E01ACA4
CrashReporter Key: 207bf1d2971018f44f385b92648252feaf7a5f60
Hardware Model: iPhone6,1
Process: Wealthfront [165]
Version: 1.0 (1.0)
Code Type: ARM (Native)
Parent Process: launchd [1]
Date/Time: 2014-02-24 08:14:56.266 -0800
OS Version: iOS 7.0.4 (11B554a)
Report Version: 104
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x000000009176107d
Triggered by Thread: 0
Thread 0 Crashed:
0 libobjc.A.dylib 0x389b3b67 objc_msgSend + 7
1 UIKit 0x30ed7335 -[UIScrollView(UIScrollViewInternal) _delegateScrollViewAnimationEnded] + 49
2 UIKit 0x30ed72c1 -[UIScrollView(UIScrollViewInternal) _scrollViewAnimationEnded:finished:] + 173
3 UIKit 0x30f7fd67 -[UIAnimator stopAnimation:] + 463
4 UIKit 0x30f7f749 -[UIAnimator(Static) _advanceAnimationsOfType:withTimestamp:] + 281
5 UIKit 0x30f7f629 -[UIAnimator(Static) _LCDHeartbeatCallback:] + 49
6 QuartzCore 0x30ab2acf CA::Display::DisplayLinkItem::dispatch() + 95
7 QuartzCore 0x30ab2879 CA::Display::DisplayLink::dispatch_items(unsigned long long, unsigned long long, unsigned long long) + 341
8 IOMobileFramebuffer 0x336dc76b IOMobileFramebufferVsyncNotifyFunc + 103
9 IOKit 0x2f339be3 IODispatchCalloutFromCFMessage + 247
10 CoreFoundation 0x2e617b7f __CFMachPortPerform + 135
11 CoreFoundation 0x2e622775 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ + 33
12 CoreFoundation 0x2e62270f __CFRunLoopDoSource1 + 343
13 CoreFoundation 0x2e620edb __CFRunLoopRun + 1403
14 CoreFoundation 0x2e58b46d CFRunLoopRunSpecific + 521
15 CoreFoundation 0x2e58b24f CFRunLoopRunInMode + 103
16 GraphicsServices 0x332bf2e7 GSEventRunModal + 135
17 UIKit 0x30e40841 UIApplicationMain + 1133
18 Wealthfront 0x0003719f 0x28000 + 61855
19 libdyld.dylib 0x38eb1ab5 start + 1
view raw crash.txt hosted with ❤ by GitHub

There isn’t much to go on, but two things stand out:

  1. SIGSEGV generally means something was over released, or since this is ARC, we left a dangling weak reference around.
  2. The bomb went off while a scroll view was animating; much sadness ensued.

From the method signature of the symbolicated log we can see that the scroll view was trying to message a delegate about an animation that was finishing. A quick search of our codebase revealed that none of our classes implement this method, so we’ve likely mishandled an object or two during the run of the app that lead to this crash.

Another clue is that all of the methods for UIScrollViewDelegate are optional, so there is likely a check to see if the delegate actually implements that selector like this:

if ([_delegate respondToSelector:@selector(scrollViewDidEndScrollingAnimation:)]) {
[_delegate scrollViewDidEndScrollingAnimation:self];
}

But how can we be sure? Enter Hopper.

Hopper is a Mac App designed to poke around compiled binaries, in this case UIKit. Here’s what Hopper reveals about _delegateScrollViewAnimationEnded


As you can see there is indeed a call to respondsToSelector; Hopper can even approximate a pseudo code implementation for us:

So now we can see how a dangling weak reference could cause the crash. The delegate was deallocated without setting the scroll view’s delegate property to nil. Now we just need to find out which scroll view in our app could have caused this.

After a bit of sleuthing (we don’t actually have any scroll views, but lots of table views) I settled on the culprit likely being a tableview that is used in one of our views. To confirm this I wrote a simple test:

- (void)testView {
WFTableViewController *vc = [[WFTableViewController alloc] init];
[vc view];
UITableView *tv = vc.tableView;
vc = nil;
STAssertNil(tv.dataSource, @"Should be nil");
STAssertNil(tv.delegate, @"Should be nil");
}
view raw test.m hosted with ❤ by GitHub

Sure enough that test failed, the table view controller was indeed leaving a dangling reference as both the dataSource and delegate of the table view. By simply setting both to nil in dealloc the test is satisfied.

- (void)dealloc {
_tableView.dataSource = nil;
_tableView.delegate = nil;
}
view raw fix.m hosted with ❤ by GitHub

Make Double Sure

Crashes like this are sometimes easy to reproduce live on a device or in the iOS simulator. However, I could not reproduce this on my own and thus settled for manipulating our unit tests to see if I could craft a test that crashed with the same stack trace:

@interface UIScrollView (PrivateTestMethods)
- (void)_scrollViewAnimationEnded:(UIScrollView *)scrollView finished:(BOOL)finished;
@end
- (void)testView {
WFTableViewController *vc = [[WFTableViewController alloc] init];
[vc view];
UITableView *tv = vc.tableView;
vc = nil;
[tv _scrollViewAnimationEnded:tv finished:YES];
STAssertNil(tv.dataSource, @"Should be nil");
STAssertNil(tv.delegate, @"Should be nil");
}
view raw test2.m hosted with ❤ by GitHub

By invoking the finish selector myself I found I could duplicate the call stack that of the original crash.

(lldb) bt
* thread #1: tid = 0x17e7cf, 0x020150b2 libobjc.A.dylib`objc_msgSend + 14, queue = 'com.apple.main-thread, stop reason = EXC_BAD_ACCESS (code=1, address=0x8000000c)
frame #0: 0x020150b2 libobjc.A.dylib`objc_msgSend + 14
frame #1: 0x008956a5 UIKit`-[UIScrollView(UIScrollViewInternal) _delegateScrollViewAnimationEnded] + 62
frame #2: 0x008957bc UIKit`-[UIScrollView(UIScrollViewInternal) _scrollViewAnimationEnded:finished:] + 189
frame #3: 0x09ad6083 WealthfrontTests`-[WFTableViewControllerTest testView](self=0x08ef2db0, _cmd=0x09d106d0) + 17891 at WFTableViewControllerTest.m:125

I don’t believe that is the right way to test this particular failure because _scrollViewAnimationEnded:finished: is private and its implementation could change in a future release. Calling it directly from the test introduces unnecessary variability in our test suite and makes the tests very brittle.

Instead our test should validate that the root cause has been addressed. Specifically it should validate that the view controller sets the delegate and dataSource properties to nil when it is deallocated. The only test we need to add for this particular crash is one we’ve already seen:

- (void)testView {
WFTableViewController *vc = [[WFTableViewController alloc] init];
[vc view];
UITableView *tv = vc.tableView;
vc = nil;
STAssertNil(tv.dataSource, @"Should be nil");
STAssertNil(tv.delegate, @"Should be nil");
}
view raw test.m hosted with ❤ by GitHub

This small test validates that the root cause of this crash, as well as any others that may have been caused by the same issue, are now fixed.