Skip to content

Commit c151f4b

Browse files
committed
Concurrency: Introduce a CheckedContinuation adapter.
To help catch runtime issues adopting `withUnsafeContinuation`, such as callback-based APIs that misleadingly invoke their callback multiple times and/or not at all, provide a couple of classes that can take ownership of a fresh `UnsafeContinuation` or `UnsafeThrowingContinuation`, and log attempts to resume the continuation multiple times or discard the object without ever resuming the continuation.
1 parent 62e3896 commit c151f4b

File tree

5 files changed

+230
-0
lines changed

5 files changed

+230
-0
lines changed

include/swift/Runtime/Concurrency.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,10 @@ void swift_continuation_throwingResumeWithError(/* +1 */ SwiftError *error,
345345
void *continuation,
346346
const Metadata *resumeType);
347347

348+
/// SPI helper to log a misuse of a `CheckedContinuation` to the appropriate places in the OS.
349+
extern "C" SWIFT_CC(swift)
350+
void swift_continuation_logFailedCheck(const char *message);
351+
348352
}
349353

350354
#endif

stdlib/public/Concurrency/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ endif()
3636
add_swift_target_library(swift_Concurrency ${SWIFT_STDLIB_LIBRARY_BUILD_TYPES} IS_STDLIB
3737
Actor.cpp
3838
Actor.swift
39+
CheckedContinuation.swift
3940
GlobalExecutor.cpp
4041
PartialAsyncTask.swift
4142
Task.cpp
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import Swift
2+
3+
@_silgen_name("swift_continuation_logFailedCheck")
4+
internal func logFailedCheck(_ message: UnsafeRawPointer)
5+
6+
/// A wrapper class for `UnsafeContinuation` that logs misuses of the
7+
/// continuation, logging a message if the continuation is resumed
8+
/// multiple times, or if an object is destroyed without its continuation
9+
/// ever being resumed.
10+
///
11+
/// Raw `UnsafeContinuation`, like other unsafe constructs, requires the
12+
/// user to apply it correctly in order to maintain invariants. The key
13+
/// invariant is that the continuation must be resumed exactly once,
14+
/// and bad things happen if this invariant is not upheld--if a continuation
15+
/// is abandoned without resuming the task, then the task will be stuck in
16+
/// the suspended state forever, and conversely, if the same continuation is
17+
/// resumed multiple times, it will put the task in an undefined state.
18+
/// `UnsafeContinuation` avoids enforcing these invariants at runtime because
19+
/// it aims to be a low-overhead mechanism for interfacing Swift tasks with
20+
/// event loops, delegate methods, callbacks, and other non-`async` scheduling
21+
/// mechanisms. However, during development, being able to verify that the
22+
/// invariants are being upheld in testing is important.
23+
///
24+
/// `CheckedContinuation` is designed to be a drop-in API replacement for
25+
/// `UnsafeContinuation` that can be used for testing purposes, at the cost
26+
/// of an extra allocation and indirection for the wrapper object.
27+
/// Changing a call of `withUnsafeContinuation` into a call of
28+
/// `withCheckedContinuation` should be enough to obtain the extra checking
29+
/// without further source modification in most circumstances.
30+
public final class CheckedContinuation<T> {
31+
var continuation: UnsafeContinuation<T>?
32+
var function: String
33+
34+
/// Initialize a `CheckedContinuation` wrapper around an
35+
/// `UnsafeContinuation`.
36+
///
37+
/// In most cases, you should use `withCheckedContinuation` instead.
38+
/// You only need to initialize your own `CheckedContinuation<T>` if you
39+
/// already have an `UnsafeContinuation` you want to impose checking on.
40+
///
41+
/// - Parameters:
42+
/// - continuation: a fresh `UnsafeContinuation` that has not yet
43+
/// been resumed. The `UnsafeContinuation` must not be used outside of
44+
/// this object once it's been given to the new object.
45+
/// - function: a string identifying the declaration that is the notional
46+
/// source for the continuation, used to identify the continuation in
47+
/// runtime diagnostics related to misuse of this continuation.
48+
public init(continuation: UnsafeContinuation<T>, function: String = #function) {
49+
self.continuation = continuation
50+
self.function = function
51+
}
52+
53+
/// Resume the task awaiting the continuation by having it return normally
54+
/// from its suspension point.
55+
///
56+
/// A continuation must be resumed exactly once. If the continuation has
57+
/// already been resumed through this object, then the attempt to resume
58+
/// the continuation again will be logged, but otherwise have no effect.
59+
public func resume(returning x: __owned T) {
60+
if let c = continuation {
61+
c.resume(returning: x)
62+
// Clear out the continuation so we don't try to resume again
63+
continuation = nil
64+
} else {
65+
logFailedCheck("SWIFT TASK CONTINUATION MISUSE: \(function) tried to resume its continuation more than once, returning \(x)!\n")
66+
}
67+
}
68+
69+
/// Log if the object is deallocated before its continuation is resumed.
70+
deinit {
71+
if continuation != nil {
72+
logFailedCheck("SWIFT TASK CONTINUATION MISUSE: \(function) leaked its continuation!\n")
73+
}
74+
}
75+
}
76+
77+
public func withCheckedContinuation<T>(
78+
function: String = #function,
79+
_ body: (CheckedContinuation<T>) -> Void
80+
) async -> T {
81+
return await withUnsafeContinuation {
82+
body(CheckedContinuation(continuation: $0, function: function))
83+
}
84+
}
85+
86+
/// A wrapper class for `UnsafeThrowingContinuation` that logs misuses of the
87+
/// continuation, logging a message if the continuation is resumed
88+
/// multiple times, or if an object is destroyed without its continuation
89+
/// ever being resumed.
90+
///
91+
/// Raw `UnsafeThrowingContinuation`, like other unsafe constructs, requires the
92+
/// user to apply it correctly in order to maintain invariants. The key
93+
/// invariant is that the continuation must be resumed exactly once,
94+
/// and bad things happen if this invariant is not upheld--if a continuation
95+
/// is abandoned without resuming the task, then the task will be stuck in
96+
/// the suspended state forever, and conversely, if the same continuation is
97+
/// resumed multiple times, it will put the task in an undefined state.
98+
/// `UnsafeThrowingContinuation` avoids enforcing these invariants at runtime because
99+
/// it aims to be a low-overhead mechanism for interfacing Swift tasks with
100+
/// event loops, delegate methods, callbacks, and other non-`async` scheduling
101+
/// mechanisms. However, during development, being able to verify that the
102+
/// invariants are being upheld in testing is important.
103+
///
104+
/// `CheckedThrowingContinuation` is designed to be a drop-in API replacement for
105+
/// `UnsafeThrowingContinuation` that can be used for testing purposes, at the cost
106+
/// of an extra allocation and indirection for the wrapper object.
107+
/// Changing a call of `withUnsafeThrowingContinuation` into a call of
108+
/// `withCheckedThrowingContinuation` should be enough to obtain the extra checking
109+
/// without further source modification in most circumstances.
110+
public final class CheckedThrowingContinuation<T> {
111+
var continuation: UnsafeThrowingContinuation<T>?
112+
var function: String
113+
114+
/// Initialize a `CheckedThrowingContinuation` wrapper around an
115+
/// `UnsafeThrowingContinuation`.
116+
///
117+
/// In most cases, you should use `withCheckedThrowingContinuation` instead.
118+
/// You only need to initialize your own `CheckedThrowingContinuation<T>` if you
119+
/// already have an `UnsafeThrowingContinuation` you want to impose checking on.
120+
///
121+
/// - Parameters:
122+
/// - continuation: a fresh `UnsafeThrowingContinuation` that has not yet
123+
/// been resumed. The `UnsafeThrowingContinuation` must not be used outside of
124+
/// this object once it's been given to the new object.
125+
/// - function: a string identifying the declaration that is the notional
126+
/// source for the continuation, used to identify the continuation in
127+
/// runtime diagnostics related to misuse of this continuation.
128+
public init(continuation: UnsafeThrowingContinuation<T>, function: String = #function) {
129+
self.continuation = continuation
130+
self.function = function
131+
}
132+
133+
/// Resume the task awaiting the continuation by having it return normally
134+
/// from its suspension point.
135+
///
136+
/// A continuation must be resumed exactly once. If the continuation has
137+
/// already been resumed through this object, whether by `resume(returning:)`
138+
/// or by `resume(throwing:)`, then the attempt to resume
139+
/// the continuation again will be logged, but otherwise have no effect.
140+
public func resume(returning x: __owned T) {
141+
if let c = continuation {
142+
c.resume(returning: x)
143+
// Clear out the continuation so we don't try to resume again
144+
continuation = nil
145+
} else {
146+
logFailedCheck("SWIFT TASK CONTINUATION MISUSE: \(function) tried to resume its continuation more than once, returning \(x)!\n")
147+
}
148+
}
149+
150+
/// Resume the task awaiting the continuation by having it throw an error
151+
/// from its suspension point.
152+
///
153+
/// A continuation must be resumed exactly once. If the continuation has
154+
/// already been resumed through this object, whether by `resume(returning:)`
155+
/// or by `resume(throwing:)`, then the attempt to resume
156+
/// the continuation again will be logged, but otherwise have no effect.
157+
public func resume(throwing x: __owned Error) {
158+
if let c = continuation {
159+
c.resume(throwing: x)
160+
// Clear out the continuation so we don't try to resume again
161+
continuation = nil
162+
} else {
163+
logFailedCheck("SWIFT TASK CONTINUATION MISUSE: \(function) tried to resume its continuation more than once, throwing \(x)!\n")
164+
}
165+
}
166+
167+
/// Log if the object is deallocated before its continuation is resumed.
168+
deinit {
169+
if continuation != nil {
170+
logFailedCheck("SWIFT TASK CONTINUATION MISUSE: \(function) leaked its continuation!\n")
171+
}
172+
}
173+
}
174+
175+
public func withCheckedThrowingContinuation<T>(
176+
function: String = #function,
177+
_ body: (CheckedThrowingContinuation<T>) -> Void
178+
) async throws -> T {
179+
return await try withUnsafeThrowingContinuation {
180+
body(CheckedThrowingContinuation(continuation: $0, function: function))
181+
}
182+
}
183+

stdlib/public/Concurrency/Task.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,3 +523,8 @@ void swift::swift_continuation_throwingResumeWithError(/* +1 */ SwiftError *erro
523523
bool swift::swift_task_isCancelled(AsyncTask *task) {
524524
return task->isCancelled();
525525
}
526+
527+
SWIFT_CC(swift)
528+
void swift::swift_continuation_logFailedCheck(const char *message) {
529+
swift_reportError(0, message);
530+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// RUN: %target-run-simple-swift(-Xfrontend -enable-experimental-concurrency) 2>&1 | %FileCheck %s
2+
3+
// REQUIRES: executable_test
4+
// REQUIRES: concurrency
5+
6+
import _Concurrency
7+
8+
struct TestError: Error {}
9+
10+
runAsyncAndBlock {
11+
let x: Int = await withCheckedContinuation { c in
12+
c.resume(returning: 17)
13+
c.resume(returning: 38)
14+
}
15+
// CHECK: main tried to resume its continuation more than once, returning 38!
16+
17+
do {
18+
let x: Int = await try withCheckedThrowingContinuation { c in
19+
c.resume(throwing: TestError())
20+
c.resume(returning: 679)
21+
c.resume(throwing: TestError())
22+
}
23+
} catch {
24+
// CHECK-NEXT: main tried to resume its continuation more than once, returning 679!
25+
// CHECK-NEXT: main tried to resume its continuation more than once, throwing TestError()!
26+
}
27+
28+
_ = Task.runDetached {
29+
let _: Void = await withCheckedContinuation { _ in
30+
/*do nothing, leaking the task*/
31+
}
32+
// TODO: Whether the detached task gets a chance to run or not before
33+
// the process exits is currently platform-dependent, and we don't yet
34+
// have the API for yielding to the task runtime.
35+
// C/HECK-NEXT: main leaked its continuation!
36+
}
37+
}

0 commit comments

Comments
 (0)