Skip to content

Commit 3b72191

Browse files
authored
Prevent duplicate resume continuation on unary request (#340)
Issue #333 reported an issue where the continuation in the `UnaryAsyncWrapper` was getting resumed twice, which results in a fatal error. Additional details are available in the issue, but the gist is - When the client is configured to use the gRPC protocol (therefore using swift-nio for networking instead of URLSession), and it is configured with a short timeout internal, when the server responds in error at around the same time that the timeout occurs, the callback that resumes the continuation may fire twice. I was able to replicate this issue by adjusting the Eliza app's `ProtocolClient` to have a timeout of 0.25s and to point Eliza at a different Connect server (my own team's), such that requests to the Eliza RPCs would result in 404 errors. This change simply introduces a thread-safe `Bool` to track whether the continuation has been resumed, and if the callback attempts to fire twice, it will throw an `assertionFailure` and return instead of calling `resume` again and causing a fatal error. Closes #333 --------- Signed-off-by: Eddie Seay <[email protected]>
1 parent 6535324 commit 3b72191

File tree

2 files changed

+38
-12
lines changed

2 files changed

+38
-12
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ jobs:
123123
run-swiftlint:
124124
runs-on: ubuntu-latest
125125
container:
126-
image: ghcr.io/realm/swiftlint:0.57.1
126+
image: ghcr.io/realm/swiftlint:0.58.2
127127
steps:
128128
- uses: actions/checkout@v4
129129
- name: Run SwiftLint

Libraries/Connect/Internal/Unary/UnaryAsyncWrapper.swift

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import os.log
1516
import SwiftProtobuf
1617

1718
/// Internal actor used to wrap closure-based unary API calls in a way that allows them
@@ -21,7 +22,7 @@ import SwiftProtobuf
2122
/// https://forums.swift.org/t/how-to-use-withtaskcancellationhandler-properly/54341/37
2223
/// https://stackoverflow.com/q/71898080
2324
@available(iOS 13, *)
24-
actor UnaryAsyncWrapper<Output: ProtobufMessage>: Sendable {
25+
actor UnaryAsyncWrapper<Output: ProtobufMessage> {
2526
private var cancelable: Cancelable?
2627
private let sendUnary: PerformClosure
2728

@@ -41,20 +42,45 @@ actor UnaryAsyncWrapper<Output: ProtobufMessage>: Sendable {
4142
///
4243
/// - returns: The response/result of the request.
4344
func send() async -> ResponseMessage<Output> {
44-
return await withTaskCancellationHandler(operation: {
45-
return await withCheckedContinuation { continuation in
46-
if Task.isCancelled {
45+
await withTaskCancellationHandler {
46+
await withCheckedContinuation { continuation in
47+
guard !Task.isCancelled else {
4748
continuation.resume(
48-
returning: .init(code: .canceled, result: .failure(.canceled()))
49+
returning: ResponseMessage(
50+
code: .canceled,
51+
result: .failure(.canceled())
52+
)
4953
)
50-
} else {
51-
self.cancelable = self.sendUnary { response in
52-
continuation.resume(returning: response)
54+
return
55+
}
56+
57+
let hasResumed = Locked(false)
58+
self.cancelable = self.sendUnary { response in
59+
// In some circumstances where a request timeout and a server
60+
// error occur at nearly the same moment, the underlying
61+
// `swift-nio` system will trigger this callback twice. This check
62+
// discards the second occurrence to avoid resuming `continuation`
63+
// multiple times, which would result in a crash.
64+
guard !hasResumed.value else {
65+
os_log(
66+
.fault,
67+
"""
68+
`sendUnary` received duplicate callback and \
69+
attempted to resume its continuation twice.
70+
"""
71+
)
72+
return
5373
}
74+
continuation.resume(returning: response)
75+
hasResumed.perform(action: { $0 = true })
5476
}
5577
}
56-
}, onCancel: {
57-
Task { await self.cancelable?.cancel() }
58-
})
78+
} onCancel: {
79+
// When `Task.cancel` signals for this function to be canceled,
80+
// the underlying function will be canceled as well.
81+
Task(priority: .high) {
82+
await self.cancelable?.cancel()
83+
}
84+
}
5985
}
6086
}

0 commit comments

Comments
 (0)