Swift Task.sleep timeout race condition in iOS 15 and iOS 16
0 reputation · 20 Apr 2022, 10:40 UTC
0 reputation · 20 Apr 2022, 10:40 UTC
Developers want to guarantee that a timeout set with Task.sleep stops any further work as soon as the cancellation signal is received, preventing unnecessary execution after the deadline.
The common approach of racing Task.sleep against a task and checking Task.isCancelled after the sleep finishes creates a race window: the task may observe cancellation only after the sleep has already completed, allowing extra iterations or work to proceed beyond the intended timeout.
It remains unclear whether Apple plans to introduce an API that combines timeout and cancellation atomically, or if developers should adopt specific wrapper patterns that close this window without adding third‑party dependencies.
What are the recommended patterns to eliminate the race window using only the standard library? Does Apple intend to provide an atomic timeout‑cancellation construct in a future Swift release?
35275 reputation · 20 Apr 2022, 20:17 UTC
Recommended patterns: Use a TaskGroup with two children (the operation and a sleep task) on iOS 15; on iOS 16+ add withTaskCancellationHandler to register a synchronous cancel action that runs atomically with cancellation delivery.
Apple's plans: No public Swift Evolution proposal or WWDC session through 2024 announces a built-in Task.withTimeout or similar atomic timeout primitive. Apple engineers have indicated the structured-concurrency primitives (TaskGroup, cancellation handlers) are the intended building blocks.
After try await Task.sleep(...) returns, the task resumes at the next line. A cancellation delivered during that gap is only observed at the next explicit Task.checkCancellation() or suspension point, allowing extra work to proceed past the deadline.
func withTimeout<T>(_ duration: UInt64, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: duration)
throw CancellationError()
}
guard let result = try await group.next() else { throw CancellationError() }
group.cancelAll()
return result
}
}The group cancels all children on first completion, bounding extra work to at most one iteration of a looping child.
func withTimeout<T>(_ duration: Duration, operation: @escaping () async throws -> T) async throws -> T {
try await withTaskCancellationHandler {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: duration)
throw CancellationError()
}
guard let result = try await group.next() else { throw CancellationError() }
group.cancelAll()
return result
}
} onCancel: {
// Synchronous cancel action (e.g., close stream, set atomic flag)
// Must not await or block
}
}withTaskCancellationHandler runs its onCancel closure atomically with cancellation delivery, narrowing the window further. For repeating work, combine it with a loop that calls Task.checkCancellation() at each yield point and an Atomic<Bool> flag set by the handler.
Task.sleep(for:) (Duration-based) requires iOS 16 / Swift 5.7; iOS 15 must use the nanoseconds overload.withTaskCancellationHandler's onCancel runs synchronously on the cancelling thread and must not await.TaskGroup cancellation only affects child tasks that respect cancellation; detached or unstructured tasks are unaffected.withTimeout pattern above and instrument with os_signpost or timestamp prints to confirm no work runs after timeout expiry.Are you targeting iOS 15 only, iOS 16+ only, or both? The answer determines whether you need the nanoseconds-based TaskGroup pattern alone or can also use withTaskCancellationHandler and Task.sleep(for:).
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.