From 887b7c1d4d53aac241b9d20a997578ffb1f411d0 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 15 Sep 2025 18:26:15 +0200 Subject: [PATCH 01/21] grpc: Add client interceptor support Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ClientInterceptor.kt | 45 ++++ .../kotlin/kotlinx/rpc/grpc/GrpcClient.kt | 61 +++-- .../kotlin/kotlinx/rpc/grpc/ManagedChannel.kt | 2 + .../kotlin/kotlinx/rpc/grpc/credentials.kt | 2 + .../rpc/grpc/internal/CallbackFuture.kt | 0 .../rpc/grpc/internal/suspendClientCalls.kt | 240 +++++++++++------- .../rpc/grpc/test/BaseGrpcServiceTest.kt | 3 +- .../rpc/grpc/test/RawClientServerTest.kt | 52 ++-- .../kotlinx/rpc/grpc/test/RawClientTest.kt | 33 ++- .../grpc/test/proto/ClientInterceptorTest.kt | 178 +++++++++++++ .../rpc/grpc/test/proto/GrpcProtoTest.kt | 6 +- .../grpc/test/proto/JavaPackageOptionTest.kt | 11 +- .../kotlinx/rpc/grpc/credentials.jvm.kt | 9 +- .../kotlinx/rpc/grpc/ManagedChannel.native.kt | 2 +- .../kotlin/kotlinx/rpc/grpc/Server.native.kt | 2 +- .../kotlinx/rpc/grpc/credentials.native.kt | 7 +- 16 files changed, 485 insertions(+), 168 deletions(-) create mode 100644 grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt rename grpc/grpc-core/src/{nativeMain => commonMain}/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt (100%) create mode 100644 grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt new file mode 100644 index 000000000..ad74cc5e3 --- /dev/null +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.grpc + +import kotlinx.coroutines.flow.Flow +import kotlinx.rpc.grpc.internal.GrpcCallOptions +import kotlinx.rpc.grpc.internal.MethodDescriptor + +/** + * Represents a client call scope within a coroutine context, providing access to properties and + * functions required to manage the lifecycle and behavior of a client-side remote procedure call + * (RPC) in a coroutine-based environment. + * + * @param Request the type of the request message sent to the gRPC server. + * @param Response the type of the response message received from the gRPC server. + */ +public interface ClientCallScope { + public val method: MethodDescriptor + public val metadata: GrpcTrailers + public val callOptions: GrpcCallOptions + public fun onHeaders(block: (GrpcTrailers) -> Unit) + public fun onClose(block: (Status, GrpcTrailers) -> Unit) + public fun cancel(message: String, cause: Throwable? = null) + public fun proceed(request: Flow): Flow +} + +public interface ClientInterceptor { + + /** + * Intercepts and transforms the flow of requests and responses in a client call. + * An interceptor can throw an exception at any time to cancel the call. + * + * @param scope The scope of the client call, providing context and methods for managing + * the call lifecycle and metadata. + * @param request A flow of requests to be sent to the server. + * @return A flow of responses received from the server. + */ + public fun intercept( + scope: ClientCallScope, + request: Flow, + ): Flow + +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt index 8204b6f7f..8ba3e4fa1 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt @@ -32,8 +32,9 @@ private typealias RequestClient = Any * @field channel The [ManagedChannel] used to communicate with remote gRPC services. */ public class GrpcClient internal constructor( - private val channel: ManagedChannel, + internal val channel: ManagedChannel, messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver, + internal val interceptors: List, ) : RpcClient { private val delegates = RpcInternalConcurrentHashMap() private val messageCodecResolver = messageCodecResolver + ThrowingMessageCodecResolver @@ -58,7 +59,6 @@ public class GrpcClient internal constructor( return when (methodDescriptor.type) { MethodType.UNARY -> unaryRpc( - channel = channel.platformApi, descriptor = methodDescriptor, request = request, callOptions = callOptions, @@ -66,7 +66,6 @@ public class GrpcClient internal constructor( ) MethodType.CLIENT_STREAMING -> @Suppress("UNCHECKED_CAST") clientStreamingRpc( - channel = channel.platformApi, descriptor = methodDescriptor, requests = request as Flow, callOptions = callOptions, @@ -83,7 +82,6 @@ public class GrpcClient internal constructor( when (methodDescriptor.type) { MethodType.SERVER_STREAMING -> serverStreamingRpc( - channel = channel.platformApi, descriptor = methodDescriptor, request = request, callOptions = callOptions, @@ -91,7 +89,6 @@ public class GrpcClient internal constructor( ) MethodType.BIDI_STREAMING -> @Suppress("UNCHECKED_CAST") bidirectionalStreamingRpc( - channel = channel.platformApi, descriptor = methodDescriptor, requests = request as Flow, callOptions = callOptions, @@ -131,12 +128,10 @@ public class GrpcClient internal constructor( public fun GrpcClient( hostname: String, port: Int, - credentials: ClientCredentials? = null, - messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver, - configure: ManagedChannelBuilder<*>.() -> Unit = {}, + configure: GrpcClientConfiguration.() -> Unit = {}, ): GrpcClient { - val channel = ManagedChannelBuilder(hostname, port, credentials).apply(configure).buildChannel() - return GrpcClient(channel, messageCodecResolver) + val config = GrpcClientConfiguration().apply(configure) + return GrpcClient(ManagedChannelBuilder(hostname, port, config.credentials), config) } /** @@ -144,10 +139,46 @@ public fun GrpcClient( */ public fun GrpcClient( target: String, - credentials: ClientCredentials? = null, - messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver, - configure: ManagedChannelBuilder<*>.() -> Unit = {}, + configure: GrpcClientConfiguration.() -> Unit = {}, +): GrpcClient { + val config = GrpcClientConfiguration().apply(configure) + return GrpcClient(ManagedChannelBuilder(target, config.credentials), config) +} + +private fun GrpcClient( + builder: ManagedChannelBuilder<*>, + config: GrpcClientConfiguration, ): GrpcClient { - val channel = ManagedChannelBuilder(target, credentials).apply(configure).buildChannel() - return GrpcClient(channel, messageCodecResolver) + val channel = builder.apply { + config.overrideAuthority?.let { overrideAuthority(it) } + }.buildChannel() + return GrpcClient(channel, config.messageCodecResolver, config.interceptors) } + +public class GrpcClientConfiguration internal constructor() { + internal var messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver + internal var credentials: ClientCredentials? = null + internal var overrideAuthority: String? = null + internal val interceptors: MutableList = mutableListOf() + + public fun usePlaintext() { + credentials = createInsecureClientCredentials() + } + + public fun useCredentials(credentials: ClientCredentials) { + this@GrpcClientConfiguration.credentials = credentials + } + + public fun overrideAuthority(authority: String) { + overrideAuthority = authority + } + + public fun useMessageCodecResolver(messageCodecResolver: MessageCodecResolver) { + this.messageCodecResolver = messageCodecResolver + } + + public fun intercept(vararg interceptors: ClientInterceptor) { + this.interceptors.addAll(interceptors) + } + +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.kt index bcfa015f5..1e1dc7e5a 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.kt @@ -68,6 +68,8 @@ public interface ManagedChannel { * Builder class for [ManagedChannel]. */ public expect abstract class ManagedChannelBuilder> { + + // TODO: Not used anymore public fun usePlaintext(): T public abstract fun overrideAuthority(authority: String): T diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/credentials.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/credentials.kt index dcebf9d5f..90291ba66 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/credentials.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/credentials.kt @@ -10,6 +10,8 @@ public expect abstract class ServerCredentials public expect class InsecureClientCredentials : ClientCredentials public expect class InsecureServerCredentials : ServerCredentials +internal expect fun createInsecureClientCredentials(): ClientCredentials + public expect class TlsClientCredentials : ClientCredentials public expect class TlsServerCredentials : ServerCredentials diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt similarity index 100% rename from grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt rename to grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt index 39635bdc9..52e6c1bb2 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.single import kotlinx.rpc.grpc.* import kotlinx.rpc.internal.utils.InternalRpcApi @@ -17,8 +18,7 @@ import kotlinx.rpc.internal.utils.InternalRpcApi // https://github.com/grpc/grpc-kotlin/blob/master/stub/src/main/java/io/grpc/kotlin/ClientCalls.kt @InternalRpcApi -public suspend fun unaryRpc( - channel: GrpcChannel, +public suspend fun GrpcClient.unaryRpc( descriptor: MethodDescriptor, request: Request, callOptions: GrpcCallOptions = GrpcDefaultCallOptions, @@ -30,17 +30,15 @@ public suspend fun unaryRpc( } return rpcImpl( - channel = channel, descriptor = descriptor, callOptions = callOptions, trailers = trailers, - request = ClientRequest.Unary(request) + request = flowOf(request) ).singleOrStatus("request", descriptor) } @InternalRpcApi -public fun serverStreamingRpc( - channel: GrpcChannel, +public fun GrpcClient.serverStreamingRpc( descriptor: MethodDescriptor, request: Request, callOptions: GrpcCallOptions = GrpcDefaultCallOptions, @@ -52,17 +50,15 @@ public fun serverStreamingRpc( } return rpcImpl( - channel = channel, descriptor = descriptor, callOptions = callOptions, trailers = trailers, - request = ClientRequest.Unary(request) + request = flowOf(request) ) } @InternalRpcApi -public suspend fun clientStreamingRpc( - channel: GrpcChannel, +public suspend fun GrpcClient.clientStreamingRpc( descriptor: MethodDescriptor, requests: Flow, callOptions: GrpcCallOptions = GrpcDefaultCallOptions, @@ -74,17 +70,15 @@ public suspend fun clientStreamingRpc( } return rpcImpl( - channel = channel, descriptor = descriptor, callOptions = callOptions, trailers = trailers, - request = ClientRequest.Flowing(requests) + request = requests ).singleOrStatus("response", descriptor) } @InternalRpcApi -public fun bidirectionalStreamingRpc( - channel: GrpcChannel, +public fun GrpcClient.bidirectionalStreamingRpc( descriptor: MethodDescriptor, requests: Flow, callOptions: GrpcCallOptions = GrpcDefaultCallOptions, @@ -96,11 +90,10 @@ public fun bidirectionalStreamingRpc( } return rpcImpl( - channel = channel, descriptor = descriptor, callOptions = callOptions, trailers = trailers, - request = ClientRequest.Flowing(requests) + request = requests ) } @@ -133,86 +126,21 @@ private sealed interface ClientRequest { } } -private fun rpcImpl( - channel: GrpcChannel, +private fun GrpcClient.rpcImpl( descriptor: MethodDescriptor, callOptions: GrpcCallOptions, trailers: GrpcTrailers, - request: ClientRequest, -): Flow = flow { - coroutineScope { - val handler = channel.newCall(descriptor, callOptions) - - /* - * We maintain a buffer of size 1 so onMessage never has to block: it only gets called after - * we request a response from the server, which only happens when responses is empty and - * there is room in the buffer. - */ - val responses = Channel(1) - val ready = Ready { handler.isReady() } - - handler.start(channelResponseListener(responses, ready), trailers) - - val fullMethodName = descriptor.getFullMethodName() - val sender = launch(CoroutineName("grpc-send-message-$fullMethodName")) { - try { - request.sendTo(handler, ready) - handler.halfClose() - } catch (ex: Exception) { - handler.cancel("Collection of requests completed exceptionally", ex) - throw ex // propagate failure upward - } - } - - try { - handler.request(1) - for (response in responses) { - emit(response) - handler.request(1) - } - } catch (e: Exception) { - withContext(NonCancellable) { - sender.cancel("Collection of responses completed exceptionally", e) - sender.join() - // we want the sender to be done cancelling before we cancel the handler, or it might try - // sending to a dead call, which results in ugly exception messages - handler.cancel("Collection of responses completed exceptionally", e) - } - throw e - } - - if (!sender.isCompleted) { - sender.cancel("Collection of responses completed before collection of requests") - } - } + request: Flow, +): Flow { + val clientCallScope = ClientCallScopeImpl( + client = this, + method = descriptor, + metadata = trailers, + callOptions = callOptions, + ) + return clientCallScope.proceed(request) } -private fun channelResponseListener( - responses: Channel, - ready: Ready, -) = clientCallListener( - onHeaders = { - // todo check what happens here - }, - onMessage = { message: Response -> - responses.trySend(message).onFailure { e -> - throw e ?: AssertionError("onMessage should never be called until responses is ready") - } - }, - onClose = { status: Status, trailers: GrpcTrailers -> - val cause = when { - status.statusCode == StatusCode.OK -> null - status.getCause() is CancellationException -> status.getCause() - else -> StatusException(status, trailers) - } - - responses.close(cause = cause) - }, - onReady = { - ready.onReady() - }, -) - // todo really needed? internal fun Flow.singleOrStatusFlow( expected: String, @@ -261,3 +189,133 @@ internal class Ready(private val isReallyReady: () -> Boolean) { } } } + +private class ClientCallScopeImpl( + val client: GrpcClient, + override val method: MethodDescriptor, + override val metadata: GrpcTrailers, + override val callOptions: GrpcCallOptions, +) : ClientCallScope { + + val call = client.channel.platformApi.newCall(method, callOptions) + val interceptors = client.interceptors + val onHeadersFuture = CallbackFuture() + val onCloseFuture = CallbackFuture>() + + var interceptorIndex = 0 + + override fun onHeaders(block: (GrpcTrailers) -> Unit) { + onHeadersFuture.onComplete { block(it) } + } + + override fun onClose(block: (Status, GrpcTrailers) -> Unit) { + onCloseFuture.onComplete { block(it.first, it.second) } + } + + override fun cancel(message: String, cause: Throwable?) { + call.cancel(message, cause) + } + + override fun proceed(request: Flow): Flow { + return if (interceptorIndex < interceptors.size) { + interceptors[interceptorIndex++] + .intercept(this, request) + } else { + // if the interceptor chain is exhausted, we start the actual call + doCall(request) + } + } + + private fun doCall(request: Flow): Flow = flow { + coroutineScope { + + /* + * We maintain a buffer of size 1 so onMessage never has to block: it only gets called after + * we request a response from the server, which only happens when responses is empty and + * there is room in the buffer. + */ + val responses = Channel(1) + val ready = Ready { call.isReady() } + + call.start(channelResponseListener(responses, ready), metadata) + + suspend fun Flow.send() { + if (method.type == MethodType.UNARY || method.type == MethodType.SERVER_STREAMING) { + call.sendMessage(single()) + } else { + ready.suspendUntilReady() + this.collect { request -> + call.sendMessage(request) + ready.suspendUntilReady() + } + } + } + + val fullMethodName = method.getFullMethodName() + val sender = launch(CoroutineName("grpc-send-message-$fullMethodName")) { + try { + request.send() + call.halfClose() + } catch (ex: Exception) { + call.cancel("Collection of requests completed exceptionally", ex) + throw ex // propagate failure upward + } + } + + try { + call.request(1) + for (response in responses) { + emit(response) + call.request(1) + } + } catch (e: Exception) { + withContext(NonCancellable) { + sender.cancel("Collection of responses completed exceptionally", e) + sender.join() + // we want the sender to be done cancelling before we cancel the handler, or it might try + // sending to a dead call, which results in ugly exception messages + call.cancel("Collection of responses completed exceptionally", e) + } + throw e + } + + if (!sender.isCompleted) { + sender.cancel("Collection of responses completed before collection of requests") + } + } + } + + private fun channelResponseListener( + responses: Channel, + ready: Ready, + ) = clientCallListener( + onHeaders = { onHeadersFuture.complete(it) }, + onMessage = { message: Response -> + responses.trySend(message).onFailure { e -> + throw e ?: AssertionError("onMessage should never be called until responses is ready") + } + }, + onClose = { status: Status, trailers: GrpcTrailers -> + var cause = when { + status.statusCode == StatusCode.OK -> null + status.getCause() is CancellationException -> status.getCause() + else -> StatusException(status, trailers) + } + + try { + onCloseFuture.complete(status to trailers) + } catch (exception: Throwable) { + cause = exception + if (exception !is StatusException) { + val status = Status(StatusCode.INTERNAL, "Interceptor threw an error", exception) + cause = StatusException(status) + } + } + + responses.close(cause = cause) + }, + onReady = { + ready.onReady() + }, + ) +} diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt index 08ed33cd4..7bc082776 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt @@ -39,7 +39,8 @@ abstract class BaseGrpcServiceTest { server.start() - val client = GrpcClient("localhost", PORT, messageCodecResolver = resolver) { + val client = GrpcClient("localhost", PORT) { + useMessageCodecResolver(messageCodecResolver) usePlaintext() } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt index 2702d32a2..535f9feba 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt @@ -14,10 +14,9 @@ import kotlinx.io.Buffer import kotlinx.io.Source import kotlinx.io.readString import kotlinx.io.writeString -import kotlinx.rpc.grpc.ManagedChannelBuilder +import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.Server import kotlinx.rpc.grpc.ServerBuilder -import kotlinx.rpc.grpc.buildChannel import kotlinx.rpc.grpc.codec.SourcedMessageCodec import kotlinx.rpc.grpc.internal.GrpcChannel import kotlinx.rpc.grpc.internal.MethodDescriptor @@ -48,8 +47,8 @@ class RawClientServerTest { methodDefinition = { descriptor -> unaryServerMethodDefinition(descriptor, typeOf()) { it + it } }, - ) { channel, descriptor -> - val response = unaryRpc(channel, descriptor, "Hello") + ) { client, descriptor -> + val response = client.unaryRpc(descriptor, "Hello") assertEquals("HelloHello", response) } @@ -63,8 +62,8 @@ class RawClientServerTest { flowOf(it, it) } } - ) { channel, descriptor -> - val response = serverStreamingRpc(channel, descriptor, "Hello") + ) { client, descriptor -> + val response = client.serverStreamingRpc(descriptor, "Hello") assertEquals(listOf("Hello", "Hello"), response.toList()) } @@ -78,40 +77,41 @@ class RawClientServerTest { it.toList().joinToString(separator = "") } } - ) { channel, descriptor -> - val response = clientStreamingRpc(channel, descriptor, flowOf("Hello", "World")) + ) { client, descriptor -> + val response = client.clientStreamingRpc(descriptor, flowOf("Hello", "World")) assertEquals("HelloWorld", response) } @Test - fun bidirectionalStreamingCall() = runTest( - methodName = "bidirectionalStreaming", - type = MethodType.BIDI_STREAMING, - methodDefinition = { descriptor -> - bidiStreamingServerMethodDefinition(descriptor, typeOf()) { - it.map { str -> str + str } + fun bidirectionalStreamingCall() { + runTest( + methodName = "bidirectionalStreaming", + type = MethodType.BIDI_STREAMING, + methodDefinition = { descriptor -> + bidiStreamingServerMethodDefinition(descriptor, typeOf()) { + it.map { str -> str + str } + } } - } - ) { channel, descriptor -> - val response = bidirectionalStreamingRpc(channel, descriptor, flowOf("Hello", "World")) - .toList() + ) { client, descriptor -> + val response = client.bidirectionalStreamingRpc(descriptor, flowOf("Hello", "World")) + .toList() - assertEquals(listOf("HelloHello", "WorldWorld"), response) + assertEquals(listOf("HelloHello", "WorldWorld"), response) + } } - private fun runTest( methodName: String, type: MethodType, methodDefinition: CoroutineScope.(MethodDescriptor) -> ServerMethodDefinition, - block: suspend (GrpcChannel, MethodDescriptor) -> Unit, + block: suspend (GrpcClient, MethodDescriptor) -> Unit, ) = kotlinx.coroutines.test.runTest { val serverJob = Job() val serverScope = CoroutineScope(serverJob) - val clientChannel = ManagedChannelBuilder("localhost", PORT).apply { + val client = GrpcClient("localhost", PORT) { usePlaintext() - }.buildChannel() + } val descriptor = methodDescriptor( fullMethodName = "${SERVICE_NAME}/$methodName", @@ -139,11 +139,11 @@ class RawClientServerTest { val server = Server(builder) server.start() - block(clientChannel.platformApi, descriptor) + block(client, descriptor) serverJob.cancelAndJoin() - clientChannel.shutdown() - clientChannel.awaitTermination() + client.shutdown() + client.awaitTermination() server.shutdown() server.awaitTermination() } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt index f50105f5a..4689ba394 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt @@ -9,9 +9,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest +import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.GrpcServer -import kotlinx.rpc.grpc.ManagedChannelBuilder -import kotlinx.rpc.grpc.buildChannel import kotlinx.rpc.grpc.internal.* import kotlinx.rpc.registerService import kotlin.test.Test @@ -32,8 +31,8 @@ class RawClientTest { fun unaryEchoTest() = runTest( methodName = "UnaryEcho", type = MethodType.UNARY, - ) { channel, descriptor -> - val response = unaryRpc(channel, descriptor, EchoRequest { message = "Eccchhooo" }) + ) { client, descriptor -> + val response = client.unaryRpc(descriptor, EchoRequest { message = "Eccchhooo" }) assertEquals("Eccchhooo", response.message) } @@ -41,8 +40,8 @@ class RawClientTest { fun serverStreamingEchoTest() = runTest( methodName = "ServerStreamingEcho", type = MethodType.SERVER_STREAMING, - ) { channel, descriptor -> - val response = serverStreamingRpc(channel, descriptor, EchoRequest { message = "Eccchhooo" }) + ) { client, descriptor -> + val response = client.serverStreamingRpc(descriptor, EchoRequest { message = "Eccchhooo" }) var i = 0 response.collect { println("Received: ${i++}") @@ -54,8 +53,8 @@ class RawClientTest { fun clientStreamingEchoTest() = runTest( methodName = "ClientStreamingEcho", type = MethodType.CLIENT_STREAMING, - ) { channel, descriptor -> - val response = clientStreamingRpc(channel, descriptor, flow { + ) { client, descriptor -> + val response = client.clientStreamingRpc(descriptor, flow { repeat(5) { delay(100) println("Sending: ${it + 1}") @@ -70,8 +69,8 @@ class RawClientTest { fun bidirectionalStreamingEchoTest() = runTest( methodName = "BidirectionalStreamingEcho", type = MethodType.BIDI_STREAMING, - ) { channel, descriptor -> - val response = bidirectionalStreamingRpc(channel, descriptor, flow { + ) { client, descriptor -> + val response = client.bidirectionalStreamingRpc(descriptor, flow { repeat(5) { emit(EchoRequest { message = "Eccchhooo" }) } @@ -88,11 +87,11 @@ class RawClientTest { fun runTest( methodName: String, type: MethodType, - block: suspend (GrpcChannel, MethodDescriptor) -> Unit, + block: suspend (GrpcClient, MethodDescriptor) -> Unit, ) = runTest { - val channel = ManagedChannelBuilder("localhost:50051") - .usePlaintext() - .buildChannel() + val client = GrpcClient("localhost:50051") { + usePlaintext() + } val methodDescriptor = methodDescriptor( fullMethodName = "kotlinx.rpc.grpc.test.EchoService/$methodName", @@ -106,10 +105,10 @@ class RawClientTest { ) try { - block(channel.platformApi, methodDescriptor) + block(client, methodDescriptor) } finally { - channel.shutdown() - channel.awaitTermination() + client.shutdown() + client.awaitTermination() } } } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt new file mode 100644 index 000000000..bffb3c409 --- /dev/null +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.grpc.test.proto + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.rpc.RpcServer +import kotlinx.rpc.grpc.ClientCallScope +import kotlinx.rpc.grpc.ClientInterceptor +import kotlinx.rpc.grpc.GrpcClient +import kotlinx.rpc.grpc.StatusCode +import kotlinx.rpc.grpc.StatusException +import kotlinx.rpc.grpc.internal.bidirectionalStreamingRpc +import kotlinx.rpc.grpc.statusCode +import kotlinx.rpc.grpc.test.EchoRequest +import kotlinx.rpc.grpc.test.EchoResponse +import kotlinx.rpc.grpc.test.EchoService +import kotlinx.rpc.grpc.test.EchoServiceImpl +import kotlinx.rpc.grpc.test.invoke +import kotlinx.rpc.registerService +import kotlinx.rpc.withService +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ClientInterceptorTest: GrpcProtoTest() { + + override fun RpcServer.registerServices() { + registerService { EchoServiceImpl() } + } + + @Test + fun `throw during intercept - should fail with thrown exception`() { + val error = assertFailsWith { + val interceptor = interceptor { _, _ -> + throw IllegalStateException("Failing in interceptor") + } + runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) + } + + assertEquals(error.message, "Failing in interceptor") + } + + @Test + fun `throw during onHeader - should fail with status exception containing the thrown exception`() { + val error = assertFailsWith { + val interceptor = interceptor { scope, req -> + scope.onHeaders { + throw IllegalStateException("Failing in onHeader") + } + scope.proceed(req) + } + runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) + } + + assertIs(error.cause) + assertEquals("Failing in onHeader", error.cause?.message) + } + + @Test + fun `throw during onClose - should fail with status exception containing the thrown exception`() { + val error = assertFailsWith { + val interceptor = interceptor { scope, req -> + scope.onClose { _, _ -> + throw IllegalStateException("Failing in onClose") + } + scope.proceed(req) + } + runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) + } + + assertIs(error.cause) + assertEquals("Failing in onClose", error.cause?.message) + } + + @Test + fun `cancel in intercept - should fail with cancellation`() { + val error = assertFailsWith { + val interceptor = interceptor { scope, req -> + scope.cancel("Canceling in interceptor", IllegalStateException("Cancellation cause")) + scope.proceed(req) + } + runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) + } + + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) + assertContains(error.message!!, "Canceling in interceptor") + assertIs(error.cause) + assertEquals("Cancellation cause", error.cause?.message) + } + + @Test + fun `modify request message - should return modified message`() { + val interceptor = interceptor { scope, req -> + val modified = req.map { EchoRequest { message = "Modified" } } + scope.proceed(modified) + } + runGrpcTest(clientInterceptors = interceptor) { + val service = it.withService() + val response = service.UnaryEcho(EchoRequest { message = "Hello" }) + assertEquals("Modified", response.message) + } + } + + @Test + fun `modify response message - should return modified message`() { + val interceptor = interceptor { scope, req -> + scope.proceed(req).map { EchoResponse { message = "Modified" } } + } + runGrpcTest(clientInterceptors = interceptor) { + val service = it.withService() + val response = service.UnaryEcho(EchoRequest { message = "Hello" }) + assertEquals("Modified", response.message) + } + } + + @Test + fun `append a response message once closed`() = repeat(1000) { + val interceptor = interceptor { scope, req -> channelFlow { + scope.proceed(req).collect { + trySend(it) + } + scope.onClose { _, _ -> + trySend(EchoResponse { message = "Appended-after-close" }) + } + } } + + runGrpcTest( + clientInterceptors = interceptor + ) { client -> + val svc = client.withService() + val responses = svc.BidirectionalStreamingEcho(flow { + repeat(5) { + emit(EchoRequest { message = "Eccchhooo" }) + } + }).toList() + + println("Respone messages: ${responses.map { it.message }}") + assertEquals(6, responses.size) + assertTrue(responses.any { it.message == "Appended-after-close" }) + } + } + + private suspend fun unaryCall(grpcClient: GrpcClient) { + val service = grpcClient.withService() + val response = service.UnaryEcho(EchoRequest { message = "Hello" }) + assertEquals("Hello", response.message) + } + +} + +private fun interceptor( + block: (ClientCallScope , Flow) -> Flow +): List { + return listOf(object : ClientInterceptor { + @Suppress("UNCHECKED_CAST") + override fun intercept( + scope: ClientCallScope, + request: Flow, + ): Flow { + return block(scope as ClientCallScope, request as Flow) as Flow + } + }) +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt index 92050dd19..17500c76b 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.test.runTest import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.ClientCredentials +import kotlinx.rpc.grpc.ClientInterceptor import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.GrpcServer import kotlinx.rpc.grpc.ServerCredentials @@ -22,14 +23,17 @@ abstract class GrpcProtoTest { serverCreds: ServerCredentials? = null, clientCreds: ClientCredentials? = null, overrideAuthority: String? = null, + clientInterceptors: List = emptyList(), test: suspend (GrpcClient) -> Unit, ) = runTest { serverMutex.withLock { - val grpcClient = GrpcClient("localhost", PORT, credentials = clientCreds) { + val grpcClient = GrpcClient("localhost", PORT) { + if (clientCreds != null) useCredentials(clientCreds) if (overrideAuthority != null) overrideAuthority(overrideAuthority) if (clientCreds == null) { usePlaintext() } + clientInterceptors.forEach { intercept(it) } } val grpcServer = GrpcServer( diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/JavaPackageOptionTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/JavaPackageOptionTest.kt index 1c7dd3017..51db59c9a 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/JavaPackageOptionTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/JavaPackageOptionTest.kt @@ -8,8 +8,7 @@ import com.google.protobuf.kotlin.Empty import com.google.protobuf.kotlin.EmptyInternal import com.google.protobuf.kotlin.invoke import kotlinx.rpc.RpcServer -import kotlinx.rpc.grpc.ManagedChannelBuilder -import kotlinx.rpc.grpc.buildChannel +import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.internal.MethodType import kotlinx.rpc.grpc.internal.methodDescriptor import kotlinx.rpc.grpc.internal.unaryRpc @@ -35,11 +34,7 @@ class JavaPackageOptionTest : GrpcProtoTest() { * Tests that the generated service descriptor uses the `package` name. */ @Test - fun testJavaPackageOptionRaw() = runGrpcTest { _ -> - val channel = ManagedChannelBuilder("localhost", PORT) - .usePlaintext() - .buildChannel() - + fun testJavaPackageOptionRaw() = runGrpcTest { client -> val descriptor = methodDescriptor( fullMethodName = "protopackage.TheService/TheMethod", requestCodec = EmptyInternal.CODEC, @@ -51,7 +46,7 @@ class JavaPackageOptionTest : GrpcProtoTest() { sampledToLocalTracing = true, ) - unaryRpc(channel.platformApi, descriptor, Empty {}) + client.unaryRpc(descriptor, Empty {}) // just reach this without an error } diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt index 3ba8e4ce4..99d22c6eb 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt @@ -16,6 +16,11 @@ public actual typealias InsecureServerCredentials = io.grpc.InsecureServerCreden public actual typealias TlsClientCredentials = io.grpc.TlsChannelCredentials public actual typealias TlsServerCredentials = io.grpc.TlsServerCredentials + +internal actual fun createInsecureClientCredentials(): ClientCredentials { + return InsecureClientCredentials.create() +} + internal actual fun TlsClientCredentialsBuilder(): TlsClientCredentialsBuilder = JvmTlsCLientCredentialBuilder() internal actual fun TlsServerCredentialsBuilder( certChain: String, @@ -80,6 +85,4 @@ private fun TlsClientAuth.toJava(): io.grpc.TlsServerCredentials.ClientAuth = wh TlsClientAuth.NONE -> io.grpc.TlsServerCredentials.ClientAuth.NONE TlsClientAuth.OPTIONAL -> io.grpc.TlsServerCredentials.ClientAuth.OPTIONAL TlsClientAuth.REQUIRE -> io.grpc.TlsServerCredentials.ClientAuth.REQUIRE -} - - +} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt index 85a5439e6..97e628fa3 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt @@ -34,7 +34,7 @@ internal class NativeManagedChannelBuilder( private var authority: String? = null override fun usePlaintext(): NativeManagedChannelBuilder { - credentials = lazy { InsecureChannelCredentials() } + credentials = lazy { createInsecureClientCredentials() } return this } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/Server.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/Server.native.kt index 476214555..05af2c8cf 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/Server.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/Server.native.kt @@ -43,7 +43,7 @@ private class NativeServerBuilder( } internal actual fun ServerBuilder(port: Int, credentials: ServerCredentials?): ServerBuilder<*> { - return NativeServerBuilder(port, credentials ?: InsecureServerCredentials()) + return NativeServerBuilder(port, credentials ?: createInsecureServerCredentials()) } internal actual fun Server(builder: ServerBuilder<*>): Server { diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/credentials.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/credentials.native.kt index b10e7f65b..be06a7df6 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/credentials.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/credentials.native.kt @@ -61,18 +61,17 @@ public actual class TlsClientCredentials internal constructor( raw: CPointer, ) : ClientCredentials(raw) -public actual class TlsServerCredentials( +public actual class TlsServerCredentials internal constructor( raw: CPointer, ) : ServerCredentials(raw) - -public fun InsecureChannelCredentials(): ClientCredentials { +internal actual fun createInsecureClientCredentials(): ClientCredentials { return InsecureClientCredentials( grpc_insecure_credentials_create() ?: error("grpc_insecure_credentials_create() returned null") ) } -public fun InsecureServerCredentials(): ServerCredentials { +internal fun createInsecureServerCredentials(): ServerCredentials { return InsecureServerCredentials( grpc_insecure_server_credentials_create() ?: error("grpc_insecure_server_credentials_create() returned null") ) From d2ed13dba448056c03a11373bde410ba95389751 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 16 Sep 2025 12:34:05 +0200 Subject: [PATCH 02/21] grpc: Add client interceptor support Signed-off-by: Johannes Zottele --- .../rpc/grpc/internal/suspendClientCalls.kt | 2 +- .../grpc/test/proto/ClientInterceptorTest.kt | 12 ++-- .../kotlin/kotlinx/rpc/grpc/Status.native.kt | 4 ++ .../rpc/grpc/internal/NativeClientCall.kt | 56 ++++++++++++++----- 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt index 52e6c1bb2..f66a56124 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt @@ -307,7 +307,7 @@ private class ClientCallScopeImpl( } catch (exception: Throwable) { cause = exception if (exception !is StatusException) { - val status = Status(StatusCode.INTERNAL, "Interceptor threw an error", exception) + val status = Status(StatusCode.CANCELLED, "Interceptor threw an error", exception) cause = StatusException(status) } } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt index bffb3c409..3f2b01a64 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt @@ -67,6 +67,7 @@ class ClientInterceptorTest: GrpcProtoTest() { runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) } + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) assertIs(error.cause) assertEquals("Failing in onHeader", error.cause?.message) } @@ -83,6 +84,7 @@ class ClientInterceptorTest: GrpcProtoTest() { runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) } + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) assertIs(error.cause) assertEquals("Failing in onClose", error.cause?.message) } @@ -129,13 +131,13 @@ class ClientInterceptorTest: GrpcProtoTest() { } @Test - fun `append a response message once closed`() = repeat(1000) { + fun `append a response message once closed`() { val interceptor = interceptor { scope, req -> channelFlow { scope.proceed(req).collect { trySend(it) } - scope.onClose { _, _ -> - trySend(EchoResponse { message = "Appended-after-close" }) + scope.onClose { status, _ -> + trySend(EchoResponse { message = "Appended-after-close-with-${status.statusCode}" }) } } } @@ -148,10 +150,8 @@ class ClientInterceptorTest: GrpcProtoTest() { emit(EchoRequest { message = "Eccchhooo" }) } }).toList() - - println("Respone messages: ${responses.map { it.message }}") assertEquals(6, responses.size) - assertTrue(responses.any { it.message == "Appended-after-close" }) + assertTrue(responses.any { it.message == "Appended-after-close-with-OK" }) } } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/Status.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/Status.native.kt index 1f14bb606..b99808810 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/Status.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/Status.native.kt @@ -12,6 +12,10 @@ public actual class Status internal constructor( public actual fun getDescription(): String? = description public actual fun getCause(): Throwable? = cause + + override fun toString(): String { + return "Status(description=$description, statusCode=$statusCode, cause=$cause)" + } } public actual val Status.statusCode: StatusCode diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt index 69c637b32..fab968753 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt @@ -81,7 +81,7 @@ internal class NativeClientCall( private var listener: Listener? = null private var halfClosed = false private var cancelled = false - private var closed = atomic(false) + private val closed = atomic(false) // tracks how many operations are in flight (not yet completed by the listener). // if 0 and we got a closeInfo (containing the status), there are no more ongoing operations. @@ -133,7 +133,9 @@ internal class NativeClientCall( val lst = checkNotNull(listener) { internalError("Not yet started") } // allows the managed channel to join for the call to finish. callJob.complete() - lst.onClose(info.first, info.second) + safeUserCode("Failed to call onClose.") { + lst.onClose(info.first, info.second) + } } } @@ -142,9 +144,8 @@ internal class NativeClientCall( * This is called as soon as the RECV_STATUS_ON_CLIENT batch (started with [startRecvStatus]) finished. */ private fun markClosePending(status: Status, trailers: GrpcTrailers) { - if (closeInfo.compareAndSet(null, Pair(status, trailers))) { - tryToCloseCall() - } + closeInfo.compareAndSet(null, Pair(status, trailers)) + tryToCloseCall() } /** @@ -153,7 +154,9 @@ internal class NativeClientCall( */ private fun turnReady() { if (ready.compareAndSet(expect = false, update = true)) { - listener?.onReady() + safeUserCode("Failed to call onClose.") { + listener?.onReady() + } } } @@ -163,7 +166,6 @@ internal class NativeClientCall( headers: GrpcTrailers, ) { check(listener == null) { internalError("Already started") } - check(!cancelled) { internalError("Already cancelled.") } listener = responseListener @@ -254,7 +256,8 @@ internal class NativeClientCall( is BatchResult.Submitted -> { callResult.future.onComplete { val details = statusDetails.toByteArray().toKString() - val status = Status(statusCode.value.toKotlin(), details, null) + val kStatusCode = statusCode.value.toKotlin() + val status = Status(kStatusCode, details, null) val trailers = GrpcTrailers() // cleanup @@ -306,7 +309,9 @@ internal class NativeClientCall( grpc_metadata_array_destroy(meta.ptr) arena.clear() }) { - // TODO: Send headers to listener + safeUserCode("Failed to call onHeaders.") { + listener?.onHeaders(GrpcTrailers()) + } } } @@ -319,7 +324,10 @@ internal class NativeClientCall( // limit numMessages to prevent potential stack overflows check(numMessages <= 16) { internalError("numMessages must be <= 16") } val listener = checkNotNull(listener) { internalError("Not yet started") } - check(!cancelled) { internalError("Already cancelled") } + if (cancelled) { + // no need to send message if the call got already cancelled. + return + } var remainingMessages = numMessages @@ -342,7 +350,9 @@ internal class NativeClientCall( val buf = recvPtr.value ?: return@runBatch val msg = methodDescriptor.getResponseMarshaller() .parse(buf.toKotlin().asInputStream()) - listener.onMessage(msg) + safeUserCode("Failed to call onClose.") { + listener.onMessage(msg) + } post() } } @@ -353,8 +363,11 @@ internal class NativeClientCall( override fun cancel(message: String?, cause: Throwable?) { cancelled = true - val message = if (cause != null) "$message: ${cause.message}" else message - cancelInternal(grpc_status_code.GRPC_STATUS_CANCELLED, message ?: "Call cancelled") + val status = Status(StatusCode.CANCELLED, message ?: "Call cancelled", cause) + // user side cancellation must always win over any other status (even if the call is already completed). + // this will also preserve the cancellation cause, which cannot be passed to the grpc-core. + closeInfo.value = Pair(status, GrpcTrailers()) + cancelInternal(grpc_status_code.GRPC_STATUS_CANCELLED, message ?: "Call cancelled with cause: ${cause?.message}") } private fun cancelInternal(statusCode: grpc_status_code, message: String) { @@ -366,7 +379,7 @@ internal class NativeClientCall( override fun halfClose() { check(!halfClosed) { internalError("Already half closed.") } - check(!cancelled) { internalError("Already cancelled.") } + if (cancelled) return halfClosed = true val arena = Arena() @@ -384,9 +397,10 @@ internal class NativeClientCall( override fun sendMessage(message: Request) { checkNotNull(listener) { internalError("Not yet started") } check(!halfClosed) { internalError("Already half closed.") } - check(!cancelled) { internalError("Already cancelled.") } check(isReady()) { internalError("Not yet ready.") } + if (cancelled) return + // set ready false, as only one message can be sent at a time. ready.value = false @@ -408,6 +422,18 @@ internal class NativeClientCall( turnReady() } } + + /** + * Safely executes the provided block of user code, catching any thrown exceptions or errors. + * If an exception is caught, it cancels the operation with the specified message and cause. + */ + private fun safeUserCode(cancelMsg: String, block: () -> Unit) { + try { + block() + } catch (e: Throwable) { + cancel(cancelMsg, e) + } + } } From 6c189db1d7d8707a675236f40380866f0de539d6 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 16 Sep 2025 15:59:09 +0200 Subject: [PATCH 03/21] grpc: Add server interceptor support Signed-off-by: Johannes Zottele --- .../kotlin/kotlinx/rpc/grpc/GrpcServer.kt | 69 +++++++++++++---- .../kotlinx/rpc/grpc/ServerInterceptor.kt | 25 ++++++ .../kotlin/kotlinx/rpc/grpc/credentials.kt | 3 + .../rpc/grpc/internal/suspendServerCalls.kt | 77 ++++++++++++++++--- .../rpc/grpc/test/BaseGrpcServiceTest.kt | 4 +- .../rpc/grpc/test/RawClientServerTest.kt | 10 +-- .../rpc/grpc/test/proto/GrpcProtoTest.kt | 7 +- .../grpc/test/proto/ServerInterceptorTest.kt | 63 +++++++++++++++ .../kotlinx/rpc/grpc/credentials.jvm.kt | 4 + .../kotlinx/rpc/grpc/credentials.native.kt | 2 +- .../kotlinx/rpc/grpc/ktor/server/Server.kt | 2 +- 11 files changed, 229 insertions(+), 37 deletions(-) create mode 100644 grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt create mode 100644 grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt index 1a73e9c74..37780a24c 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt @@ -44,14 +44,14 @@ private typealias ResponseServer = Any * * @property port Specifies the port used by the server to listen for incoming connections. * @param parentContext - * @param configure exposes platform-specific Server builder. + * @param serverBuilder exposes platform-specific Server builder. */ public class GrpcServer internal constructor( - override val port: Int = 8080, - credentials: ServerCredentials? = null, + override val port: Int, + private val serverBuilder: ServerBuilder<*>, + private val interceptors: List, messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver, parentContext: CoroutineContext = EmptyCoroutineContext, - configure: ServerBuilder<*>.() -> Unit, ) : RpcServer, Server { private val internalContext = SupervisorJob(parentContext[Job]) private val internalScope = CoroutineScope(parentContext + internalContext) @@ -61,9 +61,8 @@ public class GrpcServer internal constructor( private var isBuilt = false private lateinit var internalServer: Server - private val serverBuilder: ServerBuilder<*> = ServerBuilder(port, credentials).apply(configure) private val registry: MutableHandlerRegistry by lazy { - MutableHandlerRegistry().apply { serverBuilder.fallbackHandlerRegistry(this) } + MutableHandlerRegistry().apply { this@GrpcServer.serverBuilder.fallbackHandlerRegistry(this) } } private val localRegistry = RpcInternalConcurrentHashMap, ServerServiceDefinition>() @@ -79,7 +78,7 @@ public class GrpcServer internal constructor( if (isBuilt) { registry.addService(definition) } else { - serverBuilder.addService(definition) + this@GrpcServer.serverBuilder.addService(definition) } } @@ -105,7 +104,8 @@ public class GrpcServer internal constructor( as? MethodDescriptor ?: error("Expected a gRPC method descriptor") - it.toDefinitionOn(methodDescriptor, service) + // TODO: support per service and per method interceptors (KRPC-222) + it.toDefinitionOn(methodDescriptor, service, interceptors) } return serverServiceDefinition(delegate.serviceDescriptor, methods) @@ -114,29 +114,40 @@ public class GrpcServer internal constructor( private fun <@Grpc Service : Any> RpcCallable.toDefinitionOn( descriptor: MethodDescriptor, service: Service, + interceptors: List, ): ServerMethodDefinition { return when (descriptor.type) { MethodType.UNARY -> { - internalScope.unaryServerMethodDefinition(descriptor, returnType.kType) { request -> + internalScope.unaryServerMethodDefinition(descriptor, returnType.kType, interceptors) { request -> unaryInvokator.call(service, arrayOf(request)) as ResponseServer } } MethodType.CLIENT_STREAMING -> { - internalScope.clientStreamingServerMethodDefinition(descriptor, returnType.kType) { requests -> + internalScope.clientStreamingServerMethodDefinition( + descriptor, + returnType.kType, + interceptors + ) { requests -> unaryInvokator.call(service, arrayOf(requests)) as ResponseServer } } MethodType.SERVER_STREAMING -> { - internalScope.serverStreamingServerMethodDefinition(descriptor, returnType.kType) { request -> + internalScope.serverStreamingServerMethodDefinition( + descriptor, returnType.kType, interceptors + ) { request -> @Suppress("UNCHECKED_CAST") flowInvokator.call(service, arrayOf(request)) as Flow } } MethodType.BIDI_STREAMING -> { - internalScope.bidiStreamingServerMethodDefinition(descriptor, returnType.kType) { requests -> + internalScope.bidiStreamingServerMethodDefinition( + descriptor, + returnType.kType, + interceptors + ) { requests -> @Suppress("UNCHECKED_CAST") flowInvokator.call(service, arrayOf(requests)) as Flow } @@ -152,7 +163,7 @@ public class GrpcServer internal constructor( internal fun build() { if (buildLock.compareAndSet(expect = false, update = true)) { - internalServer = Server(serverBuilder) + internalServer = Server(this@GrpcServer.serverBuilder) isBuilt = true } } @@ -192,12 +203,36 @@ public class GrpcServer internal constructor( */ public fun GrpcServer( port: Int, - credentials: ServerCredentials? = null, - messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver, parentContext: CoroutineContext = EmptyCoroutineContext, - configure: ServerBuilder<*>.() -> Unit = {}, + configure: GrpcServerConfiguration.() -> Unit = {}, builder: RpcServer.() -> Unit = {}, ): GrpcServer { - return GrpcServer(port, credentials, messageCodecResolver, parentContext, configure).apply(builder) + val config = GrpcServerConfiguration().apply(configure) + val serverBuilder = ServerBuilder(port, config.credentials).apply { + config.fallbackHandlerRegistry?.let { fallbackHandlerRegistry(it) } + } + return GrpcServer(port, serverBuilder, config.interceptors, config.messageCodecResolver, parentContext) + .apply(builder) .apply { build() } } + +public class GrpcServerConfiguration internal constructor() { + internal var messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver + internal var credentials: ServerCredentials? = null + internal val interceptors: MutableList = mutableListOf() + internal var fallbackHandlerRegistry: HandlerRegistry? = null + internal var services: ServerBuilder<*>? = null + + public fun useCredentials(credentials: ServerCredentials) { + this.credentials = credentials + } + + public fun useMessageCodecResolver(messageCodecResolver: MessageCodecResolver) { + this.messageCodecResolver = messageCodecResolver + } + + public fun intercept(vararg interceptors: ServerInterceptor) { + this.interceptors.addAll(interceptors) + } + +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt new file mode 100644 index 000000000..55bb90b35 --- /dev/null +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.grpc + +import kotlinx.coroutines.flow.Flow +import kotlinx.rpc.grpc.internal.GrpcCallOptions +import kotlinx.rpc.grpc.internal.MethodDescriptor + +public interface ServerCallScope { + public val method: MethodDescriptor + public val responseHeaders: GrpcTrailers + public fun onCancel(block: () -> Unit) + public fun onComplete(block: () -> Unit) + public fun proceed(request: Flow): Flow +} + +public interface ServerInterceptor { + public fun intercept( + scope: ServerCallScope, + requestHeaders: GrpcTrailers, + request: Flow, + ): Flow +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/credentials.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/credentials.kt index 90291ba66..22717ed3b 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/credentials.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/credentials.kt @@ -10,7 +10,10 @@ public expect abstract class ServerCredentials public expect class InsecureClientCredentials : ClientCredentials public expect class InsecureServerCredentials : ServerCredentials +// we need a wrapper for InsecureChannelCredentials as our constructor would conflict with the private +// java constructor. internal expect fun createInsecureClientCredentials(): ClientCredentials +internal expect fun createInsecureServerCredentials(): ServerCredentials public expect class TlsClientCredentials : ClientCredentials public expect class TlsServerCredentials : ServerCredentials diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt index 2c16c6193..f0dc62046 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.ServerCallScope +import kotlinx.rpc.grpc.ServerInterceptor import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.StatusException @@ -29,6 +31,7 @@ import kotlin.reflect.typeOf public fun CoroutineScope.unaryServerMethodDefinition( descriptor: MethodDescriptor, responseKType: KType, + interceptors: List, implementation: suspend (request: Request) -> Response, ): ServerMethodDefinition { val type = descriptor.type @@ -36,7 +39,7 @@ public fun CoroutineScope.unaryServerMethodDefinition( "Expected a unary method descriptor but got $descriptor" } - return serverMethodDefinition(descriptor, responseKType) { requests -> + return serverMethodDefinition(descriptor, responseKType, interceptors) { requests -> requests .singleOrStatusFlow("request", descriptor) .map { implementation(it) } @@ -47,6 +50,7 @@ public fun CoroutineScope.unaryServerMethodDefinition( public fun CoroutineScope.clientStreamingServerMethodDefinition( descriptor: MethodDescriptor, responseKType: KType, + interceptors: List, implementation: suspend (requests: Flow) -> Response, ): ServerMethodDefinition { val type = descriptor.type @@ -54,7 +58,7 @@ public fun CoroutineScope.clientStreamingServerMethodDefinit "Expected a client streaming method descriptor but got $descriptor" } - return serverMethodDefinition(descriptor, responseKType) { requests -> + return serverMethodDefinition(descriptor, responseKType, interceptors) { requests -> flow { val response = implementation(requests) emit(response) @@ -66,6 +70,7 @@ public fun CoroutineScope.clientStreamingServerMethodDefinit public fun CoroutineScope.serverStreamingServerMethodDefinition( descriptor: MethodDescriptor, responseKType: KType, + interceptors: List, implementation: (request: Request) -> Flow, ): ServerMethodDefinition { val type = descriptor.type @@ -73,7 +78,7 @@ public fun CoroutineScope.serverStreamingServerMethodDefinit "Expected a server streaming method descriptor but got $descriptor" } - return serverMethodDefinition(descriptor, responseKType) { requests -> + return serverMethodDefinition(descriptor, responseKType, interceptors) { requests -> flow { requests .singleOrStatusFlow("request", descriptor) @@ -90,6 +95,7 @@ public fun CoroutineScope.serverStreamingServerMethodDefinit public fun CoroutineScope.bidiStreamingServerMethodDefinition( descriptor: MethodDescriptor, responseKType: KType, + interceptors: List, implementation: (requests: Flow) -> Flow, ): ServerMethodDefinition { val type = descriptor.type @@ -97,29 +103,36 @@ public fun CoroutineScope.bidiStreamingServerMethodDefinitio "Expected a bidi streaming method descriptor but got $descriptor" } - return serverMethodDefinition(descriptor, responseKType, implementation) + return serverMethodDefinition(descriptor, responseKType, interceptors, implementation) } private fun CoroutineScope.serverMethodDefinition( descriptor: MethodDescriptor, responseKType: KType, + interceptors: List, implementation: (Flow) -> Flow, -): ServerMethodDefinition = serverMethodDefinition(descriptor, serverCallHandler(responseKType, implementation)) +): ServerMethodDefinition = + serverMethodDefinition(descriptor, serverCallHandler(descriptor, responseKType, interceptors, implementation)) private fun CoroutineScope.serverCallHandler( + descriptor: MethodDescriptor, responseKType: KType, + interceptors: List, implementation: (Flow) -> Flow, ): ServerCallHandler = - ServerCallHandler { call, _ -> - serverCallListenerImpl(call, responseKType, implementation) + ServerCallHandler { call, headers -> + serverCallListenerImpl(descriptor, call, responseKType, interceptors, implementation, headers) } private fun CoroutineScope.serverCallListenerImpl( + descriptor: MethodDescriptor, handler: ServerCall, responseKType: KType, + interceptors: List, implementation: (Flow) -> Flow, + requestHeaders: GrpcTrailers, ): ServerCall.Listener { - val ready = Ready { handler.isReady()} + val ready = Ready { handler.isReady() } val requestsChannel = Channel(1) val requestsStarted = AtomicBoolean(false) // enforces read-once @@ -144,11 +157,18 @@ private fun CoroutineScope.serverCallListenerImpl( } } + val serverCallScope = ServerCallScopeImpl( + method = descriptor, + responseHeaders = GrpcTrailers(), + interceptors = interceptors, + implementation = implementation, + requestHeaders = requestHeaders, + ) val rpcJob = launch(GrpcContextElement.current()) { val mutex = Mutex() val headersSent = AtomicBoolean(false) // enforces only sending headers once val failure = runCatching { - implementation(requests).collect { response -> + serverCallScope.proceed(requests).collect { response -> @Suppress("UNCHECKED_CAST") // fix for KRPC-173 val value = if (responseKType == unitKType) Unit as Response else response @@ -205,6 +225,7 @@ private fun CoroutineScope.serverCallListenerImpl( return serverCallListener( state = ServerCallListenerState(), onCancel = { + serverCallScope.onCancelFuture.complete(Unit) rpcJob.cancel("Cancellation received from client") }, onMessage = { state, message: Request -> @@ -230,7 +251,9 @@ private fun CoroutineScope.serverCallListenerImpl( onReady = { ready.onReady() }, - onComplete = {} + onComplete = { + serverCallScope.onCompleteFuture.complete(Unit) + } ) } @@ -242,4 +265,36 @@ private class ServerCallListenerState { var isReceiving = true } -private val unitKType = typeOf() +private val unitKType = typeOf() + + +private class ServerCallScopeImpl( + override val method: MethodDescriptor, + override val responseHeaders: GrpcTrailers, + val interceptors: List, + val implementation: (Flow) -> Flow, + val requestHeaders: GrpcTrailers, +) : ServerCallScope { + + val onCancelFuture = CallbackFuture() + val onCompleteFuture = CallbackFuture() + val onCloseFuture = CallbackFuture>() + var interceptorIndex = 0 + + override fun onCancel(block: () -> Unit) { + onCancelFuture.onComplete { block() } + } + + override fun onComplete(block: () -> Unit) { + onCompleteFuture.onComplete { block() } + } + + override fun proceed(request: Flow): Flow { + return if (interceptorIndex < interceptors.size) { + interceptors[interceptorIndex++] + .intercept(this, requestHeaders, request) + } else { + implementation(request) + } + } +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt index 7bc082776..58242c165 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt @@ -30,8 +30,10 @@ abstract class BaseGrpcServiceTest { ) = runTest { val server = GrpcServer( port = PORT, - messageCodecResolver = resolver, parentContext = coroutineContext, + configure = { + useMessageCodecResolver(resolver) + }, builder = { registerService(kClass) { impl } } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt index 535f9feba..967eca0fb 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt @@ -18,7 +18,6 @@ import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.Server import kotlinx.rpc.grpc.ServerBuilder import kotlinx.rpc.grpc.codec.SourcedMessageCodec -import kotlinx.rpc.grpc.internal.GrpcChannel import kotlinx.rpc.grpc.internal.MethodDescriptor import kotlinx.rpc.grpc.internal.MethodType import kotlinx.rpc.grpc.internal.ServerMethodDefinition @@ -45,7 +44,7 @@ class RawClientServerTest { methodName = "unary", type = MethodType.UNARY, methodDefinition = { descriptor -> - unaryServerMethodDefinition(descriptor, typeOf()) { it + it } + unaryServerMethodDefinition(descriptor, typeOf(), emptyList()) { it + it } }, ) { client, descriptor -> val response = client.unaryRpc(descriptor, "Hello") @@ -58,7 +57,7 @@ class RawClientServerTest { methodName = "serverStreaming", type = MethodType.SERVER_STREAMING, methodDefinition = { descriptor -> - serverStreamingServerMethodDefinition(descriptor, typeOf()) { + serverStreamingServerMethodDefinition(descriptor, typeOf(), emptyList()) { flowOf(it, it) } } @@ -73,7 +72,7 @@ class RawClientServerTest { methodName = "clientStreaming", type = MethodType.CLIENT_STREAMING, methodDefinition = { descriptor -> - clientStreamingServerMethodDefinition(descriptor, typeOf()) { + clientStreamingServerMethodDefinition(descriptor, typeOf(), emptyList()) { it.toList().joinToString(separator = "") } } @@ -89,7 +88,7 @@ class RawClientServerTest { methodName = "bidirectionalStreaming", type = MethodType.BIDI_STREAMING, methodDefinition = { descriptor -> - bidiStreamingServerMethodDefinition(descriptor, typeOf()) { + bidiStreamingServerMethodDefinition(descriptor, typeOf(), emptyList()) { it.map { str -> str + str } } } @@ -100,6 +99,7 @@ class RawClientServerTest { assertEquals(listOf("HelloHello", "WorldWorld"), response) } } + private fun runTest( methodName: String, type: MethodType, diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt index 17500c76b..8c7b5f6ea 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt @@ -13,6 +13,7 @@ import kotlinx.rpc.grpc.ClientInterceptor import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.GrpcServer import kotlinx.rpc.grpc.ServerCredentials +import kotlinx.rpc.grpc.ServerInterceptor abstract class GrpcProtoTest { private val serverMutex = Mutex() @@ -24,6 +25,7 @@ abstract class GrpcProtoTest { clientCreds: ClientCredentials? = null, overrideAuthority: String? = null, clientInterceptors: List = emptyList(), + serverInterceptors: List = emptyList(), test: suspend (GrpcClient) -> Unit, ) = runTest { serverMutex.withLock { @@ -38,7 +40,10 @@ abstract class GrpcProtoTest { val grpcServer = GrpcServer( PORT, - credentials = serverCreds, + configure = { + serverCreds?.let { useCredentials(it) } + serverInterceptors.forEach { intercept(it) } + }, builder = { registerServices() }) diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt new file mode 100644 index 000000000..13d593147 --- /dev/null +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.grpc.test.proto + +import kotlinx.coroutines.flow.Flow +import kotlinx.rpc.RpcServer +import kotlinx.rpc.grpc.GrpcClient +import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.ServerCallScope +import kotlinx.rpc.grpc.ServerInterceptor +import kotlinx.rpc.grpc.test.EchoRequest +import kotlinx.rpc.grpc.test.EchoService +import kotlinx.rpc.grpc.test.EchoServiceImpl +import kotlinx.rpc.grpc.test.invoke +import kotlinx.rpc.registerService +import kotlinx.rpc.withService +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ServerInterceptorTest : GrpcProtoTest() { + + override fun RpcServer.registerServices() { + registerService { EchoServiceImpl() } + } + + @Test + fun `throw during intercept - should fail with thrown exception`() { + val error = assertFailsWith { + val interceptor = interceptor { scope, headers, request -> + scope.proceed(request) + } + runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) + } + + assertEquals(error.message, "Failing in interceptor") + } + + + private suspend fun unaryCall(grpcClient: GrpcClient) { + val service = grpcClient.withService() + val response = service.UnaryEcho(EchoRequest { message = "Hello" }) + assertEquals("Hello", response.message) + } +} + + +private fun interceptor( + block: (ServerCallScope, GrpcTrailers, Flow) -> Flow, +): List { + return listOf(object : ServerInterceptor { + @Suppress("UNCHECKED_CAST") + override fun intercept( + scope: ServerCallScope, + requestHeaders: GrpcTrailers, + request: Flow, + ): Flow { + return block(scope as ServerCallScope, requestHeaders, request as Flow) as Flow + } + }) +} \ No newline at end of file diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt index 99d22c6eb..3a44a4ec3 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt @@ -21,6 +21,10 @@ internal actual fun createInsecureClientCredentials(): ClientCredentials { return InsecureClientCredentials.create() } +internal actual fun createInsecureServerCredentials(): ServerCredentials { + return InsecureServerCredentials.create() +} + internal actual fun TlsClientCredentialsBuilder(): TlsClientCredentialsBuilder = JvmTlsCLientCredentialBuilder() internal actual fun TlsServerCredentialsBuilder( certChain: String, diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/credentials.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/credentials.native.kt index be06a7df6..77f9e3082 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/credentials.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/credentials.native.kt @@ -71,7 +71,7 @@ internal actual fun createInsecureClientCredentials(): ClientCredentials { ) } -internal fun createInsecureServerCredentials(): ServerCredentials { +internal actual fun createInsecureServerCredentials(): ServerCredentials { return InsecureServerCredentials( grpc_insecure_server_credentials_create() ?: error("grpc_insecure_server_credentials_create() returned null") ) diff --git a/grpc/grpc-ktor-server/src/commonMain/kotlin/kotlinx/rpc/grpc/ktor/server/Server.kt b/grpc/grpc-ktor-server/src/commonMain/kotlin/kotlinx/rpc/grpc/ktor/server/Server.kt index 6642885dc..a88c9d0d9 100644 --- a/grpc/grpc-ktor-server/src/commonMain/kotlin/kotlinx/rpc/grpc/ktor/server/Server.kt +++ b/grpc/grpc-ktor-server/src/commonMain/kotlin/kotlinx/rpc/grpc/ktor/server/Server.kt @@ -66,7 +66,7 @@ public fun Application.grpc( port = port, messageCodecResolver = messageCodecResolver, parentContext = coroutineContext, - configure = configure, + serverBuilder = configure, builder = builder, ) } From 8b961c9ab09d6e3c92e02890e1c65a2aa83ffe50 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 16 Sep 2025 18:27:23 +0200 Subject: [PATCH 04/21] grpc: Refactor server scope API Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ServerInterceptor.kt | 19 ++-- .../rpc/grpc/internal/suspendServerCalls.kt | 35 +++---- .../grpc/test/proto/ServerInterceptorTest.kt | 91 +++++++++++++++++-- 3 files changed, 113 insertions(+), 32 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt index 55bb90b35..db6ff3792 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt @@ -5,21 +5,28 @@ package kotlinx.rpc.grpc import kotlinx.coroutines.flow.Flow -import kotlinx.rpc.grpc.internal.GrpcCallOptions +import kotlinx.coroutines.flow.FlowCollector import kotlinx.rpc.grpc.internal.MethodDescriptor public interface ServerCallScope { public val method: MethodDescriptor + public val requestHeaders: GrpcTrailers public val responseHeaders: GrpcTrailers - public fun onCancel(block: () -> Unit) - public fun onComplete(block: () -> Unit) + public val responseTrailers: GrpcTrailers + + public fun onClose(block: (Status, GrpcTrailers) -> Unit) + public fun close(status: Status, trailers: GrpcTrailers = GrpcTrailers()): Nothing public fun proceed(request: Flow): Flow + + public suspend fun FlowCollector.proceedFlow(request: Flow) { + proceed(request).collect { + emit(it) + } + } } public interface ServerInterceptor { - public fun intercept( - scope: ServerCallScope, - requestHeaders: GrpcTrailers, + public fun ServerCallScope.intercept( request: Flow, ): Flow } \ No newline at end of file diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt index f0dc62046..f3e6250fb 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt @@ -159,11 +159,12 @@ private fun CoroutineScope.serverCallListenerImpl( val serverCallScope = ServerCallScopeImpl( method = descriptor, - responseHeaders = GrpcTrailers(), interceptors = interceptors, implementation = implementation, requestHeaders = requestHeaders, + serverCall = handler, ) + val rpcJob = launch(GrpcContextElement.current()) { val mutex = Mutex() val headersSent = AtomicBoolean(false) // enforces only sending headers once @@ -219,13 +220,13 @@ private fun CoroutineScope.serverCallListenerImpl( mutex.withLock { handler.close(closeStatus, trailers) + serverCallScope.onCloseFuture.complete(Pair(closeStatus, trailers)) } } return serverCallListener( state = ServerCallListenerState(), onCancel = { - serverCallScope.onCancelFuture.complete(Unit) rpcJob.cancel("Cancellation received from client") }, onMessage = { state, message: Request -> @@ -251,9 +252,7 @@ private fun CoroutineScope.serverCallListenerImpl( onReady = { ready.onReady() }, - onComplete = { - serverCallScope.onCompleteFuture.complete(Unit) - } + onComplete = { } ) } @@ -270,29 +269,33 @@ private val unitKType = typeOf() private class ServerCallScopeImpl( override val method: MethodDescriptor, - override val responseHeaders: GrpcTrailers, val interceptors: List, val implementation: (Flow) -> Flow, - val requestHeaders: GrpcTrailers, + override val requestHeaders: GrpcTrailers, + val serverCall: ServerCall, ) : ServerCallScope { - val onCancelFuture = CallbackFuture() - val onCompleteFuture = CallbackFuture() - val onCloseFuture = CallbackFuture>() + override val responseHeaders: GrpcTrailers = GrpcTrailers() + override val responseTrailers: GrpcTrailers = GrpcTrailers() + + // keeps track of already processed interceptors var interceptorIndex = 0 + val onCloseFuture = CallbackFuture>() - override fun onCancel(block: () -> Unit) { - onCancelFuture.onComplete { block() } + override fun onClose(block: (Status, GrpcTrailers) -> Unit) { + onCloseFuture.onComplete { block(it.first, it.second) } } - override fun onComplete(block: () -> Unit) { - onCompleteFuture.onComplete { block() } + override fun close(status: Status, trailers: GrpcTrailers): Nothing { + // this will be cached by the rpcImpl() runCatching{} and turns it into a close() + throw StatusException(status, trailers) } override fun proceed(request: Flow): Flow { return if (interceptorIndex < interceptors.size) { - interceptors[interceptorIndex++] - .intercept(this, requestHeaders, request) + with(interceptors[interceptorIndex++]) { + intercept(request) + } } else { implementation(request) } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt index 13d593147..ec1c5d8ba 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt @@ -5,11 +5,16 @@ package kotlinx.rpc.grpc.test.proto import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.GrpcTrailers import kotlinx.rpc.grpc.ServerCallScope import kotlinx.rpc.grpc.ServerInterceptor +import kotlinx.rpc.grpc.Status +import kotlinx.rpc.grpc.StatusCode +import kotlinx.rpc.grpc.StatusException +import kotlinx.rpc.grpc.statusCode import kotlinx.rpc.grpc.test.EchoRequest import kotlinx.rpc.grpc.test.EchoService import kotlinx.rpc.grpc.test.EchoServiceImpl @@ -17,8 +22,10 @@ import kotlinx.rpc.grpc.test.invoke import kotlinx.rpc.registerService import kotlinx.rpc.withService import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs class ServerInterceptorTest : GrpcProtoTest() { @@ -27,18 +34,82 @@ class ServerInterceptorTest : GrpcProtoTest() { } @Test - fun `throw during intercept - should fail with thrown exception`() { - val error = assertFailsWith { - val interceptor = interceptor { scope, headers, request -> - scope.proceed(request) + fun `throw during intercept - should fail with unknown status on client`() { + var cause: Throwable? = null + val error = assertFailsWith { + val interceptor = interceptor { + onClose { status, _ -> cause = status.getCause() } + // this exception is not propagated to the client (only as UNKNOWN status code) + throw IllegalStateException("Failing in interceptor") } runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) } - assertEquals(error.message, "Failing in interceptor") + assertEquals(StatusCode.UNKNOWN, error.getStatus().statusCode) + assertIs(cause) + assertEquals("Failing in interceptor", cause?.message) } + @Test + fun `close during intercept - should fail with correct status on client`() { + val error = assertFailsWith { + val interceptor = interceptor { + close(Status(StatusCode.UNAUTHENTICATED, "Close in interceptor"), GrpcTrailers()) + } + runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) + } + + assertEquals(StatusCode.UNAUTHENTICATED, error.getStatus().statusCode) + assertContains(error.message!!, "Close in interceptor") + } + + @Test + fun `close during request flow - should fail with correct status on client`() { + val error = assertFailsWith { + val interceptor = interceptor { + proceed( + it.map { + close(Status(StatusCode.UNAUTHENTICATED, "Close in request flow"), GrpcTrailers()) + } + ) + } + runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) + } + + assertEquals(StatusCode.UNAUTHENTICATED, error.getStatus().statusCode) + assertContains(error.message!!, "Close in request flow") + } + + @Test + fun `close during response flow - should fail with correct status on client`() { + val error = assertFailsWith { + val interceptor = interceptor { + proceed(it).map { + close(Status(StatusCode.UNAUTHENTICATED, "Close in response flow"), GrpcTrailers()) + } + } + runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) + } + + assertEquals(StatusCode.UNAUTHENTICATED, error.getStatus().statusCode) + assertContains(error.message!!, "Close in response flow") + } + + @Test + fun `close during onClose - should fail with correct status on client`() { + val error = assertFailsWith { + val interceptor = interceptor { + onClose { _, _ -> close(Status(StatusCode.UNAUTHENTICATED, "Close in onClose"), GrpcTrailers()) } + proceed(it) + } + runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) + } + + assertEquals(StatusCode.UNAUTHENTICATED, error.getStatus().statusCode) + assertContains(error.message!!, "Close in onClose") + } + private suspend fun unaryCall(grpcClient: GrpcClient) { val service = grpcClient.withService() val response = service.UnaryEcho(EchoRequest { message = "Hello" }) @@ -48,16 +119,16 @@ class ServerInterceptorTest : GrpcProtoTest() { private fun interceptor( - block: (ServerCallScope, GrpcTrailers, Flow) -> Flow, + block: ServerCallScope.(Flow) -> Flow, ): List { return listOf(object : ServerInterceptor { @Suppress("UNCHECKED_CAST") - override fun intercept( - scope: ServerCallScope, - requestHeaders: GrpcTrailers, + override fun ServerCallScope.intercept( request: Flow, ): Flow { - return block(scope as ServerCallScope, requestHeaders, request as Flow) as Flow + with(this as ServerCallScope) { + return block(request as Flow) as Flow + } } }) } \ No newline at end of file From 7f3b766c176d7df5ecc27c20727091c766b6a164 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Wed, 17 Sep 2025 10:13:30 +0200 Subject: [PATCH 05/21] grpc: Refactor client scope API Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ClientInterceptor.kt | 16 ++--- .../rpc/grpc/internal/suspendClientCalls.kt | 21 ++++-- .../grpc/test/proto/ClientInterceptorTest.kt | 72 +++++++++---------- 3 files changed, 56 insertions(+), 53 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt index ad74cc5e3..1e9b9e5d1 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt @@ -8,14 +8,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.rpc.grpc.internal.GrpcCallOptions import kotlinx.rpc.grpc.internal.MethodDescriptor -/** - * Represents a client call scope within a coroutine context, providing access to properties and - * functions required to manage the lifecycle and behavior of a client-side remote procedure call - * (RPC) in a coroutine-based environment. - * - * @param Request the type of the request message sent to the gRPC server. - * @param Response the type of the response message received from the gRPC server. - */ public interface ClientCallScope { public val method: MethodDescriptor public val metadata: GrpcTrailers @@ -32,13 +24,15 @@ public interface ClientInterceptor { * Intercepts and transforms the flow of requests and responses in a client call. * An interceptor can throw an exception at any time to cancel the call. * - * @param scope The scope of the client call, providing context and methods for managing + * The interceptor must ensure that it emits an expected number of values. + * E.g. if the intercepted method is a unary call, the interceptor's returned flow must emit exactly one value. + * + * @param this The scope of the client call, providing context and methods for managing * the call lifecycle and metadata. * @param request A flow of requests to be sent to the server. * @return A flow of responses received from the server. */ - public fun intercept( - scope: ClientCallScope, + public fun ClientCallScope.intercept( request: Flow, ): Flow diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt index f66a56124..0a1ecf32b 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt @@ -4,14 +4,26 @@ package kotlinx.rpc.grpc.internal -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.single -import kotlinx.rpc.grpc.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.rpc.grpc.ClientCallScope +import kotlinx.rpc.grpc.GrpcClient +import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.Status +import kotlinx.rpc.grpc.StatusCode +import kotlinx.rpc.grpc.StatusException +import kotlinx.rpc.grpc.statusCode import kotlinx.rpc.internal.utils.InternalRpcApi // heavily inspired by @@ -218,8 +230,9 @@ private class ClientCallScopeImpl( override fun proceed(request: Flow): Flow { return if (interceptorIndex < interceptors.size) { - interceptors[interceptorIndex++] - .intercept(this, request) + with(interceptors[interceptorIndex++]) { + intercept(request) + } } else { // if the interceptor chain is exhausted, we start the actual call doCall(request) diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt index 3f2b01a64..5f01b6572 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt @@ -4,24 +4,17 @@ package kotlinx.rpc.grpc.test.proto -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.ClientCallScope import kotlinx.rpc.grpc.ClientInterceptor import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.StatusException -import kotlinx.rpc.grpc.internal.bidirectionalStreamingRpc import kotlinx.rpc.grpc.statusCode import kotlinx.rpc.grpc.test.EchoRequest import kotlinx.rpc.grpc.test.EchoResponse @@ -37,7 +30,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertTrue -class ClientInterceptorTest: GrpcProtoTest() { +class ClientInterceptorTest : GrpcProtoTest() { override fun RpcServer.registerServices() { registerService { EchoServiceImpl() } @@ -46,7 +39,7 @@ class ClientInterceptorTest: GrpcProtoTest() { @Test fun `throw during intercept - should fail with thrown exception`() { val error = assertFailsWith { - val interceptor = interceptor { _, _ -> + val interceptor = interceptor { throw IllegalStateException("Failing in interceptor") } runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) @@ -58,11 +51,11 @@ class ClientInterceptorTest: GrpcProtoTest() { @Test fun `throw during onHeader - should fail with status exception containing the thrown exception`() { val error = assertFailsWith { - val interceptor = interceptor { scope, req -> - scope.onHeaders { + val interceptor = interceptor { + onHeaders { throw IllegalStateException("Failing in onHeader") } - scope.proceed(req) + proceed(it) } runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) } @@ -75,11 +68,11 @@ class ClientInterceptorTest: GrpcProtoTest() { @Test fun `throw during onClose - should fail with status exception containing the thrown exception`() { val error = assertFailsWith { - val interceptor = interceptor { scope, req -> - scope.onClose { _, _ -> + val interceptor = interceptor { + onClose { _, _ -> throw IllegalStateException("Failing in onClose") } - scope.proceed(req) + proceed(it) } runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) } @@ -92,9 +85,9 @@ class ClientInterceptorTest: GrpcProtoTest() { @Test fun `cancel in intercept - should fail with cancellation`() { val error = assertFailsWith { - val interceptor = interceptor { scope, req -> - scope.cancel("Canceling in interceptor", IllegalStateException("Cancellation cause")) - scope.proceed(req) + val interceptor = interceptor { + cancel("Canceling in interceptor", IllegalStateException("Cancellation cause")) + proceed(it) } runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) } @@ -107,9 +100,9 @@ class ClientInterceptorTest: GrpcProtoTest() { @Test fun `modify request message - should return modified message`() { - val interceptor = interceptor { scope, req -> - val modified = req.map { EchoRequest { message = "Modified" } } - scope.proceed(modified) + val interceptor = interceptor { + val modified = it.map { EchoRequest { message = "Modified" } } + proceed(modified) } runGrpcTest(clientInterceptors = interceptor) { val service = it.withService() @@ -120,8 +113,8 @@ class ClientInterceptorTest: GrpcProtoTest() { @Test fun `modify response message - should return modified message`() { - val interceptor = interceptor { scope, req -> - scope.proceed(req).map { EchoResponse { message = "Modified" } } + val interceptor = interceptor { + proceed(it).map { EchoResponse { message = "Modified" } } } runGrpcTest(clientInterceptors = interceptor) { val service = it.withService() @@ -132,24 +125,26 @@ class ClientInterceptorTest: GrpcProtoTest() { @Test fun `append a response message once closed`() { - val interceptor = interceptor { scope, req -> channelFlow { - scope.proceed(req).collect { - trySend(it) - } - scope.onClose { status, _ -> - trySend(EchoResponse { message = "Appended-after-close-with-${status.statusCode}" }) + val interceptor = interceptor { + channelFlow { + proceed(it).collect { + trySend(it) + } + onClose { status, _ -> + trySend(EchoResponse { message = "Appended-after-close-with-${status.statusCode}" }) + } } - } } + } runGrpcTest( clientInterceptors = interceptor ) { client -> val svc = client.withService() val responses = svc.BidirectionalStreamingEcho(flow { - repeat(5) { - emit(EchoRequest { message = "Eccchhooo" }) - } - }).toList() + repeat(5) { + emit(EchoRequest { message = "Eccchhooo" }) + } + }).toList() assertEquals(6, responses.size) assertTrue(responses.any { it.message == "Appended-after-close-with-OK" }) } @@ -164,15 +159,16 @@ class ClientInterceptorTest: GrpcProtoTest() { } private fun interceptor( - block: (ClientCallScope , Flow) -> Flow + block: ClientCallScope.(Flow) -> Flow, ): List { return listOf(object : ClientInterceptor { @Suppress("UNCHECKED_CAST") - override fun intercept( - scope: ClientCallScope, + override fun ClientCallScope.intercept( request: Flow, ): Flow { - return block(scope as ClientCallScope, request as Flow) as Flow + with(this as ClientCallScope) { + return block(request as Flow) as Flow + } } }) } \ No newline at end of file From 80232d5ee1be505690dca3d33a2faaba8b18ad72 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Wed, 17 Sep 2025 16:46:28 +0200 Subject: [PATCH 06/21] grpc: Add tests Signed-off-by: Johannes Zottele --- .../grpc/test/proto/ServerInterceptorTest.kt | 40 +++++++- .../rpc/grpc/internal/NativeClientCall.kt | 5 +- .../rpc/grpc/internal/NativeServerCall.kt | 96 +++++++++++++------ .../kotlin/kotlinx/rpc/grpc/internal/utils.kt | 2 +- 4 files changed, 111 insertions(+), 32 deletions(-) diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt index ec1c5d8ba..11192014f 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt @@ -5,6 +5,7 @@ package kotlinx.rpc.grpc.test.proto import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.GrpcClient @@ -16,6 +17,7 @@ import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.StatusException import kotlinx.rpc.grpc.statusCode import kotlinx.rpc.grpc.test.EchoRequest +import kotlinx.rpc.grpc.test.EchoResponse import kotlinx.rpc.grpc.test.EchoService import kotlinx.rpc.grpc.test.EchoServiceImpl import kotlinx.rpc.grpc.test.invoke @@ -61,7 +63,7 @@ class ServerInterceptorTest : GrpcProtoTest() { } assertEquals(StatusCode.UNAUTHENTICATED, error.getStatus().statusCode) - assertContains(error.message!!, "Close in interceptor") + assertContains(error.getStatus().getDescription()!!, "Close in interceptor") } @Test @@ -110,6 +112,42 @@ class ServerInterceptorTest : GrpcProtoTest() { assertContains(error.message!!, "Close in onClose") } + @Test + fun `dont proceed and return custom message - should succeed on client`() { + val interceptor = interceptor { + flowOf(EchoResponse { message = "Custom message" }) + } + runGrpcTest(serverInterceptors = interceptor) { + val service = it.withService() + val response = service.UnaryEcho(EchoRequest { message = "Hello" }) + assertEquals("Custom message", response.message) + } + } + + @Test + fun `manipulate request - should succeed on client`() { + val interceptor = interceptor { + proceed(it.map { EchoRequest { message = "Modified" } }) + } + runGrpcTest(serverInterceptors = interceptor) { + val service = it.withService() + val response = service.UnaryEcho(EchoRequest { message = "Hello" }) + assertEquals("Modified", response.message) + } + } + + @Test + fun `manipulate response - should succeed on client`() { + val interceptor = interceptor { + proceed(it).map { EchoResponse { message = "Modified" } } + } + runGrpcTest(serverInterceptors = interceptor) { + val service = it.withService() + val response = service.UnaryEcho(EchoRequest { message = "Hello" }) + assertEquals("Modified", response.message) + } + } + private suspend fun unaryCall(grpcClient: GrpcClient) { val service = grpcClient.withService() val response = service.UnaryEcho(EchoRequest { message = "Hello" }) diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt index fab968753..f4f44599c 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt @@ -367,7 +367,10 @@ internal class NativeClientCall( // user side cancellation must always win over any other status (even if the call is already completed). // this will also preserve the cancellation cause, which cannot be passed to the grpc-core. closeInfo.value = Pair(status, GrpcTrailers()) - cancelInternal(grpc_status_code.GRPC_STATUS_CANCELLED, message ?: "Call cancelled with cause: ${cause?.message}") + cancelInternal( + grpc_status_code.GRPC_STATUS_CANCELLED, + message ?: "Call cancelled with cause: ${cause?.message}" + ) } private fun cancelInternal(statusCode: grpc_status_code, message: String) { diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt index c9d419f1f..d6b46fa37 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt @@ -16,11 +16,15 @@ import kotlinx.cinterop.CPointerVar import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.IntVar import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.convert +import kotlinx.cinterop.get import kotlinx.cinterop.ptr -import kotlinx.cinterop.readValue import kotlinx.cinterop.value import kotlinx.rpc.grpc.GrpcTrailers import kotlinx.rpc.grpc.Status +import kotlinx.rpc.grpc.StatusCode +import kotlinx.rpc.grpc.StatusException import kotlinx.rpc.protobuf.input.stream.asInputStream import kotlinx.rpc.protobuf.input.stream.asSource import libkgrpc.GRPC_OP_RECV_CLOSE_ON_SERVER @@ -33,7 +37,6 @@ import libkgrpc.grpc_byte_buffer_destroy import libkgrpc.grpc_call_cancel_with_status import libkgrpc.grpc_call_unref import libkgrpc.grpc_op -import libkgrpc.grpc_slice import libkgrpc.grpc_slice_unref import libkgrpc.grpc_status_code import kotlin.concurrent.Volatile @@ -66,7 +69,12 @@ internal class NativeServerCall( private var cancelled = false private val finalized = atomic(false) - // Tracks whether at least one request message has been received on this call. + // tracks whether the initial metadata has been sent. + // this is used to determine if we have to send the initial metadata + // when we try to close the call. + private var sentInitialMetadata = false + + // tracks whether at least one request message has been received on this call. private var receivedFirstMessage = false // we currently don't buffer messages, so after one `sendMessage` call, ready turns false. (KRPC-192) @@ -245,6 +253,7 @@ internal class NativeServerCall( data.send_initial_metadata.metadata = null } + sentInitialMetadata = true runBatch(op.ptr, 1u, cleanup = { arena.clear() }) { // nothing to do here } @@ -256,20 +265,22 @@ internal class NativeServerCall( val methodDescriptor = checkNotNull(methodDescriptor) { internalError("Method descriptor not set") } val arena = Arena() - val inputStream = methodDescriptor.getResponseMarshaller().stream(message) - val byteBuffer = inputStream.asSource().toGrpcByteBuffer() - ready.value = false - - val op = arena.alloc { - op = GRPC_OP_SEND_MESSAGE - data.send_message.send_message = byteBuffer - } + tryRun { + val inputStream = methodDescriptor.getResponseMarshaller().stream(message) + val byteBuffer = inputStream.asSource().toGrpcByteBuffer() + ready.value = false + + val op = arena.alloc { + op = GRPC_OP_SEND_MESSAGE + data.send_message.send_message = byteBuffer + } - runBatch(op.ptr, 1u, cleanup = { - arena.clear() - grpc_byte_buffer_destroy(byteBuffer) - }) { - turnReady() + runBatch(op.ptr, 1u, cleanup = { + arena.clear() + grpc_byte_buffer_destroy(byteBuffer) + }) { + turnReady() + } } } @@ -277,21 +288,29 @@ internal class NativeServerCall( check(initialized) { internalError("Call not initialized") } val arena = Arena() - val details = status.getDescription()?.let { - arena.alloc { - it.toGrpcSlice() - } - } - val op = arena.alloc { - op = GRPC_OP_SEND_STATUS_FROM_SERVER - data.send_status_from_server.status = status.statusCode.toRawCallAllocation() - data.send_status_from_server.status_details = details?.ptr - data.send_status_from_server.trailing_metadata_count = 0u - data.send_status_from_server.trailing_metadata = null + val details = status.getDescription()?.toGrpcSlice() + val detailsPtr = details?.getPointer(arena) + + val nOps = if (sentInitialMetadata) 1uL else 2uL + + val ops = arena.allocArray(nOps.convert()) + + ops[0].op = GRPC_OP_SEND_STATUS_FROM_SERVER + ops[0].data.send_status_from_server.status = status.statusCode.toRaw() + ops[0].data.send_status_from_server.status_details = detailsPtr + ops[0].data.send_status_from_server.trailing_metadata_count = 0u + ops[0].data.send_status_from_server.trailing_metadata = null + + if (!sentInitialMetadata) { + // if we haven't sent GRPC_OP_SEND_INITIAL_METADATA yet, + // so we must do it together with the close operation. + ops[1].op = GRPC_OP_SEND_INITIAL_METADATA + ops[1].data.send_initial_metadata.count = 0u + ops[1].data.send_initial_metadata.metadata = null } - runBatch(op.ptr, 1u, cleanup = { - if (details != null) grpc_slice_unref(details.readValue()) + runBatch(ops, nOps, cleanup = { + if (details != null) grpc_slice_unref(details) arena.clear() }) { // nothing to do here @@ -306,6 +325,25 @@ internal class NativeServerCall( val methodDescriptor = checkNotNull(methodDescriptor) { internalError("Method descriptor not set") } return methodDescriptor } + + + private fun tryRun(block: () -> T): T { + try { + return block() + } catch (e: Throwable) { + // TODO: Log internal error as warning + val status = when (e) { + is StatusException -> e.getStatus() + else -> Status( + StatusCode.INTERNAL, + description = "Internal error, so canceling the stream", + cause = e + ) + } + cancel(status.statusCode.toRaw(), status.getDescription() ?: "Unknown error") + throw StatusException(status, trailers = null) + } + } } /** diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt index c9f8ebc1e..289b5b2e5 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt @@ -193,7 +193,7 @@ internal fun grpc_status_code.toKotlin(): StatusCode = when (this) { else -> error("Invalid status code: $this") } -internal fun StatusCode.toRawCallAllocation(): grpc_status_code = when (this) { +internal fun StatusCode.toRaw(): grpc_status_code = when (this) { StatusCode.OK -> grpc_status_code.GRPC_STATUS_OK StatusCode.CANCELLED -> grpc_status_code.GRPC_STATUS_CANCELLED StatusCode.UNKNOWN -> grpc_status_code.GRPC_STATUS_UNKNOWN From 4504de3fd6ed20f1867b75cad750987658c047f8 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Wed, 17 Sep 2025 16:49:02 +0200 Subject: [PATCH 07/21] grpc: Rename GrpcTrailers to GrpcMetadata.kt Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ClientInterceptor.kt | 6 ++--- .../kotlin/kotlinx/rpc/grpc/GrpcClient.kt | 4 +-- .../grpc/{GrpcTrailers.kt => GrpcMetadata.kt} | 4 +-- .../kotlinx/rpc/grpc/ServerInterceptor.kt | 10 +++---- .../kotlinx/rpc/grpc/StatusException.kt | 8 +++--- .../kotlinx/rpc/grpc/internal/ClientCall.kt | 12 ++++----- .../kotlinx/rpc/grpc/internal/ServerCall.kt | 8 +++--- .../rpc/grpc/internal/suspendClientCalls.kt | 24 ++++++++--------- .../rpc/grpc/internal/suspendServerCalls.kt | 22 ++++++++-------- .../kotlinx/rpc/grpc/test/CoreClientTest.kt | 26 +++++++++---------- .../grpc/test/proto/ServerInterceptorTest.kt | 10 +++---- ...rpcTrailers.jvm.kt => GrpcMetadata.jvm.kt} | 2 +- .../rpc/grpc/internal/ClientCall.jvm.kt | 6 ++--- ...ilers.native.kt => GrpcMetadata.native.kt} | 4 +-- .../rpc/grpc/StatusException.native.kt | 12 ++++----- .../rpc/grpc/internal/ClientCall.native.kt | 16 ++++++------ .../rpc/grpc/internal/NativeClientCall.kt | 20 +++++++------- .../rpc/grpc/internal/NativeServerCall.kt | 6 ++--- .../rpc/grpc/internal/ServerCall.native.kt | 8 +++--- .../rpc/grpc/internal/serverCallTags.kt | 6 ++--- 20 files changed, 107 insertions(+), 107 deletions(-) rename grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/{GrpcTrailers.kt => GrpcMetadata.kt} (67%) rename grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/{GrpcTrailers.jvm.kt => GrpcMetadata.jvm.kt} (79%) rename grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/{GrpcTrailers.native.kt => GrpcMetadata.native.kt} (65%) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt index 1e9b9e5d1..e257923c3 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt @@ -10,10 +10,10 @@ import kotlinx.rpc.grpc.internal.MethodDescriptor public interface ClientCallScope { public val method: MethodDescriptor - public val metadata: GrpcTrailers + public val metadata: GrpcMetadata public val callOptions: GrpcCallOptions - public fun onHeaders(block: (GrpcTrailers) -> Unit) - public fun onClose(block: (Status, GrpcTrailers) -> Unit) + public fun onHeaders(block: (GrpcMetadata) -> Unit) + public fun onClose(block: (Status, GrpcMetadata) -> Unit) public fun cancel(message: String, cause: Throwable? = null) public fun proceed(request: Flow): Flow } diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt index 8ba3e4fa1..4edf60b23 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt @@ -55,7 +55,7 @@ public class GrpcClient internal constructor( override suspend fun call(call: RpcCall): T = withGrpcCall(call) { methodDescriptor, request -> val callOptions = GrpcDefaultCallOptions - val trailers = GrpcTrailers() + val trailers = GrpcMetadata() return when (methodDescriptor.type) { MethodType.UNARY -> unaryRpc( @@ -78,7 +78,7 @@ public class GrpcClient internal constructor( override fun callServerStreaming(call: RpcCall): Flow = withGrpcCall(call) { methodDescriptor, request -> val callOptions = GrpcDefaultCallOptions - val trailers = GrpcTrailers() + val trailers = GrpcMetadata() when (methodDescriptor.type) { MethodType.SERVER_STREAMING -> serverStreamingRpc( diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt similarity index 67% rename from grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.kt rename to grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt index e8f2fb903..d5f5749e3 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt @@ -5,6 +5,6 @@ package kotlinx.rpc.grpc @Suppress("RedundantConstructorKeyword") -public expect class GrpcTrailers constructor() { - public fun merge(trailers: GrpcTrailers) +public expect class GrpcMetadata constructor() { + public fun merge(trailers: GrpcMetadata) } diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt index db6ff3792..fa47eef80 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt @@ -10,12 +10,12 @@ import kotlinx.rpc.grpc.internal.MethodDescriptor public interface ServerCallScope { public val method: MethodDescriptor - public val requestHeaders: GrpcTrailers - public val responseHeaders: GrpcTrailers - public val responseTrailers: GrpcTrailers + public val requestHeaders: GrpcMetadata + public val responseHeaders: GrpcMetadata + public val responseTrailers: GrpcMetadata - public fun onClose(block: (Status, GrpcTrailers) -> Unit) - public fun close(status: Status, trailers: GrpcTrailers = GrpcTrailers()): Nothing + public fun onClose(block: (Status, GrpcMetadata) -> Unit) + public fun close(status: Status, trailers: GrpcMetadata = GrpcMetadata()): Nothing public fun proceed(request: Flow): Flow public suspend fun FlowCollector.proceedFlow(request: Flow) { diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/StatusException.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/StatusException.kt index 0cc1fd20b..eae3df45e 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/StatusException.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/StatusException.kt @@ -9,16 +9,16 @@ package kotlinx.rpc.grpc */ public expect class StatusException : Exception { public constructor(status: Status) - public constructor(status: Status, trailers: GrpcTrailers?) + public constructor(status: Status, trailers: GrpcMetadata?) public fun getStatus(): Status - public fun getTrailers(): GrpcTrailers? + public fun getTrailers(): GrpcMetadata? } public expect class StatusRuntimeException : RuntimeException { public constructor(status: Status) - public constructor(status: Status, trailers: GrpcTrailers?) + public constructor(status: Status, trailers: GrpcMetadata?) public fun getStatus(): Status - public fun getTrailers(): GrpcTrailers? + public fun getTrailers(): GrpcMetadata? } diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.kt index 2c3146bd2..7eac10ff4 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.kt @@ -4,7 +4,7 @@ package kotlinx.rpc.grpc.internal -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.internal.utils.InternalRpcApi @@ -31,13 +31,13 @@ import kotlinx.rpc.internal.utils.InternalRpcApi public expect abstract class ClientCall { @InternalRpcApi public abstract class Listener { - public open fun onHeaders(headers: GrpcTrailers) + public open fun onHeaders(headers: GrpcMetadata) public open fun onMessage(message: Message) - public open fun onClose(status: Status, trailers: GrpcTrailers) + public open fun onClose(status: Status, trailers: GrpcMetadata) public open fun onReady() } - public abstract fun start(responseListener: Listener, headers: GrpcTrailers) + public abstract fun start(responseListener: Listener, headers: GrpcMetadata) public abstract fun request(numMessages: Int) public abstract fun cancel(message: String?, cause: Throwable?) public abstract fun halfClose() @@ -47,8 +47,8 @@ public expect abstract class ClientCall { @InternalRpcApi public expect fun clientCallListener( - onHeaders: (headers: GrpcTrailers) -> Unit, + onHeaders: (headers: GrpcMetadata) -> Unit, onMessage: (message: Message) -> Unit, - onClose: (status: Status, trailers: GrpcTrailers) -> Unit, + onClose: (status: Status, trailers: GrpcMetadata) -> Unit, onReady: () -> Unit, ): ClientCall.Listener diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/ServerCall.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/ServerCall.kt index 8b390e756..cdd32aacd 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/ServerCall.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/ServerCall.kt @@ -4,13 +4,13 @@ package kotlinx.rpc.grpc.internal -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.internal.utils.InternalRpcApi @InternalRpcApi public expect fun interface ServerCallHandler { - public fun startCall(call: ServerCall, headers: GrpcTrailers): ServerCall.Listener + public fun startCall(call: ServerCall, headers: GrpcMetadata): ServerCall.Listener } @InternalRpcApi @@ -25,9 +25,9 @@ public expect abstract class ServerCall { } public abstract fun request(numMessages: Int) - public abstract fun sendHeaders(headers: GrpcTrailers) + public abstract fun sendHeaders(headers: GrpcMetadata) public abstract fun sendMessage(message: Response) - public abstract fun close(status: Status, trailers: GrpcTrailers) + public abstract fun close(status: Status, trailers: GrpcMetadata) public open fun isReady(): Boolean public abstract fun isCancelled(): Boolean diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt index 0a1ecf32b..75031f393 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.rpc.grpc.ClientCallScope import kotlinx.rpc.grpc.GrpcClient -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.StatusException @@ -34,7 +34,7 @@ public suspend fun GrpcClient.unaryRpc( descriptor: MethodDescriptor, request: Request, callOptions: GrpcCallOptions = GrpcDefaultCallOptions, - trailers: GrpcTrailers = GrpcTrailers(), + trailers: GrpcMetadata = GrpcMetadata(), ): Response { val type = descriptor.type require(type == MethodType.UNARY) { @@ -54,7 +54,7 @@ public fun GrpcClient.serverStreamingRpc( descriptor: MethodDescriptor, request: Request, callOptions: GrpcCallOptions = GrpcDefaultCallOptions, - trailers: GrpcTrailers = GrpcTrailers(), + trailers: GrpcMetadata = GrpcMetadata(), ): Flow { val type = descriptor.type require(type == MethodType.SERVER_STREAMING) { @@ -74,7 +74,7 @@ public suspend fun GrpcClient.clientStreamingRpc( descriptor: MethodDescriptor, requests: Flow, callOptions: GrpcCallOptions = GrpcDefaultCallOptions, - trailers: GrpcTrailers = GrpcTrailers(), + trailers: GrpcMetadata = GrpcMetadata(), ): Response { val type = descriptor.type require(type == MethodType.CLIENT_STREAMING) { @@ -94,7 +94,7 @@ public fun GrpcClient.bidirectionalStreamingRpc( descriptor: MethodDescriptor, requests: Flow, callOptions: GrpcCallOptions = GrpcDefaultCallOptions, - trailers: GrpcTrailers = GrpcTrailers(), + trailers: GrpcMetadata = GrpcMetadata(), ): Flow { val type = descriptor.type check(type == MethodType.BIDI_STREAMING) { @@ -141,7 +141,7 @@ private sealed interface ClientRequest { private fun GrpcClient.rpcImpl( descriptor: MethodDescriptor, callOptions: GrpcCallOptions, - trailers: GrpcTrailers, + trailers: GrpcMetadata, request: Flow, ): Flow { val clientCallScope = ClientCallScopeImpl( @@ -205,22 +205,22 @@ internal class Ready(private val isReallyReady: () -> Boolean) { private class ClientCallScopeImpl( val client: GrpcClient, override val method: MethodDescriptor, - override val metadata: GrpcTrailers, + override val metadata: GrpcMetadata, override val callOptions: GrpcCallOptions, ) : ClientCallScope { val call = client.channel.platformApi.newCall(method, callOptions) val interceptors = client.interceptors - val onHeadersFuture = CallbackFuture() - val onCloseFuture = CallbackFuture>() + val onHeadersFuture = CallbackFuture() + val onCloseFuture = CallbackFuture>() var interceptorIndex = 0 - override fun onHeaders(block: (GrpcTrailers) -> Unit) { + override fun onHeaders(block: (GrpcMetadata) -> Unit) { onHeadersFuture.onComplete { block(it) } } - override fun onClose(block: (Status, GrpcTrailers) -> Unit) { + override fun onClose(block: (Status, GrpcMetadata) -> Unit) { onCloseFuture.onComplete { block(it.first, it.second) } } @@ -308,7 +308,7 @@ private class ClientCallScopeImpl( throw e ?: AssertionError("onMessage should never be called until responses is ready") } }, - onClose = { status: Status, trailers: GrpcTrailers -> + onClose = { status: Status, trailers: GrpcMetadata -> var cause = when { status.statusCode == StatusCode.OK -> null status.getCause() is CancellationException -> status.getCause() diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt index f3e6250fb..7a8abd732 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.ServerCallScope import kotlinx.rpc.grpc.ServerInterceptor import kotlinx.rpc.grpc.Status @@ -130,7 +130,7 @@ private fun CoroutineScope.serverCallListenerImpl( responseKType: KType, interceptors: List, implementation: (Flow) -> Flow, - requestHeaders: GrpcTrailers, + requestHeaders: GrpcMetadata, ): ServerCall.Listener { val ready = Ready { handler.isReady() } val requestsChannel = Channel(1) @@ -177,7 +177,7 @@ private fun CoroutineScope.serverCallListenerImpl( // once we have a response message, check if we've sent headers yet - if not, do so if (headersSent.value.compareAndSet(expect = false, update = true)) { mutex.withLock { - handler.sendHeaders(GrpcTrailers()) + handler.sendHeaders(GrpcMetadata()) } } ready.suspendUntilReady() @@ -190,7 +190,7 @@ private fun CoroutineScope.serverCallListenerImpl( // no elements or threw an exception, then we wouldn't have sent them if (failure == null && headersSent.value.compareAndSet(expect = false, update = true)) { mutex.withLock { - handler.sendHeaders(GrpcTrailers()) + handler.sendHeaders(GrpcMetadata()) } } @@ -216,7 +216,7 @@ private fun CoroutineScope.serverCallListenerImpl( null } } - } ?: GrpcTrailers() + } ?: GrpcMetadata() mutex.withLock { handler.close(closeStatus, trailers) @@ -271,22 +271,22 @@ private class ServerCallScopeImpl( override val method: MethodDescriptor, val interceptors: List, val implementation: (Flow) -> Flow, - override val requestHeaders: GrpcTrailers, + override val requestHeaders: GrpcMetadata, val serverCall: ServerCall, ) : ServerCallScope { - override val responseHeaders: GrpcTrailers = GrpcTrailers() - override val responseTrailers: GrpcTrailers = GrpcTrailers() + override val responseHeaders: GrpcMetadata = GrpcMetadata() + override val responseTrailers: GrpcMetadata = GrpcMetadata() // keeps track of already processed interceptors var interceptorIndex = 0 - val onCloseFuture = CallbackFuture>() + val onCloseFuture = CallbackFuture>() - override fun onClose(block: (Status, GrpcTrailers) -> Unit) { + override fun onClose(block: (Status, GrpcMetadata) -> Unit) { onCloseFuture.onComplete { block(it.first, it.second) } } - override fun close(status: Status, trailers: GrpcTrailers): Nothing { + override fun close(status: Status, trailers: GrpcMetadata): Nothing { // this will be cached by the rpcImpl() runCatching{} and turns it into a close() throw StatusException(status, trailers) } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt index 4b2e08c4c..e607bd47c 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout import kotlinx.rpc.grpc.GrpcServer -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.ManagedChannel import kotlinx.rpc.grpc.ManagedChannelBuilder import kotlinx.rpc.grpc.Status @@ -84,7 +84,7 @@ class GrpcCoreClientTest { onClose = { status, _ -> statusDeferred.complete(status) } ) - call.start(listener, GrpcTrailers()) + call.start(listener, GrpcMetadata()) call.sendMessage(req) call.halfClose() call.request(1) @@ -108,8 +108,8 @@ class GrpcCoreClientTest { val listener = createClientCallListener( onClose = { status, _ -> statusDeferred.complete(status) } ) - call.start(listener, GrpcTrailers()) - assertFailsWith { call.start(listener, GrpcTrailers()) } + call.start(listener, GrpcMetadata()) + assertFailsWith { call.start(listener, GrpcMetadata()) } // cancel to finish the call quickly call.cancel("Double start test", null) runBlocking { withTimeout(5000) { statusDeferred.await() } } @@ -125,7 +125,7 @@ class GrpcCoreClientTest { val listener = createClientCallListener( onClose = { status, _ -> statusDeferred.complete(status) } ) - call.start(listener, GrpcTrailers()) + call.start(listener, GrpcMetadata()) call.halfClose() assertFailsWith { call.sendMessage(req) } // Ensure call completes @@ -142,7 +142,7 @@ class GrpcCoreClientTest { val listener = createClientCallListener( onClose = { status, _ -> statusDeferred.complete(status) } ) - call.start(listener, GrpcTrailers()) + call.start(listener, GrpcMetadata()) assertFails { call.request(-1) } call.cancel("cleanup", null) runBlocking { withTimeout(5000) { statusDeferred.await() } } @@ -157,7 +157,7 @@ class GrpcCoreClientTest { val listener = createClientCallListener( onClose = { status, _ -> statusDeferred.complete(status) } ) - call.start(listener, GrpcTrailers()) + call.start(listener, GrpcMetadata()) call.cancel("user cancel", null) runBlocking { withTimeout(10000) { @@ -177,7 +177,7 @@ class GrpcCoreClientTest { onClose = { status, _ -> statusDeferred.complete(status) } ) - call.start(listener, GrpcTrailers()) + call.start(listener, GrpcMetadata()) call.sendMessage(helloReq()) call.halfClose() call.request(1) @@ -198,7 +198,7 @@ class GrpcCoreClientTest { val listener = createClientCallListener() assertFailsWith { try { - call.start(listener, GrpcTrailers()) + call.start(listener, GrpcMetadata()) call.halfClose() call.sendMessage(helloReq()) } finally { @@ -218,7 +218,7 @@ class GrpcCoreClientTest { channel.shutdown() runBlocking { channel.awaitTermination() } - call.start(listener, GrpcTrailers()) + call.start(listener, GrpcMetadata()) call.sendMessage(helloReq()) call.halfClose() call.request(1) @@ -240,7 +240,7 @@ class GrpcCoreClientTest { onClose = { status, _ -> statusDeferred.complete(status) } ) - call.start(listener, GrpcTrailers()) + call.start(listener, GrpcMetadata()) // set timeout on the server to 1000 ms, to simulate a long-running call call.sendMessage(helloReq(1000u)) call.halfClose() @@ -292,9 +292,9 @@ class GreeterServiceImpl : GreeterService { private fun createClientCallListener( - onHeaders: (headers: GrpcTrailers) -> Unit = {}, + onHeaders: (headers: GrpcMetadata) -> Unit = {}, onMessage: (message: T) -> Unit = {}, - onClose: (status: Status, trailers: GrpcTrailers) -> Unit = { _, _ -> }, + onClose: (status: Status, trailers: GrpcMetadata) -> Unit = { _, _ -> }, onReady: () -> Unit = {}, ) = clientCallListener( onHeaders = onHeaders, diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt index 11192014f..dadd8cd4f 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.GrpcClient -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.ServerCallScope import kotlinx.rpc.grpc.ServerInterceptor import kotlinx.rpc.grpc.Status @@ -57,7 +57,7 @@ class ServerInterceptorTest : GrpcProtoTest() { fun `close during intercept - should fail with correct status on client`() { val error = assertFailsWith { val interceptor = interceptor { - close(Status(StatusCode.UNAUTHENTICATED, "Close in interceptor"), GrpcTrailers()) + close(Status(StatusCode.UNAUTHENTICATED, "Close in interceptor"), GrpcMetadata()) } runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) } @@ -72,7 +72,7 @@ class ServerInterceptorTest : GrpcProtoTest() { val interceptor = interceptor { proceed( it.map { - close(Status(StatusCode.UNAUTHENTICATED, "Close in request flow"), GrpcTrailers()) + close(Status(StatusCode.UNAUTHENTICATED, "Close in request flow"), GrpcMetadata()) } ) } @@ -88,7 +88,7 @@ class ServerInterceptorTest : GrpcProtoTest() { val error = assertFailsWith { val interceptor = interceptor { proceed(it).map { - close(Status(StatusCode.UNAUTHENTICATED, "Close in response flow"), GrpcTrailers()) + close(Status(StatusCode.UNAUTHENTICATED, "Close in response flow"), GrpcMetadata()) } } runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) @@ -102,7 +102,7 @@ class ServerInterceptorTest : GrpcProtoTest() { fun `close during onClose - should fail with correct status on client`() { val error = assertFailsWith { val interceptor = interceptor { - onClose { _, _ -> close(Status(StatusCode.UNAUTHENTICATED, "Close in onClose"), GrpcTrailers()) } + onClose { _, _ -> close(Status(StatusCode.UNAUTHENTICATED, "Close in onClose"), GrpcMetadata()) } proceed(it) } runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.jvm.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt similarity index 79% rename from grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.jvm.kt rename to grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt index 090e3c718..fbb286a8c 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.jvm.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt @@ -7,4 +7,4 @@ package kotlinx.rpc.grpc import kotlinx.rpc.internal.utils.InternalRpcApi @InternalRpcApi -public actual typealias GrpcTrailers = io.grpc.Metadata +public actual typealias GrpcMetadata = io.grpc.Metadata diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.jvm.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.jvm.kt index bd3dde915..010091179 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.jvm.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.jvm.kt @@ -6,7 +6,7 @@ package kotlinx.rpc.grpc.internal import io.grpc.Metadata import io.grpc.ClientCall -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.internal.utils.InternalRpcApi @@ -14,9 +14,9 @@ internal actual typealias ClientCall = ClientCall clientCallListener( - crossinline onHeaders: (headers: GrpcTrailers) -> Unit, + crossinline onHeaders: (headers: GrpcMetadata) -> Unit, crossinline onMessage: (message: Message) -> Unit, - crossinline onClose: (status: Status, trailers: GrpcTrailers) -> Unit, + crossinline onClose: (status: Status, trailers: GrpcMetadata) -> Unit, crossinline onReady: () -> Unit, ): ClientCall.Listener { return object : ClientCall.Listener() { diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt similarity index 65% rename from grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.native.kt rename to grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt index 9cfb2249d..91d154c77 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcTrailers.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt @@ -5,6 +5,6 @@ package kotlinx.rpc.grpc @Suppress(names = ["RedundantConstructorKeyword"]) -public actual class GrpcTrailers actual constructor() { - public actual fun merge(trailers: GrpcTrailers) {} +public actual class GrpcMetadata actual constructor() { + public actual fun merge(trailers: GrpcMetadata) {} } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/StatusException.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/StatusException.native.kt index e319178e4..f5fe62187 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/StatusException.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/StatusException.native.kt @@ -6,11 +6,11 @@ package kotlinx.rpc.grpc public actual class StatusException : Exception { private val status: Status - private val trailers: GrpcTrailers? + private val trailers: GrpcMetadata? public actual constructor(status: Status) : this(status, null) - public actual constructor(status: Status, trailers: GrpcTrailers?) : super( + public actual constructor(status: Status, trailers: GrpcMetadata?) : super( "${status.statusCode}: ${status.getDescription()}", status.getCause() ) { @@ -20,16 +20,16 @@ public actual class StatusException : Exception { public actual fun getStatus(): Status = status - public actual fun getTrailers(): GrpcTrailers? = trailers + public actual fun getTrailers(): GrpcMetadata? = trailers } public actual class StatusRuntimeException : RuntimeException { private val status: Status - private val trailers: GrpcTrailers? + private val trailers: GrpcMetadata? public actual constructor(status: Status) : this(status, null) - public actual constructor(status: Status, trailers: GrpcTrailers?) : super( + public actual constructor(status: Status, trailers: GrpcMetadata?) : super( "${status.statusCode}: ${status.getDescription()}", status.getCause() ) { @@ -39,5 +39,5 @@ public actual class StatusRuntimeException : RuntimeException { public actual fun getStatus(): Status = status - public actual fun getTrailers(): GrpcTrailers? = trailers + public actual fun getTrailers(): GrpcMetadata? = trailers } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.native.kt index f650f9e17..ea8c3ed46 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/ClientCall.native.kt @@ -7,7 +7,7 @@ package kotlinx.rpc.grpc.internal import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.internal.utils.InternalRpcApi import kotlin.experimental.ExperimentalNativeApi @@ -16,7 +16,7 @@ import kotlin.experimental.ExperimentalNativeApi public actual abstract class ClientCall { public actual abstract fun start( responseListener: Listener, - headers: GrpcTrailers, + headers: GrpcMetadata, ) public actual abstract fun request(numMessages: Int) @@ -30,10 +30,10 @@ public actual abstract class ClientCall { @InternalRpcApi public actual abstract class Listener { - public actual open fun onHeaders(headers: GrpcTrailers) { + public actual open fun onHeaders(headers: GrpcMetadata) { } - public actual open fun onClose(status: Status, trailers: GrpcTrailers) { + public actual open fun onClose(status: Status, trailers: GrpcMetadata) { } public actual open fun onMessage(message: Message) { @@ -46,13 +46,13 @@ public actual abstract class ClientCall { @InternalRpcApi public actual fun clientCallListener( - onHeaders: (headers: GrpcTrailers) -> Unit, + onHeaders: (headers: GrpcMetadata) -> Unit, onMessage: (message: Message) -> Unit, - onClose: (status: Status, trailers: GrpcTrailers) -> Unit, + onClose: (status: Status, trailers: GrpcMetadata) -> Unit, onReady: () -> Unit, ): ClientCall.Listener { return object : ClientCall.Listener() { - override fun onHeaders(headers: GrpcTrailers) { + override fun onHeaders(headers: GrpcMetadata) { onHeaders(headers) } @@ -60,7 +60,7 @@ public actual fun clientCallListener( onMessage(message) } - override fun onClose(status: Status, trailers: GrpcTrailers) { + override fun onClose(status: Status, trailers: GrpcMetadata) { onClose(status, trailers) } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt index f4f44599c..6e2b0cdcb 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt @@ -23,7 +23,7 @@ import kotlinx.cinterop.toKString import kotlinx.cinterop.value import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableJob -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.protobuf.input.stream.asInputStream @@ -92,7 +92,7 @@ internal class NativeClientCall( // holds the received status information returned by the RECV_STATUS_ON_CLIENT batch. // if null, the call is still in progress. otherwise, the call can be closed as soon as inFlight is 0. - private val closeInfo = atomic?>(null) + private val closeInfo = atomic?>(null) // we currently don't buffer messages, so after one `sendMessage` call, ready turns false. (KRPC-192) private val ready = atomic(true) @@ -143,7 +143,7 @@ internal class NativeClientCall( * Sets the [closeInfo] and calls [tryToCloseCall]. * This is called as soon as the RECV_STATUS_ON_CLIENT batch (started with [startRecvStatus]) finished. */ - private fun markClosePending(status: Status, trailers: GrpcTrailers) { + private fun markClosePending(status: Status, trailers: GrpcMetadata) { closeInfo.compareAndSet(null, Pair(status, trailers)) tryToCloseCall() } @@ -163,7 +163,7 @@ internal class NativeClientCall( override fun start( responseListener: Listener, - headers: GrpcTrailers, + headers: GrpcMetadata, ) { check(listener == null) { internalError("Already started") } @@ -258,7 +258,7 @@ internal class NativeClientCall( val details = statusDetails.toByteArray().toKString() val kStatusCode = statusCode.value.toKotlin() val status = Status(kStatusCode, details, null) - val trailers = GrpcTrailers() + val trailers = GrpcMetadata() // cleanup grpc_slice_unref(statusDetails.readValue()) @@ -273,7 +273,7 @@ internal class NativeClientCall( BatchResult.CQShutdown -> { arena.clear() - markClosePending(Status(StatusCode.UNAVAILABLE, "Channel shutdown"), GrpcTrailers()) + markClosePending(Status(StatusCode.UNAVAILABLE, "Channel shutdown"), GrpcMetadata()) return false } @@ -281,7 +281,7 @@ internal class NativeClientCall( arena.clear() markClosePending( Status(StatusCode.INTERNAL, "Failed to start call: ${callResult.error}"), - GrpcTrailers() + GrpcMetadata() ) return false } @@ -310,7 +310,7 @@ internal class NativeClientCall( arena.clear() }) { safeUserCode("Failed to call onHeaders.") { - listener?.onHeaders(GrpcTrailers()) + listener?.onHeaders(GrpcMetadata()) } } } @@ -366,7 +366,7 @@ internal class NativeClientCall( val status = Status(StatusCode.CANCELLED, message ?: "Call cancelled", cause) // user side cancellation must always win over any other status (even if the call is already completed). // this will also preserve the cancellation cause, which cannot be passed to the grpc-core. - closeInfo.value = Pair(status, GrpcTrailers()) + closeInfo.value = Pair(status, GrpcMetadata()) cancelInternal( grpc_status_code.GRPC_STATUS_CANCELLED, message ?: "Call cancelled with cause: ${cause?.message}" @@ -376,7 +376,7 @@ internal class NativeClientCall( private fun cancelInternal(statusCode: grpc_status_code, message: String) { val cancelResult = grpc_call_cancel_with_status(raw, statusCode, message, null) if (cancelResult != grpc_call_error.GRPC_CALL_OK) { - markClosePending(Status(StatusCode.INTERNAL, "Failed to cancel call: $cancelResult"), GrpcTrailers()) + markClosePending(Status(StatusCode.INTERNAL, "Failed to cancel call: $cancelResult"), GrpcMetadata()) } } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt index d6b46fa37..1fd3e1e90 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt @@ -21,7 +21,7 @@ import kotlinx.cinterop.convert import kotlinx.cinterop.get import kotlinx.cinterop.ptr import kotlinx.cinterop.value -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.StatusException @@ -243,7 +243,7 @@ internal class NativeServerCall( } } - override fun sendHeaders(headers: GrpcTrailers) { + override fun sendHeaders(headers: GrpcMetadata) { check(initialized) { internalError("Call not initialized") } val arena = Arena() // TODO: Implement header metadata operation @@ -284,7 +284,7 @@ internal class NativeServerCall( } } - override fun close(status: Status, trailers: GrpcTrailers) { + override fun close(status: Status, trailers: GrpcMetadata) { check(initialized) { internalError("Call not initialized") } val arena = Arena() diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/ServerCall.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/ServerCall.native.kt index bda5a17be..e57f2eb96 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/ServerCall.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/ServerCall.native.kt @@ -4,7 +4,7 @@ package kotlinx.rpc.grpc.internal -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.internal.utils.InternalRpcApi @@ -12,16 +12,16 @@ import kotlinx.rpc.internal.utils.InternalRpcApi public actual fun interface ServerCallHandler { public actual fun startCall( call: ServerCall, - headers: GrpcTrailers, + headers: GrpcMetadata, ): ServerCall.Listener } @InternalRpcApi public actual abstract class ServerCall { public actual abstract fun request(numMessages: Int) - public actual abstract fun sendHeaders(headers: GrpcTrailers) + public actual abstract fun sendHeaders(headers: GrpcMetadata) public actual abstract fun sendMessage(message: Response) - public actual abstract fun close(status: Status, trailers: GrpcTrailers) + public actual abstract fun close(status: Status, trailers: GrpcMetadata) public actual open fun isReady(): Boolean { // Default implementation returns true - subclasses can override if they need flow control diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/serverCallTags.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/serverCallTags.kt index f22bd1f0e..ef24ff71e 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/serverCallTags.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/serverCallTags.kt @@ -15,7 +15,7 @@ import kotlinx.cinterop.alloc import kotlinx.cinterop.cValue import kotlinx.cinterop.ptr import kotlinx.cinterop.value -import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.HandlerRegistry import libkgrpc.gpr_timespec import libkgrpc.grpc_call_details @@ -62,7 +62,7 @@ internal class RegisteredServerCallTag( // ownership of the core call is transferred to the NativeServerCall. val call = NativeServerCall(rawCall.value!!, cq, method.getMethodDescriptor()) // TODO: Turn metadata into a kotlin GrpcTrailers. - val trailers = GrpcTrailers() + val trailers = GrpcMetadata() // start the actual call. val listener = method.getServerCallHandler().startCall(call, trailers) call.setListener(listener) @@ -141,7 +141,7 @@ internal class LookupServerCallTag( definition.getMethodDescriptor() as MethodDescriptor ) // TODO: Turn metadata into a kotlin GrpcTrailers. - val metadata = GrpcTrailers() + val metadata = GrpcMetadata() val listener = callHandler.startCall(call, metadata) call.setListener(listener) } From 9a41ac96ac9bf9770cdf99029ddf428120cc5c00 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Wed, 17 Sep 2025 17:32:29 +0200 Subject: [PATCH 08/21] grpc: Refactor cancel API in ClientCallScope to return Nothing Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ClientInterceptor.kt | 2 +- .../rpc/grpc/internal/suspendClientCalls.kt | 14 ++- .../grpc/test/proto/ClientInterceptorTest.kt | 93 ++++++++++++++++++- .../grpc/test/proto/ServerInterceptorTest.kt | 21 +++++ .../rpc/grpc/internal/NativeServerCall.kt | 1 + 5 files changed, 126 insertions(+), 5 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt index e257923c3..1e972fce0 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt @@ -14,7 +14,7 @@ public interface ClientCallScope { public val callOptions: GrpcCallOptions public fun onHeaders(block: (GrpcMetadata) -> Unit) public fun onClose(block: (Status, GrpcMetadata) -> Unit) - public fun cancel(message: String, cause: Throwable? = null) + public fun cancel(message: String, cause: Throwable? = null): Nothing public fun proceed(request: Flow): Flow } diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt index 75031f393..00cd7a4ec 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt @@ -224,8 +224,8 @@ private class ClientCallScopeImpl( onCloseFuture.onComplete { block(it.first, it.second) } } - override fun cancel(message: String, cause: Throwable?) { - call.cancel(message, cause) + override fun cancel(message: String, cause: Throwable?): Nothing { + throw StatusException(Status(StatusCode.CANCELLED, message, cause)) } override fun proceed(request: Flow): Flow { @@ -302,7 +302,15 @@ private class ClientCallScopeImpl( responses: Channel, ready: Ready, ) = clientCallListener( - onHeaders = { onHeadersFuture.complete(it) }, + onHeaders = { + try { + onHeadersFuture.complete(it) + } catch (e: StatusException) { + // if a client interceptor called cancel, we throw a StatusException. + // as the JVM implementation treats them differently, we need to catch them here. + call.cancel(e.message, e.cause) + } + }, onMessage = { message: Response -> responses.trySend(message).onFailure { e -> throw e ?: AssertionError("onMessage should never be called until responses is ready") diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt index 5f01b6572..c385d467d 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt @@ -87,7 +87,6 @@ class ClientInterceptorTest : GrpcProtoTest() { val error = assertFailsWith { val interceptor = interceptor { cancel("Canceling in interceptor", IllegalStateException("Cancellation cause")) - proceed(it) } runGrpcTest(clientInterceptors = interceptor, test = ::unaryCall) } @@ -98,6 +97,85 @@ class ClientInterceptorTest : GrpcProtoTest() { assertEquals("Cancellation cause", error.cause?.message) } + @Test + fun `cancel in request flow - should fail with cancellation`() { + val error = assertFailsWith { + val interceptor = interceptor { + proceed(it.map { + val msg = it as EchoRequest + if (msg.message == "Echo-3") { + cancel("Canceling in request flow", IllegalStateException("Cancellation cause")) + } + it + }) + } + runGrpcTest(clientInterceptors = interceptor, test = ::bidiStream) + } + + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) + assertContains(error.message!!, "Canceling in request flow") + assertIs(error.cause) + assertEquals("Cancellation cause", error.cause?.message) + } + + @Test + fun `cancel in response flow - should fail with cancellation`() { + val error = assertFailsWith { + val interceptor = interceptor { + flow { + proceed(it).collect { resp -> + val msg = resp as EchoResponse + if (msg.message == "Echo-3") { + cancel("Canceling in response flow", IllegalStateException("Cancellation cause")) + } + emit(resp) + } + } + } + runGrpcTest(clientInterceptors = interceptor, test = ::bidiStream) + } + + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) + assertContains(error.message!!, "Canceling in response flow") + assertIs(error.cause) + assertEquals("Cancellation cause", error.cause?.message) + } + + @Test + fun `cancel onHeaders - should fail with cancellation`() { + val error = assertFailsWith { + val interceptor = interceptor { + this.onHeaders { + cancel("Canceling in headers", IllegalStateException("Cancellation cause")) + } + proceed(it) + } + runGrpcTest(clientInterceptors = interceptor, test = ::bidiStream) + } + + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) + assertContains(error.message!!, "Canceling in headers") + assertIs(error.cause) + assertEquals("Cancellation cause", error.cause?.message) + } + + @Test + fun `cancel onClose - should fail with cancellation`() { + val error = assertFailsWith { + val interceptor = interceptor { + this.onClose { _, _ -> + cancel("Canceling in onClose", IllegalStateException("Cancellation cause")) + } + proceed(it) + } + runGrpcTest(clientInterceptors = interceptor, test = ::bidiStream) + } + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) + assertContains(error.message!!, "Canceling in onClose") + assertIs(error.cause) + assertEquals("Cancellation cause", error.cause?.message) + } + @Test fun `modify request message - should return modified message`() { val interceptor = interceptor { @@ -156,6 +234,19 @@ class ClientInterceptorTest : GrpcProtoTest() { assertEquals("Hello", response.message) } + private suspend fun bidiStream(grpcClient: GrpcClient) { + val service = grpcClient.withService() + val responses = service.BidirectionalStreamingEcho(flow { + repeat(5) { + emit(EchoRequest { message = "Echo-$it" }) + } + }).toList() + assertEquals(5, responses.size) + repeat(5) { + assertEquals("Echo-$it", responses[it].message) + } + } + } private fun interceptor( diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt index dadd8cd4f..ab304996d 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt @@ -148,6 +148,27 @@ class ServerInterceptorTest : GrpcProtoTest() { } } + @Test + fun `proceedFlow - should succeed on client`() { + val interceptor = interceptor { + kotlinx.coroutines.flow.flow { + proceedFlow(it) + } + } + runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) + } + + @Test + fun `method descriptor - full method name is exposed`() { + var methodName: String? = null + val interceptor = interceptor { + methodName = method.getFullMethodName() + proceed(it) + } + runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) + assertContains(methodName!!, "EchoService/UnaryEcho") + } + private suspend fun unaryCall(grpcClient: GrpcClient) { val service = grpcClient.withService() val response = service.UnaryEcho(EchoRequest { message = "Hello" }) diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt index 1fd3e1e90..00016a76c 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt @@ -101,6 +101,7 @@ internal class NativeServerCall( } private fun initialize() { + println("Initializing native server call") // finishes if the whole connection is closed. // this triggers onClose()/onCanceled() callback. val arena = Arena() From 99da0ba23e21f3405b279b2904e982c3fb09219f Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Wed, 17 Sep 2025 17:42:34 +0200 Subject: [PATCH 09/21] grpc: Remove println Signed-off-by: Johannes Zottele --- .../kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt index 00016a76c..1fd3e1e90 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt @@ -101,7 +101,6 @@ internal class NativeServerCall( } private fun initialize() { - println("Initializing native server call") // finishes if the whole connection is closed. // this triggers onClose()/onCanceled() callback. val arena = Arena() From ca75e4659eac39f4945c61ad63005b1162453bbe Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Wed, 17 Sep 2025 17:54:49 +0200 Subject: [PATCH 10/21] grpc: Adjust metadata names Signed-off-by: Johannes Zottele --- .../commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt | 6 +++--- .../kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt index 1e972fce0..5ac8f6f1f 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt @@ -10,10 +10,10 @@ import kotlinx.rpc.grpc.internal.MethodDescriptor public interface ClientCallScope { public val method: MethodDescriptor - public val metadata: GrpcMetadata + public val requestHeaders: GrpcMetadata public val callOptions: GrpcCallOptions - public fun onHeaders(block: (GrpcMetadata) -> Unit) - public fun onClose(block: (Status, GrpcMetadata) -> Unit) + public fun onHeaders(block: (responseHeaders: GrpcMetadata) -> Unit) + public fun onClose(block: (closeStatus: Status, responseTrailers: GrpcMetadata) -> Unit) public fun cancel(message: String, cause: Throwable? = null): Nothing public fun proceed(request: Flow): Flow } diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt index 00cd7a4ec..0621b9810 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendClientCalls.kt @@ -147,7 +147,7 @@ private fun GrpcClient.rpcImpl( val clientCallScope = ClientCallScopeImpl( client = this, method = descriptor, - metadata = trailers, + requestHeaders = trailers, callOptions = callOptions, ) return clientCallScope.proceed(request) @@ -205,7 +205,7 @@ internal class Ready(private val isReallyReady: () -> Boolean) { private class ClientCallScopeImpl( val client: GrpcClient, override val method: MethodDescriptor, - override val metadata: GrpcMetadata, + override val requestHeaders: GrpcMetadata, override val callOptions: GrpcCallOptions, ) : ClientCallScope { @@ -250,7 +250,7 @@ private class ClientCallScopeImpl( val responses = Channel(1) val ready = Ready { call.isReady() } - call.start(channelResponseListener(responses, ready), metadata) + call.start(channelResponseListener(responses, ready), requestHeaders) suspend fun Flow.send() { if (method.type == MethodType.UNARY || method.type == MethodType.SERVER_STREAMING) { From 6485b61c46cd74a0b99af656b5caea7ee0985f88 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Thu, 18 Sep 2025 12:28:04 +0200 Subject: [PATCH 11/21] grpc: Fix Ktor server constructor Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ktor/server/Server.kt | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/grpc/grpc-ktor-server/src/commonMain/kotlin/kotlinx/rpc/grpc/ktor/server/Server.kt b/grpc/grpc-ktor-server/src/commonMain/kotlin/kotlinx/rpc/grpc/ktor/server/Server.kt index a88c9d0d9..339d79af6 100644 --- a/grpc/grpc-ktor-server/src/commonMain/kotlin/kotlinx/rpc/grpc/ktor/server/Server.kt +++ b/grpc/grpc-ktor-server/src/commonMain/kotlin/kotlinx/rpc/grpc/ktor/server/Server.kt @@ -4,17 +4,13 @@ package kotlinx.rpc.grpc.ktor.server -import io.ktor.server.application.Application -import io.ktor.server.application.ApplicationStopped -import io.ktor.server.application.ApplicationStopping -import io.ktor.server.application.log -import io.ktor.server.config.getAs -import io.ktor.util.AttributeKey +import io.ktor.server.application.* +import io.ktor.server.config.* +import io.ktor.util.* import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.GrpcServer +import kotlinx.rpc.grpc.GrpcServerConfiguration import kotlinx.rpc.grpc.ServerBuilder -import kotlinx.rpc.grpc.codec.EmptyMessageCodecResolver -import kotlinx.rpc.grpc.codec.MessageCodecResolver @Suppress("ConstPropertyName") public object GrpcConfigKeys { @@ -51,8 +47,7 @@ public val GrpcServerKey: AttributeKey = AttributeKey("G */ public fun Application.grpc( port: Int = environment.config.propertyOrNull(GrpcConfigKeys.grpcHostPortPath)?.getAs() ?: 8001, - messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver, - configure: ServerBuilder<*>.() -> Unit = {}, + configure: GrpcServerConfiguration.() -> Unit = {}, builder: RpcServer.() -> Unit, ): GrpcServer { if (attributes.contains(GrpcServerKey)) { @@ -64,9 +59,8 @@ public fun Application.grpc( newServer = true GrpcServer( port = port, - messageCodecResolver = messageCodecResolver, parentContext = coroutineContext, - serverBuilder = configure, + configure = configure, builder = builder, ) } From 1cdb7f72d1491fb47185ae885c4832e86aa55880 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Thu, 18 Sep 2025 15:28:38 +0200 Subject: [PATCH 12/21] grpc: Add documentation Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ClientInterceptor.kt | 109 ++++++++++++++++-- .../kotlinx/rpc/grpc/ServerInterceptor.kt | 94 +++++++++++++++ .../kotlinx/rpc/grpc/internal/GrpcContext.kt | 5 +- .../rpc/grpc/internal/suspendServerCalls.kt | 5 +- .../kotlinx/rpc/grpc/internal/GrpcContext.kt | 4 +- .../rpc/grpc/internal/GrpcContext.native.kt | 6 +- 6 files changed, 206 insertions(+), 17 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt index 5ac8f6f1f..04729e899 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt @@ -8,29 +8,118 @@ import kotlinx.coroutines.flow.Flow import kotlinx.rpc.grpc.internal.GrpcCallOptions import kotlinx.rpc.grpc.internal.MethodDescriptor +/** + * The scope of a single outgoing gRPC client call observed by a [ClientInterceptor]. + * + * An interceptor receives this scope instance for every call and can: + * - Inspect the RPC [method] being invoked. + * - Read or populate [requestHeaders] before the request is sent. + * - Read [callOptions] that affect transport-level behavior. + * - Register callbacks with [onHeaders] and [onClose] to observe response metadata and final status. + * - Cancel the call early via [cancel]. + * - Continue the call by calling [proceed] with a (possibly transformed) request [Flow]. + * - Transform the response by modifying the returned [Flow]. + * + * ```kt + * val interceptor = object : ClientInterceptor { + * override fun ClientCallScope.intercept( + * request: Flow + * ): Flow { + * // Example: add a header before proceeding + * requestHeaders[MyKeys.Authorization] = token + * + * // Example: observe response metadata + * onHeaders { headers -> /* inspect headers */ } + * onClose { status, trailers -> /* log status/trailers */ } + * + * // IMPORTANT: proceed forwards the call to the next interceptor/transport. + * // If you do not call proceed, no request will be sent and the call is short-circuited. + * return proceed(request) + * } + * } + * ``` + * + * @param Request the request message type of the RPC. + * @param Response the response message type of the RPC. + */ public interface ClientCallScope { + /** Descriptor of the RPC method (name, marshalling, type) being invoked. */ public val method: MethodDescriptor + + /** + * Outgoing request headers for this call. + * + * Interceptors may read and mutate this metadata + * before calling [proceed] so the headers are sent to the server. Headers added after + * the call has already been proceeded may not be reflected on the wire. + */ public val requestHeaders: GrpcMetadata + + /** + * Transport/engine options used for this call (deadlines, compression, etc.). + * Modifying this object is only possible before the call is proceeded. + */ public val callOptions: GrpcCallOptions + + /** + * Register a callback invoked when the initial response headers are received. + * Typical gRPC semantics guarantee headers are delivered at most once per call + * and before the first message is received. + */ public fun onHeaders(block: (responseHeaders: GrpcMetadata) -> Unit) - public fun onClose(block: (closeStatus: Status, responseTrailers: GrpcMetadata) -> Unit) + + /** + * Register a callback invoked when the call completes, successfully or not. + * The final `status` and trailing `responseTrailers` are provided. + */ + public fun onClose(block: (status: Status, responseTrailers: GrpcMetadata) -> Unit) + + /** + * Cancel the call locally, providing a human-readable [message] and an optional [cause]. + * This method won't return and abort all further processing. + */ public fun cancel(message: String, cause: Throwable? = null): Nothing + + /** + * Continue the invocation by forwarding it to the next interceptor or to the underlying transport. + * + * This function is the heart of an interceptor: + * - It must be called to actually perform the RPC. If you never call [proceed], the request is not sent + * and the call is effectively short-circuited by the interceptor. + * - You may transform the [request] flow before passing it to [proceed] (e.g., logging, retry orchestration, + * compression, metrics). The returned [Flow] yields response messages and can also be transformed + * before being returned to the caller. + * - Call [proceed] at most once per intercepted call. Calling it multiple times or after cancellation + * is not supported. + */ public fun proceed(request: Flow): Flow } +/** + * Client-side interceptor for gRPC calls. + * + * Implementations can observe and modify client calls in a structured way. The primary entry point is the + * [intercept] extension function on [ClientCallScope], which receives the inbound request [Flow] and must + * call [ClientCallScope.proceed] to forward the call. + * + * Common use-cases include: + * - Adding authentication or custom headers. + * - Implementing logging/metrics. + * - Observing headers/trailers and final status. + * - Transforming request/response flows (e.g., mapping, buffering, throttling). + */ public interface ClientInterceptor { - /** - * Intercepts and transforms the flow of requests and responses in a client call. - * An interceptor can throw an exception at any time to cancel the call. + * Intercept a client call. * - * The interceptor must ensure that it emits an expected number of values. - * E.g. if the intercepted method is a unary call, the interceptor's returned flow must emit exactly one value. + * You can: + * - Inspect [ClientCallScope.method] and [ClientCallScope.callOptions]. + * - Read or populate [ClientCallScope.requestHeaders]. + * - Register [ClientCallScope.onHeaders] and [ClientCallScope.onClose] callbacks. + * - Transform the [request] flow or wrap the resulting response flow. * - * @param this The scope of the client call, providing context and methods for managing - * the call lifecycle and metadata. - * @param request A flow of requests to be sent to the server. - * @return A flow of responses received from the server. + * IMPORTANT: [ClientCallScope.proceed] must eventually be called to actually execute the RPC and obtain + * the response [Flow]. If [ClientCallScope.proceed] is omitted, the call will not reach the server. */ public fun ClientCallScope.intercept( request: Flow, diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt index fa47eef80..03434af0c 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt @@ -6,18 +6,84 @@ package kotlinx.rpc.grpc import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector +import kotlinx.rpc.grpc.internal.GrpcContext import kotlinx.rpc.grpc.internal.MethodDescriptor +/** + * Th scope of a single incoming gRPC server call observed by a [ServerInterceptor]. + * + * An interceptor receives this scope instance for every RPC invocation arriving to the server and can: + * - Inspect the target RPC [method]. + * - Read client-provided [requestHeaders]. + * - Populate [responseHeaders] (sent before the first response message) and [responseTrailers] + * (sent when the call completes). + * - Register a completion callback with [onClose]. + * - Abort the call early with [close]. + * - Continue handling by calling [proceed] with the inbound request [Flow] and optionally transform + * the returned response [Flow]. + * + * @param Request the request message type of the RPC. + * @param Response the response message type of the RPC. + */ public interface ServerCallScope { + /** Descriptor of the RPC method (name, marshalling, type) being executed. */ public val method: MethodDescriptor + + /** Metadata received from the client with the initial request headers. Read-only from the server perspective. */ public val requestHeaders: GrpcMetadata + + /** + * Initial response headers to be sent to the client. + * Interceptors and handlers may add entries before the first response element is emitted + * (i.e., before proceeding or before producing output), otherwise headers might have already been sent. + */ public val responseHeaders: GrpcMetadata + + /** + * Trailing metadata to be sent with the final status when the call completes. + * Interceptors can add diagnostics or custom metadata here. + */ public val responseTrailers: GrpcMetadata + /** + * The [GrpcContext] associated with this call. + * + * It can be used by the interceptor to provide call-scoped information about + * the current call, such as the identity of the caller or the current authentication state. + */ + public val grpcContext: GrpcContext + + /** + * Register a callback invoked when the call is closed (successfully or exceptionally). + * Provides the final [Status] and the sent [GrpcMetadata] trailers. + */ public fun onClose(block: (Status, GrpcMetadata) -> Unit) + + /** + * Immediately terminate the call with the given [status] and optional [trailers]. + * + * This method does not return (declared as [Nothing]). After calling it, no further messages will be processed + * or sent. Prefer setting [responseHeaders]/[responseTrailers] before closing if you need to include metadata. + */ public fun close(status: Status, trailers: GrpcMetadata = GrpcMetadata()): Nothing + + /** + * Continue processing by forwarding the request to the next interceptor or the actual service implementation. + * + * IMPORTANT: + * - You must call [proceed] exactly once to actually handle the RPC; otherwise, the call will be short-circuited + * and the service method will not be invoked. + * - You may transform the incoming [request] flow (e.g., validation, logging, metering) before passing it to + * [proceed]. You may also transform the resulting response [Flow] before returning it to the framework. + * - The interceptor must ensure to provide and return a valid number of messages, depending on the method type. + * - The interceptor must not throw an exception. Use [close] to terminate the call with an error. + */ public fun proceed(request: Flow): Flow + /** + * Convenience for flow builders: proceeds with [request] and emits the resulting response elements into this + * [FlowCollector]. Useful inside `flow {}` blocks within interceptors. + */ public suspend fun FlowCollector.proceedFlow(request: Flow) { proceed(request).collect { emit(it) @@ -25,7 +91,35 @@ public interface ServerCallScope { } } +/** + * Server-side interceptor for gRPC calls. + * + * Implementations can observe and modify server handling in a structured way. The entry point is the + * [intercept] extension function on [ServerCallScope], which receives the inbound request [Flow] and must + * call [ServerCallScope.proceed] to forward the call to the next interceptor or the target service method. + * + * Common use-cases include: + * - Authentication/authorization checks and context propagation. + * - Setting response headers and trailers. + * - Structured logging and metrics. + * - Transforming request/response flows (e.g., validation, mapping, throttling). + * + * See ServerInterceptorTest for practical usage patterns. + */ public interface ServerInterceptor { + /** + * Intercept a server call. + * + * You can: + * - Inspect [ServerCallScope.method]. + * - Read [ServerCallScope.requestHeaders] and populate [ServerCallScope.responseHeaders]/[ServerCallScope.responseTrailers]. + * - Register [ServerCallScope.onClose] callbacks. + * - Transform the [request] flow or wrap the resulting response flow. + * - Append information to the [ServerCallScope.grpcContext]. + * + * IMPORTANT: You must eventually call [ServerCallScope.proceed] to actually invoke the service logic and produce + * the response [Flow]. If [ServerCallScope.proceed] is omitted, the call will never reach the service. + */ public fun ServerCallScope.intercept( request: Flow, ): Flow diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.kt index 2ec2e4487..2e624556d 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.kt @@ -6,10 +6,13 @@ package kotlinx.rpc.grpc.internal import kotlin.coroutines.CoroutineContext -internal expect class GrpcContext +public expect class GrpcContext + internal expect val CurrentGrpcContext: GrpcContext internal expect class GrpcContextElement : CoroutineContext.Element { + val grpcContext: GrpcContext + companion object Key : CoroutineContext.Key { fun current(): GrpcContextElement } diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt index 7a8abd732..d8d93bf6e 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt @@ -157,15 +157,17 @@ private fun CoroutineScope.serverCallListenerImpl( } } + val context = GrpcContextElement.current() val serverCallScope = ServerCallScopeImpl( method = descriptor, interceptors = interceptors, implementation = implementation, requestHeaders = requestHeaders, serverCall = handler, + grpcContext = context.grpcContext, ) - val rpcJob = launch(GrpcContextElement.current()) { + val rpcJob = launch() { val mutex = Mutex() val headersSent = AtomicBoolean(false) // enforces only sending headers once val failure = runCatching { @@ -273,6 +275,7 @@ private class ServerCallScopeImpl( val implementation: (Flow) -> Flow, override val requestHeaders: GrpcMetadata, val serverCall: ServerCall, + override val grpcContext: GrpcContext, ) : ServerCallScope { override val responseHeaders: GrpcMetadata = GrpcMetadata() diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.kt index 4309b1c6f..1a65baf6e 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.kt @@ -8,12 +8,12 @@ import io.grpc.Context import kotlinx.coroutines.ThreadContextElement import kotlin.coroutines.CoroutineContext -internal actual typealias GrpcContext = Context +public actual typealias GrpcContext = Context internal actual val CurrentGrpcContext: GrpcContext get() = GrpcContext.current() -internal actual class GrpcContextElement(private val grpcContext: GrpcContext) : ThreadContextElement { +internal actual class GrpcContextElement(actual val grpcContext: GrpcContext) : ThreadContextElement { actual companion object Key : CoroutineContext.Key { actual fun current(): GrpcContextElement = GrpcContextElement(CurrentGrpcContext) } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.native.kt index dd60f03e1..9b275708c 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/GrpcContext.native.kt @@ -6,20 +6,20 @@ package kotlinx.rpc.grpc.internal import kotlin.coroutines.CoroutineContext -internal actual class GrpcContext +public actual class GrpcContext private val currentGrpcContext = GrpcContext() internal actual val CurrentGrpcContext: GrpcContext get() = currentGrpcContext -internal actual class GrpcContextElement : CoroutineContext.Element { +internal actual class GrpcContextElement(actual val grpcContext: GrpcContext) : CoroutineContext.Element { actual override val key: CoroutineContext.Key get() = Key actual companion object Key : CoroutineContext.Key { actual fun current(): GrpcContextElement { - return GrpcContextElement() + return GrpcContextElement(currentGrpcContext) } } } From 7cadf725f3adec8a18d6eed928c9495470306fdf Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 19 Sep 2025 16:27:43 +0200 Subject: [PATCH 13/21] grpc: Adjust client/server DSL and provide documentation Signed-off-by: Johannes Zottele --- .../kotlin/kotlinx/rpc/grpc/GrpcClient.kt | 136 ++++++++++++++--- .../kotlin/kotlinx/rpc/grpc/GrpcServer.kt | 138 ++++++++++++++++-- .../rpc/grpc/test/BaseGrpcServiceTest.kt | 13 +- .../kotlinx/rpc/grpc/test/CoreClientTest.kt | 9 +- .../rpc/grpc/test/RawClientServerTest.kt | 2 +- .../kotlinx/rpc/grpc/test/RawClientTest.kt | 13 +- .../rpc/grpc/test/proto/GrpcProtoTest.kt | 19 +-- .../grpc/test/proto/JavaPackageOptionTest.kt | 1 - .../kotlinx/rpc/grpc/credentials.jvm.kt | 4 +- .../rpc/grpc/ktor/server/test/TestServer.kt | 7 +- 10 files changed, 275 insertions(+), 67 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt index 4edf60b23..657a98631 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcClient.kt @@ -123,7 +123,20 @@ public class GrpcClient internal constructor( } /** - * Constructor function for the [GrpcClient] class. + * Creates and configures a gRPC client instance. + * + * This function initializes a new gRPC client with the specified target server + * and allows optional customization of the client's configuration through a configuration block. + * + * @param hostname The gRPC server hostname to connect to. + * @param port The gRPC server port to connect to. + * @param configure An optional configuration block to customize the [GrpcClientConfiguration]. + * This can include setting up interceptors, specifying credentials, customizing message codec + * resolution, and overriding default authority. + * + * @return A new instance of [GrpcClient] configured with the specified target and options. + * + * @see [GrpcClientConfiguration] */ public fun GrpcClient( hostname: String, @@ -134,8 +147,22 @@ public fun GrpcClient( return GrpcClient(ManagedChannelBuilder(hostname, port, config.credentials), config) } + /** - * Constructor function for the [GrpcClient] class. + * Creates and configures a gRPC client instance. + * + * This function initializes a new gRPC client with the specified target server + * and allows optional customization of the client's configuration through a configuration block. + * + * @param target The gRPC server endpoint to connect to, typically specified in + * the format `hostname:port`. + * @param configure An optional configuration block to customize the [GrpcClientConfiguration]. + * This can include setting up interceptors, specifying credentials, customizing message codec + * resolution, and overriding default authority. + * + * @return A new instance of [GrpcClient] configured with the specified target and options. + * + * @see [GrpcClientConfiguration] */ public fun GrpcClient( target: String, @@ -155,30 +182,105 @@ private fun GrpcClient( return GrpcClient(channel, config.messageCodecResolver, config.interceptors) } + +/** + * Configuration class for a gRPC client, providing customization options + * for client behavior, including interceptors, credentials, codec resolution, + * and authority overrides. + */ public class GrpcClientConfiguration internal constructor() { - internal var messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver - internal var credentials: ClientCredentials? = null - internal var overrideAuthority: String? = null internal val interceptors: MutableList = mutableListOf() - public fun usePlaintext() { - credentials = createInsecureClientCredentials() - } + /** + * Configurable resolver used to determine the appropriate codec for a given Kotlin type + * during message serialization and deserialization in gRPC calls. + * + * Custom implementations of [MessageCodecResolver] can be provided to handle specific serialization + * for arbitrary types. + * For custom types prefer using the [kotlinx.rpc.grpc.codec.WithCodec] annotation. + * + * @see MessageCodecResolver + * @see kotlinx.rpc.grpc.codec.SourcedMessageCodec + * @see kotlinx.rpc.grpc.codec.WithCodec + */ + public var messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver - public fun useCredentials(credentials: ClientCredentials) { - this@GrpcClientConfiguration.credentials = credentials - } - public fun overrideAuthority(authority: String) { - overrideAuthority = authority - } + /** + * Configures the client credentials used for secure gRPC requests made by the client. + * + * By default, the client uses default TLS credentials. + * To use custom TLS credentials, use the [tls] constructor function which returns a + * [TlsClientCredentials] instance. + * + * To use plaintext communication, use the [plaintext] constructor function. + * Should only be used for testing or for APIs where the use of such API or + * the data exchanged is not sensitive. + * + * ``` + * GrpcClient("localhost", 50051) { + * credentials = plaintext() // for testing purposes only! + * } + * ``` + */ + public var credentials: ClientCredentials? = null + + /** + * Overrides the authority used with TLS and HTTP virtual hosting. + * It does not change what the host is actually connected to. + * Is commonly in the form `host:port`. + */ + public var overrideAuthority: String? = null - public fun useMessageCodecResolver(messageCodecResolver: MessageCodecResolver) { - this.messageCodecResolver = messageCodecResolver - } + /** + * Adds one or more client-side interceptors to the current gRPC client configuration. + * Interceptors enable extended customization of gRPC calls + * by observing or altering the behaviors of requests and responses. + * + * The order of interceptors added via this method is significant. + * Interceptors are executed in the order they are added, + * while one interceptor has to invoke the next interceptor to proceed with the call. + * + * @param interceptors Interceptors to be added to the current configuration. + * Each provided instance of [ClientInterceptor] may perform operations such as modifying headers, + * observing call metadata, logging, or transforming data flows. + * + * @see ClientInterceptor + * @see ClientCallScope + */ public fun intercept(vararg interceptors: ClientInterceptor) { this.interceptors.addAll(interceptors) } + /** + * Provides insecure client credentials for the gRPC client configuration. + * + * Typically, this would be used for local development, testing, or other + * environments where security is not a concern. + * + * @return An insecure [ClientCredentials] instance that must be passed to [credentials]. + */ + public fun plaintext(): ClientCredentials = createInsecureClientCredentials() + + /** + * Configures and creates secure client credentials for the gRPC client. + * + * This method takes a configuration block in which TLS-related parameters, + * such as trust managers and key managers, can be defined. The resulting + * credentials are used to establish secure communication between the gRPC client + * and server, ensuring encrypted transmission of data and mutual authentication + * if configured. + * + * Alternatively, you can use the [TlsClientCredentials] constructor. + * + * @param configure A configuration block that allows setting up the TLS parameters + * using the [TlsClientCredentialsBuilder]. + * @return A secure [ClientCredentials] instance that must be passed to [credentials]. + * + * @see credentials + */ + public fun tls(configure: TlsClientCredentialsBuilder.() -> Unit): ClientCredentials = + TlsClientCredentials(configure) + } \ No newline at end of file diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt index 37780a24c..bb4a82a65 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt @@ -198,41 +198,147 @@ public class GrpcServer internal constructor( } } + /** - * Constructor function for the [GrpcServer] class. + * Creates and configures a gRPC server instance. + * + * This function initializes a gRPC server with the provided port and a configuration block + * ([GrpcServerConfiguration]). + * + * To start the server, call the [GrpcServer.start] method. + * To clean up resources, call the [GrpcServer.shutdown] or [GrpcServer.shutdownNow] methods. + * + * ```kt + * GrpcServer(port) { + * credentials = tls(myCertChain, myPrivateKey) + * services { + * registerService(MyService()) + * registerService(MyOtherService()) + * } + * } + * ``` + * + * @param port The port number where the gRPC server will listen for incoming connections. + * This must be a valid and available port on the host system. + * @param parentContext The parent coroutine context used for managing server-related operations. + * Defaults to an empty coroutine context if not specified. + * @param configure A configuration lambda receiver, + * allowing customization of server behavior such as credentials, interceptors, + * codecs, and service registration logic. + * @return A fully configured `GrpcServer` instance, which must be started explicitly to handle requests. */ public fun GrpcServer( port: Int, parentContext: CoroutineContext = EmptyCoroutineContext, configure: GrpcServerConfiguration.() -> Unit = {}, - builder: RpcServer.() -> Unit = {}, ): GrpcServer { val config = GrpcServerConfiguration().apply(configure) val serverBuilder = ServerBuilder(port, config.credentials).apply { config.fallbackHandlerRegistry?.let { fallbackHandlerRegistry(it) } } return GrpcServer(port, serverBuilder, config.interceptors, config.messageCodecResolver, parentContext) - .apply(builder) + .apply(config.serviceBuilder) .apply { build() } } +/** + * A configuration class for setting up a gRPC server. + * + * This class provides an API to configure various server parameters, such as message codecs, + * security credentials, server-side interceptors, and service registration. + */ public class GrpcServerConfiguration internal constructor() { - internal var messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver - internal var credentials: ServerCredentials? = null - internal val interceptors: MutableList = mutableListOf() - internal var fallbackHandlerRegistry: HandlerRegistry? = null - internal var services: ServerBuilder<*>? = null - - public fun useCredentials(credentials: ServerCredentials) { - this.credentials = credentials - } - - public fun useMessageCodecResolver(messageCodecResolver: MessageCodecResolver) { - this.messageCodecResolver = messageCodecResolver - } + internal val interceptors: MutableList = mutableListOf() + internal var serviceBuilder: RpcServer.() -> Unit = { } + + + /** + * Sets the credentials to be used by the gRPC server for secure communication. + * + * By default, the server does not have any credentials configured and the communication is plaintext. + * To set up transport-layer security provide a [TlsServerCredentials] by constructing it with the + * [tls] function. + * + * @see TlsServerCredentials + * @see tls + */ + public var credentials: ServerCredentials? = null + + /** + * Sets a custom [MessageCodecResolver] to be used by the gRPC server for resolving the appropriate + * codec for message serialization and deserialization. + * + * When not explicitly set, a default [EmptyMessageCodecResolver] is used, which may not perform + * any specific resolution. + * Provide a custom [MessageCodecResolver] to resolve codecs based on the message's `KType`. + */ + public var messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver + + + /** + * Sets a custom [HandlerRegistry] to be used by the gRPC server for resolving service implementations + * that were not registered before via the [services] configuration block. + * + * If not set, unknown services not registered will cause a `UNIMPLEMENTED` status + * to be returned to the client. + */ + public var fallbackHandlerRegistry: HandlerRegistry? = null + + /** + * Registers one or more server-side interceptors for the gRPC server. + * + * Interceptors allow observing and modifying incoming gRPC calls before they reach the service + * implementation logic. + * They are commonly used to implement cross-cutting concerns like + * authentication, logging, metrics, or custom request/response transformations. + * + * @param interceptors One or more instances of [ServerInterceptor] to be applied to incoming calls. + * @see ServerInterceptor + */ public fun intercept(vararg interceptors: ServerInterceptor) { this.interceptors.addAll(interceptors) } + /** + * Configures the gRPC server to register services. + * + * This method allows defining a block of logic to configure an [RpcServer] instance, + * where multiple services can be registered: + * ```kt + * GrpcServer(port) { + * services { + * registerService(MyService()) + * registerService(MyOtherService()) + * } + * } + * ``` + * + * @param block A lambda with [RpcServer] as its receiver, allowing service registration. + */ + public fun services(block: RpcServer.() -> Unit) { + serviceBuilder = block + } + + /** + * Configures and creates TLS (Transport Layer Security) credentials for the gRPC server. + * + * This method allows specifying the server's certificate chain, private key, and additional + * configurations needed for setting up a secure communication channel over TLS. + * + * @param certificateChain A string representing the PEM-encoded certificate chain for the server. + * @param privateKey A string representing the PKCS#8 formatted private key corresponding to the certificate. + * @param configure A lambda to further customize the [TlsServerCredentialsBuilder], enabling configurations + * like setting trusted root certificates or enabling client authentication. + * @return An instance of [ServerCredentials] representing the configured TLS credentials that must be passed + * to [credentials]. + * + * @see credentials + */ + public fun tls( + certificateChain: String, + privateKey: String, + configure: TlsServerCredentialsBuilder.() -> Unit, + ): ServerCredentials = + TlsServerCredentials(certificateChain, privateKey, configure) } \ No newline at end of file diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt index 58242c165..36ac0815b 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/BaseGrpcServiceTest.kt @@ -31,19 +31,18 @@ abstract class BaseGrpcServiceTest { val server = GrpcServer( port = PORT, parentContext = coroutineContext, - configure = { - useMessageCodecResolver(resolver) - }, - builder = { + ) { + messageCodecResolver = resolver + services { registerService(kClass) { impl } } - ) + } server.start() val client = GrpcClient("localhost", PORT) { - useMessageCodecResolver(messageCodecResolver) - usePlaintext() + messageCodecResolver = resolver + credentials = plaintext() } val service = client.withService(kClass) diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt index e607bd47c..6351a3449 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt @@ -8,8 +8,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout -import kotlinx.rpc.grpc.GrpcServer import kotlinx.rpc.grpc.GrpcMetadata +import kotlinx.rpc.grpc.GrpcServer import kotlinx.rpc.grpc.ManagedChannel import kotlinx.rpc.grpc.ManagedChannelBuilder import kotlinx.rpc.grpc.Status @@ -274,8 +274,11 @@ class GreeterServiceImpl : GreeterService { fun runServer() = runTest { val server = GrpcServer( port = PORT, - builder = { registerService { GreeterServiceImpl() } } - ) + ) { + services { + registerService { GreeterServiceImpl() } + } + } try { server.start() diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt index 967eca0fb..d86dfd4a2 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientServerTest.kt @@ -110,7 +110,7 @@ class RawClientServerTest { val serverScope = CoroutineScope(serverJob) val client = GrpcClient("localhost", PORT) { - usePlaintext() + credentials = plaintext() } val descriptor = methodDescriptor( diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt index 4689ba394..acf978484 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt @@ -11,7 +11,13 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.GrpcServer -import kotlinx.rpc.grpc.internal.* +import kotlinx.rpc.grpc.internal.MethodDescriptor +import kotlinx.rpc.grpc.internal.MethodType +import kotlinx.rpc.grpc.internal.bidirectionalStreamingRpc +import kotlinx.rpc.grpc.internal.clientStreamingRpc +import kotlinx.rpc.grpc.internal.methodDescriptor +import kotlinx.rpc.grpc.internal.serverStreamingRpc +import kotlinx.rpc.grpc.internal.unaryRpc import kotlinx.rpc.registerService import kotlin.test.Test import kotlin.test.assertEquals @@ -90,7 +96,7 @@ class RawClientTest { block: suspend (GrpcClient, MethodDescriptor) -> Unit, ) = runTest { val client = GrpcClient("localhost:50051") { - usePlaintext() + credentials = plaintext() } val methodDescriptor = methodDescriptor( @@ -151,8 +157,7 @@ class EchoServiceImpl : EchoService { fun runServer() = runTest(timeout = Duration.INFINITE) { val server = GrpcServer( port = PORT, - builder = { registerService { EchoServiceImpl() } } - ) + ) { services { registerService { EchoServiceImpl() } } } try { server.start() diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt index 8c7b5f6ea..367a71b40 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt @@ -30,23 +30,18 @@ abstract class GrpcProtoTest { ) = runTest { serverMutex.withLock { val grpcClient = GrpcClient("localhost", PORT) { - if (clientCreds != null) useCredentials(clientCreds) - if (overrideAuthority != null) overrideAuthority(overrideAuthority) - if (clientCreds == null) { - usePlaintext() - } + credentials = clientCreds ?: plaintext() + if (overrideAuthority != null) this.overrideAuthority = overrideAuthority clientInterceptors.forEach { intercept(it) } } val grpcServer = GrpcServer( PORT, - configure = { - serverCreds?.let { useCredentials(it) } - serverInterceptors.forEach { intercept(it) } - }, - builder = { - registerServices() - }) + ) { + credentials = serverCreds + serverInterceptors.forEach { intercept(it) } + services { registerServices() } + } grpcServer.start() try { diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/JavaPackageOptionTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/JavaPackageOptionTest.kt index 51db59c9a..c0775bbdf 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/JavaPackageOptionTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/JavaPackageOptionTest.kt @@ -8,7 +8,6 @@ import com.google.protobuf.kotlin.Empty import com.google.protobuf.kotlin.EmptyInternal import com.google.protobuf.kotlin.invoke import kotlinx.rpc.RpcServer -import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.internal.MethodType import kotlinx.rpc.grpc.internal.methodDescriptor import kotlinx.rpc.grpc.internal.unaryRpc diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt index 3a44a4ec3..12d6213cd 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/credentials.jvm.kt @@ -8,8 +8,6 @@ public actual typealias ClientCredentials = io.grpc.ChannelCredentials public actual typealias ServerCredentials = io.grpc.ServerCredentials -// we need a wrapper for InsecureChannelCredentials as our constructor would conflict with the private -// java constructor. public actual typealias InsecureClientCredentials = io.grpc.InsecureChannelCredentials public actual typealias InsecureServerCredentials = io.grpc.InsecureServerCredentials @@ -17,6 +15,8 @@ public actual typealias TlsClientCredentials = io.grpc.TlsChannelCredentials public actual typealias TlsServerCredentials = io.grpc.TlsServerCredentials +// we need a wrapper for InsecureChannelCredentials as our constructor would conflict with the private +// java constructor. internal actual fun createInsecureClientCredentials(): ClientCredentials { return InsecureClientCredentials.create() } diff --git a/grpc/grpc-ktor-server/src/jvmTest/kotlin/kotlinx/rpc/grpc/ktor/server/test/TestServer.kt b/grpc/grpc-ktor-server/src/jvmTest/kotlin/kotlinx/rpc/grpc/ktor/server/test/TestServer.kt index f299fd649..6482d41de 100644 --- a/grpc/grpc-ktor-server/src/jvmTest/kotlin/kotlinx/rpc/grpc/ktor/server/test/TestServer.kt +++ b/grpc/grpc-ktor-server/src/jvmTest/kotlin/kotlinx/rpc/grpc/ktor/server/test/TestServer.kt @@ -4,12 +4,11 @@ package kotlinx.rpc.grpc.ktor.server.test -import io.ktor.server.testing.testApplication +import io.ktor.server.testing.* import kotlinx.rpc.grpc.GrpcClient -import kotlin.test.Test import kotlinx.rpc.grpc.ktor.server.grpc -import kotlinx.rpc.registerService import kotlinx.rpc.withService +import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.minutes @@ -33,7 +32,7 @@ class TestServer { startApplication() val client = GrpcClient("localhost", PORT) { - usePlaintext() + credentials = plaintext() } val response = client.withService().sayHello(Hello { message = "Hello" }) From 1dbd6a49fdf1503356d8adce36069bec67632077 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 19 Sep 2025 17:13:49 +0200 Subject: [PATCH 14/21] grpc: Fix race condition bug Signed-off-by: Johannes Zottele --- .../kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt | 2 ++ .../kotlin/kotlinx/rpc/grpc/internal/CompletionQueue.kt | 4 +++- .../kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt | 9 ++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt index 6dbdb5b74..1cd0b907b 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt @@ -46,4 +46,6 @@ internal class CallbackFuture { } } } + + val isCompleted: Boolean get() = state.value is State.Done } \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CompletionQueue.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CompletionQueue.kt index 329c45ae1..59b6cbcd3 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CompletionQueue.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CompletionQueue.kt @@ -119,6 +119,8 @@ internal class CompletionQueue { * See [BatchResult] for possible outcomes. */ fun runBatch(call: CPointer, ops: CPointer, nOps: ULong): BatchResult { + if (_shutdownDone.isCompleted) return BatchResult.CQShutdown + val completion = CallbackFuture() val tag = newCbTag(completion, OPS_COMPLETE_CB) @@ -194,8 +196,8 @@ private fun opsCompleteCb(functor: CPointer?, ok: private fun shutdownCb(functor: CPointer?, ok: Int) { val tag = functor!!.reinterpret() val cq = tag.pointed.user_data!!.asStableRef().get() - cq._shutdownDone.complete(Unit) cq._state.value = CompletionQueue.State.CLOSED + cq._shutdownDone.complete(Unit) grpc_completion_queue_destroy(cq.raw) } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt index 1fd3e1e90..b075cf783 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt @@ -67,6 +67,7 @@ internal class NativeServerCall( private val callbackMutex = ReentrantLock() private var initialized = false private var cancelled = false + private var closed = false private val finalized = atomic(false) // tracks whether the initial metadata has been sent. @@ -143,6 +144,7 @@ internal class NativeServerCall( } fun cancel(status: grpc_status_code, message: String) { + cancelled = true grpc_call_cancel_with_status(raw, status, message, null) } @@ -168,6 +170,9 @@ internal class NativeServerCall( cleanup: () -> Unit = {}, onSuccess: () -> Unit = {}, ) { + // if we are already closed, we cannot run any more batches. + if (closed || cancelled) return cleanup() + when (val result = cq.runBatch(raw, ops, nOps)) { is BatchResult.Submitted -> { result.future.onComplete { @@ -286,6 +291,8 @@ internal class NativeServerCall( override fun close(status: Status, trailers: GrpcMetadata) { check(initialized) { internalError("Call not initialized") } + closed = true + val arena = Arena() val details = status.getDescription()?.toGrpcSlice() @@ -327,7 +334,7 @@ internal class NativeServerCall( } - private fun tryRun(block: () -> T): T { + private inline fun tryRun(crossinline block: () -> T): T { try { return block() } catch (e: Throwable) { From ed5dc220ba0bd041a9a008fafcf2099f2f24db85 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 19 Sep 2025 17:26:20 +0200 Subject: [PATCH 15/21] grpc: Fix context not set Signed-off-by: Johannes Zottele --- .../kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt index d8d93bf6e..afb13e71b 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt @@ -167,7 +167,7 @@ private fun CoroutineScope.serverCallListenerImpl( grpcContext = context.grpcContext, ) - val rpcJob = launch() { + val rpcJob = launch(context) { val mutex = Mutex() val headersSent = AtomicBoolean(false) // enforces only sending headers once val failure = runCatching { From 0f81e01136d585cb9810dac75aa47370d5e67f40 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 19 Sep 2025 18:30:03 +0200 Subject: [PATCH 16/21] grpc: Add multi interceptor tests Signed-off-by: Johannes Zottele --- .../grpc/test/proto/ClientInterceptorTest.kt | 51 +++++++++++++++++++ .../grpc/test/proto/ServerInterceptorTest.kt | 33 ++++++++++++ .../rpc/grpc/internal/NativeClientCall.kt | 3 +- .../rpc/grpc/internal/NativeServerCall.kt | 2 +- 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt index c385d467d..d900305ef 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt @@ -176,6 +176,57 @@ class ClientInterceptorTest : GrpcProtoTest() { assertEquals("Cancellation cause", error.cause?.message) } + @Test + fun `cancel in two interceptors - should fail with cancellation`() { + val error = assertFailsWith { + val interceptor1 = interceptor { + onClose { _, _ -> cancel("[1] Canceling in onClose", IllegalStateException("Cancellation cause")) } + proceed(it) + } + val interceptor2 = interceptor { + onClose { _, _ -> cancel("[2] Canceling in onClose", IllegalStateException("Cancellation cause")) } + proceed(it) + } + runGrpcTest(clientInterceptors = interceptor1 + interceptor2, test = ::unaryCall) + } + + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) + assertContains(error.message!!, "[1] Canceling in onClose") + assertIs(error.cause) + assertEquals("Cancellation cause", error.cause?.message) + } + + @Test + fun `cancel in two interceptors withing response stream - should fail with cancellation`() { + val error = assertFailsWith { + val interceptor1 = interceptor { + proceed(it).map { + val msg = it as EchoResponse + if (msg.message == "Echo-3") { + cancel("[1] Canceling in response flow", IllegalStateException("Cancellation cause")) + } + it + } + } + val interceptor2 = interceptor { + proceed(it).map { + val msg = it as EchoResponse + // this is cancelled before the first one + if (msg.message == "Echo-2") { + cancel("[2] Canceling in response flow", IllegalStateException("Cancellation cause")) + } + it + } + } + runGrpcTest(clientInterceptors = interceptor1 + interceptor2, test = ::bidiStream) + } + + assertEquals(StatusCode.CANCELLED, error.getStatus().statusCode) + assertContains(error.message!!, "[2] Canceling in response flow") + assertIs(error.cause) + assertEquals("Cancellation cause", error.cause?.message) + } + @Test fun `modify request message - should return modified message`() { val interceptor = interceptor { diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt index ab304996d..b1ebc8221 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt @@ -35,6 +35,21 @@ class ServerInterceptorTest : GrpcProtoTest() { registerService { EchoServiceImpl() } } + @Test + fun `throw during onClosing - should fail propagate the exception to the server root`() { + val error = assertFailsWith { + val interceptor = interceptor { + onClose { _, _ -> throw IllegalStateException("Illegal failing in onClose") } + proceed(it) + } + runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) + } + + assertContains(error.message!!, "Illegal failing in onClose") + // check that the error is indeed causing a server crash + assertContains(error.stackTraceToString(), "suspendServerCall") + } + @Test fun `throw during intercept - should fail with unknown status on client`() { var cause: Throwable? = null @@ -112,6 +127,24 @@ class ServerInterceptorTest : GrpcProtoTest() { assertContains(error.message!!, "Close in onClose") } + @Test + fun `close in two interceptors - should fail with correct status on client`() { + val error = assertFailsWith { + val interceptor1 = interceptor { + onClose { _, _ -> close(Status(StatusCode.UNAUTHENTICATED, "[1] Close in onClose"), GrpcMetadata()) } + proceed(it) + } + val interceptor2 = interceptor { + onClose { _, _ -> close(Status(StatusCode.UNAUTHENTICATED, "[2] Close in onClose"), GrpcMetadata()) } + proceed(it) + } + runGrpcTest(serverInterceptors = interceptor1 + interceptor2, test = ::unaryCall) + } + + assertEquals(StatusCode.UNAUTHENTICATED, error.getStatus().statusCode) + assertContains(error.message!!, "[1] Close in onClose") + } + @Test fun `dont proceed and return custom message - should succeed on client`() { val interceptor = interceptor { diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt index 6e2b0cdcb..9a02dd6e3 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt @@ -154,7 +154,7 @@ internal class NativeClientCall( */ private fun turnReady() { if (ready.compareAndSet(expect = false, update = true)) { - safeUserCode("Failed to call onClose.") { + safeUserCode("Failed to call onReady.") { listener?.onReady() } } @@ -200,6 +200,7 @@ internal class NativeClientCall( callResult.future.onComplete { success -> try { if (success) { + // if the batch doesn't succeed, this is reflected in the recv status op batch. onSuccess() } } finally { diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt index b075cf783..00c58b72e 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeServerCall.kt @@ -291,7 +291,6 @@ internal class NativeServerCall( override fun close(status: Status, trailers: GrpcMetadata) { check(initialized) { internalError("Call not initialized") } - closed = true val arena = Arena() @@ -320,6 +319,7 @@ internal class NativeServerCall( if (details != null) grpc_slice_unref(details) arena.clear() }) { + closed = true // nothing to do here } } From 121e5e94e7c5fea5d305de6a496b4a5a976a726d Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 19 Sep 2025 19:42:27 +0200 Subject: [PATCH 17/21] grpc: Address PR comments Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ClientInterceptor.kt | 3 + .../kotlin/kotlinx/rpc/grpc/ManagedChannel.kt | 4 -- .../kotlinx/rpc/grpc/ServerInterceptor.kt | 23 ++++++- .../rpc/grpc/internal/suspendServerCalls.kt | 4 +- .../kotlinx/rpc/grpc/test/CoreClientTest.kt | 8 ++- .../grpc/test/proto/ServerInterceptorTest.kt | 68 ++++++++++++++++++- .../kotlinx/rpc/grpc/ManagedChannel.native.kt | 9 --- 7 files changed, 96 insertions(+), 23 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt index 04729e899..c81d17f90 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ClientInterceptor.kt @@ -77,6 +77,9 @@ public interface ClientCallScope { /** * Cancel the call locally, providing a human-readable [message] and an optional [cause]. * This method won't return and abort all further processing. + * + * We made cancel throw a [StatusException] instead of returning, so control flow is explicit and + * race conditions between interceptors and the transport layer are avoided. */ public fun cancel(message: String, cause: Throwable? = null): Nothing diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.kt index 1e1dc7e5a..ea10366d3 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.kt @@ -68,10 +68,6 @@ public interface ManagedChannel { * Builder class for [ManagedChannel]. */ public expect abstract class ManagedChannelBuilder> { - - // TODO: Not used anymore - public fun usePlaintext(): T - public abstract fun overrideAuthority(authority: String): T } diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt index 03434af0c..b08a6f946 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt @@ -51,7 +51,7 @@ public interface ServerCallScope { * It can be used by the interceptor to provide call-scoped information about * the current call, such as the identity of the caller or the current authentication state. */ - public val grpcContext: GrpcContext + public val context: GrpcContext /** * Register a callback invoked when the call is closed (successfully or exceptionally). @@ -64,6 +64,9 @@ public interface ServerCallScope { * * This method does not return (declared as [Nothing]). After calling it, no further messages will be processed * or sent. Prefer setting [responseHeaders]/[responseTrailers] before closing if you need to include metadata. + * + * We made close throw a [StatusException] instead of returning, so control flow is explicit and race conditions + * between interceptors and the service implementation are avoided. */ public fun close(status: Status, trailers: GrpcMetadata = GrpcMetadata()): Nothing @@ -83,8 +86,22 @@ public interface ServerCallScope { /** * Convenience for flow builders: proceeds with [request] and emits the resulting response elements into this * [FlowCollector]. Useful inside `flow {}` blocks within interceptors. + * + * ``` + * val myAuthInterceptor = object : ServerInterceptor { + * override fun ServerCallScope.intercept(request: Flow): Flow = + * flow { + * val authorized = mySuspendAuth(requestHeaders) + * if (!authorized) { + * close(Status(StatusCode.UNAUTHENTICATED, "Not authorized")) + * } + * + * proceedUnmodified(request) + * } + * } + * ``` */ - public suspend fun FlowCollector.proceedFlow(request: Flow) { + public suspend fun FlowCollector.proceedUnmodified(request: Flow) { proceed(request).collect { emit(it) } @@ -115,7 +132,7 @@ public interface ServerInterceptor { * - Read [ServerCallScope.requestHeaders] and populate [ServerCallScope.responseHeaders]/[ServerCallScope.responseTrailers]. * - Register [ServerCallScope.onClose] callbacks. * - Transform the [request] flow or wrap the resulting response flow. - * - Append information to the [ServerCallScope.grpcContext]. + * - Append information to the [ServerCallScope.context]. * * IMPORTANT: You must eventually call [ServerCallScope.proceed] to actually invoke the service logic and produce * the response [Flow]. If [ServerCallScope.proceed] is omitted, the call will never reach the service. diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt index afb13e71b..b56076544 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/suspendServerCalls.kt @@ -164,7 +164,7 @@ private fun CoroutineScope.serverCallListenerImpl( implementation = implementation, requestHeaders = requestHeaders, serverCall = handler, - grpcContext = context.grpcContext, + context = context.grpcContext, ) val rpcJob = launch(context) { @@ -275,7 +275,7 @@ private class ServerCallScopeImpl( val implementation: (Flow) -> Flow, override val requestHeaders: GrpcMetadata, val serverCall: ServerCall, - override val grpcContext: GrpcContext, + override val context: GrpcContext, ) : ServerCallScope { override val responseHeaders: GrpcMetadata = GrpcMetadata() diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt index 6351a3449..622ce833d 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt @@ -15,6 +15,7 @@ import kotlinx.rpc.grpc.ManagedChannelBuilder import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.buildChannel +import kotlinx.rpc.grpc.createInsecureClientCredentials import kotlinx.rpc.grpc.internal.ClientCall import kotlinx.rpc.grpc.internal.GrpcDefaultCallOptions import kotlinx.rpc.grpc.internal.MethodDescriptor @@ -52,9 +53,10 @@ class GrpcCoreClientTest { private fun ManagedChannel.newHelloCall(fullName: String = "kotlinx.rpc.grpc.test.GreeterService/SayHello"): ClientCall = platformApi.newCall(descriptorFor(fullName), GrpcDefaultCallOptions) - private fun createChannel(): ManagedChannel = ManagedChannelBuilder("localhost:$PORT") - .usePlaintext() - .buildChannel() + private fun createChannel(): ManagedChannel = ManagedChannelBuilder( + target = "localhost:$PORT", + credentials = createInsecureClientCredentials() + ).buildChannel() private fun helloReq(timeout: UInt = 0u): HelloRequest = HelloRequest { diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt index b1ebc8221..287d40e82 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ServerInterceptorTest.kt @@ -5,8 +5,10 @@ package kotlinx.rpc.grpc.test.proto import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.GrpcClient import kotlinx.rpc.grpc.GrpcMetadata @@ -184,13 +186,61 @@ class ServerInterceptorTest : GrpcProtoTest() { @Test fun `proceedFlow - should succeed on client`() { val interceptor = interceptor { - kotlinx.coroutines.flow.flow { - proceedFlow(it) + flow { + proceedUnmodified(it) } } runGrpcTest(serverInterceptors = interceptor, test = ::unaryCall) } + @Test + fun `test exact order of interceptor execution`() { + val order = mutableListOf() + val interceptor1 = interceptor { request -> + flow { + order.add(1) + var i1 = 0 + val ids = listOf(3, 7) + val req = request.map { order.add(ids[i1++]); it } + + var i2 = 0 + val respIds = listOf(6, 10) + proceed(req).collect { + order.add(respIds[i2++]) + emit(it) + } + + order.add(12) + } + } + + val interceptor2 = interceptor { request -> + flow { + order.add(2) + var i1 = 0 + val reqIds = listOf(4, 8) + val req = request.map { order.add(reqIds[i1++]); it } + + var i2 = 0 + val respIds = listOf(5, 9) + proceed(req).collect { + order.add(respIds[i2++]) + emit(it) + } + + order.add(11) + } + } + val both = interceptor1 + interceptor2 + + runGrpcTest(serverInterceptors = both) { bidiStream(it, 2) } + + assertEquals( + listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), + order + ) + } + @Test fun `method descriptor - full method name is exposed`() { var methodName: String? = null @@ -207,6 +257,20 @@ class ServerInterceptorTest : GrpcProtoTest() { val response = service.UnaryEcho(EchoRequest { message = "Hello" }) assertEquals("Hello", response.message) } + + private suspend fun bidiStream(grpcClient: GrpcClient, count: Int = 5) { + val service = grpcClient.withService() + val responses = service.BidirectionalStreamingEcho(flow { + repeat(count) { + emit(EchoRequest { message = "Echo-$it" }) + } + }).toList() + assertEquals(count, responses.size) + repeat(count) { + assertEquals("Echo-$it", responses[it].message) + } + } + } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt index 97e628fa3..4f8a27de7 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt @@ -19,10 +19,6 @@ public actual abstract class ManagedChannelPlatform : GrpcChannel() * Builder class for [ManagedChannel]. */ public actual abstract class ManagedChannelBuilder> { - public actual open fun usePlaintext(): T { - error("Builder does not support usePlaintext()") - } - public actual abstract fun overrideAuthority(authority: String): T } @@ -33,11 +29,6 @@ internal class NativeManagedChannelBuilder( private var authority: String? = null - override fun usePlaintext(): NativeManagedChannelBuilder { - credentials = lazy { createInsecureClientCredentials() } - return this - } - override fun overrideAuthority(authority: String): NativeManagedChannelBuilder { this.authority = authority return this From 1ec13a6d8cd15b7e3f7663ecdeccbc01d366a63e Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 26 Sep 2025 11:41:44 +0200 Subject: [PATCH 18/21] grpc: Add client interceptor execution order test Signed-off-by: Johannes Zottele --- .../grpc/test/proto/ClientInterceptorTest.kt | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt index d900305ef..3ccf86132 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/ClientInterceptorTest.kt @@ -279,21 +279,58 @@ class ClientInterceptorTest : GrpcProtoTest() { } } + @Test + fun `test exact order of interceptor execution`() { + val order = mutableListOf() + val interceptor1 = interceptor { request -> + order.add(1) + flow { + order.add(2) + val req = request.map { order.add(5); it } + proceed(req).collect { + order.add(8) + emit(it) + } + order.add(10) + } + } + val interceptor2 = interceptor { request -> + order.add(3) + flow { + order.add(4) + val req = request.map { order.add(6); it } + proceed(req).collect { + order.add(7) + emit(it) + } + order.add(9) + } + } + + val both = interceptor1 + interceptor2 + runGrpcTest(clientInterceptors = both) { unaryCall(it) } + + assertEquals( + listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), + order + ) + } + private suspend fun unaryCall(grpcClient: GrpcClient) { val service = grpcClient.withService() val response = service.UnaryEcho(EchoRequest { message = "Hello" }) assertEquals("Hello", response.message) } - private suspend fun bidiStream(grpcClient: GrpcClient) { + private suspend fun bidiStream(grpcClient: GrpcClient, count: Int = 5) { val service = grpcClient.withService() val responses = service.BidirectionalStreamingEcho(flow { - repeat(5) { + repeat(count) { emit(EchoRequest { message = "Echo-$it" }) } }).toList() - assertEquals(5, responses.size) - repeat(5) { + assertEquals(count, responses.size) + repeat(count) { assertEquals("Echo-$it", responses[it].message) } } From 0a34abe6d87e3dcea7ced19ffd13ac7ca00543bb Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 26 Sep 2025 11:52:02 +0200 Subject: [PATCH 19/21] grpc: Address PR comments Signed-off-by: Johannes Zottele --- .../src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt | 9 ++++----- .../kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt | 2 +- .../kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt | 1 - 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt index bb4a82a65..dc21c8c6d 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcServer.kt @@ -198,7 +198,6 @@ public class GrpcServer internal constructor( } } - /** * Creates and configures a gRPC server instance. * @@ -212,8 +211,8 @@ public class GrpcServer internal constructor( * GrpcServer(port) { * credentials = tls(myCertChain, myPrivateKey) * services { - * registerService(MyService()) - * registerService(MyOtherService()) + * registerService { MyServiceImpl() } + * registerService { MyOtherServiceImpl() } * } * } * ``` @@ -308,8 +307,8 @@ public class GrpcServerConfiguration internal constructor() { * ```kt * GrpcServer(port) { * services { - * registerService(MyService()) - * registerService(MyOtherService()) + * registerService { MyServiceImpl() } + * registerService { MyOtherServiceImpl() } * } * } * ``` diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt index b08a6f946..5f24f09f8 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/ServerInterceptor.kt @@ -93,7 +93,7 @@ public interface ServerCallScope { * flow { * val authorized = mySuspendAuth(requestHeaders) * if (!authorized) { - * close(Status(StatusCode.UNAUTHENTICATED, "Not authorized")) + * close(Status(StatusCode.PERMISSION_DENIED, "Not authorized")) * } * * proceedUnmodified(request) diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt index acf978484..bde3d3741 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt @@ -62,7 +62,6 @@ class RawClientTest { ) { client, descriptor -> val response = client.clientStreamingRpc(descriptor, flow { repeat(5) { - delay(100) println("Sending: ${it + 1}") emit(EchoRequest { message = "Eccchhooo" }) } From 1b232a2f07b1818d7c7d576b6330e782d5922707 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 26 Sep 2025 12:04:14 +0200 Subject: [PATCH 20/21] grpc: Fixing bug after rebase Signed-off-by: Johannes Zottele --- .../test/CustomResolverGrpcServiceTest.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CustomResolverGrpcServiceTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CustomResolverGrpcServiceTest.kt index 7fcc9af5e..0f2983606 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CustomResolverGrpcServiceTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CustomResolverGrpcServiceTest.kt @@ -20,24 +20,25 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals -@WithCodec(Message.Companion::class) -class Message(val value: String) { - companion object : SourcedMessageCodec { - override fun encodeToSource(value: Message): Source { +@WithCodec(CustomResolverMessage.Companion::class) +class CustomResolverMessage(val value: String) { + companion object Companion : SourcedMessageCodec { + override fun encodeToSource(value: CustomResolverMessage): Source { return Buffer().apply { writeString(value.value) } } - override fun decodeFromSource(stream: Source): Message { - return Message(stream.readString()) + override fun decodeFromSource(stream: Source): CustomResolverMessage { + return CustomResolverMessage(stream.readString()) } } } + @Grpc interface GrpcService { suspend fun plainString(value: String): String - suspend fun message(value: Message): Message + suspend fun message(value: CustomResolverMessage): CustomResolverMessage suspend fun krpc173() @@ -53,8 +54,8 @@ class GrpcServiceImpl : GrpcService { return "$value $value" } - override suspend fun message(value: Message): Message { - return Message("${value.value} ${value.value}") + override suspend fun message(value: CustomResolverMessage): CustomResolverMessage { + return CustomResolverMessage("${value.value} ${value.value}") } override suspend fun krpc173() { @@ -88,7 +89,7 @@ class CustomResolverGrpcServiceTest : BaseGrpcServiceTest() { resolver = simpleResolver, impl = GrpcServiceImpl(), ) { service -> - assertEquals("test test", service.message(Message("test")).value) + assertEquals("test test", service.message(CustomResolverMessage("test")).value) } @Test From 285b839ec29b06a606be302008aeceae623690d7 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 29 Sep 2025 13:40:11 +0200 Subject: [PATCH 21/21] grpc: Fix default proto package in service name --- cinterop-c/MODULE.bazel.lock | 8 ++++---- .../rpc/codegen/extension/RpcDeclarationScanner.kt | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cinterop-c/MODULE.bazel.lock b/cinterop-c/MODULE.bazel.lock index 4da8305f4..43b3be81c 100644 --- a/cinterop-c/MODULE.bazel.lock +++ b/cinterop-c/MODULE.bazel.lock @@ -245,8 +245,8 @@ "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", "https://bcr.bazel.build/modules/rules_java/7.4.0/MODULE.bazel": "a592852f8a3dd539e82ee6542013bf2cadfc4c6946be8941e189d224500a8934", "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", - "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", - "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", + "https://bcr.bazel.build/modules/rules_java/8.12.0/MODULE.bazel": "8e6590b961f2defdfc2811c089c75716cb2f06c8a4edeb9a8d85eaa64ee2a761", + "https://bcr.bazel.build/modules/rules_java/8.12.0/source.json": "cbd5d55d9d38d4008a7d00bee5b5a5a4b6031fcd4a56515c9accbcd42c7be2ba", "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", @@ -534,7 +534,7 @@ }, "@@rules_foreign_cc+//foreign_cc:extensions.bzl%tools": { "general": { - "bzlTransitiveDigest": "jO6HNyY7/eIylNs2RYABjCfbAgUNb1oiXpl3aY4V/hI=", + "bzlTransitiveDigest": "ginC3lIGOKKivBi0nyv2igKvSiz42Thm8yaX2RwVaHg=", "usagesDigest": "9LXdVp01HkdYQT8gYPjYLO6VLVJHo9uFfxWaU1ymiRE=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, @@ -848,7 +848,7 @@ }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "OlvsB0HsvxbR8ZN+J9Vf00X/+WVz/Y/5Xrq2LgcVfdo=", + "bzlTransitiveDigest": "hUTp2w+RUVdL7ma5esCXZJAFnX7vLbVfLd7FwnQI6bU=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, diff --git a/compiler-plugin/compiler-plugin-backend/src/main/kotlin/kotlinx/rpc/codegen/extension/RpcDeclarationScanner.kt b/compiler-plugin/compiler-plugin-backend/src/main/kotlin/kotlinx/rpc/codegen/extension/RpcDeclarationScanner.kt index 80cdf8620..259b1b48d 100644 --- a/compiler-plugin/compiler-plugin-backend/src/main/kotlin/kotlinx/rpc/codegen/extension/RpcDeclarationScanner.kt +++ b/compiler-plugin/compiler-plugin-backend/src/main/kotlin/kotlinx/rpc/codegen/extension/RpcDeclarationScanner.kt @@ -18,6 +18,7 @@ import org.jetbrains.kotlin.ir.expressions.IrExpression import org.jetbrains.kotlin.ir.util.dumpKotlinLike import org.jetbrains.kotlin.ir.util.getAnnotation import org.jetbrains.kotlin.ir.util.hasDefaultValue +import org.jetbrains.kotlin.ir.util.packageFqName /** * This class scans user declared RPC service @@ -31,7 +32,9 @@ internal object RpcDeclarationScanner { var stubClass: IrClass? = null val grpcAnnotation = service.getAnnotation(RpcClassId.grpcAnnotation.asSingleFqName()) - val protoPackage = grpcAnnotation?.arguments?.getOrNull(0)?.asConstString() ?: "" + // if the protoPackage is not set by the annotation, we use the service kotlin package name + val protoPackage = grpcAnnotation?.arguments?.getOrNull(0)?.asConstString() + ?: service.packageFqName?.asString() ?: "" val declarations = service.declarations.memoryOptimizedMap { declaration -> when (declaration) {