From 7b394d2050db0b51045b997ebacb86c71918872a Mon Sep 17 00:00:00 2001 From: Michael Rebello Date: Tue, 12 Sep 2023 16:23:13 -0700 Subject: [PATCH 1/4] Update Swift docs for concurrency annotations Updates the docs to correspond to the changes being made for Swift concurrency in https://github.com/bufbuild/connect-swift/pull/155. --- docs/swift/interceptors.md | 9 +++++++-- docs/swift/testing.md | 27 +++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/swift/interceptors.md b/docs/swift/interceptors.md index 91c084b..f7c3efc 100644 --- a/docs/swift/interceptors.md +++ b/docs/swift/interceptors.md @@ -59,7 +59,7 @@ let client = ProtocolClient( networkProtocol: .connect, codec: ProtoCodec(), //highlight-next-line - interceptors: [ExampleAuthInterceptor.init] + interceptors: [{ ExampleAuthInterceptor(config: $0) }] ) ) ``` @@ -70,7 +70,12 @@ path, and in LIFO order on the response path. For example, if the following interceptors are registered: ```swift -InterceptorsOption(interceptors: [A.init, B.init, C.init, D.init]) +interceptors: [ + { A(config: $0) }, + { B(config: $0) }, + { C(config: $0) }, + { D(config: $0) }, +] ``` They'll be created each time a request is initiated by the client, then diff --git a/docs/swift/testing.md b/docs/swift/testing.md index 95fa991..58483d8 100644 --- a/docs/swift/testing.md +++ b/docs/swift/testing.md @@ -87,19 +87,19 @@ import Connect import Foundation import SwiftProtobuf -public protocol Connectrpc_Eliza_V1_ElizaServiceClientInterface { +public protocol Connectrpc_Eliza_V1_ElizaServiceClientInterface: Sendable { @discardableResult - func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers, completion: @escaping (ResponseMessage) -> Void) -> Cancelable + func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers, completion: @escaping @Sendable (ResponseMessage) -> Void) -> Cancelable func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers) async -> ResponseMessage - func `converse`(headers: Headers, onResult: @escaping (StreamResult) -> Void) -> any BidirectionalStreamInterface + func `converse`(headers: Headers, onResult: @escaping @Sendable (StreamResult) -> Void) -> any BidirectionalStreamInterface func `converse`(headers: Headers) -> any BidirectionalAsyncStreamInterface } /// Concrete implementation of `Connectrpc_Eliza_V1_ElizaServiceClientInterface`. -public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_ElizaServiceClientInterface { +public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_ElizaServiceClientInterface, Sendable { private let client: ProtocolClientInterface public init(client: ProtocolClientInterface) { @@ -107,7 +107,7 @@ public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_E } @discardableResult - public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping (ResponseMessage) -> Void) -> Cancelable { + public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping @Sendable (ResponseMessage) -> Void) -> Cancelable { return self.client.unary(path: "connectrpc.eliza.v1.ElizaService/Say", request: request, headers: headers, completion: completion) } @@ -115,7 +115,7 @@ public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_E return await self.client.unary(path: "connectrpc.eliza.v1.ElizaService/Say", request: request, headers: headers) } - public func `converse`(headers: Headers = [:], onResult: @escaping (StreamResult) -> Void) -> any BidirectionalStreamInterface { + public func `converse`(headers: Headers = [:], onResult: @escaping @Sendable (StreamResult) -> Void) -> any BidirectionalStreamInterface { return self.client.bidirectionalStream(path: "connectrpc.eliza.v1.ElizaService/Converse", headers: headers, onResult: onResult) } @@ -141,12 +141,7 @@ import Foundation import SwiftProtobuf /// Mock implementation of `Connectrpc_Eliza_V1_ElizaServiceClientInterface`. -/// -/// Production implementations can be substituted with instances of this -/// class, allowing for mocking RPC calls. Behavior can be customized -/// either through the properties on this class or by -/// subclassing the class and overriding its methods. -open class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_ElizaServiceClientInterface { +public final class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_ElizaServiceClientInterface, @unchecked Sendable { private var cancellables = [Combine.AnyCancellable]() /// Mocked for calls to `say()`. @@ -161,21 +156,21 @@ open class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_Eliza public init() {} @discardableResult - open func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping (ResponseMessage) -> Void) -> Cancelable { + public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping @Sendable (ResponseMessage) -> Void) -> Cancelable { completion(self.mockSay(request)) return Cancelable {} } - open func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:]) async -> ResponseMessage { + public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:]) async -> ResponseMessage { return self.mockAsyncSay(request) } - open func `converse`(headers: Headers = [:], onResult: @escaping (StreamResult) -> Void) -> any BidirectionalStreamInterface { + public func `converse`(headers: Headers = [:], onResult: @escaping @Sendable (StreamResult) -> Void) -> any BidirectionalStreamInterface { self.mockConverse.$inputs.first { !$0.isEmpty }.sink { _ in self.mockConverse.outputs.forEach(onResult) }.store(in: &self.cancellables) return self.mockConverse } - open func `converse`(headers: Headers = [:]) -> any BidirectionalAsyncStreamInterface { + public func `converse`(headers: Headers = [:]) -> any BidirectionalAsyncStreamInterface { return self.mockAsyncConverse } } From 45f05dcb2a1c5ac314a53cf0e0197d1b9829d31e Mon Sep 17 00:00:00 2001 From: Michael Rebello Date: Wed, 13 Sep 2023 10:30:26 -0700 Subject: [PATCH 2/4] Update testing.md --- docs/swift/testing.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/swift/testing.md b/docs/swift/testing.md index 58483d8..84b0df3 100644 --- a/docs/swift/testing.md +++ b/docs/swift/testing.md @@ -141,7 +141,7 @@ import Foundation import SwiftProtobuf /// Mock implementation of `Connectrpc_Eliza_V1_ElizaServiceClientInterface`. -public final class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_ElizaServiceClientInterface, @unchecked Sendable { +open class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_ElizaServiceClientInterface, @unchecked Sendable { private var cancellables = [Combine.AnyCancellable]() /// Mocked for calls to `say()`. @@ -156,21 +156,21 @@ public final class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_ public init() {} @discardableResult - public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping @Sendable (ResponseMessage) -> Void) -> Cancelable { + open func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping @Sendable (ResponseMessage) -> Void) -> Cancelable { completion(self.mockSay(request)) return Cancelable {} } - public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:]) async -> ResponseMessage { + open func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:]) async -> ResponseMessage { return self.mockAsyncSay(request) } - public func `converse`(headers: Headers = [:], onResult: @escaping @Sendable (StreamResult) -> Void) -> any BidirectionalStreamInterface { + open func `converse`(headers: Headers = [:], onResult: @escaping @Sendable (StreamResult) -> Void) -> any BidirectionalStreamInterface { self.mockConverse.$inputs.first { !$0.isEmpty }.sink { _ in self.mockConverse.outputs.forEach(onResult) }.store(in: &self.cancellables) return self.mockConverse } - public func `converse`(headers: Headers = [:]) -> any BidirectionalAsyncStreamInterface { + open func `converse`(headers: Headers = [:]) -> any BidirectionalAsyncStreamInterface { return self.mockAsyncConverse } } From 39cb3c1e88ffa94db1160eff2f848583181b8911 Mon Sep 17 00:00:00 2001 From: Michael Rebello Date: Wed, 13 Sep 2023 10:41:05 -0700 Subject: [PATCH 3/4] fixup --- docs/swift/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/swift/testing.md b/docs/swift/testing.md index 84b0df3..200341c 100644 --- a/docs/swift/testing.md +++ b/docs/swift/testing.md @@ -234,7 +234,7 @@ final class ElizaAppTests: XCTestCase { let client = Connectrpc_Eliza_V1_ElizaServiceClientMock() client.mockAsyncSay = { request in XCTAssertEqual(request.sentence, "hello!") - return ResponseMessage(message: .with { $0.sentence = "hi, i'm eliza!" }) + return ResponseMessage(result: .success(.with { $0.sentence = "hi, i'm eliza!" })) } let viewModel = MessagingViewModel(elizaClient: client) From cc74bead0043428461e262d8e75a3eca5848c6a6 Mon Sep 17 00:00:00 2001 From: Michael Rebello Date: Wed, 13 Sep 2023 16:15:08 -0700 Subject: [PATCH 4/4] add notes on testing --- docs/swift/testing.md | 72 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/swift/testing.md b/docs/swift/testing.md index 200341c..3785003 100644 --- a/docs/swift/testing.md +++ b/docs/swift/testing.md @@ -275,6 +275,78 @@ func testBidirectionalStreamMessagingViewModel() async { } ``` +## Testing with `@Sendable` closures + +If your codebase is not yet using `async`/`await` and is instead consuming +generated clients that provide completion/result closures which are annotated +with `@Sendable`, writing tests can prove challenging. For example: + +```swift +func testGetUser() { + let client = Users_V1_UsersMock() + client.mockGetUserInfo = { request in + return ResponseMessage(result: .success(...)) + } + + var receivedMessage: Users_V1_UserInfoResponse? + client.getUserInfo(request: Users_V1_UserInfoRequest()) { response in + //highlight-next-line + // ERROR: Mutation of captured var 'receivedMessage' in concurrently-executing code + //highlight-next-line + receivedMessage = response.message + } + XCTAssertEqual(receivedMessage?.name, "jane") +} +``` + +One workaround for this is to wrap the captured type with a class +that conforms to `Sendable`. For example: + +```swift +public final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var wrappedValue: T + + /// Thread-safe access to the underlying value. + public var value: T { + get { + self.lock.lock() + defer { self.lock.unlock() } + return self.wrappedValue + } + set { + self.lock.lock() + self.wrappedValue = newValue + self.lock.unlock() + } + } + + public init(_ value: T) { + self.wrappedValue = value + } +} +``` + +The above error can be solved by updating the test to use this wrapper: + +```swift +func testGetUser() { + let client = Users_V1_UsersMock() + client.mockGetUserInfo = { request in + return ResponseMessage(result: .success(...)) + } + + //highlight-next-line + let receivedMessage = Locked(nil) + client.getUserInfo(request: Users_V1_UserInfoRequest()) { response in + //highlight-next-line + receivedMessage.value = response.message + } + //highlight-next-line + XCTAssertEqual(receivedMessage.value?.name, "jane") +} +``` + [connect-swift]: https://github.com/bufbuild/connect-swift [connect-swift-plugin]: https://buf.build/bufbuild/connect-swift [connect-swift-mocks-plugin]: https://buf.build/bufbuild/connect-swift-mocks