Skip to content

Commit 81de346

Browse files
author
Brandon Schoenfeld
committed
fix: avoid leaking CheckedContinuations by removing possible Channel actor reentrancy
In the Channel.fulfill and Channel.fail methods, the line `await state.removeAllWaiters()` allows for actor reentrance. This means that after resuming all the CheckedContinuations (waiters), but before removing them, another waiter could be added to the array. Then once the `state.removeAllWaiters` resumes execution, any CheckedContinuations would be removed without having been resumed. This leads to leaking CheckedContinuations. This was observed in my application, where I would get a logger message SWIFT TASK CONTINUATION MISUSE: value leaked its continuation without resuming it. This may cause tasks waiting on it to remain suspended forever. which originates from Swift here: https://github.com/swiftlang/swift/blob/b06eed151c8aa2bc2f4e081b0bb7b5e5c65f3bba/stdlib/public/Concurrency/CheckedContinuation.swift#L82 I verified that Channel.value was the culprit by renaming it and observing the changed name in the CONTINUATION MISUSE message. This change set removes the possibility of actor reentrancy and leaking CheckedContinuations. By inlining the State data members in Channel and deleting the State actor completely, we remove all await calls inside Channel.fulfill and Channel.fail.
1 parent df48b99 commit 81de346

File tree

3 files changed

+59
-80
lines changed

3 files changed

+59
-80
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
actor Channel<Success: Sendable, Failure: Error>: Sendable {
2+
private var waiters = [Waiter<Success, Failure>]()
3+
private var result: Success?
4+
private var failure: Failure?
5+
}
6+
7+
typealias Waiter<Success, Failure> = CheckedContinuation<Success, Error>
8+
9+
extension Channel {
10+
@discardableResult
11+
func fulfill(_ value: Success) -> Bool {
12+
if result == nil {
13+
result = value
14+
15+
for waiter in waiters {
16+
waiter.resume(returning: value)
17+
}
18+
19+
waiters.removeAll()
20+
21+
return false
22+
}
23+
24+
return true
25+
}
26+
27+
@discardableResult
28+
func fail(_ failure: Failure) -> Bool {
29+
if self.failure == nil {
30+
self.failure = failure
31+
32+
for waiter in waiters {
33+
waiter.resume(throwing: failure)
34+
}
35+
36+
waiters.removeAll()
37+
38+
return false
39+
}
40+
41+
return true
42+
}
43+
44+
var value: Success {
45+
get async throws {
46+
try await withCheckedThrowingContinuation { continuation in
47+
Task {
48+
if let result = self.result {
49+
continuation.resume(returning: result)
50+
} else if let failure = self.failure {
51+
continuation.resume(throwing: failure)
52+
} else {
53+
waiters.append(continuation)
54+
}
55+
}
56+
}
57+
}
58+
}
59+
}

Sources/AsyncDataLoader/Channel/Channel.swift

Lines changed: 0 additions & 55 deletions
This file was deleted.

Sources/AsyncDataLoader/Channel/State.swift

Lines changed: 0 additions & 25 deletions
This file was deleted.

0 commit comments

Comments
 (0)