diff --git a/Package.swift b/Package.swift index 54059e4..db38820 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,10 @@ let products: [Product] = [ name: "GRPCInterceptors", targets: ["GRPCInterceptors"] ), + .library( + name: "GRPCServiceLifecycle", + targets: ["GRPCServiceLifecycle"] + ), .library( name: "GRPCInteropTests", targets: ["GRPCInteropTests"] @@ -39,7 +43,7 @@ let products: [Product] = [ let dependencies: [Package.Dependency] = [ .package( url: "https://github.com/grpc/grpc-swift.git", - exact: "2.0.0-beta.3" + branch: "main" ), .package( url: "https://github.com/grpc/grpc-swift-protobuf.git", @@ -53,6 +57,10 @@ let dependencies: [Package.Dependency] = [ url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.1.2" ), + .package( + url: "https://github.com/swift-server/swift-service-lifecycle.git", + from: "2.6.3" + ), ] let defaultSwiftSettings: [SwiftSetting] = [ @@ -126,6 +134,26 @@ let targets: [Target] = [ swiftSettings: defaultSwiftSettings ), + // Retroactive conformances of gRPC client and server to swift-server-lifecycle's Service. + .target( + name: "GRPCServiceLifecycle", + dependencies: [ + .product(name: "GRPCCore", package: "grpc-swift"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + ], + swiftSettings: defaultSwiftSettings + ), + .testTarget( + name: "GRPCServiceLifecycleTests", + dependencies: [ + .target(name: "GRPCServiceLifecycle"), + .product(name: "GRPCCore", package: "grpc-swift"), + .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), + .product(name: "GRPCInProcessTransport", package: "grpc-swift"), + ], + swiftSettings: defaultSwiftSettings + ), + // gRPC interop test implementation. .target( name: "GRPCInteropTests", diff --git a/Sources/GRPCServiceLifecycle/GRPCClient+Service.swift b/Sources/GRPCServiceLifecycle/GRPCClient+Service.swift new file mode 100644 index 0000000..b601e59 --- /dev/null +++ b/Sources/GRPCServiceLifecycle/GRPCClient+Service.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2025, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +public import GRPCCore +public import ServiceLifecycle + +// A `@retroactive` conformance here is okay because this project is also owned by the owners of +// `GRPCCore`, and thus, the owners of `GRPCClient`. A conflicting conformance won't be added. +extension GRPCClient: @retroactive Service { + public func run() async throws { + try await withGracefulShutdownHandler { + try await self.runConnections() + } onGracefulShutdown: { + self.beginGracefulShutdown() + } + } +} diff --git a/Sources/GRPCServiceLifecycle/GRPCServer+Service.swift b/Sources/GRPCServiceLifecycle/GRPCServer+Service.swift new file mode 100644 index 0000000..95acf02 --- /dev/null +++ b/Sources/GRPCServiceLifecycle/GRPCServer+Service.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2025, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +public import GRPCCore +public import ServiceLifecycle + +// A `@retroactive` conformance here is okay because this project is also owned by the owners of +// `GRPCCore`, and thus, the owners of `GRPCServer`. A conflicting conformance won't be added. +extension GRPCServer: @retroactive Service { + public func run() async throws { + try await withGracefulShutdownHandler { + try await self.serve() + } onGracefulShutdown: { + self.beginGracefulShutdown() + } + } +} diff --git a/Tests/GRPCServiceLifecycleTests/ServiceLifecycleConformanceTests.swift b/Tests/GRPCServiceLifecycleTests/ServiceLifecycleConformanceTests.swift new file mode 100644 index 0000000..c509cb1 --- /dev/null +++ b/Tests/GRPCServiceLifecycleTests/ServiceLifecycleConformanceTests.swift @@ -0,0 +1,60 @@ +/* + * Copyright 2025, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GRPCCore +import GRPCInProcessTransport +import GRPCServiceLifecycle +import ServiceLifecycleTestKit +import Testing + +@Suite("gRPC ServiceLifecycle/Service conformance tests") +struct ServiceLifecycleConformanceTests { + @Test("Client respects graceful shutdown") + func clientGracefulShutdown() async throws { + let inProcess = InProcessTransport() + try await testGracefulShutdown { trigger in + try await withThrowingDiscardingTaskGroup { group in + group.addTask { + let client = GRPCClient(transport: inProcess.client) + try await client.run() + } + + group.addTask { + try await Task.sleep(for: .milliseconds(10)) + trigger.triggerGracefulShutdown() + } + } + } + } + + @Test("Server respects graceful shutdown") + func serverGracefulShutdown() async throws { + let inProcess = InProcessTransport() + try await testGracefulShutdown { trigger in + try await withThrowingDiscardingTaskGroup { group in + group.addTask { + let server = GRPCServer(transport: inProcess.server, services: []) + try await server.run() + } + + group.addTask { + try await Task.sleep(for: .milliseconds(10)) + trigger.triggerGracefulShutdown() + } + } + } + } +} diff --git a/Tests/InProcessInteropTests/InProcessInteroperabilityTests.swift b/Tests/InProcessInteropTests/InProcessInteroperabilityTests.swift index af0459c..685b243 100644 --- a/Tests/InProcessInteropTests/InProcessInteroperabilityTests.swift +++ b/Tests/InProcessInteropTests/InProcessInteroperabilityTests.swift @@ -35,7 +35,7 @@ final class InProcessInteroperabilityTests: XCTestCase { try await withThrowingTaskGroup(of: Void.self) { clientGroup in let client = GRPCClient(transport: inProcess.client) clientGroup.addTask { - try await client.run() + try await client.runConnections() } try await interopTestCase.makeTest().run(client: client)