Alisa Martirosyan
Alisa Martirosyan

Reputation: 98

Avoiding @Sendable for Callbacks in Timer-Based Swift 6 API Manager

I'm upgrading my API manager to Swift 6, but I’m running into issues with @Sendable requirements for callbacks due to Timer usage.

In my API manager, I have methods that take completion callbacks (like onSuccess, onFailure, etc.) which are then executed within a Timer. These callbacks are triggered based on network connectivity checks, retries, and timeouts within the Timer. However, Swift 6 requires the Timer’s closure to be @Sendable, and since my callbacks capture various non-sendable types, adding @Sendable isn’t an option for me.

Is there a way to structure this so that I can still use the Timer and callbacks without needing to mark them as @Sendable? Alternatively, are there any patterns that allow keeping callbacks without modifying the whole API manager to avoid @Sendable?

Here's a simplified version of the problematic code:

func performRequest<T: Decodable>(
    request: T,
    onSuccess: @escaping (T) -> Void,
    onFailure: @escaping (Error) -> Void,
    onOffline: (() -> Void)? = nil
) {
    // Network check and timer setup
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
        // Callbacks are invoked here
        if someCondition {
            onSuccess(someResult)
        } else {
            onFailure(someError)
        }
    }
}

What’s the best approach here to avoid @Sendable requirements without rearchitecting the entire API manager?

Any help or best practices for handling this in Swift 6 would be greatly appreciated!

Upvotes: 2

Views: 302

Answers (2)

Rob
Rob

Reputation: 438467

You can retire the Timer.scheduledTimer and instead just loop with Task.sleep:

@MainActor
class ApiManager {
    private var task: Task<Void, any Error>?

    func performRequest<T: Decodable>(
        request: T,
        onSuccess: @escaping (T) -> Void,
        onFailure: @escaping (any Error) -> Void,
        onOffline: (() -> Void)? = nil
    ) {
        // cancel previous, if any
        task?.cancel()

        task = Task {
            …

            while !Task.isCancelled {
                try await Task.sleep(for: .seconds(1))

                if someCondition {
                    onSuccess(someResult)
                } else {
                    onFailure(someError)
                }
            }
        }
    }
}

That requires that performRequest is actor-isolated. I just isolated the whole type, but if you want to isolate performRequest (and task) individually, you can do that, too.


Alternatively, you can use AsyncTimerSequence from Apple’s swift-async-algorithms package:

@MainActor
class ApiManager {
    private var task: Task<Void, any Error>?

    func performRequest<T: Decodable>(
        request: T,
        onSuccess: @escaping (T) -> Void,
        onFailure: @escaping (any Error) -> Void,
        onOffline: (() -> Void)? = nil
    ) {
        // cancel previous, if any
        task?.cancel()

        task = Task {
            …

            for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
                if someCondition {
                    onSuccess(someResult)
                } else {
                    onFailure(someError)
                }
            }
        }
    }
}

Personally, I would generally stay within structured concurrency:

@MainActor
class ApiManager {
    func performRequest<T: Decodable>(
        request: T,
        onSuccess: @escaping (T) -> Void,
        onFailure: @escaping (any Error) -> Void,
        onOffline: (() -> Void)? = nil
    ) async {
        …

        for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
            if someCondition {
                onSuccess(someResult)
            } else {
                onFailure(someError)
            }
        }
    }
}

But that would likely require a more general refactoring of the call points, which I gather you are trying to minimize.


I would not advise it, but you could also use a GCD timer:

private var timer: (any DispatchSourceTimer)?

func performRequest<T: Decodable>(
    request: T,
    onSuccess: @escaping (T) -> Void,
    onFailure: @escaping (any Error) -> Void,
    onOffline: (() -> Void)? = nil
) {
    …

    let timer = DispatchSource.makeTimerSource(queue: .main)
    self.timer = timer

    timer.schedule(deadline: .now() + 1, repeating: 1)

    timer.setEventHandler {
        if someCondition {
            onSuccess(someResult)
        } else {
            onFailure(someError)
        }
    }

    timer.activate()
}

And to cancel the timer, set timer to nil or call timer?.cancel().

In terms of embracing Swift 6, this is a step backwards. But, it does avoid the compiler warning/error (currently, at least; I suspect when they audit this for Swift concurrency, this will likely produce errors similar to what you are seeing with Timer.scheduledTimer).

Upvotes: 4

duckSern1108
duckSern1108

Reputation: 1125

Here is my workaround for your solution:

func performRequest<T: Decodable>(
    request: T,
    onSuccess: @escaping (T) -> Void,
    onFailure: @escaping (Error) -> Void,
    onOffline: (() -> Void)? = nil
) {
    // Network check and timer setup
    var comple = Completion<T>()
    comple.onFailure = onFailure
    comple.onSuccess = onSuccess
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [comple] timer in
        // Callbacks are invoked here
        if true {
            comple.onSuccess?()
        } else {
            comple.onFailure?(NSError(domain: "", code: 0))
        }
    }
}

struct Completion<T: Decodable>: @unchecked Sendable {
    var onFailure: ((Error) -> Void)?
    var onSuccess: ((T) -> Void)?
}

Upvotes: 0

Related Questions