iosswiftclosuresswift6sendable

Swift 6 error: non-sendable type 'Timer' in a `@Sendable` closure


In Xcode 16, I get this error, related to Swift 6.

Capture of 'timer' with non-sendable type 'Timer' in a @Sendable closure; this is an error in the Swift 6 language mode

enter image description here

How can I make this code Swift 6 compliant?

func updateBuyAmountWithJsValue(url: String, delaySeconds: Double, maxTries: Int, js: String) {
    var tries = 0
    Timer.scheduledTimer(withTimeInterval: delaySeconds, repeats: true) { timer in
        tries += 1
        if (self.webView.url?.absoluteString != url || tries > maxTries) {
            timer.invalidate()
            return
        }
        self.webView.evaluateJavaScript(js) { (result, error) in
            if let error = error {
                print("Error executing JavaScript: \(error)")
            } else if let value = result as? String {
                if value.contains(".") || (tries==maxTries && value.count < 10) {
                    self.updateBuyProgress("AMOUNT*" + value)
                    timer.invalidate()
                }
            }
        }
    }
}

Solution

  • The immediate problem is that the timer handler closure is not actor-isolated. We can solve that by adding MainActor.assumeIsolated {…} (not, generally, Task { @MainActor in …}).

    There is a more subtle problem that is only evidenced when you change the “Strict concurrency checking” build setting to “Complete”, namely that the Timer parameter of the scheduledTimer closure is “task isolated” and this non-Sendable type cannot be updated from an actor-isolated context.

    You can solve both of these problems by moving the Timer reference to an actor-isolated property in conjunction with assumeIsolated:

    private var timer: Timer?              // main actor isolated property
    
    func updateBuyAmountWithJsValue(url: String, delaySeconds: Double, maxTries: Int, js: String) {
        var tries = 0
            
        timer = Timer.scheduledTimer(withTimeInterval: delaySeconds, repeats: true) { _ in
            MainActor.assumeIsolated {     // let compiler know this is being called on main thread, and therefore can be isolated to main actor
                tries += 1
                if self.webView.url?.absoluteString != url || tries > maxTries {
                    self.timer?.invalidate()
                    return
                }
                self.webView.evaluateJavaScript(js) { result, error in
                    if let error {
                        print("Error executing JavaScript: \(error)")
                    } else if let value = result as? String {
                        if value.contains(".") || (tries==maxTries && value.count < 10) {
                            self.updateBuyProgress("AMOUNT*" + value)
                            self.timer?.invalidate()
                        }
                    }
                }
            }
        }
    }
    

    There are other refinements I might suggest, but this illustrates the basic idea.


    Alternatively, if you are willing to adopt Swift concurrency, it is simpler:

    func updateBuyAmountWithJsValue(url: String, delaySeconds: TimeInterval, maxTries: Int, js: String) async throws {
        for _ in 0 ..< maxTries {
            try await Task.sleep(for: .seconds(delaySeconds))
    
            if webView.url?.absoluteString != url {
                return
            }
    
            if
                let value = try await webView.evaluateJavaScript(js) as? String,
                value.contains(".") || value.count < 10
            {
                updateBuyProgress("AMOUNT*" + value)
                return
            }
        }
    }
    

    Or you could use an AsyncTimerSequence, but the idea would be the same: Namely, retire the closure-based API and thereby eliminate all the issues those introduce in Swift concurrency (especially with Swift 6 and/or “strict concurrency checking”).