Skip to content

Commit 9a3a0df

Browse files
authored
Merge pull request #1730 from ahoppen/finish-immediately-timeout
When `withTimeout` hits a timeout, don’t wait for cooperative cancellation before returning
2 parents f6eac86 + 86653b3 commit 9a3a0df

File tree

2 files changed

+64
-9
lines changed

2 files changed

+64
-9
lines changed

Sources/SwiftExtensions/AsyncUtils.swift

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -176,18 +176,46 @@ package func withTimeout<T: Sendable>(
176176
_ duration: Duration,
177177
_ body: @escaping @Sendable () async throws -> T
178178
) async throws -> T {
179-
try await withThrowingTaskGroup(of: T.self) { taskGroup in
180-
taskGroup.addTask {
181-
try await Task.sleep(for: duration)
182-
throw TimeoutError()
179+
var mutableTasks: [Task<Void, Error>] = []
180+
let stream = AsyncThrowingStream<T, Error> { continuation in
181+
let bodyTask = Task<Void, Error> {
182+
do {
183+
let result = try await body()
184+
continuation.yield(result)
185+
} catch {
186+
continuation.yield(with: .failure(error))
187+
}
183188
}
184-
taskGroup.addTask {
185-
return try await body()
189+
190+
let timeoutTask = Task {
191+
try await Task.sleep(for: duration)
192+
bodyTask.cancel()
193+
continuation.yield(with: .failure(TimeoutError()))
186194
}
187-
for try await value in taskGroup {
188-
taskGroup.cancelAll()
195+
mutableTasks = [bodyTask, timeoutTask]
196+
}
197+
198+
let tasks = mutableTasks
199+
200+
return try await withTaskPriorityChangedHandler {
201+
for try await value in stream {
189202
return value
190203
}
191-
throw CancellationError()
204+
// The only reason for the loop above to terminate is if the Task got cancelled or if the continuation finishes
205+
// (which it never does).
206+
if Task.isCancelled {
207+
for task in tasks {
208+
task.cancel()
209+
}
210+
throw CancellationError()
211+
} else {
212+
preconditionFailure("Continuation never finishes")
213+
}
214+
} taskPriorityChanged: {
215+
for task in tasks {
216+
Task(priority: Task.currentPriority) {
217+
_ = try? await task.value
218+
}
219+
}
192220
}
193221
}

Tests/SKSupportTests/AsyncUtilsTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,31 @@ final class AsyncUtilsTests: XCTestCase {
2929
}
3030
try await fulfillmentOfOrThrow([expectation])
3131
}
32+
33+
func testWithTimeoutReturnsImmediatelyEvenIfBodyDoesntCooperateInCancellation() async throws {
34+
let start = Date()
35+
await assertThrowsError(
36+
try await withTimeout(.seconds(0.1)) { sleep(10) }
37+
) { error in
38+
XCTAssert(error is TimeoutError, "Received unexpected error \(error)")
39+
}
40+
XCTAssert(Date().timeIntervalSince(start) < 5)
41+
}
42+
43+
func testWithTimeoutEscalatesPriority() async throws {
44+
let expectation = self.expectation(description: "Timeout started")
45+
let task = Task(priority: .background) {
46+
// We don't actually hit the timeout. It's just a large value.
47+
try await withTimeout(.seconds(defaultTimeout * 2)) {
48+
expectation.fulfill()
49+
try await repeatUntilExpectedResult {
50+
return Task.currentPriority > .background
51+
}
52+
}
53+
}
54+
try await fulfillmentOfOrThrow([expectation])
55+
try await Task(priority: .high) {
56+
try await task.value
57+
}.value
58+
}
3259
}

0 commit comments

Comments
 (0)