Skip to content

Data race crash in VoltraLiveActivityService.monitorActivity — Set mutated from multiple cooperative threads #88

@alex-vance

Description

@alex-vance

Summary

VoltraLiveActivityService is a plain class with unprotected mutable state (monitoredActivityIds: Set<String> and monitoringTasks: [Task<Void, Never>]). These properties are mutated concurrently from multiple Swift cooperative threads, causing a EXC_BAD_ACCESS (SIGSEGV) crash.

Environment

  • voltra version: 1.2.1
  • iOS version: 18.x (simulator, macOS 26.2)
  • Expo SDK: 54
  • React Native: 0.81.4

Crash Details

The crash occurs at swift_isUniquelyReferenced_nonNull_native during Set._Variant.insert(_:) inside monitorActivity(_:enablePush:). This is a classic Swift copy-on-write data race — the Set's backing buffer is referenced from multiple threads simultaneously.

Crash thread (Thread 8):

0  libswiftCore.dylib  swift_isUniquelyReferenced_nonNull_native + 0
1  libswiftCore.dylib  Set._Variant.insert(_:) + 660
2  PUMPDStaging.debug.dylib  VoltraLiveActivityService.monitorActivity(_:enablePush:) + 468
3  PUMPDStaging.debug.dylib  closure #1 in VoltraLiveActivityService.startActivityUpdatesObservation(enablePush:) + 96

At the time of the crash, three separate cooperative threads (Threads 8, 10, and 15) were all inside VoltraLiveActivityService.monitorActivity(_:enablePush:) concurrently.

Root Cause

In ios/app/VoltraLiveActivityService.swift:

Line 80VoltraLiveActivityService is a plain class, not an actor:

public class VoltraLiveActivityService {

Lines 261-262 — unprotected mutable state:

private var monitoredActivityIds: Set<String> = []
private var monitoringTasks: [Task<Void, Never>] = []

Lines 317-322 — concurrent mutation without synchronization:

private func monitorActivity(_ activity: Activity<VoltraAttributes>, enablePush: Bool) {
    let activityId = activity.id
    guard !monitoredActivityIds.contains(activityId) else { return }  // READ
    monitoredActivityIds.insert(activityId)                           // WRITE — crash
    ...
}

This method is called from:

  1. A synchronous loop over existing activities (line 303)
  2. A Task iterating Activity<VoltraAttributes>.activityUpdates (line 308) — fires on a cooperative thread when a new activity is created
  3. The activityUpdates stream can deliver multiple updates concurrently on different cooperative threads

Additionally, monitoringTasks.append() at lines 298, 313, 335, and 350 has the same unprotected concurrent mutation problem on the Array.

Reproduction Path

  1. App launches → OnStartObserving calls startMonitoring() → spawns a Task listening to activityUpdates
  2. User triggers a live activity → JS calls startLiveActivity → native createActivity() calls Activity.request() on a cooperative thread
  3. Activity.request() triggers the activityUpdates async sequence on another cooperative thread
  4. Multiple cooperative threads enter monitorActivity simultaneously → crash on Set.insert

Suggested Fix

Make VoltraLiveActivityService an actor instead of a class:

public actor VoltraLiveActivityService {
    private var monitoredActivityIds: Set<String> = []
    private var monitoringTasks: [Task<Void, Never>] = []
    // ...
}

This would provide automatic isolation for all mutable state. Alternatively, protect the mutable properties with a lock or serial DispatchQueue if actor conversion is not feasible.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions