mapbox / mapbox/mapbox-maps-flutter
iOS: Thread-unsafe Dictionary access in TileStoreController causes SIGSEGV crash
Nobody has claimed this yet.
- Dominant language
- Dart
- Stars
- 380
- Forks
- 204
- PR merge metrics
- No merged PRs in 30d
Description
## Environment
- **mapbox_maps_flutter**: 2.10.0 (also confirmed unpatched on `main` as of 2.21.0-SNAPSHOT)
- **Device**: iPhone 17,2 (iPhone 16 Pro Max)
- **iOS**: 26.4 (23E246)
- **Flutter**: 3.35.7
- **Distribution**: TestFlight
## Bug Description
`TileStoreController.loadTileRegion` crashes with `EXC_BAD_ACCESS (SIGSEGV)` due to unsynchronized concurrent access to the `tileRegionLoadProgressHandlers` Swift Dictionary from multiple MapboxCommon background threads.
We have **two independent crash reports** from the same device/build, both crashing at `TileStoreController.swift:33`, the `removeValue(forKey:)` call inside the completion closure.
## Root Cause
In [`TileStoreController.swift`](https://github.com/mapbox/mapbox-maps-flutter/blob/main/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/Offline/TileStoreController.swift#L28-L34):
```swift
tileStore.loadTileRegion(forId: id, loadOptions: loadOptions) { [weak self] progress in
guard let self else { return }
// READ from Dictionary -- on MapboxCommon background thread
self.tileRegionLoadProgressHandlers[id]?.eventSink?(progress.toFLTTileRegionLoadProgress().toList())
} completion: { [weak self] result in
executeOnMainThread(completion)(result.map { $0.toFLTTileRegion() })
// WRITE to Dictionary -- also on MapboxCommon background thread (NOT main thread)
self?.tileRegionLoadProgressHandlers.removeValue(forKey: id) // <- line 33: crash site
}
```
Two problems:
1. **`removeValue(forKey:)` (line 33) executes on the MapboxCommon callback thread**, not on the main thread. `executeOnMainThread` only wraps `completion`, the `removeValue` call that follows runs on the original background thread.
2. **Progress callbacks (line 30) also read from the same Dictionary on background threads.** When multiple tile regions are downloading, a progress read on one thread and a completion write on another thread race on the same Dictionary.
Swift's `Dictionary` is a value type with copy-on-write semantics and is **not thread-safe** for concurrent read+write access. This results in `EXC_BAD_ACCESS`.
The same pattern exists for `tileRegionEstimateProgressHandlers` in `estimateTileRegion` (lines 54-59).
## Crash Signatures
**Crash 1** -- concurrent read during mutation:
```
Thread 29 Crashed:
0 Runner specialized __RawDictionaryStorage.find(_:)
1 Runner specialized Dictionary._Variant.removeValue(forKey:)
2 Runner specialized Dictionary.removeValue(forKey:) (TileStoreController.swift:33)
3 Runner closure #2 in TileStoreController.loadTileRegion(id:loadOptions:completion:)
4-6 Runner
7+ MapboxCommon (background worker thread)
```
**Crash 2** -- CoW uniqueness check on freed backing storage:
```
Thread 23 Crashed:
0 libswiftCore.dylib swift_isUniquelyReferenced_nonNull_native
1 Runner specialized Dictionary._Variant.removeValue(forKey:)
2 Runner specialized Dictionary.removeValue(forKey:) (TileStoreController.swift:33)
3 Runner closure #2 in TileStoreController.loadTileRegion(id:loadOptions:completion:)
4-6 Runner
7+ MapboxCommon (background worker thread)
```
Both are classic symptoms of unsynchronized concurrent Dictionary access in Swift.
## Suggested Fix
Dispatch all Dictionary mutations and reads to the main thread (consistent with how `completion` is already dispatched):
```swift
tileStore.loadTileRegion(forId: id, loadOptions: loadOptions) { [weak self] progress in
DispatchQueue.main.async {
guard let self else { return }
self.tileRegionLoadProgressHandlers[id]?.eventSink?(progress.toFLTTileRegionLoadProgress().toList())
}
} completion: { [weak self] result in
DispatchQueue.main.async {
completion(result.map { $0.toFLTTileRegion() })
self?.tileRegionLoadProgressHandlers.removeValue(forKey: id)
}
}
```
Alternatively, protect the Dictionary with a lock or use a serial DispatchQueue.
The same fix should be applied to `estimateTileRegion` and its `tileRegionEstimateProgressHandlers`.
## Reproduction
This crash occurs when downloading multiple tile regions in sequence or with overlapping lifetimes. In our case, we serialize downloads from the Dart side, but the native MapboxCommon layer can still have overlapping callbacks at the boundary between two operations (completion of region N racing with early progress of region N+1, or internal parallelism within MapboxCommon).
## Crash Logs
Full crash logs available on request (two `.crash` files from the same device, ~30 minutes apart).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/Offline/TileStoreController.swift, focusing on loadTileRegion and estimateTileRegion and their progress-handler dictionaries. Trace the progress and completion callbacks, then ensure all dictionary access is serialized consistently. Done means both handlers are protected from concurrent reads and writes during overlapping tile-region downloads.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- flutter, swift
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100