From 1aef2a6c5564f0a07fb84b8f8ed85a6041d77454 Mon Sep 17 00:00:00 2001 From: tarikdotcom <61876765+tarikfp@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:19:50 +0100 Subject: [PATCH 1/2] fix: prevent crash when monitoring Live Activities Add safety checks to prevent crashes in monitorActivity: 1. Skip activities that are already in .ended state before accessing their properties (prevents accessing deallocated objects) 2. Use serial dispatch queue for thread-safe access to monitoredActivityIds Set (prevents concurrent modification crashes when multiple async tasks call monitorActivity simultaneously) The crashes occurred when: - Accessing activity.id on activities that had already ended - Multiple async tasks from activityUpdates stream tried to modify the monitoredActivityIds Set concurrently --- ios/app/VoltraModule.swift | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/ios/app/VoltraModule.swift b/ios/app/VoltraModule.swift index 219b90d..8aecefd 100644 --- a/ios/app/VoltraModule.swift +++ b/ios/app/VoltraModule.swift @@ -10,6 +10,8 @@ public class VoltraModule: Module { private let TIMELINE_WARNING_SIZE = 100_000 // 100KB per timeline private let liveActivityService = VoltraLiveActivityService() private var wasLaunchedInBackground: Bool = false + /// Serial queue to synchronize access to monitoredActivityIds across async tasks + private let monitoredActivityIdsQueue = DispatchQueue(label: "com.voltra.monitoredActivityIds") private var monitoredActivityIds: Set = [] enum VoltraErrors: Error { @@ -55,7 +57,9 @@ public class VoltraModule: Module { OnStopObserving { VoltraEventBus.shared.unsubscribe() - monitoredActivityIds.removeAll() + monitoredActivityIdsQueue.sync { + monitoredActivityIds.removeAll() + } } OnCreate { @@ -608,11 +612,19 @@ private extension VoltraModule { /// Set up observers for an activity's lifecycle (only once per activity) private func monitorActivity(_ activity: Activity) { + // Safety check: skip if activity is already ended to avoid accessing stale references + guard activity.activityState != .ended else { return } + let activityId = activity.id - // Skip if we're already monitoring this activity - guard !monitoredActivityIds.contains(activityId) else { return } - monitoredActivityIds.insert(activityId) + // Thread-safe check-and-insert to prevent race conditions when multiple + // async tasks call monitorActivity simultaneously + let shouldMonitor = monitoredActivityIdsQueue.sync { () -> Bool in + guard !monitoredActivityIds.contains(activityId) else { return false } + monitoredActivityIds.insert(activityId) + return true + } + guard shouldMonitor else { return } // Observe lifecycle state changes (active → dismissed → ended) Task { From 5f0f0f316b807d5c94a88c20a9df2445ad28b274 Mon Sep 17 00:00:00 2001 From: Tarik Date: Mon, 26 Jan 2026 17:29:19 +0100 Subject: [PATCH 2/2] fix: use OSAllocatedUnfairLock instead of DispatchQueue DispatchQueue.sync doesn't work correctly with Swift Concurrency. OSAllocatedUnfairLock provides proper synchronous locking that works with the cooperative thread pool. --- ios/app/VoltraModule.swift | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/ios/app/VoltraModule.swift b/ios/app/VoltraModule.swift index 8aecefd..0625e7c 100644 --- a/ios/app/VoltraModule.swift +++ b/ios/app/VoltraModule.swift @@ -2,6 +2,7 @@ import ActivityKit import Compression import ExpoModulesCore import Foundation +import os import WidgetKit public class VoltraModule: Module { @@ -10,9 +11,7 @@ public class VoltraModule: Module { private let TIMELINE_WARNING_SIZE = 100_000 // 100KB per timeline private let liveActivityService = VoltraLiveActivityService() private var wasLaunchedInBackground: Bool = false - /// Serial queue to synchronize access to monitoredActivityIds across async tasks - private let monitoredActivityIdsQueue = DispatchQueue(label: "com.voltra.monitoredActivityIds") - private var monitoredActivityIds: Set = [] + private let monitoredActivityIds = OSAllocatedUnfairLock>(initialState: []) enum VoltraErrors: Error { case unsupportedOS @@ -57,9 +56,7 @@ public class VoltraModule: Module { OnStopObserving { VoltraEventBus.shared.unsubscribe() - monitoredActivityIdsQueue.sync { - monitoredActivityIds.removeAll() - } + monitoredActivityIds.withLock { $0.removeAll() } } OnCreate { @@ -612,40 +609,38 @@ private extension VoltraModule { /// Set up observers for an activity's lifecycle (only once per activity) private func monitorActivity(_ activity: Activity) { - // Safety check: skip if activity is already ended to avoid accessing stale references guard activity.activityState != .ended else { return } let activityId = activity.id + let activityName = activity.attributes.name + let pushEnabled = pushNotificationsEnabled - // Thread-safe check-and-insert to prevent race conditions when multiple - // async tasks call monitorActivity simultaneously - let shouldMonitor = monitoredActivityIdsQueue.sync { () -> Bool in - guard !monitoredActivityIds.contains(activityId) else { return false } - monitoredActivityIds.insert(activityId) + // Thread-safe check-and-insert + let shouldMonitor = monitoredActivityIds.withLock { ids -> Bool in + guard !ids.contains(activityId) else { return false } + ids.insert(activityId) return true } guard shouldMonitor else { return } - // Observe lifecycle state changes (active → dismissed → ended) Task { for await state in activity.activityStateUpdates { VoltraEventBus.shared.send( .stateChange( - activityName: activity.attributes.name, + activityName: activityName, state: String(describing: state) ) ) } } - // Observe push token updates if enabled - if pushNotificationsEnabled { + if pushEnabled { Task { for await pushTokenData in activity.pushTokenUpdates { let pushTokenString = pushTokenData.hexString VoltraEventBus.shared.send( .tokenReceived( - activityName: activity.attributes.name, + activityName: activityName, pushToken: pushTokenString ) )