skip to Main Content

I have the following polling task. Its roughly exactly like this but obviously a bit contrived to make the question simple.

class SocketClass {
    private var pollingTask: Task<Void, Never>?
    func startPoll() {
        // Stop previous polling task
        pollingTask?.cancel()
        pollingTask = nil

        pollingTask = Task {
            while !Task.isCancelled {
                // Send ping
                // Wait for pong timeout
                await self.waitForPong()

                // Wait for the next ping interval
                try? await Task.sleep(nanoseconds: UInt64(30_000_000_000))
            }
        }
        // Is it possible that at this point that there are two tasks 
        // attempting to poll before the previous one heard it needed to cancel?
    }

    private func waitForPong(_ uuid: UUID, _ count: Int) async -> Bool {
        let pongReceived = await withCheckedContinuation { continuation in
            socket.once("pong") { [weak self] _, _ in
                continuation.resume(returning: true)
            }
        }
        return pongReceived
    }
}

My concern is when startPoll() is called that the previous polling task may not be canceled by the time the new one starts. I would feel safer if I could await the cancelation of the previous task but not quite sure how to do that.

But with the code as is there are no guarantees that there might be a moment where there are two polling tasks working simultaneously for at least a little bit right? Especially if the ping/pong code doesnt check if the task is canceled.

2

Answers


  1. You could change your task to throw when it is cancelled by calling checkCancellation instead of checking isCancelled. You can then await pollingTask.result – Once that await completes you know that the task has stopped:

    class SocketClass {
        private var pollingTask: Task<Void, Error>?
        func startPoll() {
            // Stop previous polling task
            if let pollingTask {
                pollingTask.cancel()
                let _ = await pollingTask.value
            }
    
            pollingTask = Task {
                while true {
                    Task.checkCancellation()
                    // Send ping
                    // Wait for pong timeout
                    await withTimeout(seconds: pongTimeout) {
                        await self.waitForPong()
                    }
    
                    // Wait for the next ping interval
                    try? await Task.sleep(nanoseconds: UInt64(30_000_000_000))
                }
            }
        }
    }
    
    Login or Signup to reply.
  2. Your assumption is correct, that there can be a race of the cancellation and starting the poll, which makes it possible, that two operations run at the same time. A solution requires that the start of the operation should only begin, when the previous one (if any) is completed.

    The following class solves this issue. Note, that there are other solutions possible.

    Note also, that the class is running on the MainActor to make it thread-safe. The function start() cannot overlap when called from multiple clients, however start() can be called, when the internal operation is still running. In this case, the current task is cancelled, then its completion is awaited and only then, the new task will be created and started.

    @MainActor
    final class Operation {
        var task: Task<Void, Never>?
        var result: Result<String, Error>?
        
        func start() {
            Task { [weak self] in
                if let self {
                    self.cancel()
                    await self.task?.value
                    print("Start operation")
                    self.task = makeTask()
                }
            }
        }
        
        func cancel() {
            if let task, !task.isCancelled {
                print("Cancel operation")
                task.cancel()
            }
        }
        
        var isRunning: Bool { task != nil }
    
        private func makeTask() -> Task<Void, Never> {
            Task { [weak self] in
                do {
                    try await Task.sleep(for: .seconds(5))
                    self?.result = .success("Hello, world!")
                    self?.task = nil
                    print("Operation succeeded.")
                } catch {
                    self?.result = .failure(error)
                    self?.task = nil
                    print("Operation failed with error (error).")
                }
            }
        }
    }
    

    You can visualise and test this class when making it an Observable and show the state in a View:

    import Observation
    
    @Observable
    @MainActor
    final class Operation {
        ... 
    }
    

    and a SwiftUI view:

    import SwiftUI 
    
    struct ModelView: View {
        @State private var model = Operation()
        
        var body: some View {
            ContentView(
                result: model.result,
                isRunning: model.isRunning,
                startOperation: model.start,
                cancelOperation: model.cancel
            )
        } 
    }
    
    struct ContentView: View {
        let result: Result<String, Error>?
        let isRunning: Bool
        let startOperation: () -> Void
        let cancelOperation: () -> Void
    
        var body: some View {
            ZStack {
                VStack {
                    if let result {
                        Text("(result)")
                    } else {
                        Text("no result")
                    }
                    
                    Button("Start Operation") {
                        startOperation()
                    }
                    .padding()
                    
                    Button("Cancel Operation") {
                        cancelOperation()
                    }
                    .padding()
                }
                if isRunning {
                    ProgressView()
                }
            }
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search