swiftasync-awaitconcurrency

Use async/await with NWPathMonitor to wait for internet connection then execute a task


How can I execute a callback when the internet connection is ready while using NWPathMonitor and this method signature :

func performWhenNetworkIsAvailable<T: Sendable>(timeoutDuration: Duration,
                                                _ operation: @escaping @Sendable () async -> T) async -> Result<T, NetworkOperationError>

with the error enum :

public enum NetworkOperationError: Error {
    case timeout
}

The usage must be something like this :

let result = await networkOperationManager.performWhenNetworkIsAvailable(timeoutDuration: .seconds(10)) {
    return await fetchDataFromAPI()
}        

switch result {
    case .success(let data) :
        print("Got data: \(data)")
    case .failure(let error):
        print("Error fetching data: \(error.localizedDescription)")
}

Apparently I could use TaskGroup to accomplish this, but I still cannot see how it could work, and what would be the best way to do it.

Here's my shot at it, but it doesn't work (timeout doesn't unlock the task group) :

actor NetworkOperationManager {
    private let monitor = NWPathMonitor()
    private var isNetworkAvailable = false
    private var continuations: [CheckedContinuation<Void, Never>] = []
    
    init() {
        Task {
            print("Starting monitor")
            monitor.start(queue: DispatchQueue(label: "NetworkMonitor"))

            for await path in monitor {
                print("New update from monitor : \(path.status == .satisfied ? "is connected" : "not connected")")
                let isAvailable = path.status == .satisfied
                guard await isAvailable != isNetworkAvailable else {
                    return
                }
                await setNetworkStatus(isAvailable: isAvailable)
                await resumeAllContinuations()
            }
        }
    }
    
    func setNetworkStatus(isAvailable: Bool) {
        isNetworkAvailable = isAvailable
    }
    
    func addContinuation(_ continuation: CheckedContinuation<Void, Never>) {
        continuations.append(continuation)
    }
    
    private func resumeAllContinuations() {
        continuations.forEach { $0.resume() }
        continuations.removeAll()
    }
    
    func performWhenNetworkIsAvailable<T: Sendable>(timeoutDuration: Duration,
                                                    _ operation: @escaping @Sendable () async -> T) async -> Result<T, NetworkOperationError> {
        guard !isNetworkAvailable else {
            print("Internet connection already available, executing operation")
            return await .success(operation())
        }
        
        let result = await withTaskGroup(of: Bool.self) { group -> Bool in
            group.addTask { @MainActor in
                try? await Task.sleep(for: timeoutDuration)
                print("End of timeout")
                return false
            }
            
            group.addTask { @MainActor [weak self] in
                guard await self?.isNetworkAvailable == false else {
                    print("Internet connection already available")
                    return true
                }
                
                await withCheckedContinuation { [weak self] continuation in
                    Task {
                        guard await self?.isNetworkAvailable == false else {
                            print("Internet connection already available")
                            continuation.resume()
                            return
                        }
                        await self?.addContinuation(continuation)
                    }
                }
                
                return true
            }
            
            return await group.next() ?? false
        }
        
        print("Got group result")
        
        if result {
            print("Success")
            return await .success(operation())
        } else {
            print("Failure")
            return .failure(NetworkOperationError.timeout)
        }
    }
}

If anyone has an idea on how to respect the method signature, while doing an efficient and thread-safe approach, that would help me quite a lot! And of top of this, if we can try to maintain the order in which the information is sent and received, that would be awesome.

Thanks in advance


Solution

  • The title of your question says that you want to, “Use async/await with NWPathMonitor to wait for internet connection then execute a task”. If that is the case, I might consider a simpler pattern, excising the closure entirely, and just await the NWPathMonitor being satisfied:

    func waitForNetwork() async throws {
        let monitor = NWPathMonitor()
        for await path in monitor {
            if path.status == .satisfied { return }
        }
        try Task.checkCancellation()
    }
    

    Or if you want timeout logic, that’s where you might introduce a task group:

    func waitForNetworkWithTimeout(after duration: Duration = .seconds(10)) async throws {
        try await withThrowingTaskGroup(of: Void.self) { group in
            defer { group.cancelAll() }
    
            group.addTask {
                try await waitForNetwork()
            }
            group.addTask {
                try await Task.sleep(for: duration)
                throw NetworkOperationExecutionError.timedOut
            }
    
            try await group.next()
        }
    }
    

    Anyway, now that you have that you can just call it before your network call:

    try await waitForNetworkWithTimeout()
    try await performNetworkRequest()
    

    No closures required (or desired). This also eliminates the unstructured concurrency.