TabNews is an iOS app I built for reading TabNews, a Brazilian tech community. If you've never heard of it, I've written about it before: how the newsletter notifications work and the mini-games I hid inside it.
A recent TabNews update was a small one. Some cards on the home feed could stay on the loading skeleton forever when the connection was bad or the app came back from the background, and the update changed how the post previews are loaded to fix that.
After it went out, the App Store build started closing a few seconds after launch. This post is about how I tracked it down, because most of the time was spent on the wrong lead.
The Going deeper blocks are closed by default. Open them if you want the commands.
The crash report
The crash reports were all very similar. The app opened, the feed loaded, the previews started to fill in, and about eight seconds later it aborted. The crashing thread only had Swift runtime frames:
__cxa_pure_virtual
swift::AsyncTask::completeFuture(swift::AsyncContext*)
completeTaskWithClosure(swift::AsyncContext*, swift::SwiftError*)
There was no frame from the app on that thread, so the report alone didn't say much beyond "an async task finished and something went wrong."
Going deeper: getting the crash reports
The crashes from App Store users show up in Xcode under Window → Organizer → Crashes, but only for people who share analytics, and they can take a while to appear.
When the crash happens on your own iPhone, you don't need to wait. The reports are on the phone, in Settings → Privacy & Security → Analytics & Improvements → Analytics Data. Each one is an .ips file named after the app and the date, and you can share it with AirDrop straight to your Mac.
An .ips file is JSON. The first line is a small header with the app version and the binary UUID, and the rest has the exception, every thread and the list of loaded images.
Trying to reproduce it
I checked out the commit that went to the App Store, built it in Release and installed it on the same iPhone. It didn't crash. It didn't crash in the simulator either.
The iPhone was on iOS 27, so my first guess was that something in the new OS didn't work with the new code. While I looked into it, I reverted the change so I had a safe version ready to ship.
Later I ran the same code in a simulator with a newer Xcode, and there it crashed in about fifteen seconds, on iOS 26.5. So it wasn't iOS 27. What changed between the builds that worked and the ones that crashed was the Xcode version:
| Build | Built with | Result |
|---|---|---|
| Installed on the iPhone from Xcode | Xcode 26.2 | no crash |
| Simulator | Xcode 26.2 | no crash |
| Simulator | Xcode 26.5 | crash |
| App Store build | Xcode 26.5 | crash |
The App Store build had been archived with Xcode 26.5 and Swift 6.3.2. All my attempts to reproduce it used Xcode 26.2. Same commit, but not the same binary. iOS 27 only appeared in the reports because that was the OS people were running the App Store build on.
Going deeper: checking which Xcode built a binary
Every app Xcode builds records the Xcode build number in its Info.plist. For an archive:
/usr/libexec/PlistBuddy -c "Print :DTXcodeBuild" \
"MyApp.xcarchive/Products/Applications/MyApp.app/Info.plist"That prints something like 17F42. Compare it with the output of xcodebuild -version on the machine you're testing on. If the build numbers are different, you're not testing the binary you shipped.
What the crash logs showed
With a way to reproduce it, I went back to the crash logs, symbolicated them with the dSYM from the archive and looked at all the threads, not only the one that crashed.
In two of the reports the crash happened one frame deeper, in swift::TaskGroup::offer. That's where a child task hands its result to its task group. The app had a single task group in the whole binary, in the function that downloads the post bodies for the feed cards:
await withTaskGroup(of: (String, PostRequest?).self) { group in
for post in pending {
group.addTask {
(post.stableKey, try? await service.getPost(user: user, slug: slug))
}
}
for await (key, full) in group {
// update the card as soon as its body arrives
}
}The invalid addresses in those two reports were also readable as text. One of them was "o exempl", which looks like part of a post in Portuguese. So when a child task tried to deliver its result, the memory where the group should be was already being used for something else, most likely post content.
Going deeper: symbolicating with the right dSYM
Symbolication only works if the dSYM comes from the exact build that crashed. Check the UUID first:
dwarfdump --uuid MyApp.xcarchive/dSYMs/MyApp.app.dSYMIt has to match the UUID in the .ips header. If it does, Xcode usually symbolicates the report on its own when you open it. To do a single frame by hand, use atos with the load address of the app image (it's in the usedImages list of the report):
atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
-l 0x100bb0000 0x100c50f68Going deeper: reading a bad address as text
When a pointer is just garbage, it's usually random bytes. When it decodes to text, it's a good sign the memory was freed and reused for a string. iPhones are little-endian, so read the bytes from the end:
python3 -c "print((0x6c706d657865206f).to_bytes(8, 'little'))"
# b'o exempl'The fix
The previous version of this code ran the task group outside the main actor and never crashed, so my first attempt was to do the same. It crashed the same way in the simulator.
The second attempt removed the task group. Each post gets its own Task, which updates its card when the body arrives, and the function waits for all of them at the end:
let tasks = requests.map { request in
Task {
let full = try? await service.getPost(user: request.user, slug: request.slug)
await onResult(request.key, full)
}
}
for task in tasks {
await task.value
}For the user it behaves the same: each card fills in as soon as its text arrives. The simulator build that used to crash in fifteen seconds ran for a few minutes without problems, and this is what went into the next update.
I still don't know exactly why the task group version breaks with Xcode 26.5. It could be a bug in Swift 6.3, or something in my code that the previous compiler happened to tolerate. I want to build a small project that reproduces it before calling it a compiler bug.
Going deeper: catching the crash in the simulator with lldb
The simulator didn't write an .ips file for this crash, so I attached the debugger before the app started. Build in Release, install it in a booted simulator, then:
xcrun simctl launch --wait-for-debugger booted com.example.myapp
# prints the process id, for example: com.example.myapp: 31442
xcrun lldb -p 31442
(lldb) continue
# wait for the crash, then:
(lldb) thread backtrace all--wait-for-debugger keeps the app paused at launch, so lldb is already attached when the crash happens, even if it's in the first few seconds.
What I changed in my process
- I archive and test with the same Xcode. In this case the build I tested and the build I shipped came from different compilers.
- After updating Xcode, I treat the next build as something that needs testing even if the code didn't change.
- Before sending a version for review, I install the Release build through TestFlight and leave it open for a few minutes. This crash showed up in fifteen seconds.
- When a crash report doesn't have app frames on the crashing thread, I look at the other reports and threads. The useful clue here was in only two of the twelve reports.