A crash that only a real iPhone could show
Span can scan a whole room on iPhones with LiDAR, using Apple’s RoomPlan, and turn it into a floor plan and a 3D model. Every test passed. Then I installed the first build from TestFlight on my iPhone 12 Pro Max, started a room scan, and the app closed a few seconds later.
Reading the crash report
TestFlight keeps crash reports, and I exported one as an .ips file. A few lines in it told most of the story.
- EXC_BREAKPOINT and SIGTRAP. This is not bad memory. It means the app stopped itself on purpose, because a check failed
- dispatch_assert_queue, then _swift_task_checkIsolatedSwift, called from RoomPlan on a background thread. That is Swift 6 checking that code which promised to run on the main thread really is on the main thread
The report only gave a memory address for Span’s own code, because the names are kept in a separate debug symbols file, the dSYM. The crash report’s build ID matched the dSYM in my Xcode archive, and atos turned the address into a name:
atos -arch arm64 -o Span.app.dSYM/Contents/Resources/DWARF/Span -l <load address> <address>It pointed to RoomCaptureSessionDelegate.captureSession(_:didUpdate:) in RealRoomScanner.
What went wrong
RealRoomScanner was marked @MainActor, which promises that everything in it runs on the main thread. It also received RoomPlan’s delegate calls, and to stop the compiler complaining about that, the conformance was marked @preconcurrency.
I had assumed @preconcurrency just silenced a warning. It does more than that. It turns the compiler’s warning into a check at runtime. If a call arrives from another thread, the app stops on purpose. RoomPlan calls its delegate on its own background queue, so the first progress update crashed the app.
The same pattern worked in the measuring code, because ARKit calls its delegate on the main queue by default. RoomPlan does not, and nothing in the code made that difference visible.
The fix
A small separate class now receives RoomPlan’s calls. It is not tied to the main thread, and it hops across only when it needs to update the screen:
func captureSession(_ session: RoomCaptureSession, didUpdate room: CapturedRoom) {
let progress = RoomScanProgress(walls: room.walls.count, doors: room.doors.count, windows: room.windows.count)
Task { @MainActor in onProgress?(progress) }
}Reading the code again found two more problems on the way:
- The scan screen was created before the app checked for LiDAR, so a phone without LiDAR would have built it anyway. It is now created lazily, only when needed
- Rescan started the scan twice, once from the button and once when the screen appeared
A failed scan also used to do nothing visible after tapping Done. It now says so and starts again.
Why no test caught it
The simulator uses a fake scanner, so no test ever ran RoomPlan’s real code or its background thread. The tests were right about everything they could see.
With the fix in build 2, the scans finish. I have scanned three rooms with it, and each one produced a floor plan with wall lengths and a 3D model exported as a USDZ file.
The lesson I wrote down: @preconcurrency is only safe if you know which thread will call you. Check the framework’s documentation for that, and test anything that touches hardware on real hardware.