0

I'm trying to compile this simple example, first Xcode 14.3 (Swift 5.8) tells me

No such module 'Concurrency'

Then if I change that line to import _Concurrency, Xcode 14.3 tells me

Cannot find 'withTimeout' in scope

same is true using 'withDeadline()'. What is wrong?

import Foundation
import Concurrency

func fetchImageData() async throws -> Data {
    return try await withTimeout(1)  {
        // Simulate a long-running async operation
        let imageData = try await URLSession.shared.data(from: URL(string: "https://example.com/image.jpg")!)
        return imageData.0
    }
}
e987
  • 377
  • 5
  • 10
  • 1
    Swift doesn't have a `Concurrency` module, which is why you can't import it; the actual module is indeed called `_Concurrency`, but you also don't need to import it because it's imported and re-exported by default from the stdlib (i.e., it's imported by default). Either way, this module doesn't offer either a `withTimeout` or `withDeadline`; where are those methods supposed to be coming from? Are you following a tutorial or article that uses them? – Itai Ferber May 04 '23 at 15:46
  • @ItaiFerber I was using Microsoft Edge to try to figure out how to create a timeout with async/await. If there is a correct way to do this please let me know. – e987 May 04 '23 at 17:10
  • 1
    What has a web browser got to do with this? – Joakim Danielson May 04 '23 at 17:16
  • There is no general way to set a timeout on an arbitrary `Task`, because in Swift `async`/`await`, [cancellation is "cooperative"](https://developer.apple.com/documentation/swift/task/cancel()); in other words, the task itself needs to keep checking "have I been cancelled?", and if so, stop doing what it's doing. Can you share the details of the operation that you want to cancel? (Is it code you've written, or 3rd-party code?) – Itai Ferber May 04 '23 at 17:51
  • @ItaiFerber The simple problem I want to solve is if a request to a URL takes longer than x seconds I want the request to timeout. – e987 May 04 '23 at 19:10
  • You don't need to use `async`/`await` for this — you can use [`URLSessionConfiguration`](https://developer.apple.com/documentation/foundation/urlsessionconfiguration) to set timeout behavior for requests, and create a `URLSession` which uses that configuration. See [`timeoutIntervalForRequest`](https://developer.apple.com/documentation/foundation/urlsessionconfiguration/1408259-timeoutintervalforrequest) and [`timeoutIntervalForResource`](https://developer.apple.com/documentation/foundation/urlsessionconfiguration/1408153-timeoutintervalforresource). – Itai Ferber May 04 '23 at 19:33
  • @ItaiFerber - I agree that a reasonable timeout on the session is all that’s needed in this particular case. But you said: “There is no general way to set a timeout on an arbitrary `Task` … the task itself needs to keep checking ‘have I been cancelled?’” … That’s not true where the called function supports cancelation (e.g., such as `data(from:delegate:)`). Sure, in compute tasks where we’re spinning in a loop, then, yeah, you’d periodical check if canceled). But `Task` and `data(from:delegate:)` definitely support cancelation, so no periodic testing of cancelation is needed. – Rob May 05 '23 at 04:37
  • @Rob Thanks for calling that out — I could have been more precise. The code in the OP says `Simulate a long-running async operation`, so it wasn't clear whether OP was working with `URLSession`, or a custom `Task` with many steps. Indeed, if an operation supports cancellation, you don't need to think about it; but if you're running your own `Task` which executes several jobs, you do need to check for cancellation between those jobs. (And FWIW, `data(from:delegate:)` does itself need to check for cancellation in order to support it; it's just not something you need to be aware of.) – Itai Ferber May 05 '23 at 14:47

1 Answers1

1

There is no need to import anything for Swift concurrency, as that is imported for you.

But there is no withTimeout function. You could write one, though. Perhaps:

extension Task where Failure == Error {
    /// Run asynchronous task, but cancel it if not finished within a certain period of time.
    ///
    /// - Parameters:
    ///   - duration: The duration before the task is canceled.
    ///   - operation: The async closure to run. The code therein should support cancelation.
    /// - Throws: If canceled, will throw `CancellationError`. If the task fails, this will throw the task’s error.
    /// - Returns: This will return the value returned by the `async` closure.

    static func withTimeout(_ duration: Duration, operation: @Sendable @escaping () async throws -> Success) async rethrows -> Success {
        let operationTask = Task.detached {
            try await operation()
        }

        let cancelationTask = Task<Void, Error>.detached {
            try await Task<Never, Never>.sleep(for: duration)
            operationTask.cancel()
        }

        return try await withTaskCancellationHandler {
            defer { cancelationTask.cancel() }
            return try await operationTask.value
        } onCancel: {
            operationTask.cancel()
        }
    }
}

Needless to say, I replaced the TimeInterval (i.e., seconds) with a more general Duration and made it a static method of Task, but the overall idea is simple: Basically, start one task for the supplied closure, another for the cancelation of that other task, and whichever finishes first will cancel the other. And obviously, if the withTimeout, itself, is canceled, then cancel the unstructured concurrency tasks, too.

But do not get lost in the weeds of this, as there are probably many variations on the theme one might consider.

Then you could call it like so:

func fetchImageData(from url: URL) async throws -> Data {
    try await Task.withTimeout(.seconds(1))  {
        try await URLSession.shared.data(from: url).0
    }
}

All of that having been said, URLSession/URLRequest both already have a timeout parameter, so you might as well use that, perhaps:

nonisolated func fetchImageData(from url: URL) async throws -> Data {
    let request = URLRequest(url: url, timeoutInterval: 1)
    return try await URLSession.shared.data(for: request).0
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • This version of `withTimeout` is fantastic; I would consider documenting more prominently, though, that if `operation` doesn't support cancellation (either intentionally, or because it's just not aware of cancellation), the timeout won't apply. This can be very subtle and somewhat dangerous, so worth being aware of. In a code review, I'd even suggest renaming the method to something like `performCancellableWithTimeout(_:_:)` so it's more explicit at callsites (though I know you're just using the name as presented in the post.) – Itai Ferber May 05 '23 at 14:50
  • In my defense, I did try to mention cancelation in the documentation (it’s the only reason this throw-away code snippet has include inline documentation, in the first place). But I hear you, that one certainly could make that that more prominent. – Rob May 05 '23 at 17:28
  • 1
    Oh yes! Not intended as criticism (happily upvoted!) — I'm just typically _very_ risk-averse around this sort of thing, so a minor suggestion for anyone integrating this into their codebase. – Itai Ferber May 05 '23 at 20:42