Thanks everyone for providing a clear example on how to do this!
I've tried implementing this myself though and I'm seeing some behaviour I don't understand. I'm hoping someone here can sort it out better than I can, or can confirm if the same thing is happening for you (I'm using Xcode 13.2.1 running on an iOS 15.2.1 iPhone).
If the timeout child task finishes first, the TimedOutError() is thrown by that task. It is correctly propagated out of the TaskGroup, BUT then it just stops - it doesn't get rethrown by the withTimeout function until the work task completes. If the work task eventually completes, then the exception from withTimeout does at that point get thrown back up the call stack.
I'm not sure what's going on, but it feels like the throw is waiting for the task group to unlock or something and that doesn't happen until the work task returns.
I have a feeling that the problem may actually be in my work task, but I don't see it. Am I somehow blocking a thread or something? It is something like this:
func startup(timeoutAt deadline: Date = Date().advanced(by: 5)) async throws {
do {
try await withTimeLimit(deadline: deadline, do: {
try self.checkForForceUpgrade()
let tokens = try await self.services.tokenRefresher.requestValidTokens()
let channel = try await self.services.serviceC.doStuff()
try await self.services.serviceA.connect()
try await self.services.serviceB.start()
let (_, _) = try await self.services.serviceB.refresh()
})
} catch {
// If one of the async calls above never returns
// (such as when a network request never receives a response,
// `withTimeLimit()` throws, but this catch block isn't triggered.
//
// If the work task above takes longer than the timeout, the timeout
// error IS caught here when the work task finishes. So I guess it was
// waiting to be thrown that whole time?
print("STARTUP FAILED: \(error)")
throw error
}
}
for completeness, here is my withTimeLimit function, but it is basically exactly as has been described above:
public extension Task where Success == Never, Failure == Never {
static func sleepRespectingCancellation(until deadline: Date, cancelCheckIntervalNs: UInt64 = 100_000) async throws {
while Date() < deadline {
guard !isCancelled else { break }
try await sleep(nanoseconds: cancelCheckIntervalNs)
}
}
}
public func withTimeLimit<ResultType>(deadline: Date, do work: @escaping () async throws -> ResultType) async throws -> ResultType {
print("TIME LIMIT - starting")
return try await withThrowingTaskGroup(of: ResultType.self) { group in
group.addTask {
// If we throw after the sleep, the catch handler of the user of this method never seems to be triggered.
// If we throw before, it is.
// Using the normal sleep(nanoseconds:) here doesn't improve the behaviour
try await Task.sleepRespectingCancellation(until: deadline)
print("TIME LIMIT - sleep finished")
try Task.checkCancellation()
throw TJError(code: CommonError.timeout, description: "async task timed out")
}
group.addTask {
let result = try await work()
print("TIME LIMIT - work finished")
return result
}
print("TIME LIMIT - waiting")
do {
let result = try await group.next()!
print("TIME LIMIT - done")
group.cancelAll()
return result
} catch {
print("TIME LIMIT - throw \(error)")
group.cancelAll()
// In the timeout case, we hit this catch statement.
// But THIS throw doesn't trigger the catch at the next level
throw TJError(code: CommonError.timeout,
description: "async 2",
underlyingError: error)
}
}
}
Thank you in advance for any insights or guidance you can provide!