swiftuiios18

Resolving Concurrency Sendable Errors


I have an app that reads in currency exchange rates at startup. The data is read into the array moneyRate, and only select currency exchange rates are copied to rateArray, which is used throughout the app.

With the update to Swift 6.0, I am seeing some errors and warnings. I have two errors in GetRatesModel where I am writing the date, base currency, and exchange rates into moneyRates:

  1. Capture of 'self' with non-sendable type 'GetRatesModel' in a 'Sendable Closure'.
  2. Implicit capture of 'self' requires that GetRatesModel conforms to 'Sendable'.

When the @preconcurrency macro is added to the top of the viewModel GetRatesModel I get a warning in the startup file @main Sending 'self.vm' risks causing data races (Sending main actor-isolated 'self.vm' to non-isolated callee risks data races).

I have tried putting the viewModel into an actor (the warnings and errors went away except for a problem displaying the release date), but I noticed on Hacking with Swift that this isn't a recommended solution. I also looked into using an actor as an intermediary between the caller and callee to pass the home currency into checkForUpdates without success.

In @main it looks like the compiler is complaining about checkForUpdates being non-isolated. Would it help if the exchange rate viewModel was called from onAppear (within a task) in ContentView()?

Any ideas for resolving these sendable errors are much appreciated.

Below is the code for MyApp and GetRatesModel

I'm working on an update to an asynchronous URLsession API.

 @main
struct MyApp: App {
    
    @State private var vm = GetRatesModel()
    
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task(id: scenePhase) {
                    
                    switch scenePhase {
                    case .active:
                        
                        // get today's exchange rates
                        await vm.checkForUpdates(baseCur: base.baseCur.baseS) <- error here
                        
                    case .background:
                        
                    case .inactive:
                        
                    @unknown default: break
                    }
                }
        }
        .environment(vm)
    }
}


// ViewModel

@Observable final class GetRatesModel {
    
    @ObservationIgnored var currencyCode: String = ""
    @ObservationIgnored var rate: Double = 0.0
    @ObservationIgnored var storedDate: String?
    var lastRateUpdate: Date = Date()
    
    private let monitor = NWPathMonitor()
    var rateArray: [Double] = Array(repeating: 0.0, count: 220)
    var moneyRates = GetRates(date: "2020-07-04", base: "usd", rates: [:])
    
    init() {
            // read in rateArray if available
    }
    
    /*-----------------------------------------------------
     Get exchange rates from network
     Exchange rates are read into moneyRates. Valid country
     exchange rate are then copied to rateArray
     -----------------------------------------------------*/
    func checkForUpdates(baseCur: String) async -> (Bool) {
        
        // don't keep trying to retrieve data when there is no wifi signal
        if await monitor.isConnected() {
            
            // format today's date
            let date = Date.now
            let todayDate = date.formatted(.iso8601.year().month().day().dateSeparator(.dash))

            // read rate change date
            let storedDate = UserDefaults.standard.string(forKey: StorageKeys.upd.rawValue)

            // do currency rate update if storedDate is nil or today's date != storedDate
            if storedDate?.count == nil || todayDate != storedDate {
                
                let rand = Int.random(in: 1000..<10000)
                let sRand = String(rand)
                let requestType = ".json?rand=" + sRand
                let baseUrl = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/"

                guard let url = URL(string: baseUrl + baseCur + requestType) else {
                    print("Invalid URL")
                    return self.gotNewRates
                }
                let request = URLRequest(url: url)
                URLSession.shared.dataTask(with: request) { [self] data, response, error in

                    if let data = data {
                        do {
                            // result contains date, base, and rates
                            let result = try JSONSerialization.jsonObject(with: data) as! [String:Any]

                            // store downloaded exchange rates: date, base currency, and rates in moneyRate
                            var keys = Array(result.keys)

                            //get exchange rate date
                            if let dateIndex = keys.firstIndex(of: "date"),
                               let sDate = result[keys[dateIndex]] as? String, keys.count == 2 {

                                // was there a change in date?
                                if  storedDate != sDate {

                                    // get exchange rate base
                                    keys.remove(at: dateIndex)
                                    let base = keys.first!
                                    
                                    // is rateArray date different from the new data we just received?
                                    if sDate != storedDate {
                                        moneyRates.date = sDate   <- error here
                                        moneyRates.base = base
                                        moneyRates.rates = result[base] as! [String : Double]

                                        // don't update if new data stream is zero
                                        if moneyRates.rates["eur"] != 0.0 && moneyRates.rates["chf"] != 0.0 && moneyRates.rates["gbp"] != 0.0 { <- error here

                                            // set last update date to today
                                            UserDefaults.standard.setValue(sDate, forKey: StorageKeys.upd.rawValue) // this is storedDate

                                            self.gotNewRates = true
                                            getRates(baseCur: baseCur)
                                        }
                                    } else {
                                        print("Data not stored: \(sDate)")

                                    }
                                }
                            }
                        } catch {
                            print(error.localizedDescription)
                        }
                    }
                }.resume()
            }
        }
    }



Solution

  • These errors are quite clear, if you look into dataTask completion, you could see it's marked with @Sendable:

    open func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask

    However, the GetRatesModel is non-sendable. That's why it's throwing an error. You can make the model conform to Sendable to resolve the error by:

    @Observable final class GetRatesModel: Sendable {
        ...
    }
    

    Now the error within the completion has been addressed. But another error occurs for these stored properties, which looks like this:

    Stored property 'currencyCode' of 'Sendable'-conforming class 'GetRatesModel' is mutable

    Because Sendable indicates the types that are safe to share concurrently, but these properties did not have any synchronization. Thus, you can try one of these approaches:

    1. Make the model fully conform to Sendable. However, this requires value types, an actor, or immutate classes. So, in this case it should be:
    @Observable final class GetRatesModel: Sendable {
    
        @ObservationIgnored private let currencyCode: String
        @ObservationIgnored private let rate: Double
        @ObservationIgnored private let storedDate: String?
    
        nonisolated init(currencyCode: String?, rate: Double?, storedDate: String?) {
            self.currencyCode = currencyCode
            self.rate = rate
            self.storedDate = storedDate
        }
    }
    

    1. Make the mode isolate with global actors. I would use @MainActor, or you can create a new @globalActor one.
    @MainActor @Observable final class GetRatesModel {
        @ObservationIgnored var currencyCode: String?
        ...
    }
    

    And you can keep these stored properties as var as it's. Because the entire GetRatesModel is now isolated with MainActor. Whenver checkForUpdates gets called, it will execute on the main thread.

    func checkForUpdates(baseCur: String) async -> (Bool) {
        //<- Main
        URLSession.shared.dataTask(with: request) { //<- Background
            //<- Main
        }
    }
    

    1. Mark the model with @unchecked and provide an internal locking mechanism, maybe with DispatchQueue
    @Observable final class GetRatesModel: @unchecked Sendable {
        @ObservationIgnored var currencyCode: String?
        ...
        private let serialQueue = DispatchQueue(label: "internalGetRatesModelQueue")
        
        func updateCurrencyCode(_ code: String) {
            serialQueue.sync {
                self.currencyCode = code
            }
        }
    }
    

    Site note: there is a variant of URLSession.shared.dataTask to support async await. I would refactor it to:

    let (data, response) = try await URLSession.shared.data(for: request)