Skip to content

Commit e26c023

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 3945e9c commit e26c023

File tree

2 files changed

+18
-39
lines changed

2 files changed

+18
-39
lines changed

Sources/AsyncDataLoader/Channel/Channel.swift renamed to Sources/AsyncDataLoader/Channel.swift

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
actor Channel<Success: Sendable, Failure: Error>: Sendable {
2-
private var state = State<Success, Failure>()
2+
private var waiters = [Waiter<Success, Failure>]()
3+
private var result: Success?
4+
private var failure: Failure?
35
}
46

7+
typealias Waiter<Success, Failure> = CheckedContinuation<Success, Error>
8+
59
extension Channel {
610
@discardableResult
7-
func fulfill(_ value: Success) async -> Bool {
8-
if await state.result == nil {
9-
await state.setResult(result: value)
11+
func fulfill(_ value: Success) -> Bool {
12+
if result == nil {
13+
result = value
1014

11-
for waiters in await state.waiters {
15+
for waiters in waiters {
1216
waiters.resume(returning: value)
1317
}
1418

15-
await state.removeAllWaiters()
19+
waiters.removeAll()
1620

1721
return false
1822
}
@@ -21,15 +25,15 @@ extension Channel {
2125
}
2226

2327
@discardableResult
24-
func fail(_ failure: Failure) async -> Bool {
25-
if await state.failure == nil {
26-
await state.setFailure(failure: failure)
28+
func fail(_ failure: Failure) -> Bool {
29+
if self.failure == nil {
30+
self.failure = failure
2731

28-
for waiters in await state.waiters {
32+
for waiters in waiters {
2933
waiters.resume(throwing: failure)
3034
}
3135

32-
await state.removeAllWaiters()
36+
waiters.removeAll()
3337

3438
return false
3539
}
@@ -41,12 +45,12 @@ extension Channel {
4145
get async throws {
4246
try await withCheckedThrowingContinuation { continuation in
4347
Task {
44-
if let result = await state.result {
48+
if let result = self.result {
4549
continuation.resume(returning: result)
46-
} else if let failure = await self.state.failure {
50+
} else if let failure = self.failure {
4751
continuation.resume(throwing: failure)
4852
} else {
49-
await state.appendWaiters(waiters: continuation)
53+
waiters.append(continuation)
5054
}
5155
}
5256
}

Sources/AsyncDataLoader/Channel/State.swift

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

0 commit comments

Comments
 (0)