Timeout for a Continuation in Swift Concurrency

198 views Asked by At

@Sebi Pudilic and I recently begun working with Swift Concurrency. So far, I've successfully converted delegate methods into Swift asynchronous concurrency. We've created a class called GenericContinuationWrapper, which serves as a wrapper for transforming delegates into async await.

We need a way to make sure that the resume is called one time even if the user taps multiple times on a button that executes the performAction function.

We only have a task for timeout and do not know how to cancel the main task and to resumeWithFailure in case the continuation is resumed multiple times.

We are currently at a stage where we need to implement a timeout mechanism for a continuation to resume itself if the delegate isn't called for some reason (We encountered this while working with Bluetooth).

To achieve this, We've set up a task that sleeps for 3 seconds before resuming the continuation with an error.

func performAction(timeout: TimeInterval = 10, operation: () -> Void) async throws -> T {
        operation() // Trigger the delegate-based operation
        
        return try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            
            self.timeoutTask = Task {
                do {
                    try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
                    if let timeoutTask = self.timeoutTask, !timeoutTask.isCancelled {
                        self.resumeWithFailure(withError: SomeError.bluetoothOperationFailed)
                    } else {
                        self.resumeWithFailure(withError: SomeError.bluetoothOperationFailed)
                    }
                } catch {
                    self.resumeWithFailure(withError: SomeError.bluetoothOperationFailed)
                }
            }
        }
    }

When using Task, We encountered the error "Swift Task Continuation misused." In the event that a delegate is not called, the timer fires, and the continuation resumes with that error. We're curious as to why this is the case.

How can we approach this differently to make sure no leaks are received on a timeout.

This is our full code.

class GenericContinuationWrapper<T> {
    typealias Continuation = CheckedContinuation<T, Error>
    private var continuation: Continuation?
    
    /// Safety timeout (not recommended to let continuation hang)
    private var timeoutTask: Task<Void, Never>?
    
    /// This function wraps the delegate call into an async function
    func performAction(timeout: TimeInterval = 10, operation: () -> Void) async throws -> T {
        operation() // Trigger the delegate-based operation
        
        return try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            
            self.timeoutTask = Task {
                do {
                    try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
                    if let timeoutTask = self.timeoutTask, !timeoutTask.isCancelled {
                        self.resumeWithFailure(withError: SomeError.bluetoothOperationFailed)
                    } else {
                        self.resumeWithFailure(withError: SomeError.bluetoothOperationFailed)
                    }
                } catch {
                    self.resumeWithFailure(withError: SomeError.bluetoothOperationFailed)
                }
            }
        }
    }

    /// Function to be called by the delegate method on success
    func resumeWithSuccess(withResult result: T) {
        timeoutTask?.cancel()
        resumeContinuationWithSuccess(result)
    }

    /// Function to be called by the delegate method on failure
    func resumeWithFailure(withError error: Error) {
        timeoutTask?.cancel()
        resumeContinuationWithFailure(error)
    }

    /// Resuming the continuation with a result
    private func resumeContinuationWithSuccess(_ result: T) {
        continuation?.resume(returning: result)
        self.removeContinuation()
    }
    
    /// Resuming the continuation with an error
    private func resumeContinuationWithFailure(_ error: Error) {
        continuation?.resume(throwing: error)
        self.removeContinuation()
    }

    /// Should call this when we finish working with this instance of self
    func removeContinuation() {
        self.continuation = nil
        self.timeoutTask?.cancel()
        self.timeoutTask = nil
    }
}
0

There are 0 answers