Skip to content

Commit edd14fe

Browse files
author
Brandon Schoenfeld
committed
test: reproduce leaked continuation
1 parent b1f6d55 commit edd14fe

File tree

1 file changed

+107
-0
lines changed

1 file changed

+107
-0
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
@testable import class AsyncDataLoader.Channel
2+
@testable import protocol AsyncDataLoader.Stateful
3+
@testable import typealias AsyncDataLoader.Waiter
4+
import Testing
5+
6+
/// When Channel.fulfill or .fail is called, an arbitrarily long suspension of
7+
/// await state.removeAllWaiters()
8+
/// would allow actor reentrancy, where another continuation could be added, removed, but not resumed.
9+
/// This reproducer injects a mock state object that artificially pauses the removal of all waiters until a new one is added.
10+
/// At that point, Channel will no longer resume the water and just remove it.
11+
/// This test passes, but with a logged message
12+
/// SWIFT TASK CONTINUATION MISUSE: reproduceLeakingCheckedContinuation() leaked its continuation without resuming it. This may cause tasks waiting on it to remain suspended forever.
13+
///
14+
/// Note that it is not leaked in the computed property `value`. This reproducer only shows that a continuation could be leaked through the state and actor reentrancy.
15+
/// Calling Channel.value instead causes this test to hang indefinitely because it is waiting for an unresumed continuation.
16+
@Test func reproduceLeakedCheckedContinuation() async throws {
17+
// setup global variables
18+
//
19+
// fulfill task
20+
// - create state and channel
21+
// - pass out to main thread
22+
// - call fulfill so that removeAllWaiters is called
23+
// - signal removeAllWaiters was called
24+
// - suspend Task until append is called
25+
//
26+
// wait for removeAllWaiters signal
27+
//
28+
// leak continuation task
29+
// - call append
30+
// - appends the waiter
31+
// - resumes the continuation created in removeAllWaiters, allowing the fulfill task to resume
32+
//
33+
// wait for fulfill task to complete, which removesAllWaiters
34+
// test ends with SWIFT TASK CONTINUATION MISUSE
35+
36+
var state: MockState<Int, Never>?
37+
var channel: Channel<Int, Never, MockState<Int, Never>>?
38+
39+
var fulfillTask: Task<Void, Error>? = nil
40+
41+
// continuation gets resumed when state.removeAllWaiters is called
42+
await withCheckedContinuation { continuation in
43+
let localState = MockState<Int, Never>(continuation)
44+
state = localState
45+
channel = Channel(localState)
46+
fulfillTask = Task {
47+
_ = try await #require(channel).fulfill(42)
48+
}
49+
}
50+
51+
// leaking continuation task
52+
Task {
53+
// calling channel.value here would be ideal, to make that append a continuation to the state
54+
// but this causes the process to hand forever
55+
//
56+
// try await #require(channel).value
57+
58+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Int, any Error>) -> Void in
59+
Task { // wrap in task to allow throwing
60+
try await #require(state).appendWaiters(waiters: continuation)
61+
}
62+
}
63+
}
64+
65+
try await #require(fulfillTask).value
66+
}
67+
68+
actor MockState<Success, Failure>: Stateful {
69+
var waiters = [Waiter<Success, Failure>]()
70+
var result: Success?
71+
var failure: Failure?
72+
73+
/// suspend until
74+
let removeAllCalledContinuation: CheckedContinuation<Void, Never>
75+
var appendCalledContinuation: CheckedContinuation<Void, Never>?
76+
77+
init(_ continuation: CheckedContinuation<Void, Never>) {
78+
removeAllCalledContinuation = continuation
79+
}
80+
}
81+
82+
extension MockState {
83+
func setResult(result: Success) {
84+
self.result = result
85+
}
86+
87+
func setFailure(failure: Failure) {
88+
self.failure = failure
89+
}
90+
91+
func appendWaiters(waiters: Waiter<Success, Failure>...) {
92+
self.waiters.append(contentsOf: waiters)
93+
guard let continuation = appendCalledContinuation else {
94+
Issue.record("removeAllWaiters was not called before appendWaiters")
95+
return
96+
}
97+
continuation.resume()
98+
}
99+
100+
func removeAllWaiters() async {
101+
removeAllCalledContinuation.resume()
102+
await withCheckedContinuation { continuation in
103+
appendCalledContinuation = continuation
104+
}
105+
waiters.removeAll()
106+
}
107+
}

0 commit comments

Comments
 (0)