Skip to content

Thread Safety Issue: EXC_BAD_ACCESS in FeatureEvaluator.evaluateFeature() #144

@tungfam

Description

@tungfam

Thread Safety Bug: Random Crashes in FeatureEvaluator

Summary

Getting EXC_BAD_ACCESS crashes in production when using GrowthBook SDK v1.0.84. The crash happens in FeatureEvaluator.swift line 37 when features are evaluated from multiple threads.

Crash: EXC_BAD_ACCESS (code=1, address=0x8000000000000008)

The Problem

The SDK's FeatureEvaluator mutates a shared Set<String> without thread synchronization:

// FeatureEvaluator.swift:33-37
context.stackContext.evaluatedFeatures.insert(featureKey)

defer {
    context.stackContext.evaluatedFeatures.remove(featureKey) // ⚠️ CRASH: accessing deallocated Set
}

Swift's Set is not thread-safe. When multiple threads access features simultaneously (especially with backgroundSync: true), the Set can be deallocated while another thread is still using it.

When It Happens

This crashes when:

  • SwiftUI views read features on main thread
  • backgroundSync: true refreshes features on background thread
  • setAttributes() or refreshCache() called while evaluating features
  • Multiple concurrent getFeatureValue() calls

Reproduction

let sdk = GrowthBookBuilder(
    apiHost: "...",
    clientKey: "...",
    backgroundSync: true  // Background thread updates
).initializer()

// Main thread: SwiftUI view reads feature
let enabled = sdk.getFeatureValue(feature: "my-feature", default: .null)

// Background thread: Combine publisher updates attributes
userPublisher.sink { user in
    sdk.setAttributes(attributes: [...])  // Race condition!
    sdk.refreshCache()
}

Suggested Fix

Add thread-safety to the evaluatedFeatures Set. Two options:

Option 1: Lock in FeatureEvaluator

class FeatureEvaluator {
    private let lock = NSLock()
    
    func evaluateFeature() -> FeatureResult {
        lock.lock()
        defer { lock.unlock() }
        
        context.stackContext.evaluatedFeatures.insert(featureKey)
        defer {
            lock.lock()
            context.stackContext.evaluatedFeatures.remove(featureKey)
            lock.unlock()
        }
        // ... rest
    }
}

Option 2: Thread-safe StackContext

@objc public class StackContext : NSObject {
    private let queue = DispatchQueue(label: "com.growthbook.stack")
    private var _evaluatedFeatures: Set<String> = []
    
    public var evaluatedFeatures: Set<String> {
        get { queue.sync { _evaluatedFeatures } }
        set { queue.sync { _evaluatedFeatures = newValue } }
    }
}

Workaround (for now)

Users can wrap SDK calls with synchronization:

class GrowthBookService {
    private let sdk: GrowthBookSDK
    private let queue = DispatchQueue(label: "com.app.growthbook", attributes: .concurrent)
    
    func getFeature(key: String) -> JSON {
        queue.sync {
            sdk.getFeatureValue(feature: key, default: .null)
        }
    }
    
    func setAttributes(_ attrs: [String: Any]) {
        queue.async(flags: .barrier) {
            sdk.setAttributes(attributes: attrs)
        }
    }
}

Thank you!

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