🔴 SmartDJCache concurrent Dictionary crash (April 27+28 crash logs): - EXC_BAD_ACCESS + doesNotRecognizeSelector in bulkImport() - memoryCache dictionary mutated from main thread (loadBulkCache) and background Task.detached (bulkImport) simultaneously - Fix: NSLock serializes all memoryCache reads and writes 🔴 stopAVPlayer removeTimeObserver crash (April 30 crash log): - SIGABRT in -[AVPlayer removeTimeObserver:] from Previous button - timeObserver registered on old player, self.player swapped to crossfade's active player by finalizeCrossfade - Fix: remove observer from OLD player at both swap sites before assignment + reorder stopAVPlayer (observer before replaceCurrentItem) 🟡 Audit fixes (no crash logs, preventative): - KVO in radioSeekBack wrapped in DispatchQueue.main.async - Stale vis Task guarded by songId in all MainActor.run blocks - CHANGELOG.md with full findings documentation
5.8 KiB
CHANGELOG — Race Condition & Crash Audit
Build 13 (1.0.0) — 2026-04-30
🟢 ARCHITECTURE — Remove HealthKit, use standard audio background mode for speaker
Files: watchOS/Audio/WatchAudioPlayer.swift, watchOS/Resources/Info.plist, watchOS/Resources/NavidromeWatch.entitlements
What changed:
- Removed
import HealthKit,HKHealthStore,HKWorkoutSession,HKLiveWorkoutBuilder, and both delegate extensions - Removed
startWorkoutSession()/stopWorkoutSession()and all call sites (play, resume, stop, reconfigure) - Info.plist:
WKBackgroundModeschanged fromworkout-processingtoaudio; removedNSHealthShareUsageDescriptionandNSHealthUpdateUsageDescription - Entitlements: removed
com.apple.developer.healthkitandcom.apple.developer.healthkit.access - Speaker mode now uses
setActive(true)inconfigureAudioSessioninstead of deferring to workout session Why: The HKWorkoutSession workaround was a watchOS 3 technique that Apple closed in watchOS 4. The standardaudiobackground mode (available since watchOS 5/WWDC 2018) keeps the app alive while audio is actively playing through AVAudioSession — no HealthKit needed. The workout session was activating the heart rate sensor (green LEDs), showing the green workout indicator, contributing to Activity Rings, and draining 40%+ extra battery. Speaker routing is controlled entirely by AVAudioSession policy:.default= speaker,.longFormAudio= Bluetooth.
🔴 NEW — SmartDJCache.bulkImport concurrent Dictionary crash
File: iOS/Views/Companion/CompanionAPIService.swift
Function: SmartDJCache — get(), store(), bulkImport(), loadBulkCache(), clearAll(), cachedCount
What changed: Added NSLock to serialize all memoryCache reads and writes.
Why: Two crash logs (April 27 EXC_BAD_ACCESS + April 28 SIGABRT doesNotRecognizeSelector) both crash inside SmartDJCache.bulkImport(). Root cause: memoryCache is a plain [String: SmartDJProfile] dictionary mutated from the main thread (loadBulkCache at app launch) and a background Task.detached (bulkImport from server fetch) simultaneously. Swift Dictionary is not thread-safe — concurrent mutation corrupts the hash table, causing either a segfault on the corrupted bucket chain or a garbage pointer that fails objc_msgSend.
Crash logs: NavidromePlayer-2026-04-27-180336.ips, NavidromePlayer-2026-04-28-211506.ips
🔴 NEW — stopAVPlayer crashes on removeTimeObserver after crossfade player swap
File: Shared/Audio/AudioPlayer.swift
Functions: stopAVPlayer(), play(song:) crossfade path, prepareNextForCrossfade() needsNextTrack callback
What changed:
- Both player swap sites (
player = crossfade.activePlayer) now removetimeObserverfrom the OLD player first stopAVPlayer()reordered: observer removal now happens BEFOREreplaceCurrentItem(with: nil)Why: Crash log showsSIGABRTin-[AVPlayer removeTimeObserver:]called fromstopAVPlayer()→stopAll()→play(song:)→previous(). Root cause: SmartCrossfadeManager'sfinalizeCrossfade()swapsself.playerto a different AVPlayer instance, buttimeObserverwas registered on the OLD player. WhenstopAVPlayer()later triesplayer?.removeTimeObserver(observer), it removes from the wrong AVPlayer → NSException. The fix ensures the observer is always removed from the correct player before the swap. Crash log:NavidromePlayer-2026-04-30-141820.ips
🔴 Finding 2 — KVO in radioSeekBack fires on non-main thread
File: Shared/Audio/AudioPlayer.swift
Function: radioSeekBack(to:)
What changed: Wrapped the entire item.observe(\.status) KVO callback body in DispatchQueue.main.async { }.
Why: NSKeyValueObservation fires on whatever thread changes the observed property. AVPlayerItem.status is changed on AVFoundation's internal media processing thread, NOT the main thread. The callback was accessing self.player?.seek(), self.snapshotStatusObservation?.invalidate(), self.radioGoLive(), and other main-thread-only state from a background thread — a data race that could corrupt AudioPlayer state or crash under the right timing (rapid seek-back → go-live during radio timeshift).
🟡 Finding 1 — Stale analysisTask overwrites visualizer buffer after track skip
File: Shared/Audio/AudioPlayer.swift
Function: loadOfflineVisualizer(songId:url:)
What changed: Added guard self.currentSong?.id == songId else { return } to all three MainActor.run blocks (cache hit, server fetch, local analysis) and to the progress callback.
Why: When the user skips tracks quickly, stopAll() cancels analysisTask and clears offlineVisBuffer. But if the previous Task's async work had already completed, its MainActor.run block was already queued and would fire AFTER stopAll(), overwriting the new song's cleared buffer with the old song's stale vis data. This caused the visualizer to either show the wrong song's waveform or fail to start entirely for the new song. The songId guard ensures stale completions are silently discarded.
Findings NOT fixed (documented, acceptable risk)
| # | Sev | Finding | Rationale |
|---|---|---|---|
| 5 | 🟡 | shazamHandler heap alloc on render thread |
Pre-existing pattern, 15s max duration, no crash risk |
| 6 | 🟡 | debugWriteSamples I/O on render thread |
Diagnostic tool only, 5s capture window, acceptable |
| 3 | 🟢 | processFFT dead code data race |
playLocalWithEngine is never called — unreachable |
| 4 | 🟢 | timeUpdate closure fragile without @MainActor |
All callers use .main queue today — future risk only |
| 7 | 🟢 | sourceFormat data race |
ARM64 naturally-atomic pointer writes — safe in practice |
| 8 | 🟢 | Stale status observer memory spike | Guard self.playerItem === itemToObserve handles it |